feat: next proxy app router support (#22)

* feat: add nextjs app router support

* chore: rename nextjs page router demo

* feat: support nextjs app router with example

* chore(proxy): bump to 0.4.0

* chore: update package-lock.json

* chore: remove formidable

* chore: update package-lock.json

* fix: page export

* fix: type issues

* fix: remove comment

* fix: eslint any warning

* chore: client version bump 0.4.2
This commit is contained in:
Daniel Rochetti 2023-10-30 09:01:15 -07:00 committed by GitHub
parent 759d58a306
commit 3a98fd6d0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 6070 additions and 31598 deletions

2
.gitignore vendored
View File

@ -40,4 +40,4 @@ Thumbs.db
# Next.js
.next
*.local
*.local

View File

@ -0,0 +1,40 @@
{
"extends": [
"plugin:@nx/react-typescript",
"next",
"next/core-web-vitals",
"../../.eslintrc.json"
],
"ignorePatterns": ["!**/*", ".next/**/*"],
"overrides": [
{
"files": ["*.*"],
"rules": {
"@next/next/no-html-link-for-pages": "off"
}
},
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@next/next/no-html-link-for-pages": [
"error",
"apps/demo-nextjs-app-router/pages"
]
}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"],
"env": {
"jest": true
}
}
]
}

View File

@ -0,0 +1,3 @@
import { route } from '@fal-ai/serverless-proxy/nextjs';
export const { GET, POST } = route;

View File

