feat: client proxy + nextjs support (#9)
* feat(client): add function alias support * feat(client): auto config from env vars * fix(client): username in aliased url * feat: nextjs proxy handler * fix: update client tests * feat(client): built-in queue support * feat: client + nextjs e2e demo * chore: update browserslist * chore: update version info * chore: update readme badges * fix: ignore lint error on require
This commit is contained in:
parent
3b11d468a8
commit
9a0497da30
@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
/dist
|
/dist
|
||||||
/coverage
|
/coverage
|
||||||
|
package-lock.json
|
||||||
|
|||||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"[python]": {
|
||||||
|
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||||
|
},
|
||||||
|
"python.formatting.provider": "none"
|
||||||
|
}
|
||||||
@ -1,8 +1,9 @@
|
|||||||
# The fal-serverless JS Client
|
# The fal-serverless JS Client
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
## About the project
|
## About the project
|
||||||
|
|
||||||
|
|||||||
3
apps/demo-app/pages/api/_fal/proxy.ts
Normal file
3
apps/demo-app/pages/api/_fal/proxy.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// @snippet:start(client.proxy.nextjs)
|
||||||
|
export { config, handler as default } from '@fal-ai/serverless-nextjs';
|
||||||
|
// @snippet:end
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import {
|
|
||||||
generateImage,
|
|
||||||
GenerateImageInput,
|
|
||||||
} from '../../services/generateImage';
|
|
||||||
|
|
||||||
export default async function handler(req, res) {
|
|
||||||
if (req.method === 'POST') {
|
|
||||||
// not really type-safe, force cast because I can =P
|
|
||||||
const prompt = req.body as GenerateImageInput;
|
|
||||||
try {
|
|
||||||
const imageUrl = await generateImage(prompt);
|
|
||||||
res.status(200).json({ imageUrl });
|
|
||||||
} catch (error) {
|
|
||||||
res
|
|
||||||
.status(500)
|
|
||||||
.json({ error: 'Failed to update image', causedBy: error });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res.status(405).json({ error: 'Method not allowed' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import Head from 'next/head';
|
|
||||||
|
|
||||||
import { generateImage } from '../services/generateImage';
|
|
||||||
|
|
||||||
const IMG_PLACEHOLDER = '/placeholder@2x.jpg';
|
|
||||||
|
|
||||||
export default function Diffusion() {
|
|
||||||
const [prompt, setPrompt] = useState('');
|
|
||||||
const [imageUrl, setImageUrl] = useState(IMG_PLACEHOLDER);
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
setPrompt(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
// TODO replace this with direct serverless call once cors is solved
|
|
||||||
// const response = await fetch('/api/generateImage', {
|
|
||||||
// method: 'POST',
|
|
||||||
// headers: {
|
|
||||||
// 'Content-Type': 'application/json',
|
|
||||||
// },
|
|
||||||
// body: JSON.stringify({ prompt }),
|
|
||||||
// });
|
|
||||||
// const data = await response.json();
|
|
||||||
// setImageUrl(data.imageUrl);
|
|
||||||
|
|
||||||
const result = await generateImage({ prompt });
|
|
||||||
setImageUrl(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen dark:bg-gray-900 dark:text-white bg-white text-black">
|
|
||||||
<Head>
|
|
||||||
<title>fal-serverless diffusion</title>
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
<main className="flex flex-col items-center justify-center w-full flex-1 px-20 py-10">
|
|
||||||
<h1 className="text-4xl font-semibold mb-10">
|
|
||||||
fal-serverless diffusion
|
|
||||||
</h1>
|
|
||||||
<h3 className="text-2xl">Enter a prompt to generate the image</h3>
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col mt-8 w-full">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full bg-gray-50 border border-gray-300 text-gray-900 text-lg rounded focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
||||||
placeholder="e.g. cute panda in the style of ghibli studio"
|
|
||||||
value={prompt}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold text-xl py-4 px-8 mx-auto rounded focus:outline-none focus:shadow-outline"
|
|
||||||
>
|
|
||||||
Generate Image
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-10">
|
|
||||||
<Image
|
|
||||||
src={imageUrl}
|
|
||||||
alt="Generated Image"
|
|
||||||
width={1024}
|
|
||||||
height={1024}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,21 +1,23 @@
|
|||||||
import { getJoke } from '../services/getJoke';
|
import * as fal from '@fal-ai/serverless-client';
|
||||||
|
import { withNextProxy } from '@fal-ai/serverless-nextjs';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
// @snippet:start(client.config)
|
||||||
try {
|
fal.config({
|
||||||
const result = await getJoke();
|
requestMiddleware: withNextProxy(),
|
||||||
return {
|
});
|
||||||
props: {
|
// @snippet:end
|
||||||
...result,
|
|
||||||
},
|
// @snippet:start(client.result.type)
|
||||||
};
|
type Image = {
|
||||||
} catch (error) {
|
url: string;
|
||||||
return {
|
file_name: string;
|
||||||
props: {
|
file_size: number;
|
||||||
error: error.message,
|
};
|
||||||
},
|
type Result = {
|
||||||
};
|
images: Image[];
|
||||||
}
|
};
|
||||||
}
|
// @snippet:end
|
||||||
|
|
||||||
function Error(props) {
|
function Error(props) {
|
||||||
if (!props.error) {
|
if (!props.error) {
|
||||||
@ -26,43 +28,126 @@ function Error(props) {
|
|||||||
className="p-4 mb-4 text-sm text-red-800 rounded bg-red-50 dark:bg-gray-800 dark:text-red-400"
|
className="p-4 mb-4 text-sm text-red-800 rounded bg-red-50 dark:bg-gray-800 dark:text-red-400"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<span className="font-medium">Error</span> {props.error}
|
<span className="font-medium">Error</span> {props.error.message}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Index(props) {
|
const DEFAULT_PROMPT = "a city landscape of a cyberpunk metropolis, raining, purple, pink and teal neon lights, highly detailed, uhd";
|
||||||
const handleClick = async (e) => {
|
|
||||||
e.preventDefault();
|
export function Index() {
|
||||||
try {
|
// @snippet:start(client.ui.state)
|
||||||
const joke = await getJoke();
|
// Input state
|
||||||
console.log(joke);
|
const [prompt, setPrompt] = useState<string>(DEFAULT_PROMPT);
|
||||||
} catch (e) {
|
// Result state
|
||||||
console.log(e);
|
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 handleOnClick = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
reset();
|
||||||
|
// @snippet:start(client.queue.subscribe)
|
||||||
|
setLoading(true);
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const result: Result = await fal.queue.subscribe('110602490-lora', {
|
||||||
|
input: {
|
||||||
|
prompt,
|
||||||
|
model_name: 'stabilityai/stable-diffusion-xl-base-1.0',
|
||||||
|
image_size: 'square_hd',
|
||||||
|
},
|
||||||
|
onQueueUpdate(status) {
|
||||||
|
setElapsedTime(Date.now() - start);
|
||||||
|
if (status.status === 'IN_PROGRESS') {
|
||||||
|
setLogs(status.logs.map((log) => log.message));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setResult(result);
|
||||||
|
} catch (error) {
|
||||||
|
setError(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setElapsedTime(Date.now() - start);
|
||||||
|
}
|
||||||
|
// @snippet:end
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen dark:bg-gray-900 dark:text-white bg-white text-black">
|
<div className="min-h-screen dark:bg-gray-800 dark:text-gray-50 bg-gray-100 text-gray-800">
|
||||||
<main className="flex flex-col items-center justify-center w-full flex-1 px-20 py-10">
|
<main className="flex flex-col items-center justify-center w-full flex-1 px-20 py-10 space-y-8">
|
||||||
<h1 className="text-4xl font-bold mb-8">
|
<h1 className="text-4xl font-bold mb-8">
|
||||||
Hello <code>fal-serverless</code>
|
Hello <code className="font-light text-pink-600">fal</code>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg mb-10">
|
<div className="text-lg w-full">
|
||||||
This page can access <strong>fal-serverless</strong> functions when
|
<label htmlFor="prompt" className="block mb-2 text-current">
|
||||||
it's rendering.
|
Prompt
|
||||||
</p>
|
</label>
|
||||||
<Error error={props.error} />
|
<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
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleOnClick}
|
||||||
className="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold text-xl py-4 px-8 mx-auto rounded focus:outline-none focus:shadow-outline"
|
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}
|
||||||
>
|
>
|
||||||
Get Joke
|
{loading ? 'Generating...' : 'Generate Image'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="mt-10">
|
<Error error={error} />
|
||||||
Here's a joke: <strong>{props.joke}</strong>
|
|
||||||
</p>
|
<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/80 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/80 text-white/80 font-mono h-60 rounded whitespace-pre overflow-auto w-full">
|
||||||
|
{logs.filter(Boolean).join('\n')}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import * as fal from '@fal-ai/serverless-client';
|
|
||||||
|
|
||||||
export type GenerateImageInput = {
|
|
||||||
prompt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ImageType = 'gif' | 'png' | 'jpg' | 'jpeg';
|
|
||||||
type ImageDataUri = `data:image/${ImageType};base64,${string}`;
|
|
||||||
|
|
||||||
fal.config({
|
|
||||||
credentials: {
|
|
||||||
userId: '',
|
|
||||||
keyId: '',
|
|
||||||
keySecret: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function generateImage(
|
|
||||||
input: GenerateImageInput
|
|
||||||
): Promise<ImageDataUri> {
|
|
||||||
const result = await fal.run('a51c0ca0-9011-4ff0-8dc1-2ac0b42a9fd0', {
|
|
||||||
path: '/generate',
|
|
||||||
input,
|
|
||||||
});
|
|
||||||
const data = result['raw_data'];
|
|
||||||
return `data:image/jpg;base64,${data}`;
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import * as fal from '@fal-ai/serverless-client';
|
|
||||||
|
|
||||||
fal.config({
|
|
||||||
host: 'gateway.alpha.fal.ai',
|
|
||||||
credentials: {
|
|
||||||
userId: process.env.FAL_USER_ID || '',
|
|
||||||
keyId: process.env.FAL_KEY_ID || '',
|
|
||||||
keySecret: process.env.FAL_KEY_SECRET || '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type GetJokeInput = {
|
|
||||||
language?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getJoke(input?: GetJokeInput): Promise<{ joke: string }> {
|
|
||||||
return fal.run('fastapi_get_joke', { input });
|
|
||||||
}
|
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
|
|
||||||
import Index from '../pages/index';
|
import Index from '../pages/index';
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@fal-ai/serverless-client",
|
"name": "@fal-ai/serverless-client",
|
||||||
"description": "The fal serverless JS/TS client",
|
"description": "The fal serverless JS/TS client",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@ -7,11 +7,11 @@ describe('The config test suite', () => {
|
|||||||
credentials: {
|
credentials: {
|
||||||
keyId: 'key-id',
|
keyId: 'key-id',
|
||||||
keySecret: 'key-secret',
|
keySecret: 'key-secret',
|
||||||
userId: 'user-id',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
config(newConfig);
|
config(newConfig);
|
||||||
const currentConfig = getConfig();
|
const currentConfig = getConfig();
|
||||||
expect(currentConfig).toEqual(newConfig);
|
expect(currentConfig.host).toBe(newConfig.host);
|
||||||
|
expect(currentConfig.credentials).toEqual(newConfig.credentials);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,21 +1,80 @@
|
|||||||
|
import type { RequestMiddleware } from './middleware';
|
||||||
|
import type { ResponseHandler } from './response';
|
||||||
|
import { defaultResponseHandler } from './response';
|
||||||
|
|
||||||
export type Credentials = {
|
export type Credentials = {
|
||||||
keyId: string;
|
keyId: string;
|
||||||
keySecret: string;
|
keySecret: string;
|
||||||
userId: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CredentialsResolver = () => Credentials;
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
credentials: Credentials;
|
credentials?: Credentials | CredentialsResolver;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
requestMiddleware?: RequestMiddleware;
|
||||||
|
responseHandler?: ResponseHandler<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequiredConfig = Required<Config>;
|
export type RequiredConfig = Required<Config>;
|
||||||
|
|
||||||
const DEFAULT_CONFIG: Partial<Config> = {
|
/**
|
||||||
host: 'gateway.shark.fal.ai',
|
* Checks if the required FAL environment variables are set.
|
||||||
|
*
|
||||||
|
* @returns `true` if the required environment variables are set,
|
||||||
|
* `false` otherwise.
|
||||||
|
*/
|
||||||
|
function hasEnvVariables(): boolean {
|
||||||
|
return (
|
||||||
|
process &&
|
||||||
|
process.env &&
|
||||||
|
typeof process.env.FAL_KEY_ID !== 'undefined' &&
|
||||||
|
typeof process.env.FAL_KEY_SECRET !== 'undefined'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const credentialsFromEnv: CredentialsResolver = () => {
|
||||||
|
if (!hasEnvVariables()) {
|
||||||
|
return {
|
||||||
|
keyId: '',
|
||||||
|
keySecret: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
console.warn(
|
||||||
|
"The fal credentials are exposed in the browser's environment. " +
|
||||||
|
"That's not recommended for production use cases."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyId: process.env.FAL_KEY_ID || '',
|
||||||
|
keySecret: process.env.FAL_KEY_SECRET || '',
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
let configuration: RequiredConfig | undefined = undefined;
|
/**
|
||||||
|
* Get the default host for the fal-serverless gateway endpoint.
|
||||||
|
* @private
|
||||||
|
* @returns the default host. Depending on the platform it can default to
|
||||||
|
* the environment variable `FAL_HOST`.
|
||||||
|
*/
|
||||||
|
function getDefaultHost(): string {
|
||||||
|
const host = 'gateway.alpha.fal.ai';
|
||||||
|
if (process && process.env) {
|
||||||
|
return process.env.FAL_HOST || host;
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Partial<Config> = {
|
||||||
|
host: getDefaultHost(),
|
||||||
|
credentials: credentialsFromEnv,
|
||||||
|
requestMiddleware: (request) => Promise.resolve(request),
|
||||||
|
responseHandler: defaultResponseHandler,
|
||||||
|
};
|
||||||
|
|
||||||
|
let configuration: RequiredConfig;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configures the fal serverless client.
|
* Configures the fal serverless client.
|
||||||
@ -32,8 +91,8 @@ export function config(config: Config) {
|
|||||||
* @returns the current client configuration.
|
* @returns the current client configuration.
|
||||||
*/
|
*/
|
||||||
export function getConfig(): RequiredConfig {
|
export function getConfig(): RequiredConfig {
|
||||||
if (typeof configuration === 'undefined') {
|
if (!configuration) {
|
||||||
throw new Error('You must configure fal-serverless first.');
|
console.info('Using default configuration for the fal client');
|
||||||
}
|
}
|
||||||
return configuration;
|
return configuration;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { buildUrl } from './function';
|
|||||||
config({
|
config({
|
||||||
host: 'gateway.alpha.fal.ai',
|
host: 'gateway.alpha.fal.ai',
|
||||||
credentials: {
|
credentials: {
|
||||||
userId: 'github|123456',
|
|
||||||
keyId: 'a91ff3ca-71bc-4c8c-b400-859f6cbe804d',
|
keyId: 'a91ff3ca-71bc-4c8c-b400-859f6cbe804d',
|
||||||
keySecret: '0123456789abcdfeghijklmnopqrstuv',
|
keySecret: '0123456789abcdfeghijklmnopqrstuv',
|
||||||
},
|
},
|
||||||
@ -13,15 +12,14 @@ config({
|
|||||||
|
|
||||||
describe('The function test suite', () => {
|
describe('The function test suite', () => {
|
||||||
it('should build the URL with a function UUIDv4', () => {
|
it('should build the URL with a function UUIDv4', () => {
|
||||||
const { credentials } = getConfig();
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const url = buildUrl(id);
|
const url = buildUrl(`12345/${id}`);
|
||||||
expect(url).toMatch(`trigger/${credentials.userId}/${id}`);
|
expect(url).toMatch(`trigger/12345/${id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should build the URL with a function alias', () => {
|
it('should build the URL with a function alias', () => {
|
||||||
const { host } = getConfig();
|
const { host } = getConfig();
|
||||||
const alias = 'some-alias';
|
const alias = '12345-some-alias';
|
||||||
const url = buildUrl(alias);
|
const url = buildUrl(alias);
|
||||||
expect(url).toMatch(`${alias}.${host}`);
|
expect(url).toMatch(`${alias}.${host}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fetch from 'cross-fetch';
|
|
||||||
import { getConfig } from './config';
|
import { getConfig } from './config';
|
||||||
import { getUserAgent, isBrowser } from './runtime';
|
import { getUserAgent, isBrowser } from './runtime';
|
||||||
import { isUUIDv4 } from './utils';
|
import { EnqueueResult, QueueStatus } from './types';
|
||||||
|
import { isUUIDv4, isValidUrl } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The function input and other configuration when running
|
* The function input and other configuration when running
|
||||||
@ -22,7 +22,7 @@ type RunOptions<Input> = {
|
|||||||
/**
|
/**
|
||||||
* The HTTP method, defaults to `post`;
|
* The HTTP method, defaults to `post`;
|
||||||
*/
|
*/
|
||||||
readonly method?: 'get' | 'post' | 'put' | 'delete';
|
readonly method?: 'get' | 'post' | 'put' | 'delete' | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -38,150 +38,143 @@ export function buildUrl<Input>(
|
|||||||
id: string,
|
id: string,
|
||||||
options: RunOptions<Input> = {}
|
options: RunOptions<Input> = {}
|
||||||
): string {
|
): string {
|
||||||
const { credentials, host } = getConfig();
|
const { host } = getConfig();
|
||||||
const method = (options.method ?? 'post').toLowerCase();
|
const method = (options.method ?? 'post').toLowerCase();
|
||||||
const path = options.path ?? '';
|
const path = (options.path ?? '').replace(/^\//, '').replace(/\/{2,}/, '/');
|
||||||
const params =
|
const params =
|
||||||
method === 'get' ? new URLSearchParams(options.input ?? {}) : undefined;
|
method === 'get' ? new URLSearchParams(options.input ?? {}) : undefined;
|
||||||
let queryParams = '';
|
// TODO: change to params.size once it's officially supported
|
||||||
if (params) {
|
const queryParams = params && params['size'] ? `?${params.toString()}` : '';
|
||||||
queryParams = `?${params.toString()}`;
|
const parts = id.split('/');
|
||||||
|
|
||||||
|
// if a fal.ai url is passed, just use it
|
||||||
|
if (isValidUrl(id)) {
|
||||||
|
const url = id.endsWith('/') ? id : `${id}/`;
|
||||||
|
return `${url}${path}${queryParams}`;
|
||||||
}
|
}
|
||||||
if (isUUIDv4(id)) {
|
|
||||||
return `https://${host}/trigger/${credentials.userId}/${id}/${path}${queryParams}`;
|
if (parts.length === 2 && isUUIDv4(parts[1])) {
|
||||||
|
return `https://${host}/trigger/${id}/${path}${queryParams}`;
|
||||||
}
|
}
|
||||||
const userId = credentials.userId.replace(/github\|/g, '');
|
return `https://${id}.${host}/${path}${queryParams}`;
|
||||||
return `https://${userId}-${id}.${host}/${path}${queryParams}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs a fal serverless function identified by its `id`.
|
* Runs a fal serverless function identified by its `id`.
|
||||||
* TODO: expand documentation and provide examples
|
* TODO: expand documentation and provide examples
|
||||||
*
|
*
|
||||||
* @param id the registered function id
|
* @param id the registered function revision id or alias.
|
||||||
* @returns the remote function output
|
* @returns the remote function output
|
||||||
*/
|
*/
|
||||||
export async function run<Input, Output>(
|
export async function run<Input, Output>(
|
||||||
id: string,
|
id: string,
|
||||||
options: RunOptions<Input> = {}
|
options: RunOptions<Input> = {}
|
||||||
): Promise<Output> {
|
): Promise<Output> {
|
||||||
const { credentials } = getConfig();
|
const { credentials, requestMiddleware, responseHandler } = getConfig();
|
||||||
const method = (options.method ?? 'post').toLowerCase();
|
const method = (options.method ?? 'post').toLowerCase();
|
||||||
const userAgent = isBrowser ? {} : { 'User-Agent': getUserAgent() };
|
const userAgent = isBrowser() ? {} : { 'User-Agent': getUserAgent() };
|
||||||
const response = await fetch(buildUrl(id, options), {
|
const { keyId, keySecret } =
|
||||||
|
typeof credentials === 'function' ? credentials() : credentials;
|
||||||
|
|
||||||
|
const { url, headers } = await requestMiddleware({
|
||||||
|
url: buildUrl(id, options),
|
||||||
|
});
|
||||||
|
const authHeader =
|
||||||
|
keyId && keySecret ? { Authorization: `Key ${keyId}:${keySecret}` } : {};
|
||||||
|
const requestHeaders = {
|
||||||
|
...authHeader,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...userAgent,
|
||||||
|
...(headers ?? {}),
|
||||||
|
} as HeadersInit;
|
||||||
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: requestHeaders,
|
||||||
'X-Fal-Key-Id': credentials.keyId,
|
mode: 'same-origin',
|
||||||
'X-Fal-Key-Secret': credentials.keySecret,
|
credentials: 'same-origin',
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...userAgent,
|
|
||||||
},
|
|
||||||
mode: 'cors',
|
|
||||||
body:
|
body:
|
||||||
method !== 'get' && options.input
|
method !== 'get' && options.input
|
||||||
? JSON.stringify(options.input)
|
? JSON.stringify(options.input)
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
return await responseHandler(response);
|
||||||
const { status, statusText } = response;
|
|
||||||
if (status < 200 || status >= 300) {
|
|
||||||
// TODO better error type so handlers can differentiate
|
|
||||||
throw new Error(statusText);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO move this elsewhere so it can be reused by websocket impl too
|
|
||||||
const contentType = response.headers.get('Content-Type');
|
|
||||||
if (contentType?.includes('application/json')) {
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
if (contentType?.includes('text/html')) {
|
|
||||||
return response.text() as Promise<Output>;
|
|
||||||
}
|
|
||||||
if (contentType?.includes('application/octet-stream')) {
|
|
||||||
return response.arrayBuffer() as Promise<Output>;
|
|
||||||
}
|
|
||||||
// TODO convert to either number or bool automatically
|
|
||||||
return response.text() as Promise<Output>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type QueueSubscribeOptions = {
|
||||||
* An event contract for progress updates from the server function.
|
pollInterval?: number;
|
||||||
*/
|
onEnqueue?: (requestId: string) => void;
|
||||||
export interface ProgressEvent<T> {
|
onQueueUpdate?: (status: QueueStatus) => void;
|
||||||
readonly progress: number;
|
|
||||||
readonly partialData: T | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a result of a remote fal serverless function call.
|
|
||||||
*
|
|
||||||
* The contract allows developers to not only get the function
|
|
||||||
* result, but also track its progress and logs through event
|
|
||||||
* listeners.
|
|
||||||
*
|
|
||||||
* This flexibility enables developers to define complex / long-running functions
|
|
||||||
* but also simple ones with an unified developer experience.
|
|
||||||
*/
|
|
||||||
export interface FunctionExecution<T> {
|
|
||||||
/**
|
|
||||||
* Listens to `progress` events.
|
|
||||||
*
|
|
||||||
* @param event of type `progress`
|
|
||||||
* @param handler the callback to handle in-progress data.
|
|
||||||
*/
|
|
||||||
on(event: 'progress', handler: (info: ProgressEvent<T>) => void): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listens to `cancel` events.
|
|
||||||
*
|
|
||||||
* @param event of type `cancel`.
|
|
||||||
* @param handler the callback to handle the cancellation signal.
|
|
||||||
*/
|
|
||||||
on(event: 'cancel', handler: () => void): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listens to logging events.
|
|
||||||
*
|
|
||||||
* @param event of type `log`
|
|
||||||
* @param handler the callback to handle the forwarded `log`
|
|
||||||
*/
|
|
||||||
on(event: 'log', handler: (log: string) => void): void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signals to the server that the execution should be cancelled.
|
|
||||||
* Once the server cancels the execution sucessfully, the `cancel`
|
|
||||||
* event will be fired.
|
|
||||||
*
|
|
||||||
* @see #on(event:)
|
|
||||||
*/
|
|
||||||
cancel(): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async function that represents the final result of the function call.
|
|
||||||
*
|
|
||||||
* ```ts
|
|
||||||
* const image = await execution.result();
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @returns Promise<T> the final result.
|
|
||||||
* @throws in case the backing remote function raises an error.
|
|
||||||
*/
|
|
||||||
result(): Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListenOptions<Input> = {
|
|
||||||
readonly input?: Input;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
interface Queue {
|
||||||
* TODO: document me
|
submit<Input>(id: string, options: RunOptions<Input>): Promise<EnqueueResult>;
|
||||||
*
|
status(id: string, requestId: string): Promise<QueueStatus>;
|
||||||
* @param id
|
result<Output>(id: string, requestId: string): Promise<Output>;
|
||||||
* @param options
|
subscribe<Input, Output>(
|
||||||
*/
|
id: string,
|
||||||
export function listen<Input, Output>(
|
options: RunOptions<Input> & QueueSubscribeOptions
|
||||||
id: string,
|
): Promise<Output>;
|
||||||
options: ListenOptions<Input>
|
|
||||||
): FunctionExecution<Output> {
|
|
||||||
throw 'TODO: implement me!';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fal run queue module. It allows to submit a function to the queue and get its result
|
||||||
|
* on a separate call. This is useful for long running functions that can be executed
|
||||||
|
* asynchronously and not .
|
||||||
|
*/
|
||||||
|
export const queue: Queue = {
|
||||||
|
async submit<Input>(
|
||||||
|
id: string,
|
||||||
|
options: RunOptions<Input>
|
||||||
|
): Promise<EnqueueResult> {
|
||||||
|
return run(id, { ...options, method: 'post', path: '/fal/queue/submit/' });
|
||||||
|
},
|
||||||
|
async status(id: string, requestId: string): Promise<QueueStatus> {
|
||||||
|
return run(id, {
|
||||||
|
method: 'get',
|
||||||
|
path: `/fal/queue/requests/${requestId}/status`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async result<Output>(id: string, requestId: string): Promise<Output> {
|
||||||
|
return run(id, {
|
||||||
|
method: 'get',
|
||||||
|
path: `/fal/queue/requests/${requestId}/response`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async subscribe<Input, Output>(
|
||||||
|
id: string,
|
||||||
|
options: RunOptions<Input> & QueueSubscribeOptions = {}
|
||||||
|
): Promise<Output> {
|
||||||
|
const { request_id: requestId } = await queue.submit(id, options);
|
||||||
|
if (options.onEnqueue) {
|
||||||
|
options.onEnqueue(requestId);
|
||||||
|
}
|
||||||
|
return new Promise<Output>((resolve, reject) => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
const pollInterval = options.pollInterval ?? 1000;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const requestStatus = await queue.status(id, requestId);
|
||||||
|
if (options.onQueueUpdate) {
|
||||||
|
options.onQueueUpdate(requestStatus);
|
||||||
|
}
|
||||||
|
if (requestStatus.status === 'COMPLETED') {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
try {
|
||||||
|
const result = await queue.result<Output>(id, requestId);
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(poll, pollInterval);
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
timeoutId = setTimeout(poll, pollInterval);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
export { config } from './config';
|
export { config, getConfig } from './config';
|
||||||
export type { Credentials } from './config';
|
export type { Credentials } from './config';
|
||||||
export { run } from './function';
|
export { queue, run } from './function';
|
||||||
export type { FunctionExecution, ProgressEvent } from './function';
|
export { withMiddleware } from './middleware';
|
||||||
|
export type { RequestMiddleware } from './middleware';
|
||||||
|
export type { ResponseHandler } from './response';
|
||||||
|
export type { QueueStatus } from './types';
|
||||||
|
|||||||
34
libs/client/src/middleware.ts
Normal file
34
libs/client/src/middleware.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* A request configuration object.
|
||||||
|
*
|
||||||
|
* **Note:** This is a simplified version of the `RequestConfig` type from the
|
||||||
|
* `fetch` API. It contains only the properties that are relevant for the
|
||||||
|
* `fal-serverless` client. It also works around the fact that the `fetch` API
|
||||||
|
* `Request` does not support mutability, its clone method has critical limitations
|
||||||
|
* to our use case.
|
||||||
|
*/
|
||||||
|
export type RequestConfig = {
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string | string[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RequestMiddleware = (
|
||||||
|
request: RequestConfig
|
||||||
|
) => Promise<RequestConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup a execution chain of middleware functions.
|
||||||
|
*
|
||||||
|
* @param middlewares one or more middleware functions.
|
||||||
|
* @returns a middleware function that executes the given middlewares in order.
|
||||||
|
*/
|
||||||
|
export function withMiddleware(
|
||||||
|
...middlewares: RequestMiddleware[]
|
||||||
|
): RequestMiddleware {
|
||||||
|
return (config) =>
|
||||||
|
middlewares.reduce(
|
||||||
|
(configPromise, middleware) =>
|
||||||
|
configPromise.then((req) => middleware(req)),
|
||||||
|
Promise.resolve(config)
|
||||||
|
);
|
||||||
|
}
|
||||||
46
libs/client/src/response.ts
Normal file
46
libs/client/src/response.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export type ResponseHandler<Output> = (response: Response) => Promise<Output>;
|
||||||
|
|
||||||
|
type ApiErrorArgs = {
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
body?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly body?: any;
|
||||||
|
constructor({ message, status, body }: ApiErrorArgs) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function defaultResponseHandler<Output>(
|
||||||
|
response: Response
|
||||||
|
): Promise<Output> {
|
||||||
|
const { status, statusText } = response;
|
||||||
|
const contentType = response.headers.get('Content-Type');
|
||||||
|
if (!response.ok) {
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
const body = await response.json();
|
||||||
|
throw new ApiError({
|
||||||
|
message: body.message || statusText,
|
||||||
|
status,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP ${status}: ${statusText}`);
|
||||||
|
}
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return response.json() as Promise<Output>;
|
||||||
|
}
|
||||||
|
if (contentType?.includes('text/html')) {
|
||||||
|
return response.text() as Promise<Output>;
|
||||||
|
}
|
||||||
|
if (contentType?.includes('application/octet-stream')) {
|
||||||
|
return response.arrayBuffer() as Promise<Output>;
|
||||||
|
}
|
||||||
|
// TODO convert to either number or bool automatically
|
||||||
|
return response.text() as Promise<Output>;
|
||||||
|
}
|
||||||
35
libs/client/src/types.ts
Normal file
35
libs/client/src/types.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export type Result<T> = {
|
||||||
|
result: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnqueueResult = {
|
||||||
|
request_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// export type QueueStatus = {
|
||||||
|
// status: "IN_PROGRESS" | "COMPLETED";
|
||||||
|
// queue: number;
|
||||||
|
// };
|
||||||
|
|
||||||
|
export type RequestLog = {
|
||||||
|
message: string;
|
||||||
|
level: 'STDERR' | 'STDOUT' | 'ERROR' | 'INFO' | 'WARN' | 'DEBUG';
|
||||||
|
source: 'USER';
|
||||||
|
timestamp: string; // Using string to represent date-time format, but you could also use 'Date' type if you're going to construct Date objects.
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QueueStatus =
|
||||||
|
| {
|
||||||
|
status: 'IN_PROGRESS' | 'COMPLETED';
|
||||||
|
response_url: string;
|
||||||
|
logs: RequestLog[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'IN_QUEUE';
|
||||||
|
queue_position: number;
|
||||||
|
response_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isQueueStatus(obj: any): obj is QueueStatus {
|
||||||
|
return obj && obj.status && obj.response_url;
|
||||||
|
}
|
||||||
@ -6,3 +6,12 @@ export function isUUIDv4(id: string): boolean {
|
|||||||
['8', '9', 'a', 'b'].includes(id[19])
|
['8', '9', 'a', 'b'].includes(id[19])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidUrl(url: string) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
return parsedUrl.hostname.endsWith('fal.ai');
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
3
libs/nextjs/.babelrc
Normal file
3
libs/nextjs/.babelrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"presets": [["@nrwl/js/babel", { "useBuiltIns": "usage" }]]
|
||||||
|
}
|
||||||
18
libs/nextjs/.eslintrc.json
Normal file
18
libs/nextjs/.eslintrc.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": ["../../.eslintrc.json"],
|
||||||
|
"ignorePatterns": ["!**/*"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx"],
|
||||||
|
"rules": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.js", "*.jsx"],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
libs/nextjs/README.md
Normal file
11
libs/nextjs/README.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# nextjs
|
||||||
|
|
||||||
|
This library was generated with [Nx](https://nx.dev).
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
Run `nx test nextjs` to execute the unit tests via [Jest](https://jestjs.io).
|
||||||
|
|
||||||
|
## Running lint
|
||||||
|
|
||||||
|
Run `nx lint nextjs` to execute the lint via [ESLint](https://eslint.org/).
|
||||||
16
libs/nextjs/jest.config.ts
Normal file
16
libs/nextjs/jest.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
export default {
|
||||||
|
displayName: 'nextjs',
|
||||||
|
preset: '../../jest.preset.js',
|
||||||
|
globals: {
|
||||||
|
'ts-jest': {
|
||||||
|
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.[tj]sx?$': 'ts-jest',
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||||
|
coverageDirectory: '../../coverage/libs/nextjs',
|
||||||
|
};
|
||||||
32
libs/nextjs/package.json
Normal file
32
libs/nextjs/package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@fal-ai/serverless-nextjs",
|
||||||
|
"description": "The fal-serverless Next.js integration",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/fal-ai/serverless-js.git",
|
||||||
|
"directory": "libs/nextjs"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"fal",
|
||||||
|
"serverless",
|
||||||
|
"client",
|
||||||
|
"next",
|
||||||
|
"nextjs",
|
||||||
|
"proxy"
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "^13.0.0",
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
libs/nextjs/project.json
Normal file
41
libs/nextjs/project.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "nextjs",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "libs/nextjs/src",
|
||||||
|
"projectType": "library",
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "@nrwl/js:tsc",
|
||||||
|
"outputs": ["{options.outputPath}"],
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/libs/nextjs",
|
||||||
|
"tsConfig": "libs/nextjs/tsconfig.lib.json",
|
||||||
|
"packageJson": "libs/nextjs/package.json",
|
||||||
|
"main": "libs/nextjs/src/index.ts",
|
||||||
|
"assets": ["libs/nextjs/*.md"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"executor": "@nrwl/linter:eslint",
|
||||||
|
"outputs": ["{options.outputFile}"],
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": ["libs/nextjs/**/*.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"executor": "@nrwl/jest:jest",
|
||||||
|
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||||
|
"options": {
|
||||||
|
"jestConfig": "libs/nextjs/jest.config.ts",
|
||||||
|
"passWithNoTests": true
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"ci": {
|
||||||
|
"ci": true,
|
||||||
|
"codeCoverage": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
29
libs/nextjs/src/config.ts
Normal file
29
libs/nextjs/src/config.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { RequestMiddleware } from '@fal-ai/serverless-client';
|
||||||
|
|
||||||
|
export type NextProxyConfig = {
|
||||||
|
targetUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultConfig: NextProxyConfig = {
|
||||||
|
targetUrl: '/api/_fal/proxy',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TARGET_URL_HEADER = 'x-fal-target-url';
|
||||||
|
|
||||||
|
export function withNextProxy(
|
||||||
|
config: NextProxyConfig = defaultConfig
|
||||||
|
): RequestMiddleware {
|
||||||
|
// when running on the server, we don't need to proxy the request
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return (requestConfig) => Promise.resolve(requestConfig);
|
||||||
|
}
|
||||||
|
return (requestConfig) =>
|
||||||
|
Promise.resolve({
|
||||||
|
...requestConfig,
|
||||||
|
url: config.targetUrl,
|
||||||
|
headers: {
|
||||||
|
[TARGET_URL_HEADER]: requestConfig.url,
|
||||||
|
...(requestConfig.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
93
libs/nextjs/src/handler.ts
Normal file
93
libs/nextjs/src/handler.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import type { NextApiHandler, NextApiRequest, PageConfig } from 'next';
|
||||||
|
import { TARGET_URL_HEADER } from './config';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to get a header value as `string` from a Headers object.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param request the Next request object.
|
||||||
|
* @param key the header key.
|
||||||
|
* @returns the header value as `string` or `undefined` if the header is not set.
|
||||||
|
*/
|
||||||
|
function getHeader(request: NextApiRequest, key: string): string | undefined {
|
||||||
|
const headerValue = request.headers[key.toLowerCase()];
|
||||||
|
if (Array.isArray(headerValue)) {
|
||||||
|
return headerValue[0];
|
||||||
|
}
|
||||||
|
return headerValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up headers that should not be forwarded to the proxy.
|
||||||
|
* @param request the Next request object.
|
||||||
|
*/
|
||||||
|
function cleanUpHeaders(request: NextApiRequest) {
|
||||||
|
delete request.headers['origin'];
|
||||||
|
delete request.headers['referer'];
|
||||||
|
// delete request.headers['transfer-encoding'];
|
||||||
|
delete request.headers[TARGET_URL_HEADER];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Next request handler that proxies the request to the fal-serverless
|
||||||
|
* endpoint. This is useful so client-side calls to the fal-serverless endpoint
|
||||||
|
* can be made without CORS issues and the correct credentials can be added
|
||||||
|
* effortlessly.
|
||||||
|
*
|
||||||
|
* @param request the Next request object.
|
||||||
|
* @param response the Next response object.
|
||||||
|
* @returns Promise<any> the promise that will be resolved once the request is done.
|
||||||
|
*/
|
||||||
|
export const handler: NextApiHandler = async (request, response) => {
|
||||||
|
const targetUrl = getHeader(request, TARGET_URL_HEADER);
|
||||||
|
if (!targetUrl) {
|
||||||
|
response.status(400).send(`Missing the ${TARGET_URL_HEADER} header`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetUrl.indexOf('fal.ai') === -1) {
|
||||||
|
response.status(412).send(`Invalid ${TARGET_URL_HEADER} header`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanUpHeaders(request);
|
||||||
|
|
||||||
|
const authHeader =
|
||||||
|
FAL_KEY_ID && FAL_KEY_SECRET
|
||||||
|
? { authorization: `Key ${FAL_KEY_ID}:${FAL_KEY_SECRET}` }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const res = await fetch(targetUrl, {
|
||||||
|
method: request.method,
|
||||||
|
headers: {
|
||||||
|
...authHeader,
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-fal-client-proxy': '@fal-ai/serverless-nextjs',
|
||||||
|
},
|
||||||
|
body:
|
||||||
|
request.method?.toUpperCase() === 'GET'
|
||||||
|
? undefined
|
||||||
|
: JSON.stringify(request.body),
|
||||||
|
});
|
||||||
|
|
||||||
|
// copy headers from res to response
|
||||||
|
res.headers.forEach((value, key) => {
|
||||||
|
response.setHeader(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.headers.get('content-type') === 'application/json') {
|
||||||
|
const data = await res.json();
|
||||||
|
response.status(res.status).json(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.text();
|
||||||
|
response.status(res.status).send(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config: PageConfig = {
|
||||||
|
api: {},
|
||||||
|
};
|
||||||
2
libs/nextjs/src/index.ts
Normal file
2
libs/nextjs/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './config';
|
||||||
|
export * from './handler';
|
||||||
13
libs/nextjs/tsconfig.json
Normal file
13
libs/nextjs/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.lib.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
libs/nextjs/tsconfig.lib.json
Normal file
11
libs/nextjs/tsconfig.lib.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"declaration": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
20
libs/nextjs/tsconfig.spec.json
Normal file
20
libs/nextjs/tsconfig.spec.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"module": "commonjs",
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
1369
package-lock.json
generated
1369
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,10 @@
|
|||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
"cross-fetch": "^3.1.5",
|
"cross-fetch": "^3.1.5",
|
||||||
|
"encoding": "^0.1.13",
|
||||||
"fast-glob": "^3.2.12",
|
"fast-glob": "^3.2.12",
|
||||||
|
"http-proxy": "^1.18.1",
|
||||||
|
"http-proxy-middleware": "^2.0.6",
|
||||||
"js-base64": "^3.7.5",
|
"js-base64": "^3.7.5",
|
||||||
"next": "13.1.1",
|
"next": "13.1.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
@ -43,7 +46,7 @@
|
|||||||
"@testing-library/react": "13.4.0",
|
"@testing-library/react": "13.4.0",
|
||||||
"@theunderscorer/nx-semantic-release": "^2.2.1",
|
"@theunderscorer/nx-semantic-release": "^2.2.1",
|
||||||
"@types/jest": "28.1.1",
|
"@types/jest": "28.1.1",
|
||||||
"@types/node": "16.11.7",
|
"@types/node": "^18.17.14",
|
||||||
"@types/react": "18.0.20",
|
"@types/react": "18.0.20",
|
||||||
"@types/react-dom": "18.0.6",
|
"@types/react-dom": "18.0.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.55.0",
|
"@typescript-eslint/eslint-plugin": "^5.55.0",
|
||||||
@ -68,7 +71,7 @@
|
|||||||
"postcss": "8.4.19",
|
"postcss": "8.4.19",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^2.6.2",
|
||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"tailwindcss": "3.2.4",
|
"tailwindcss": "^3.3.3",
|
||||||
"ts-jest": "28.0.5",
|
"ts-jest": "28.0.5",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"ts-protoc-gen": "^0.15.0",
|
"ts-protoc-gen": "^0.15.0",
|
||||||
|
|||||||
@ -16,7 +16,8 @@
|
|||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@fal-ai/serverless-client": ["libs/client/src/index.ts"],
|
"@fal-ai/serverless-client": ["libs/client/src/index.ts"],
|
||||||
"@fal-ai/serverless-codegen": ["libs/codegen/src/index.ts"]
|
"@fal-ai/serverless-codegen": ["libs/codegen/src/index.ts"],
|
||||||
|
"@fal-ai/serverless-nextjs": ["libs/nextjs/src/index.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "tmp"]
|
"exclude": ["node_modules", "tmp"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user