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:
Daniel Rochetti 2023-09-11 20:26:06 -03:00 committed by GitHub
parent 3b11d468a8
commit 9a0497da30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1788 additions and 656 deletions

View File

@ -2,3 +2,4 @@
/dist /dist
/coverage /coverage
package-lock.json

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
},
"python.formatting.provider": "none"
}

View File

@ -1,8 +1,9 @@
# The fal-serverless JS Client # The fal-serverless JS Client
![NPM client](https://img.shields.io/npm/v/@fal-ai/serverless-client?color=%237527D7&label=client) ![@fal-ai/serverless-client npm package](https://img.shields.io/npm/v/@fal-ai/serverless-client?color=%237527D7&label=client&style=flat-square)
![Build](https://img.shields.io/github/actions/workflow/status/fal-ai/serverless-js/build.yml) ![@fal-ai/serverless-nextjs npm package](https://img.shields.io/npm/v/@fal-ai/serverless-nextjs?color=%237527D7&label=nextjs-proxy&style=flat-square)
![License](https://img.shields.io/github/license/fal-ai/serverless-js) ![Build](https://img.shields.io/github/actions/workflow/status/fal-ai/serverless-js/build.yml?style=flat-square)
![License](https://img.shields.io/github/license/fal-ai/serverless-js?style=flat-square)
## About the project ## About the project

View File

@ -0,0 +1,3 @@
// @snippet:start(client.proxy.nextjs)
export { config, handler as default } from '@fal-ai/serverless-nextjs';
// @snippet:end

View File

@ -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' });
}
}

View File

@ -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>
);
}

View File

@ -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&apos;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&apos;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>
); );

View File

@ -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}`;
}

View File

@ -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 });
}

View File

@ -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';

View File

@ -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",

View File

@ -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);
}); });
}); });

View File

@ -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;
} }

View File

@ -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}`);
}); });

View File

@ -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);
});
},
};

View File

@ -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';

View 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)
);
}

View 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
View 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;
}

View File

@ -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
View File

@ -0,0 +1,3 @@
{
"presets": [["@nrwl/js/babel", { "useBuiltIns": "usage" }]]
}

View 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
View 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/).

View 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
View 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
View 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
View 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 || {}),
},
});
}

View 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
View File

@ -0,0 +1,2 @@
export * from './config';
export * from './handler';

13
libs/nextjs/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View 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"]
}

View 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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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"]