@ -0,0 +1,18 @@
import './global.css';
export const metadata = {
title: 'Welcome to demo-nextjs-app-router',
description: 'Generated by create-nx-workspace',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View File

@ -0,0 +1,170 @@
'use client';
import * as fal from '@fal-ai/serverless-client';
import { useMemo, useState } from 'react';
// @snippet:start(client.config)
fal.config({
requestMiddleware: fal.withProxy({
targetUrl: '/api/fal/proxy', // the built-int nextjs proxy
// targetUrl: 'http://localhost:3333/api/_fal/proxy', // or your own external proxy
}),
});
// @snippet:end
// @snippet:start(client.result.type)
type Image = {
url: string;
file_name: string;
file_size: number;
};
type Result = {
images: Image[];
};
// @snippet:end
type ErrorProps = {
error: any;
};
function Error(props: ErrorProps) {
if (!props.error) {
return null;
}
return (
<div
className="p-4 mb-4 text-sm text-red-800 rounded bg-red-50 dark:bg-gray-800 dark:text-red-400"
role="alert"
>
<span className="font-medium">Error</span> {props.error.message}
</div>
);
}
const DEFAULT_PROMPT =
'a city landscape of a cyberpunk metropolis, raining, purple, pink and teal neon lights, highly detailed, uhd';
export default function Home() {
// @snippet:start("client.ui.state")
// Input state
const [prompt, setPrompt] = useState<string>(DEFAULT_PROMPT);
// Result state
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [result, setResult] = useState<Result | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [elapsedTime, setElapsedTime] = useState<number>(0);
// @snippet:end
const image = useMemo(() => {
if (!result) {
return null;
}
return result.images[0];
}, [result]);
const reset = () => {
setLoading(false);
setError(null);
setResult(null);
setLogs([]);
setElapsedTime(0);
};
const generateImage = async () => {
reset();
// @snippet:start("client.queue.subscribe")
setLoading(true);
const start = Date.now();
try {
const result: Result = await fal.subscribe('110602490-lora', {
input: {
prompt,
model_name: 'stabilityai/stable-diffusion-xl-base-1.0',
image_size: 'square_hd',
},
pollInterval: 5000, // Default is 1000 (every 1s)
logs: true,
onQueueUpdate(update) {
setElapsedTime(Date.now() - start);
if (
update.status === 'IN_PROGRESS' ||
update.status === 'COMPLETED'
) {
setLogs((update.logs || []).map((log) => log.message));
}
},
});
setResult(result);
} catch (error: any) {
setError(error);
} finally {
setLoading(false);
setElapsedTime(Date.now() - start);
}
// @snippet:end
};
return (
<div className="min-h-screen dark:bg-gray-900 bg-gray-100">
<main className="container dark:text-gray-50 text-gray-900 flex flex-col items-center justify-center w-full flex-1 py-10 space-y-8">
<h1 className="text-4xl font-bold mb-8">
Hello <code className="font-light text-pink-600">fal</code>
</h1>
<div className="text-lg w-full">
<label htmlFor="prompt" className="block mb-2 text-current">
Prompt
</label>
<input
className="w-full text-lg p-2 rounded bg-black/10 dark:bg-white/5 border border-black/20 dark:border-white/10"
id="prompt"
name="prompt"
placeholder="Imagine..."
value={prompt}
autoComplete="off"
onChange={(e) => setPrompt(e.target.value)}
onBlur={(e) => setPrompt(e.target.value.trim())}
/>
</div>
<button
onClick={(e) => {
e.preventDefault();
generateImage();
}}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-lg py-3 px-6 mx-auto rounded focus:outline-none focus:shadow-outline"
disabled={loading}
>
{loading ? 'Generating...' : 'Generate Image'}
</button>
<Error error={error} />
<div className="w-full flex flex-col space-y-4">
<div className="mx-auto">
{image && (
// eslint-disable-next-line @next/next/no-img-element
<img src={image.url} alt="" />
)}
</div>
<div className="space-y-2">
<h3 className="text-xl font-light">JSON Result</h3>
<p className="text-sm text-current/80">
{`Elapsed Time (seconds): ${(elapsedTime / 1000).toFixed(2)}`}
</p>
<pre className="text-sm bg-black/70 text-white/80 font-mono h-60 rounded whitespace-pre overflow-auto w-full">
{result
? JSON.stringify(result, null, 2)
: '// result pending...'}
</pre>
</div>
<div className="space-y-2">
<h3 className="text-xl font-light">Logs</h3>
<pre className="text-sm bg-black/70 text-white/80 font-mono h-60 rounded whitespace-pre overflow-auto w-full">
{logs.filter(Boolean).join('\n')}
</pre>
</div>
</div>
</main>
</div>
);
}

View File

@ -1,11 +1,11 @@
/* eslint-disable */
export default {
displayName: 'demo-nextjs-app',
displayName: 'demo-nextjs-app-router',
preset: '../../jest.preset.js',
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/next/babel'] }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/apps/demo-nextjs-app',
coverageDirectory: '../../coverage/apps/demo-nextjs-app-router',
};

View File

@ -0,0 +1,22 @@
//@ts-check
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { composePlugins, withNx } = require('@nx/next');
/**
* @type {import('@nx/next/plugins/with-nx').WithNxOptions}
**/
const nextConfig = {
nx: {
// Set this to true if you would like to use SVGR
// See: https://github.com/gregberge/svgr
svgr: false,
},
};
const plugins = [
// Add more Next.js plugins to this list if needed.
withNx,
];
module.exports = composePlugins(...plugins)(nextConfig);

View File

@ -0,0 +1,68 @@
{
"name": "demo-nextjs-app-router",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/demo-nextjs-app-router",
"projectType": "application",
"targets": {
"build": {
"executor": "@nx/next:build",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/demo-nextjs-app-router"
},
"configurations": {
"development": {
"outputPath": "apps/demo-nextjs-app-router"
},
"production": {}
}
},
"serve": {
"executor": "@nx/next:server",
"defaultConfiguration": "development",
"options": {
"buildTarget": "demo-nextjs-app-router:build",
"dev": true
},
"configurations": {
"development": {
"buildTarget": "demo-nextjs-app-router:build:development",
"dev": true
},
"production": {
"buildTarget": "demo-nextjs-app-router:build:production",
"dev": false
}
}
},
"export": {
"executor": "@nx/next:export",
"options": {
"buildTarget": "demo-nextjs-app-router:build:production"
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/demo-nextjs-app-router/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/demo-nextjs-app-router/**/*.{ts,tsx,js,jsx}"]
}
}
},
"tags": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,18 @@
const { createGlobPatternsForDependencies } = require('@nx/react/tailwind');
const { join } = require('path');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
join(
__dirname,
'{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}'
),
...createGlobPatternsForDependencies(__dirname),
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
};

