diff --git a/.prettierignore b/.prettierignore
index d0b804d..d8d8c06 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -2,3 +2,4 @@
/dist
/coverage
+package-lock.json
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..3445835
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,6 @@
+{
+ "[python]": {
+ "editor.defaultFormatter": "ms-python.black-formatter"
+ },
+ "python.formatting.provider": "none"
+}
diff --git a/README.md b/README.md
index d72537d..5260b27 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,9 @@
# The fal-serverless JS Client
-![NPM client](https://img.shields.io/npm/v/@fal-ai/serverless-client?color=%237527D7&label=client)
-![Build](https://img.shields.io/github/actions/workflow/status/fal-ai/serverless-js/build.yml)
-![License](https://img.shields.io/github/license/fal-ai/serverless-js)
+![@fal-ai/serverless-client npm package](https://img.shields.io/npm/v/@fal-ai/serverless-client?color=%237527D7&label=client&style=flat-square)
+![@fal-ai/serverless-nextjs npm package](https://img.shields.io/npm/v/@fal-ai/serverless-nextjs?color=%237527D7&label=nextjs-proxy&style=flat-square)
+![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
diff --git a/apps/demo-app/pages/api/_fal/proxy.ts b/apps/demo-app/pages/api/_fal/proxy.ts
new file mode 100644
index 0000000..682f38b
--- /dev/null
+++ b/apps/demo-app/pages/api/_fal/proxy.ts
@@ -0,0 +1,3 @@
+// @snippet:start(client.proxy.nextjs)
+export { config, handler as default } from '@fal-ai/serverless-nextjs';
+// @snippet:end
diff --git a/apps/demo-app/pages/api/generateImage.ts b/apps/demo-app/pages/api/generateImage.ts
deleted file mode 100644
index 07bd888..0000000
--- a/apps/demo-app/pages/api/generateImage.ts
+++ /dev/null
@@ -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' });
- }
-}
diff --git a/apps/demo-app/pages/diffusion.tsx b/apps/demo-app/pages/diffusion.tsx
deleted file mode 100644
index 854d27e..0000000
--- a/apps/demo-app/pages/diffusion.tsx
+++ /dev/null
@@ -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 (
-
-
-
fal-serverless diffusion
-
-
-
-
- fal-serverless diffusion
-
- Enter a prompt to generate the image
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/demo-app/pages/index.tsx b/apps/demo-app/pages/index.tsx
index 181a3e4..7e61668 100644
--- a/apps/demo-app/pages/index.tsx
+++ b/apps/demo-app/pages/index.tsx
@@ -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) {
- try {
- const result = await getJoke();
- return {
- props: {
- ...result,
- },
- };
- } catch (error) {
- return {
- props: {
- error: error.message,
- },
- };
- }
-}
+// @snippet:start(client.config)
+fal.config({
+ requestMiddleware: withNextProxy(),
+});
+// @snippet:end
+
+// @snippet:start(client.result.type)
+type Image = {
+ url: string;
+ file_name: string;
+ file_size: number;
+};
+type Result = {
+ images: Image[];
+};
+// @snippet:end
function Error(props) {
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"
role="alert"
>
- Error {props.error}
+ Error {props.error.message}
);
}
-export function Index(props) {
- const handleClick = async (e) => {
+const DEFAULT_PROMPT = "a city landscape of a cyberpunk metropolis, raining, purple, pink and teal neon lights, highly detailed, uhd";
+
+export function Index() {
+ // @snippet:start(client.ui.state)
+ // Input state
+ const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
+ // Result state
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [result, setResult] = useState(null);
+ const [logs, setLogs] = useState([]);
+ const [elapsedTime, setElapsedTime] = useState(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 joke = await getJoke();
- console.log(joke);
- } catch (e) {
- console.log(e);
+ 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 (
-
-
+
+
- Hello fal-serverless
+ Hello fal
-
- This page can access fal-serverless functions when
- it's rendering.
-
-
+
+
+ setPrompt(e.target.value)}
+ onBlur={(e) => setPrompt(e.target.value.trim())}
+ />
+
-
- Here's a joke: {props.joke}
-
+
+
+
+
+ {image && (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )}
+
+
+
JSON Result
+
+ {`Elapsed Time (seconds): ${(elapsedTime / 1000).toFixed(2)}`}
+
+
+ {result
+ ? JSON.stringify(result, null, 2)
+ : '// result pending...'}
+
+
+
+
+
Logs
+
+ {logs.filter(Boolean).join('\n')}
+
+
+
);
diff --git a/apps/demo-app/services/generateImage.ts b/apps/demo-app/services/generateImage.ts
deleted file mode 100644
index 2ed4e27..0000000
--- a/apps/demo-app/services/generateImage.ts
+++ /dev/null
@@ -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 {
- const result = await fal.run('a51c0ca0-9011-4ff0-8dc1-2ac0b42a9fd0', {
- path: '/generate',
- input,
- });
- const data = result['raw_data'];
- return `data:image/jpg;base64,${data}`;
-}
diff --git a/apps/demo-app/services/getJoke.ts b/apps/demo-app/services/getJoke.ts
deleted file mode 100644
index e4afad2..0000000
--- a/apps/demo-app/services/getJoke.ts
+++ /dev/null
@@ -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 });
-}
diff --git a/apps/demo-app/specs/index.spec.tsx b/apps/demo-app/specs/index.spec.tsx
index 80e1079..5d65455 100644
--- a/apps/demo-app/specs/index.spec.tsx
+++ b/apps/demo-app/specs/index.spec.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { render } from '@testing-library/react';
import Index from '../pages/index';
diff --git a/libs/client/package.json b/libs/client/package.json
index b823b85..4de3d04 100644
--- a/libs/client/package.json
+++ b/libs/client/package.json
@@ -1,7 +1,7 @@
{
"name": "@fal-ai/serverless-client",
"description": "The fal serverless JS/TS client",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "MIT",
"repository": {
"type": "git",
diff --git a/libs/client/src/config.spec.ts b/libs/client/src/config.spec.ts
index ef7458d..06d2255 100644
--- a/libs/client/src/config.spec.ts
+++ b/libs/client/src/config.spec.ts
@@ -7,11 +7,11 @@ describe('The config test suite', () => {
credentials: {
keyId: 'key-id',
keySecret: 'key-secret',
- userId: 'user-id',
},
};
config(newConfig);
const currentConfig = getConfig();
- expect(currentConfig).toEqual(newConfig);
+ expect(currentConfig.host).toBe(newConfig.host);
+ expect(currentConfig.credentials).toEqual(newConfig.credentials);
});
});
diff --git a/libs/client/src/config.ts b/libs/client/src/config.ts
index 403ae69..73d0955 100644
--- a/libs/client/src/config.ts
+++ b/libs/client/src/config.ts
@@ -1,21 +1,80 @@
+import type { RequestMiddleware } from './middleware';
+import type { ResponseHandler } from './response';
+import { defaultResponseHandler } from './response';
+
export type Credentials = {
keyId: string;
keySecret: string;
- userId: string;
};
+export type CredentialsResolver = () => Credentials;
+
export type Config = {
- credentials: Credentials;
+ credentials?: Credentials | CredentialsResolver;
host?: string;
+ requestMiddleware?: RequestMiddleware;
+ responseHandler?: ResponseHandler;
};
export type RequiredConfig = Required;
+/**
+ * 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 || '',
+ };
+};
+
+/**
+ * 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 = {
- host: 'gateway.shark.fal.ai',
+ host: getDefaultHost(),
+ credentials: credentialsFromEnv,
+ requestMiddleware: (request) => Promise.resolve(request),
+ responseHandler: defaultResponseHandler,
};
-let configuration: RequiredConfig | undefined = undefined;
+let configuration: RequiredConfig;
/**
* Configures the fal serverless client.
@@ -32,8 +91,8 @@ export function config(config: Config) {
* @returns the current client configuration.
*/
export function getConfig(): RequiredConfig {
- if (typeof configuration === 'undefined') {
- throw new Error('You must configure fal-serverless first.');
+ if (!configuration) {
+ console.info('Using default configuration for the fal client');
}
return configuration;
}
diff --git a/libs/client/src/function.spec.ts b/libs/client/src/function.spec.ts
index 2cbd87e..5735c8d 100644
--- a/libs/client/src/function.spec.ts
+++ b/libs/client/src/function.spec.ts
@@ -5,7 +5,6 @@ import { buildUrl } from './function';
config({
host: 'gateway.alpha.fal.ai',
credentials: {
- userId: 'github|123456',
keyId: 'a91ff3ca-71bc-4c8c-b400-859f6cbe804d',
keySecret: '0123456789abcdfeghijklmnopqrstuv',
},
@@ -13,15 +12,14 @@ config({
describe('The function test suite', () => {
it('should build the URL with a function UUIDv4', () => {
- const { credentials } = getConfig();
const id = randomUUID();
- const url = buildUrl(id);
- expect(url).toMatch(`trigger/${credentials.userId}/${id}`);
+ const url = buildUrl(`12345/${id}`);
+ expect(url).toMatch(`trigger/12345/${id}`);
});
it('should build the URL with a function alias', () => {
const { host } = getConfig();
- const alias = 'some-alias';
+ const alias = '12345-some-alias';
const url = buildUrl(alias);
expect(url).toMatch(`${alias}.${host}`);
});
diff --git a/libs/client/src/function.ts b/libs/client/src/function.ts
index d0a5721..93fc3b2 100644
--- a/libs/client/src/function.ts
+++ b/libs/client/src/function.ts
@@ -1,7 +1,7 @@
-import fetch from 'cross-fetch';
import { getConfig } from './config';
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
@@ -22,7 +22,7 @@ type RunOptions = {
/**
* 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(
id: string,
options: RunOptions = {}
): string {
- const { credentials, host } = getConfig();
+ const { host } = getConfig();
const method = (options.method ?? 'post').toLowerCase();
- const path = options.path ?? '';
+ const path = (options.path ?? '').replace(/^\//, '').replace(/\/{2,}/, '/');
const params =
method === 'get' ? new URLSearchParams(options.input ?? {}) : undefined;
- let queryParams = '';
- if (params) {
- queryParams = `?${params.toString()}`;
+ // TODO: change to params.size once it's officially supported
+ const queryParams = params && params['size'] ? `?${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://${userId}-${id}.${host}/${path}${queryParams}`;
+ return `https://${id}.${host}/${path}${queryParams}`;
}
/**
* Runs a fal serverless function identified by its `id`.
* 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
*/
export async function run(
id: string,
options: RunOptions = {}
): Promise