View File

@ -0,0 +1,36 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"types": ["jest", "node"]
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.js",
"**/*.jsx",
"../../apps/demo-nextjs-app-router/.next/types/**/*.ts",
"../../dist/apps/demo-nextjs-app-router/.next/types/**/*.ts",
"next-env.d.ts"
],
"exclude": [
"node_modules",
"jest.config.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@ -0,0 +1,21 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"],
"jsx": "react"
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}

View File

@ -0,0 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
declare module '*.svg' {
const content: any;
export const ReactComponent: any;
export default content;
}

View File

@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'demo-nextjs-page-router',
preset: '../../jest.preset.js',
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/next/babel'] }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/apps/demo-nextjs-page-router',
};

View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -0,0 +1,2 @@
.page {
}

View File

@ -21,7 +21,11 @@ type Result = {
};
// @snippet:end
function Error(props) {
type ErrorProps = {
error: any;
};
function Error(props: ErrorProps) {
if (!props.error) {
return null;
}
@ -64,8 +68,7 @@ export function Index() {
setElapsedTime(0);
};
const handleOnClick = async (e) => {
e.preventDefault();
const generateImage = async () => {
reset();
// @snippet:start("client.queue.subscribe")
setLoading(true);
@ -85,12 +88,12 @@ export function Index() {
update.status === 'IN_PROGRESS' ||
update.status === 'COMPLETED'
) {
setLogs(update.logs.map((log) => log.message));
setLogs((update.logs || []).map((log) => log.message));
}
},
});
setResult(result);
} catch (error) {
} catch (error: any) {
setError(error);
} finally {
setLoading(false);
@ -121,7 +124,10 @@ export function Index() {
</div>
<button
onClick={handleOnClick}
onClick={(e) => {
e.preventDefault();
generateImage();
}}
className="bg-indigo-600 hover:bg-indigo-700 text-white font-bold text-lg py-3 px-6 mx-auto rounded focus:outline-none focus:shadow-outline"
disabled={loading}
>

View File

@ -0,0 +1,400 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
-webkit-text-size-adjust: 100%;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif,
Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
line-height: 1.5;
tab-size: 4;
scroll-behavior: smooth;
}
body {
font-family: inherit;
line-height: inherit;
margin: 0;
}
h1,
h2,
p,
pre {
margin: 0;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
h1,
h2 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
text-decoration: inherit;
}
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
Liberation Mono, Courier New, monospace;
}
svg {
display: block;
vertical-align: middle;
shape-rendering: auto;
text-rendering: optimizeLegibility;
}
pre {
background-color: rgba(55, 65, 81, 1);
border-radius: 0.25rem;
color: rgba(229, 231, 235, 1);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
Liberation Mono, Courier New, monospace;
overflow: scroll;
padding: 0.5rem 0.75rem;
}
.shadow {
box-shadow: 0 0 #0000, 0 0 #0000, 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.wrapper {
width: 100%;
}
.container {
margin-left: auto;
margin-right: auto;
max-width: 768px;
padding-bottom: 3rem;
padding-left: 1rem;
padding-right: 1rem;
color: rgba(55, 65, 81, 1);
width: 100%;
}
#welcome {
margin-top: 2.5rem;
}
#welcome h1 {
font-size: 3rem;
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1;
}
#welcome span {
display: block;
font-size: 1.875rem;
font-weight: 300;
line-height: 2.25rem;
margin-bottom: 0.5rem;
}
#hero {
align-items: center;
background-color: hsla(214, 62%, 21%, 1);
border: none;
box-sizing: border-box;
color: rgba(55, 65, 81, 1);
display: grid;
grid-template-columns: 1fr;
margin-top: 3.5rem;
}
#hero .text-container {
color: rgba(255, 255, 255, 1);
padding: 3rem 2rem;
}
#hero .text-container h2 {
font-size: 1.5rem;
line-height: 2rem;
position: relative;
}
#hero .text-container h2 svg {
color: hsla(162, 47%, 50%, 1);
height: 2rem;
left: -0.25rem;
position: absolute;
top: 0;
width: 2rem;
}
#hero .text-container h2 span {
margin-left: 2.5rem;
}
#hero .text-container a {
background-color: rgba(255, 255, 255, 1);
border-radius: 0.75rem;
color: rgba(55, 65, 81, 1);
display: inline-block;
margin-top: 1.5rem;
padding: 1rem 2rem;
text-decoration: inherit;
}
#hero .logo-container {
display: none;
justify-content: center;
padding-left: 2rem;
padding-right: 2rem;
}
#hero .logo-container svg {
color: rgba(255, 255, 255, 1);
width: 66.666667%;
}
#middle-content {
align-items: flex-start;
display: grid;
gap: 4rem;
grid-template-columns: 1fr;
margin-top: 3.5rem;
}
#learning-materials {
padding: 2.5rem 2rem;
}
#learning-materials h2 {
font-weight: 500;
font-size: 1.25rem;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
.list-item-link {
align-items: center;
border-radius: 0.75rem;
display: flex;
margin-top: 1rem;
padding: 1rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 100%;
}
.list-item-link svg:first-child {
margin-right: 1rem;
height: 1.5rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 1.5rem;
}
.list-item-link > span {
flex-grow: 1;
font-weight: 400;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.list-item-link > span > span {
color: rgba(107, 114, 128, 1);
display: block;
flex-grow: 1;
font-size: 0.75rem;
font-weight: 300;
line-height: 1rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.list-item-link svg:last-child {
height: 1rem;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 1rem;
}
.list-item-link:hover {
color: rgba(255, 255, 255, 1);
background-color: hsla(162, 47%, 50%, 1);
}
.list-item-link:hover > span {
}
.list-item-link:hover > span > span {
color: rgba(243, 244, 246, 1);
}
.list-item-link:hover svg:last-child {
transform: translateX(0.25rem);
}
#other-links {
}
.button-pill {
padding: 1.5rem 2rem;
transition-duration: 300ms;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
align-items: center;
display: flex;
}
.button-pill svg {
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
flex-shrink: 0;
width: 3rem;
}
.button-pill > span {
letter-spacing: -0.025em;
font-weight: 400;
font-size: 1.125rem;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
.button-pill span span {
display: block;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.25rem;
}
.button-pill:hover svg,
.button-pill:hover {
color: rgba(255, 255, 255, 1) !important;
}
#nx-console:hover {
background-color: rgba(0, 122, 204, 1);
}
#nx-console svg {
color: rgba(0, 122, 204, 1);
}
#nx-repo:hover {
background-color: rgba(24, 23, 23, 1);
}
#nx-repo svg {
color: rgba(24, 23, 23, 1);
}
#nx-cloud {
margin-bottom: 2rem;
margin-top: 2rem;
padding: 2.5rem 2rem;
}
#nx-cloud > div {
align-items: center;
display: flex;
}
#nx-cloud > div svg {
border-radius: 0.375rem;
flex-shrink: 0;
width: 3rem;
}
#nx-cloud > div h2 {
font-size: 1.125rem;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
#nx-cloud > div h2 span {
display: block;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.25rem;
}
#nx-cloud p {
font-size: 1rem;
line-height: 1.5rem;
margin-top: 1rem;
}
#nx-cloud pre {
margin-top: 1rem;
}
#nx-cloud a {
color: rgba(107, 114, 128, 1);
display: block;
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 1.5rem;
text-align: right;
}
#nx-cloud a:hover {
text-decoration: underline;
}
#commands {
padding: 2.5rem 2rem;
margin-top: 3.5rem;
}
#commands h2 {
font-size: 1.25rem;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
#commands p {
font-size: 1rem;
font-weight: 300;
line-height: 1.5rem;
margin-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
}
details {
align-items: center;
display: flex;
margin-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
width: 100%;
}
details pre > span {
color: rgba(181, 181, 181, 1);
display: block;
}
summary {
border-radius: 0.5rem;
display: flex;
font-weight: 400;
padding: 0.5rem;
cursor: pointer;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
summary:hover {
background-color: rgba(243, 244, 246, 1);
}
summary svg {
height: 1.5rem;
margin-right: 1rem;
width: 1.5rem;
}
#love {
color: rgba(107, 114, 128, 1);
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 3.5rem;
opacity: 0.6;
text-align: center;
}
#love svg {
color: rgba(252, 165, 165, 1);
width: 1.25rem;
height: 1.25rem;
display: inline;
margin-top: -0.25rem;
}
@media screen and (min-width: 768px) {
#hero {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#hero .logo-container {
display: flex;
}
#middle-content {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,15 @@
const { join } = require('path');
// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build
// option from your application's configuration (i.e. project.json).
//
// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries
module.exports = {
plugins: {
tailwindcss: {
config: join(__dirname, 'tailwind.config.js'),
},
autoprefixer: {},
},
};

View File

@ -1,7 +1,7 @@
{
"name": "demo-nextjs-app",
"name": "demo-nextjs-page-router",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/demo-nextjs-app",
"sourceRoot": "apps/demo-nextjs-page-router",
"projectType": "application",
"targets": {
"build": {
@ -9,11 +9,11 @@
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/demo-nextjs-app"
"outputPath": "dist/apps/demo-nextjs-page-router"
},
"configurations": {
"development": {
"outputPath": "apps/demo-nextjs-app"
"outputPath": "apps/demo-nextjs-page-router"
},
"production": {}
}
@ -22,16 +22,16 @@
"executor": "@nx/next:server",
"defaultConfiguration": "development",
"options": {
"buildTarget": "demo-nextjs-app:build",
"buildTarget": "demo-nextjs-page-router:build",
"dev": true
},
"configurations": {
"development": {
"buildTarget": "demo-nextjs-app:build:development",
"buildTarget": "demo-nextjs-page-router:build:development",
"dev": true
},
"production": {
"buildTarget": "demo-nextjs-app:build:production",
"buildTarget": "demo-nextjs-page-router:build:production",
"dev": false
}
}
@ -39,14 +39,14 @@
"export": {
"executor": "@nx/next:export",
"options": {
"buildTarget": "demo-nextjs-app:build:production"
"buildTarget": "demo-nextjs-page-router:build:production"
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/demo-nextjs-app/jest.config.ts",
"jestConfig": "apps/demo-nextjs-page-router/jest.config.ts",
"passWithNoTests": true
}
},
@ -54,7 +54,9 @@
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/demo-nextjs-app/**/*.{ts,tsx,js,jsx}"]
"lintFilePatterns": [
"apps/demo-nextjs-page-router/**/*.{ts,tsx,js,jsx}"
]
}
}
},

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1,7 +1,7 @@
{
"name": "@fal-ai/serverless-client",
"description": "The fal serverless JS/TS client",
"version": "0.4.1",
"version": "0.4.2",
"license": "MIT",
"repository": {
"type": "git",

View File

@ -41,10 +41,11 @@ export function buildUrl<Input>(
const { host } = getConfig();
const method = (options.method ?? 'post').toLowerCase();
const path = (options.path ?? '').replace(/^\//, '').replace(/\/{2,}/, '/');
const input = options.input;
const params =
method === 'get' ? new URLSearchParams(options.input ?? {}) : undefined;
// TODO: change to params.size once it's officially supported
const queryParams = params && params['size'] ? `?${params.toString()}` : '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
method === 'get' && input ? new URLSearchParams(input as any) : undefined;
const queryParams = params ? `?${params.toString()}` : '';
const parts = id.split('/');
// if a fal.ai url is passed, just use it

View File

@ -1,6 +1,6 @@
{
"name": "@fal-ai/serverless-proxy",
"version": "0.3.5",
"version": "0.4.0",
"license": "MIT",
"repository": {
"type": "git",

View File

@ -24,7 +24,7 @@ export const handler: RequestHandler = async (request, response, next) => {
getHeaders: () => request.headers,
getHeader: (name) => request.headers[name],
sendHeader: (name, value) => response.setHeader(name, value),
getBody: () => JSON.stringify(request.body),
getBody: async () => JSON.stringify(request.body),
});
next();
};

View File

@ -7,19 +7,21 @@ const FAL_KEY_ID = process.env.FAL_KEY_ID || process.env.NEXT_PUBLIC_FAL_KEY_ID;
const FAL_KEY_SECRET =
process.env.FAL_KEY_SECRET || process.env.NEXT_PUBLIC_FAL_KEY_SECRET;
export type HeaderValue = string | string[] | undefined | null;
/**
* The proxy behavior that is passed to the proxy handler. This is a subset of
* request objects that are used by different frameworks, like Express and NextJS.
*/
export interface ProxyBehavior {
export interface ProxyBehavior<ResponseType> {
id: string;
method: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
respondWith(status: number, data: string | any): void;
getHeaders(): Record<string, string | string[] | undefined>;
getHeader(name: string): string | string[] | undefined;
respondWith(status: number, data: string | any): ResponseType;
getHeaders(): Record<string, HeaderValue>;
getHeader(name: string): HeaderValue;
sendHeader(name: string, value: string): void;
getBody(): string | undefined;
getBody(): Promise<string | undefined>;
}
/**
@ -29,10 +31,8 @@ export interface ProxyBehavior {
* @param request the header value.
* @returns the header value as `string` or `undefined` if the header is not set.
*/
function singleHeaderValue(
value: string | string[] | undefined
): string | undefined {
if (value === undefined) {
function singleHeaderValue(value: HeaderValue): string | undefined {
if (!value) {
return undefined;
}
if (Array.isArray(value)) {
@ -51,6 +51,8 @@ function getFalKey(): string | undefined {
return undefined;
}
const EXCLUDED_HEADERS = ['content-length'];
/**
* A request handler that proxies the request to the fal-serverless
* endpoint. This is useful so client-side calls to the fal-serverless endpoint
@ -60,31 +62,32 @@ function getFalKey(): string | undefined {
* @param behavior the request proxy behavior.
* @returns Promise<any> the promise that will be resolved once the request is done.
*/
export const handleRequest = async (behavior: ProxyBehavior) => {
export async function handleRequest<ResponseType>(
behavior: ProxyBehavior<ResponseType>
) {
const targetUrl = singleHeaderValue(behavior.getHeader(TARGET_URL_HEADER));
if (!targetUrl) {
behavior.respondWith(400, `Missing the ${TARGET_URL_HEADER} header`);
return;
return behavior.respondWith(400, `Missing the ${TARGET_URL_HEADER} header`);
}
if (targetUrl.indexOf('fal.ai') === -1) {
behavior.respondWith(412, `Invalid ${TARGET_URL_HEADER} header`);
return;
return behavior.respondWith(412, `Invalid ${TARGET_URL_HEADER} header`);
}
const falKey = getFalKey();
if (!falKey) {
behavior.respondWith(401, 'Missing fal.ai credentials');
return;
return behavior.respondWith(401, 'Missing fal.ai credentials');
}
// pass over headers prefixed with x-fal-*
const headers: Record<string, string | string[] | undefined> = {};
const headers: Record<string, HeaderValue> = {};
Object.keys(behavior.getHeaders()).forEach((key) => {
if (key.toLowerCase().startsWith('x-fal-')) {
headers[key.toLowerCase()] = behavior.getHeader(key);
}
});
const proxyUserAgent = `@fal-ai/serverless-proxy/${behavior.id}`;
const userAgent = singleHeaderValue(behavior.getHeader('user-agent'));
const res = await fetch(targetUrl, {
method: behavior.method,
headers: {
@ -94,22 +97,26 @@ export const handleRequest = async (behavior: ProxyBehavior) => {
`Key ${falKey}`,
accept: 'application/json',
'content-type': 'application/json',
'x-fal-client-proxy': `@fal-ai/serverless-proxy/${behavior.id}`,
},
'user-agent': userAgent,
'x-fal-client-proxy': proxyUserAgent,
} as HeadersInit,
body:
behavior.method?.toUpperCase() === 'GET' ? undefined : behavior.getBody(),
behavior.method?.toUpperCase() === 'GET'
? undefined
: await behavior.getBody(),
});
// copy headers from res to response
// copy headers from fal to the proxied response
res.headers.forEach((value, key) => {
behavior.sendHeader(key, value);
if (!EXCLUDED_HEADERS.includes(key.toLowerCase())) {
behavior.sendHeader(key, value);
}
});
if (res.headers.get('content-type').includes('application/json')) {
if (res.headers.get('content-type')?.includes('application/json')) {
const data = await res.json();
behavior.respondWith(res.status, data);
return;
return behavior.respondWith(res.status, data);
}
const data = await res.text();
behavior.respondWith(res.status, data);
};
return behavior.respondWith(res.status, data);
}

View File

@ -1,4 +1,5 @@
import type { NextApiHandler } from 'next/types';
import { type NextRequest, NextResponse } from 'next/server';
import { DEFAULT_PROXY_ROUTE, handleRequest } from './index';
/**
@ -8,6 +9,7 @@ export const PROXY_ROUTE = DEFAULT_PROXY_ROUTE;
/**
* The Next API route handler for the fal.ai client proxy.
* Use it with the /pages router in Next.js.
*
* @param request the Next API request object.
* @param response the Next API response object.
@ -15,8 +17,8 @@ export const PROXY_ROUTE = DEFAULT_PROXY_ROUTE;
*/
export const handler: NextApiHandler = async (request, response) => {
return handleRequest({
id: 'nextjs',
method: request.method,
id: 'nextjs-page-router',
method: request.method || 'POST',
respondWith: (status, data) =>
typeof data === 'string'
? response.status(status).json({ detail: data })
@ -24,6 +26,46 @@ export const handler: NextApiHandler = async (request, response) => {
getHeaders: () => request.headers,
getHeader: (name) => request.headers[name],
sendHeader: (name, value) => response.setHeader(name, value),
getBody: () => JSON.stringify(request.body),
getBody: async () => JSON.stringify(request.body),
});
};
function fromHeaders(headers: Headers): Record<string, string | string[]> {
// TODO once Header.entries() is available, use that instead
// Object.fromEntries(headers.entries());
const result: Record<string, string | string[]> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
/**
* The Next API route handler for the fal.ai client proxy on App Router apps.
*
* @param request the Next API request object.
* @returns a promise that resolves when the request is handled.
*/
async function routeHandler(request: NextRequest) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const responseHeaders: Record<string, any> = {};
return await handleRequest({
id: 'nextjs-app-router',
method: request.method,
respondWith: (status, data) =>
NextResponse.json(typeof data === 'string' ? { detail: data } : data, {
status,
headers: responseHeaders,
}),
getHeaders: () => fromHeaders(request.headers),
getHeader: (name) => request.headers.get(name),
sendHeader: (name, value) => (responseHeaders[name] = value),
getBody: async () => request.text(),
});
}
export const route = {
handler: routeHandler,
GET: routeHandler,
POST: routeHandler,
};

View File

@ -4,6 +4,7 @@
"module": "commonjs",
"outDir": "../../dist/out-tsc",
"declaration": true,
"esModuleInterop": true,
"types": ["node"]
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],

View File

@ -50,5 +50,5 @@
}
}
},
"defaultProject": "demo-nextjs-app"
"defaultProject": "demo-nextjs-page-router"
}

36652
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^2.0.6",
"js-base64": "^3.7.5",
"next": "13.3.0",
"next": "13.4.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"regenerator-runtime": "0.13.7",
@ -42,7 +42,7 @@
"@nx/jest": "16.10.0",
"@nx/js": "16.10.0",
"@nx/linter": "16.10.0",
"@nx/next": "16.10.0",
"@nx/next": "^16.10.0",
"@nx/node": "16.10.0",
"@nx/react": "16.10.0",
"@nx/web": "16.10.0",
@ -56,15 +56,15 @@
"@types/express": "4.17.13",
"@types/jest": "29.4.4",
"@types/node": "18.14.2",
"@types/react": "18.2.14",
"@types/react-dom": "18.2.6",
"@types/react": "18.2.24",
"@types/react-dom": "18.2.9",
"@typescript-eslint/eslint-plugin": "5.62.0",
"@typescript-eslint/parser": "5.62.0",
"autoprefixer": "10.4.13",
"babel-jest": "29.4.3",
"cypress": "^11.0.0",
"eslint": "8.46.0",
"eslint-config-next": "^13.1.1",
"eslint-config-next": "13.4.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-cypress": "2.15.1",
"eslint-plugin-import": "2.27.5",