From 78c0a5e6516aee2e31024c9a853a02a9093a2551 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Fri, 24 Nov 2023 21:09:31 -0800 Subject: [PATCH 01/14] feat(client): realtime client --- .../app/realtime/page.tsx | 62 +++++ .../components/drawing.tsx | 116 +++++++++ .../components/drawingState.json | 58 +++++ libs/client/src/config.ts | 24 +- libs/client/src/index.ts | 3 +- libs/client/src/realtime.ts | 233 ++++++++++++++++++ libs/client/src/storage.ts | 7 +- libs/client/src/utils.ts | 21 ++ package-lock.json | 11 + package.json | 1 + 10 files changed, 528 insertions(+), 8 deletions(-) create mode 100644 apps/demo-nextjs-app-router/app/realtime/page.tsx create mode 100644 apps/demo-nextjs-app-router/components/drawing.tsx create mode 100644 apps/demo-nextjs-app-router/components/drawingState.json create mode 100644 libs/client/src/realtime.ts diff --git a/apps/demo-nextjs-app-router/app/realtime/page.tsx b/apps/demo-nextjs-app-router/app/realtime/page.tsx new file mode 100644 index 0000000..39d4b86 --- /dev/null +++ b/apps/demo-nextjs-app-router/app/realtime/page.tsx @@ -0,0 +1,62 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ +import * as fal from '@fal-ai/serverless-client'; +import { DrawingCanvas } from '../../components/drawing'; +import { useState } from 'react'; + +fal.config({ + proxyUrl: '/api/fal/proxy', +}); + +const PROMPT = 'a sunset over the ocean'; + +export default function RealtimePage() { + const [image, setImage] = useState(null); + + const { send } = fal.realtime.connect('110602490-lcm-plexed-sd15-i2i', { + clientOnly: true, // in ssr+csr mode, only run in csr + connectionKey: 'single-drawing', // reuse connection between render cycles + onResult(result) { + if (result.images && result.images[0]) { + setImage(result.images[0].url); + } + }, + }); + + return ( +
+
+

+ falrealtime +

+
{PROMPT}
+
+
+ { + send({ + prompt: PROMPT, + image_url: imageData, + sync_mode: true, + seed: 612023, + }); + }} + /> +
+
+
+ {image && ( + {`${PROMPT} + )} +
+
+
+
+
+ ); +} diff --git a/apps/demo-nextjs-app-router/components/drawing.tsx b/apps/demo-nextjs-app-router/components/drawing.tsx new file mode 100644 index 0000000..cd34261 --- /dev/null +++ b/apps/demo-nextjs-app-router/components/drawing.tsx @@ -0,0 +1,116 @@ +import { type Excalidraw } from '@excalidraw/excalidraw'; +import { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'; +import { + AppState, + ExcalidrawImperativeAPI, +} from '@excalidraw/excalidraw/types/types'; +import { useCallback, useEffect, useState } from 'react'; +import initialDrawing from './drawingState.json'; + +export type CanvasChangeEvent = { + elements: readonly ExcalidrawElement[]; + appState: AppState; + imageData: string; +}; + +export type DrawingCanvasProps = { + onCanvasChange: (event: CanvasChangeEvent) => void; +}; + +async function blobToBase64(blob: Blob): Promise { + const reader = new FileReader(); + reader.readAsDataURL(blob); + return new Promise((resolve) => { + reader.onloadend = () => { + resolve(reader.result?.toString() || ''); + }; + }); +} + +export function DrawingCanvas({ onCanvasChange }: DrawingCanvasProps) { + const [ExcalidrawComponent, setExcalidrawComponent] = useState< + typeof Excalidraw | null + >(null); + const [excalidrawAPI, setExcalidrawAPI] = + useState(null); + const [sceneData, setSceneData] = useState(null); + + useEffect(() => { + import('@excalidraw/excalidraw').then((comp) => + setExcalidrawComponent(comp.Excalidraw) + ); + const onResize = () => { + if (excalidrawAPI) { + excalidrawAPI.refresh(); + } + }; + window.addEventListener('resize', onResize); + return () => { + window.removeEventListener('resize', onResize); + }; + }, []); + + const handleCanvasChanges = useCallback( + async (elements: readonly ExcalidrawElement[], appState: AppState) => { + if (!excalidrawAPI || !elements || !elements.length) { + return; + } + const { exportToBlob, convertToExcalidrawElements, serializeAsJSON } = + await import('@excalidraw/excalidraw'); + + const [boundingBoxElement] = convertToExcalidrawElements([ + { + type: 'rectangle', + x: 0, + y: 0, + width: 512, + height: 512, + fillStyle: 'solid', + backgroundColor: 'cyan', + }, + ]); + + const newSceneData = serializeAsJSON( + elements, + appState, + excalidrawAPI.getFiles(), + 'local' + ); + if (newSceneData !== sceneData) { + setSceneData(newSceneData); + const blob = await exportToBlob({ + elements: [boundingBoxElement, ...elements], + appState: { + ...appState, + frameRendering: { + ...(appState.frameRendering || {}), + clip: false, + }, + }, + files: excalidrawAPI.getFiles(), + mimeType: 'image/webp', + quality: 0.5, + exportPadding: 0, + getDimensions: () => { + return { width: 512, height: 512 }; + }, + }); + const imageData = await blobToBase64(blob); + onCanvasChange({ elements, appState, imageData }); + } + }, + [excalidrawAPI, onCanvasChange, sceneData] + ); + + return ( +
+ {ExcalidrawComponent && ( + setExcalidrawAPI(api)} + initialData={{ elements: initialDrawing as ExcalidrawElement[] }} + onChange={handleCanvasChanges} + /> + )} +
+ ); +} diff --git a/apps/demo-nextjs-app-router/components/drawingState.json b/apps/demo-nextjs-app-router/components/drawingState.json new file mode 100644 index 0000000..c856737 --- /dev/null +++ b/apps/demo-nextjs-app-router/components/drawingState.json @@ -0,0 +1,58 @@ +[ + { + "type": "ellipse", + "version": 405, + "versionNonce": 1410588077, + "isDeleted": false, + "id": "F6oN3k42RqfCqlzJLGXXS", + "fillStyle": "solid", + "strokeWidth": 1, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "angle": 0, + "x": 339.8458251953125, + "y": 207.59808349609375, + "strokeColor": "#f08c00", + "backgroundColor": "#f08c00", + "width": 124.31249999999997, + "height": 113.591796875, + "seed": 23374002, + "groupIds": [], + "frameId": null, + "roundness": { + "type": 2 + }, + "boundElements": [], + "updated": 1700883719596, + "link": null, + "locked": false + }, + { + "id": "EnLu91BTRnzWtj7m-l4Id", + "type": "rectangle", + "x": 0.8463592529296875, + "y": 418.16404724121094, + "width": 568.016487121582, + "height": 160.1866455078125, + "angle": 0, + "strokeColor": "#1971c2", + "backgroundColor": "#228be6", + "fillStyle": "solid", + "strokeWidth": 2, + "strokeStyle": "solid", + "roughness": 1, + "opacity": 100, + "groupIds": [], + "frameId": null, + "roundness": null, + "seed": 295965933, + "version": 102, + "versionNonce": 1694106829, + "isDeleted": false, + "boundElements": null, + "updated": 1700883713295, + "link": null, + "locked": false + } +] diff --git a/libs/client/src/config.ts b/libs/client/src/config.ts index 13b83ff..7def023 100644 --- a/libs/client/src/config.ts +++ b/libs/client/src/config.ts @@ -1,4 +1,8 @@ -import type { RequestMiddleware } from './middleware'; +import { + withProxy, + type RequestMiddleware, + withMiddleware, +} from './middleware'; import type { ResponseHandler } from './response'; import { defaultResponseHandler } from './response'; @@ -7,6 +11,7 @@ export type CredentialsResolver = () => string | undefined; export type Config = { credentials?: undefined | string | CredentialsResolver; host?: string; + proxyUrl?: string; requestMiddleware?: RequestMiddleware; responseHandler?: ResponseHandler; }; @@ -71,6 +76,15 @@ let configuration: RequiredConfig; */ export function config(config: Config) { configuration = { ...DEFAULT_CONFIG, ...config } as RequiredConfig; + if (config.proxyUrl) { + configuration = { + ...configuration, + requestMiddleware: withMiddleware( + configuration.requestMiddleware, + withProxy({ targetUrl: config.proxyUrl }) + ), + }; + } } /** @@ -85,3 +99,11 @@ export function getConfig(): RequiredConfig { } return configuration; } + +/** + * @returns the URL of the fal serverless rest api endpoint. + */ +export function getRestApiUrl(): string { + const { host } = getConfig(); + return host.replace('gateway', 'rest'); +} diff --git a/libs/client/src/index.ts b/libs/client/src/index.ts index 75e3f54..e84e921 100644 --- a/libs/client/src/index.ts +++ b/libs/client/src/index.ts @@ -1,10 +1,11 @@ export { config, getConfig } from './config'; -export { storageImpl as storage } from './storage'; export { queue, run, subscribe } from './function'; export { withMiddleware, withProxy } from './middleware'; export type { RequestMiddleware } from './middleware'; +export { realtimeImpl as realtime } from './realtime'; export { ApiError, ValidationError } from './response'; export type { ResponseHandler } from './response'; +export { storageImpl as storage } from './storage'; export type { QueueStatus, ValidationErrorInfo, diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts new file mode 100644 index 0000000..ffbd88e --- /dev/null +++ b/libs/client/src/realtime.ts @@ -0,0 +1,233 @@ +import { getConfig, getRestApiUrl } from './config'; +import { dispatchRequest } from './request'; +import { ApiError } from './response'; +import { debounce } from './utils'; + +/** + * A connection object that allows you to `send` request payloads to a + * realtime endpoint. + */ +export interface RealtimeConnection { + send(input: Input): void; + + close(): void; +} + +type ResultWithRequestId = { + request_id: string; +}; + +/** + * Options for connecting to the realtime endpoint. + */ +export interface RealtimeConnectionHandler { + /** + * The connection key. This is used to reuse the same connection + * across multiple calls to `connect`. This is particularly useful in + * contexts where the connection is established as part of a component + * lifecycle (e.g. React) and the component is re-rendered multiple times. + */ + connectionKey?: string; + + /** + * If `true`, the connection will only be established on the client side. + * This is useful for frameworks that reuse code for both server-side + * rendering and client-side rendering (e.g. Next.js). + */ + clientOnly?: boolean; + + /** + * The debounce duration in milliseconds. This is used to debounce the + * calls to the `send` function. Realtime apps usually react to user + * input, which can be very frequesnt (e.g. fast typing or mouse/drag movements). + * + * The default value is `16` milliseconds. + */ + debounceDuration?: number; + + /** + * Callback function that is called when a result is received. + * @param result - The result of the request. + */ + onResult(result: Output & ResultWithRequestId): void; + + /** + * Callback function that is called when an error occurs. + * @param error - The error that occurred. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onError?(error: ApiError): void; +} + +export interface RealtimeClient { + /** + * Connect to the realtime endpoint. The default implementation uses + * WebSockets to connect to fal function endpoints that support WSS. + * + * @param app the app alias or identifier. + * @param handler the connection handler. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + connect( + app: string, + handler: RealtimeConnectionHandler + ): RealtimeConnection; +} + +function builRealtimeUrl(app: string): string { + const { host } = getConfig(); + return `wss://${app}.${host}/ws`; +} + +/** + * Get a token to connect to the realtime endpoint. + */ +async function getToken(app: string): Promise { + return await dispatchRequest( + 'POST', + `https://${getRestApiUrl()}/tokens`, + { + allowed_apps: [app], + token_expiration: 60, + } + ); +} + +/** + * See https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 + */ +const WebSocketErrorCodes = { + NORMAL_CLOSURE: 1000, + GOING_AWAY: 1001, +}; + +const connections = new Map(); + +async function getConnection(app: string, key: string): Promise { + const url = builRealtimeUrl(app); + // const token = await getToken(app); + const token = '***'; + + if (connections.has(key)) { + return connections.get(key); + } + const ws = new WebSocket(url); + connections.set(key, ws); + return ws; +} + +const noop = () => { + /* No-op */ +}; + +/** + * A no-op connection that does not send any message. + * Useful on the frameworks that reuse code for both ssr and csr (e.g. Next) + * so the call when doing ssr has no side-effects. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const NoOpConnection: RealtimeConnection = { + send: noop, + close: noop, +}; + +/** + * The default implementation of the realtime client. + */ +export const realtimeImpl: RealtimeClient = { + connect( + app: string, + handler: RealtimeConnectionHandler + ): RealtimeConnection { + const { + clientOnly = false, + connectionKey = crypto.randomUUID(), + debounceDuration = 16, + onError = noop, + onResult, + } = handler; + if (clientOnly && typeof window === 'undefined') { + return NoOpConnection; + } + + const enqueueMessages: Input[] = []; + + let ws: WebSocket | null = null; + const _send = (input: Input) => { + const requestId = crypto.randomUUID(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + request_id: requestId, + ...input, + }) + ); + } else { + enqueueMessages.push(input); + connect(); + } + }; + const send = + debounceDuration > 0 ? debounce(_send, debounceDuration) : _send; + + const connect = () => { + if (ws && ws.readyState === WebSocket.OPEN) { + return; + } + getConnection(app, connectionKey) + .then((connection) => { + ws = connection; + ws.onopen = () => { + if (enqueueMessages.length > 0) { + enqueueMessages.forEach((input) => send(input)); + enqueueMessages.length = 0; + } + }; + ws.onclose = (event) => { + connections.delete(connectionKey); + if (event.code !== WebSocketErrorCodes.NORMAL_CLOSURE) { + console.log('ws onclose'); + onError( + new ApiError({ + message: 'Error closing the connection', + status: 0, + }) + ); + } + }; + ws.onerror = (event) => { + console.log('ws onerror'); + console.error(event); + onError(new ApiError({ message: 'error', status: 0 })); + }; + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + // Drop messages that are not related to the actual result. + // In the future, we might want to handle other types of messages. + if (data.status !== 'error' && data.type !== 'x-fal-message') { + onResult(data); + } + }; + }) + .catch((error) => { + console.log('ws connection error'); + console.error(error); + onError( + new ApiError({ message: 'Error opening connection', status: 0 }) + ); + }); + }; + + return { + send, + close() { + if (ws && ws.readyState === WebSocket.CLOSED) { + ws.close( + WebSocketErrorCodes.GOING_AWAY, + 'Client manually closed the connection.' + ); + } + }, + }; + }, +}; diff --git a/libs/client/src/storage.ts b/libs/client/src/storage.ts index 544bdd4..d150114 100644 --- a/libs/client/src/storage.ts +++ b/libs/client/src/storage.ts @@ -1,4 +1,4 @@ -import { getConfig } from './config'; +import { getConfig, getRestApiUrl } from './config'; import { dispatchRequest } from './request'; /** @@ -50,11 +50,6 @@ type InitiateUploadData = { content_type: string | null; }; -function getRestApiUrl(): string { - const { host } = getConfig(); - return host.replace('gateway', 'rest'); -} - /** * Get the file extension from the content type. This is used to generate * a file name if the file name is not provided. diff --git a/libs/client/src/utils.ts b/libs/client/src/utils.ts index 2819323..53e6b16 100644 --- a/libs/client/src/utils.ts +++ b/libs/client/src/utils.ts @@ -15,3 +15,24 @@ export function isValidUrl(url: string) { return false; } } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function debounce any>( + func: T, + wait: number +): (...funcArgs: Parameters) => void { + let timeout: NodeJS.Timeout | null; + + return (...args: Parameters): void => { + const later = () => { + timeout = null; + func(...args); + }; + + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(later, wait); + }; +} diff --git a/package-lock.json b/package-lock.json index f958942..337cd7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "devDependencies": { "@commitlint/cli": "^17.0.0", "@commitlint/config-conventional": "^17.0.0", + "@excalidraw/excalidraw": "^0.17.0", "@nrwl/express": "16.10.0", "@nx/cypress": "16.10.0", "@nx/eslint-plugin": "16.10.0", @@ -3073,6 +3074,16 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@excalidraw/excalidraw": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@excalidraw/excalidraw/-/excalidraw-0.17.0.tgz", + "integrity": "sha512-NzP22v5xMqxYW27ZtTHhiGFe7kE8NeBk45aoeM/mDSkXiOXPDH+PcvwzHRN/Ei+Vj/0sTPHxejn8bZyRWKGjXg==", + "dev": true, + "peerDependencies": { + "react": "^17.0.2 || ^18.2.0", + "react-dom": "^17.0.2 || ^18.2.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", diff --git a/package.json b/package.json index 1db2814..286ddbd 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "devDependencies": { "@commitlint/cli": "^17.0.0", "@commitlint/config-conventional": "^17.0.0", + "@excalidraw/excalidraw": "^0.17.0", "@nrwl/express": "16.10.0", "@nx/cypress": "16.10.0", "@nx/eslint-plugin": "16.10.0", From b3126a6fe3a83bc7ea7e9269c3a9ed8226d18007 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Fri, 24 Nov 2023 21:25:31 -0800 Subject: [PATCH 02/14] chore: alpha release --- libs/client/package.json | 2 +- libs/client/src/realtime.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/client/package.json b/libs/client/package.json index 70b90ae..90628ae 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.5.4", + "version": "0.6.0-alpha.0", "license": "MIT", "repository": { "type": "git", diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts index ffbd88e..b668a96 100644 --- a/libs/client/src/realtime.ts +++ b/libs/client/src/realtime.ts @@ -109,7 +109,7 @@ async function getConnection(app: string, key: string): Promise { const token = '***'; if (connections.has(key)) { - return connections.get(key); + return connections.get(key) as WebSocket; } const ws = new WebSocket(url); connections.set(key, ws); From 9d0bff92bffc93d3dfc9938c8287aca80eab3801 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Fri, 24 Nov 2023 23:59:22 -0800 Subject: [PATCH 03/14] fix: remove os requirement --- libs/client/package.json | 2 +- libs/client/src/realtime.ts | 7 ++++--- libs/client/src/runtime.ts | 5 +---- libs/client/tsconfig.lib.json | 1 + 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/libs/client/package.json b/libs/client/package.json index 90628ae..8ffec94 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.6.0-alpha.0", + "version": "0.6.0-alpha.1", "license": "MIT", "repository": { "type": "git", diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts index b668a96..97726ad 100644 --- a/libs/client/src/realtime.ts +++ b/libs/client/src/realtime.ts @@ -108,11 +108,12 @@ async function getConnection(app: string, key: string): Promise { // const token = await getToken(app); const token = '***'; - if (connections.has(key)) { - return connections.get(key) as WebSocket; + const connectionKey = `${key}:${token}`; + if (connections.has(connectionKey)) { + return connections.get(connectionKey) as WebSocket; } const ws = new WebSocket(url); - connections.set(key, ws); + connections.set(connectionKey, ws); return ws; } diff --git a/libs/client/src/runtime.ts b/libs/client/src/runtime.ts index 40e818a..4d06306 100644 --- a/libs/client/src/runtime.ts +++ b/libs/client/src/runtime.ts @@ -13,9 +13,6 @@ export function getUserAgent(): string { return memoizedUserAgent; } const packageInfo = require('../package.json'); - const os = require('os'); - memoizedUserAgent = `${packageInfo.name}/${ - packageInfo.version - } ${os.platform()}-${os.arch()} ${process.release.name}-${process.version}`; + memoizedUserAgent = `${packageInfo.name}/${packageInfo.version}`; return memoizedUserAgent; } diff --git a/libs/client/tsconfig.lib.json b/libs/client/tsconfig.lib.json index 4324483..d241f70 100644 --- a/libs/client/tsconfig.lib.json +++ b/libs/client/tsconfig.lib.json @@ -3,6 +3,7 @@ "compilerOptions": { "module": "commonjs", "outDir": "../../dist/out-tsc", + "inlineSources": true, "declaration": true, "allowJs": true, "checkJs": false, From 644316333e1285fb93aa83af66351e20e4b68212 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sat, 25 Nov 2023 00:12:20 -0800 Subject: [PATCH 04/14] fix: check if process is defined --- libs/client/package.json | 2 +- libs/client/src/config.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/client/package.json b/libs/client/package.json index 8ffec94..d650c43 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.6.0-alpha.1", + "version": "0.6.0-alpha.2", "license": "MIT", "repository": { "type": "git", diff --git a/libs/client/src/config.ts b/libs/client/src/config.ts index 7def023..a9ba9b8 100644 --- a/libs/client/src/config.ts +++ b/libs/client/src/config.ts @@ -26,7 +26,7 @@ export type RequiredConfig = Required; */ function hasEnvVariables(): boolean { return ( - process && + typeof process !== 'undefined' && process.env && (typeof process.env.FAL_KEY !== 'undefined' || (typeof process.env.FAL_KEY_ID !== 'undefined' && @@ -54,7 +54,7 @@ export const credentialsFromEnv: CredentialsResolver = () => { */ function getDefaultHost(): string { const host = 'gateway.alpha.fal.ai'; - if (process && process.env) { + if (typeof process !== 'undefined' && process.env) { return process.env.FAL_HOST || host; } return host; From 900e966557fb64213c57bb9e8767ca06dd25b759 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sat, 25 Nov 2023 00:34:48 -0800 Subject: [PATCH 05/14] fix: ws connection key --- libs/client/package.json | 2 +- libs/client/src/realtime.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/libs/client/package.json b/libs/client/package.json index d650c43..4f19ca4 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.6.0-alpha.2", + "version": "0.6.0-alpha.3", "license": "MIT", "repository": { "type": "git", diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts index 97726ad..b668a96 100644 --- a/libs/client/src/realtime.ts +++ b/libs/client/src/realtime.ts @@ -108,12 +108,11 @@ async function getConnection(app: string, key: string): Promise { // const token = await getToken(app); const token = '***'; - const connectionKey = `${key}:${token}`; - if (connections.has(connectionKey)) { - return connections.get(connectionKey) as WebSocket; + if (connections.has(key)) { + return connections.get(key) as WebSocket; } const ws = new WebSocket(url); - connections.set(connectionKey, ws); + connections.set(key, ws); return ws; } From 1163b8d20c474f5003d51d30dc4999df35482153 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sat, 25 Nov 2023 00:49:51 -0800 Subject: [PATCH 06/14] fix: outgoing request throttling logic --- .../app/realtime/page.tsx | 1 + libs/client/src/realtime.ts | 10 +++---- libs/client/src/utils.ts | 30 +++++++++++-------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/demo-nextjs-app-router/app/realtime/page.tsx b/apps/demo-nextjs-app-router/app/realtime/page.tsx index 39d4b86..379ec57 100644 --- a/apps/demo-nextjs-app-router/app/realtime/page.tsx +++ b/apps/demo-nextjs-app-router/app/realtime/page.tsx @@ -17,6 +17,7 @@ export default function RealtimePage() { const { send } = fal.realtime.connect('110602490-lcm-plexed-sd15-i2i', { clientOnly: true, // in ssr+csr mode, only run in csr connectionKey: 'single-drawing', // reuse connection between render cycles + throttleInterval: 64, // throttle outgoing requests to every 64ms (defaults to 16ms) onResult(result) { if (result.images && result.images[0]) { setImage(result.images[0].url); diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts index b668a96..d45aae5 100644 --- a/libs/client/src/realtime.ts +++ b/libs/client/src/realtime.ts @@ -1,7 +1,7 @@ import { getConfig, getRestApiUrl } from './config'; import { dispatchRequest } from './request'; import { ApiError } from './response'; -import { debounce } from './utils'; +import { throttle } from './utils'; /** * A connection object that allows you to `send` request payloads to a @@ -37,13 +37,13 @@ export interface RealtimeConnectionHandler { clientOnly?: boolean; /** - * The debounce duration in milliseconds. This is used to debounce the + * The throtle duration in milliseconds. This is used to throtle the * calls to the `send` function. Realtime apps usually react to user * input, which can be very frequesnt (e.g. fast typing or mouse/drag movements). * * The default value is `16` milliseconds. */ - debounceDuration?: number; + throttleInterval?: number; /** * Callback function that is called when a result is received. @@ -142,7 +142,7 @@ export const realtimeImpl: RealtimeClient = { const { clientOnly = false, connectionKey = crypto.randomUUID(), - debounceDuration = 16, + throttleInterval = 16, onError = noop, onResult, } = handler; @@ -168,7 +168,7 @@ export const realtimeImpl: RealtimeClient = { } }; const send = - debounceDuration > 0 ? debounce(_send, debounceDuration) : _send; + throttleInterval > 0 ? throttle(_send, throttleInterval) : _send; const connect = () => { if (ws && ws.readyState === WebSocket.OPEN) { diff --git a/libs/client/src/utils.ts b/libs/client/src/utils.ts index 53e6b16..1ca85f7 100644 --- a/libs/client/src/utils.ts +++ b/libs/client/src/utils.ts @@ -17,22 +17,28 @@ export function isValidUrl(url: string) { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function debounce any>( +export function throttle any>( func: T, - wait: number -): (...funcArgs: Parameters) => void { - let timeout: NodeJS.Timeout | null; + limit: number +): (...funcArgs: Parameters) => ReturnType | void { + let lastFunc: NodeJS.Timeout | null; + let lastRan: number; - return (...args: Parameters): void => { - const later = () => { - timeout = null; + return (...args: Parameters): ReturnType | void => { + if (!lastRan) { func(...args); - }; + lastRan = Date.now(); + } else { + if (lastFunc) { + clearTimeout(lastFunc); + } - if (timeout) { - clearTimeout(timeout); + lastFunc = setTimeout(() => { + if (Date.now() - lastRan >= limit) { + func(...args); + lastRan = Date.now(); + } + }, limit - (Date.now() - lastRan)); } - - timeout = setTimeout(later, wait); }; } From 256b3d7d75018970e4de503fd64f2a2466b1b54e Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sat, 25 Nov 2023 00:50:43 -0800 Subject: [PATCH 07/14] chore: 0.6.0.alpha.4 release --- libs/client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/client/package.json b/libs/client/package.json index 4f19ca4..31a9f4c 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.6.0-alpha.3", + "version": "0.6.0-alpha.4", "license": "MIT", "repository": { "type": "git", From 27a7ed32cd8bab0c44675330a8be8ced3b070b23 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sat, 25 Nov 2023 01:04:26 -0800 Subject: [PATCH 08/14] chore: update realtime demo --- .../app/realtime/page.tsx | 13 ++-- .../components/drawingState.json | 66 +++++++++---------- libs/client/src/realtime.ts | 4 +- 3 files changed, 42 insertions(+), 41 deletions(-) diff --git a/apps/demo-nextjs-app-router/app/realtime/page.tsx b/apps/demo-nextjs-app-router/app/realtime/page.tsx index 379ec57..afb0df9 100644 --- a/apps/demo-nextjs-app-router/app/realtime/page.tsx +++ b/apps/demo-nextjs-app-router/app/realtime/page.tsx @@ -9,7 +9,7 @@ fal.config({ proxyUrl: '/api/fal/proxy', }); -const PROMPT = 'a sunset over the ocean'; +const PROMPT = 'a moon in a starry night sky'; export default function RealtimePage() { const [image, setImage] = useState(null); @@ -17,7 +17,6 @@ export default function RealtimePage() { const { send } = fal.realtime.connect('110602490-lcm-plexed-sd15-i2i', { clientOnly: true, // in ssr+csr mode, only run in csr connectionKey: 'single-drawing', // reuse connection between render cycles - throttleInterval: 64, // throttle outgoing requests to every 64ms (defaults to 16ms) onResult(result) { if (result.images && result.images[0]) { setImage(result.images[0].url); @@ -26,12 +25,14 @@ export default function RealtimePage() { }); return ( -
-
-

+
+
+

falrealtime

-
{PROMPT}
+
+
{PROMPT}
+
{ * calls to the `send` function. Realtime apps usually react to user * input, which can be very frequesnt (e.g. fast typing or mouse/drag movements). * - * The default value is `16` milliseconds. + * The default value is `64` milliseconds. */ throttleInterval?: number; @@ -142,7 +142,7 @@ export const realtimeImpl: RealtimeClient = { const { clientOnly = false, connectionKey = crypto.randomUUID(), - throttleInterval = 16, + throttleInterval = 64, onError = noop, onResult, } = handler; From 4c23fe37de3f2e6b8d9509eefcc67f6c9696b0ef Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sat, 25 Nov 2023 09:27:35 -0800 Subject: [PATCH 09/14] chore: update preloaded scene --- .../components/drawingState.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/demo-nextjs-app-router/components/drawingState.json b/apps/demo-nextjs-app-router/components/drawingState.json index f0648ff..4fb7b14 100644 --- a/apps/demo-nextjs-app-router/components/drawingState.json +++ b/apps/demo-nextjs-app-router/components/drawingState.json @@ -1,8 +1,8 @@ [ { "type": "rectangle", - "version": 193, - "versionNonce": 1654266828, + "version": 240, + "versionNonce": 21728473, "isDeleted": false, "id": "EnLu91BTRnzWtj7m-l4Id", "fillStyle": "solid", @@ -11,8 +11,8 @@ "roughness": 1, "opacity": 100, "angle": 0, - "x": 0.8463592529296875, - "y": -3.7891082763671875, + "x": -3.3853912353515625, + "y": -2.3741912841796875, "strokeColor": "#1971c2", "backgroundColor": "#343a40", "width": 568.016487121582, @@ -22,14 +22,14 @@ "frameId": null, "roundness": null, "boundElements": [], - "updated": 1700902573776, + "updated": 1700904828477, "link": null, "locked": false }, { "type": "ellipse", - "version": 1118, - "versionNonce": 962567284, + "version": 3545, + "versionNonce": 647409943, "isDeleted": false, "id": "F6oN3k42RqfCqlzJLGXXS", "fillStyle": "solid", @@ -38,8 +38,8 @@ "roughness": 1, "opacity": 100, "angle": 0, - "x": 363.56545639038086, - "y": 72.59371948242188, + "x": 345.65307998657227, + "y": 81.02682495117188, "strokeColor": "#f08c00", "backgroundColor": "#ffec99", "width": 124.31249999999997, @@ -51,7 +51,7 @@ "type": 2 }, "boundElements": [], - "updated": 1700902549149, + "updated": 1700904844024, "link": null, "locked": false } From 4b8c7e86e64f10b8199e78a80cac86801ffcf4ed Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sun, 26 Nov 2023 15:34:41 -0800 Subject: [PATCH 10/14] feat: auth wip --- .../app/page.module.css | 2 - .../app/realtime/page.tsx | 30 +++++-- .../app/whisper/page.tsx | 2 +- libs/client/src/realtime.ts | 81 +++++++++++++++---- libs/client/src/response.ts | 7 +- libs/client/src/utils.ts | 22 +++++ libs/proxy/src/express.ts | 5 +- libs/proxy/src/index.ts | 4 +- libs/proxy/src/nextjs.ts | 7 +- 9 files changed, 123 insertions(+), 37 deletions(-) delete mode 100644 apps/demo-nextjs-app-router/app/page.module.css diff --git a/apps/demo-nextjs-app-router/app/page.module.css b/apps/demo-nextjs-app-router/app/page.module.css deleted file mode 100644 index 8a13e21..0000000 --- a/apps/demo-nextjs-app-router/app/page.module.css +++ /dev/null @@ -1,2 +0,0 @@ -.page { -} diff --git a/apps/demo-nextjs-app-router/app/realtime/page.tsx b/apps/demo-nextjs-app-router/app/realtime/page.tsx index afb0df9..7449c09 100644 --- a/apps/demo-nextjs-app-router/app/realtime/page.tsx +++ b/apps/demo-nextjs-app-router/app/realtime/page.tsx @@ -14,9 +14,9 @@ const PROMPT = 'a moon in a starry night sky'; export default function RealtimePage() { const [image, setImage] = useState(null); - const { send } = fal.realtime.connect('110602490-lcm-plexed-sd15-i2i', { - clientOnly: true, // in ssr+csr mode, only run in csr - connectionKey: 'single-drawing', // reuse connection between render cycles + // const { send } = fal.realtime.connect('110602490-lcm-plexed-sd15-i2i', { + const { send } = fal.realtime.connect('110602490-shared-lcm-test', { + connectionKey: 'realtime-demo', onResult(result) { if (result.images && result.images[0]) { setImage(result.images[0].url); @@ -26,7 +26,7 @@ export default function RealtimePage() { return (
-
+

falrealtime

@@ -41,8 +41,28 @@ export default function RealtimePage() { prompt: PROMPT, image_url: imageData, sync_mode: true, - seed: 612023, + seed: 6252023, }); + // WARNING this might spam the server if you drag shapes in the canvas + // fal + // .run('110602490-shared-lcm-test', { + // // .run('110602490-lcm-plexed-sd15-i2i', { + // autoUpload: false, + // input: { + // prompt: PROMPT, + // image_url: imageData, + // sync_mode: true, + // seed: 612023, + // }, + // }) + // .then((result: any) => { + // if (result.images && result.images[0]) { + // setImage(result.images[0].url); + // } + // }) + // .catch((err: any) => { + // console.error(err); + // }); }} />
diff --git a/apps/demo-nextjs-app-router/app/whisper/page.tsx b/apps/demo-nextjs-app-router/app/whisper/page.tsx index 3d15c1b..58c8c41 100644 --- a/apps/demo-nextjs-app-router/app/whisper/page.tsx +++ b/apps/demo-nextjs-app-router/app/whisper/page.tsx @@ -113,7 +113,7 @@ export default function WhisperDemo() { const result = await fal.subscribe('110602490-whisper', { input: { file_name: 'recording.wav', - url: audioFile, + audio_url: audioFile, }, pollInterval: 1000, logs: true, diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts index 6a8442e..129c608 100644 --- a/libs/client/src/realtime.ts +++ b/libs/client/src/realtime.ts @@ -1,7 +1,8 @@ import { getConfig, getRestApiUrl } from './config'; import { dispatchRequest } from './request'; import { ApiError } from './response'; -import { throttle } from './utils'; +import { isBrowser } from './runtime'; +import { isReact, throttle } from './utils'; /** * A connection object that allows you to `send` request payloads to a @@ -33,6 +34,12 @@ export interface RealtimeConnectionHandler { * If `true`, the connection will only be established on the client side. * This is useful for frameworks that reuse code for both server-side * rendering and client-side rendering (e.g. Next.js). + * + * This is set to `true` by default when running on React in the server. + * Otherwise, it is set to `false`. + * + * Note that more SSR frameworks might be automatically detected + * in the future. In the meantime, you can set this to `true` when needed. */ clientOnly?: boolean; @@ -74,7 +81,7 @@ export interface RealtimeClient { ): RealtimeConnection; } -function builRealtimeUrl(app: string): string { +function buildRealtimeUrl(app: string): string { const { host } = getConfig(); return `wss://${app}.${host}/ws`; } @@ -83,14 +90,20 @@ function builRealtimeUrl(app: string): string { * Get a token to connect to the realtime endpoint. */ async function getToken(app: string): Promise { - return await dispatchRequest( + const token: string | object = await dispatchRequest( 'POST', - `https://${getRestApiUrl()}/tokens`, + `https://${getRestApiUrl()}/tokens/`, { allowed_apps: [app], - token_expiration: 60, + token_expiration: 5, } ); + // keep this in case the response was wrapped (old versions of the proxy do that) + // should be safe to remove in the future + if (typeof token !== 'string' && token['detail']) { + return token['detail']; + } + return token; } /** @@ -101,18 +114,47 @@ const WebSocketErrorCodes = { GOING_AWAY: 1001, }; -const connections = new Map(); +const connectionManager = (() => { + const connections = new Map(); + let currentToken: string | null = null; + + return { + token(): string | null { + return currentToken; + }, + async refreshToken(app: string) { + currentToken = await getToken(app); + console.log(`refreshed token:`, JSON.stringify(currentToken)); + return currentToken; + }, + has(app: string): boolean { + return connections.has(app); + }, + get(app: string): WebSocket | undefined { + return connections.get(app); + }, + set(app: string, ws: WebSocket) { + connections.set(app, ws); + }, + remove(app: string) { + connections.delete(app); + }, + }; +})(); async function getConnection(app: string, key: string): Promise { - const url = builRealtimeUrl(app); - // const token = await getToken(app); - const token = '***'; + const url = buildRealtimeUrl(app); - if (connections.has(key)) { - return connections.get(key) as WebSocket; + if (connectionManager.has(key)) { + return connectionManager.get(key) as WebSocket; + } + let token = connectionManager.token(); + if (!token) { + token = await connectionManager.refreshToken(app); } - const ws = new WebSocket(url); - connections.set(key, ws); + const ws = new WebSocket(`${url}?fal_jwt_token=${token}`); + // const ws = new WebSocket(url); + connectionManager.set(key, ws); return ws; } @@ -140,7 +182,8 @@ export const realtimeImpl: RealtimeClient = { handler: RealtimeConnectionHandler ): RealtimeConnection { const { - clientOnly = false, + // if running on React in the server, set clientOnly to true by default + clientOnly = isReact() && !isBrowser(), connectionKey = crypto.randomUUID(), throttleInterval = 64, onError = noop, @@ -164,13 +207,13 @@ export const realtimeImpl: RealtimeClient = { ); } else { enqueueMessages.push(input); - connect(); + reconnect(); } }; const send = throttleInterval > 0 ? throttle(_send, throttleInterval) : _send; - const connect = () => { + const reconnect = () => { if (ws && ws.readyState === WebSocket.OPEN) { return; } @@ -184,7 +227,7 @@ export const realtimeImpl: RealtimeClient = { } }; ws.onclose = (event) => { - connections.delete(connectionKey); + connectionManager.remove(connectionKey); if (event.code !== WebSocketErrorCodes.NORMAL_CLOSURE) { console.log('ws onclose'); onError( @@ -198,6 +241,10 @@ export const realtimeImpl: RealtimeClient = { ws.onerror = (event) => { console.log('ws onerror'); console.error(event); + // if error 401, refresh token and retry + // if error 403, refresh token and retry + // if any of those are failed again, call onError + onError(new ApiError({ message: 'error', status: 0 })); }; ws.onmessage = (event) => { diff --git a/libs/client/src/response.ts b/libs/client/src/response.ts index f972d94..a0650d9 100644 --- a/libs/client/src/response.ts +++ b/libs/client/src/response.ts @@ -56,7 +56,11 @@ export async function defaultResponseHandler( response: Response ): Promise { const { status, statusText } = response; - const contentType = response.headers.get('Content-Type') ?? ""; + console.log('responseHandler'); + console.log(status, statusText); + const contentType = response.headers.get('Content-Type') ?? ''; + console.log(contentType); + console.log(response.ok); if (!response.ok) { if (contentType.includes('application/json')) { const body = await response.json(); @@ -70,6 +74,7 @@ export async function defaultResponseHandler( throw new ApiError({ message: `HTTP ${status}: ${statusText}`, status }); } if (contentType.includes('application/json')) { + console.log('application/json'); return response.json() as Promise; } if (contentType.includes('text/html')) { diff --git a/libs/client/src/utils.ts b/libs/client/src/utils.ts index 1ca85f7..617437f 100644 --- a/libs/client/src/utils.ts +++ b/libs/client/src/utils.ts @@ -42,3 +42,25 @@ export function throttle any>( } }; } + +let isRunningInReact: boolean; + +/** + * Not really the most optimal way to detect if we're running in React, + * but the idea here is that we can support multiple rendering engines + * (starting with React), with all their peculiarities, without having + * to add a dependency or creating custom integrations (e.g. custom hooks). + * + * Yes, a bit of magic to make things works out-of-the-box. + * @returns `true` if running in React, `false` otherwise. + */ +export function isReact() { + if (isRunningInReact === undefined) { + const stack = new Error().stack; + isRunningInReact = + stack && + (stack.includes('node_modules/react-dom/') || + stack.includes('node_modules/next/')); + } + return isRunningInReact; +} diff --git a/libs/proxy/src/express.ts b/libs/proxy/src/express.ts index cb75777..5afb765 100644 --- a/libs/proxy/src/express.ts +++ b/libs/proxy/src/express.ts @@ -17,10 +17,7 @@ export const handler: RequestHandler = async (request, response, next) => { await handleRequest({ id: 'express', method: request.method, - respondWith: (status, data) => - typeof data === 'string' - ? response.status(status).json({ detail: data }) - : response.status(status).json(data), + respondWith: (status, data) => response.status(status).json(data), getHeaders: () => request.headers, getHeader: (name) => request.headers[name], sendHeader: (name, value) => response.setHeader(name, value), diff --git a/libs/proxy/src/index.ts b/libs/proxy/src/index.ts index d5d17d0..2902876 100644 --- a/libs/proxy/src/index.ts +++ b/libs/proxy/src/index.ts @@ -1,6 +1,6 @@ export const TARGET_URL_HEADER = 'x-fal-target-url'; -export const DEFAULT_PROXY_ROUTE = '/api/_fal/proxy'; +export const DEFAULT_PROXY_ROUTE = '/api/fal/proxy'; const FAL_KEY = process.env.FAL_KEY || process.env.NEXT_PUBLIC_FAL_KEY; const FAL_KEY_ID = process.env.FAL_KEY_ID || process.env.NEXT_PUBLIC_FAL_KEY_ID; @@ -51,7 +51,7 @@ function getFalKey(): string | undefined { return undefined; } -const EXCLUDED_HEADERS = ['content-length']; +const EXCLUDED_HEADERS = ['content-length', 'content-encoding']; /** * A request handler that proxies the request to the fal-serverless diff --git a/libs/proxy/src/nextjs.ts b/libs/proxy/src/nextjs.ts index 2bb3d00..74680ae 100644 --- a/libs/proxy/src/nextjs.ts +++ b/libs/proxy/src/nextjs.ts @@ -19,10 +19,7 @@ export const handler: NextApiHandler = async (request, response) => { return handleRequest({ id: 'nextjs-page-router', method: request.method || 'POST', - respondWith: (status, data) => - typeof data === 'string' - ? response.status(status).json({ detail: data }) - : response.status(status).json(data), + respondWith: (status, data) => response.status(status).json(data), getHeaders: () => request.headers, getHeader: (name) => request.headers[name], sendHeader: (name, value) => response.setHeader(name, value), @@ -53,7 +50,7 @@ async function routeHandler(request: NextRequest) { id: 'nextjs-app-router', method: request.method, respondWith: (status, data) => - NextResponse.json(typeof data === 'string' ? { detail: data } : data, { + NextResponse.json(data, { status, headers: responseHeaders, }), From e0766f8271d496c3428739b43630fbbbe2409f08 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sun, 26 Nov 2023 16:06:43 -0800 Subject: [PATCH 11/14] fix: compilation issue --- libs/client/src/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/client/src/utils.ts b/libs/client/src/utils.ts index 617437f..a06c785 100644 --- a/libs/client/src/utils.ts +++ b/libs/client/src/utils.ts @@ -43,7 +43,7 @@ export function throttle any>( }; } -let isRunningInReact: boolean; +let isRunningInReact: boolean | undefined; /** * Not really the most optimal way to detect if we're running in React, @@ -58,7 +58,7 @@ export function isReact() { if (isRunningInReact === undefined) { const stack = new Error().stack; isRunningInReact = - stack && + !!stack && (stack.includes('node_modules/react-dom/') || stack.includes('node_modules/next/')); } From 45fff5d3fe15e629512565c3d436c98969e404b4 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Sun, 26 Nov 2023 17:41:50 -0800 Subject: [PATCH 12/14] feat: basic auth impl missing error handling --- libs/client/src/realtime.ts | 65 ++++++++++++++++++++++++------------- libs/client/src/response.ts | 5 --- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts index 129c608..9b44c5c 100644 --- a/libs/client/src/realtime.ts +++ b/libs/client/src/realtime.ts @@ -86,16 +86,19 @@ function buildRealtimeUrl(app: string): string { return `wss://${app}.${host}/ws`; } +const TOKEN_EXPIRATION_SECONDS = 120; + /** * Get a token to connect to the realtime endpoint. */ async function getToken(app: string): Promise { + const [_, ...appAlias] = app.split('-'); const token: string | object = await dispatchRequest( 'POST', `https://${getRestApiUrl()}/tokens/`, { - allowed_apps: [app], - token_expiration: 5, + allowed_apps: [appAlias.join('-')], + token_expiration: 120, } ); // keep this in case the response was wrapped (old versions of the proxy do that) @@ -116,28 +119,37 @@ const WebSocketErrorCodes = { const connectionManager = (() => { const connections = new Map(); - let currentToken: string | null = null; + const tokens = new Map(); return { - token(): string | null { - return currentToken; + token(app: string) { + return tokens.get(app); + }, + expireToken(app: string) { + tokens.delete(app); }, async refreshToken(app: string) { - currentToken = await getToken(app); - console.log(`refreshed token:`, JSON.stringify(currentToken)); - return currentToken; + const token = await getToken(app); + tokens.set(app, token); + // Very simple token expiration mechanism. + // We should make it more robust in the future. + setTimeout(() => { + console.log('token expired'); + tokens.delete(app); + }, TOKEN_EXPIRATION_SECONDS * 0.9 * 1000); + return token; }, - has(app: string): boolean { - return connections.has(app); + has(connectionKey: string): boolean { + return connections.has(connectionKey); }, - get(app: string): WebSocket | undefined { - return connections.get(app); + get(connectionKey: string): WebSocket | undefined { + return connections.get(connectionKey); }, - set(app: string, ws: WebSocket) { - connections.set(app, ws); + set(connectionKey: string, ws: WebSocket) { + connections.set(connectionKey, ws); }, - remove(app: string) { - connections.delete(app); + remove(connectionKey: string) { + connections.delete(connectionKey); }, }; })(); @@ -148,12 +160,11 @@ async function getConnection(app: string, key: string): Promise { if (connectionManager.has(key)) { return connectionManager.get(key) as WebSocket; } - let token = connectionManager.token(); + let token = connectionManager.token(app); if (!token) { token = await connectionManager.refreshToken(app); } const ws = new WebSocket(`${url}?fal_jwt_token=${token}`); - // const ws = new WebSocket(url); connectionManager.set(key, ws); return ws; } @@ -195,6 +206,7 @@ export const realtimeImpl: RealtimeClient = { const enqueueMessages: Input[] = []; + let reconnecting = false; let ws: WebSocket | null = null; const _send = (input: Input) => { const requestId = crypto.randomUUID(); @@ -207,7 +219,10 @@ export const realtimeImpl: RealtimeClient = { ); } else { enqueueMessages.push(input); - reconnect(); + if (!reconnecting) { + reconnecting = true; + reconnect(); + } } }; const send = @@ -221,15 +236,16 @@ export const realtimeImpl: RealtimeClient = { .then((connection) => { ws = connection; ws.onopen = () => { + reconnecting = false; if (enqueueMessages.length > 0) { enqueueMessages.forEach((input) => send(input)); enqueueMessages.length = 0; } }; ws.onclose = (event) => { + console.log('ws onclose', event.code, event.reason); connectionManager.remove(connectionKey); if (event.code !== WebSocketErrorCodes.NORMAL_CLOSURE) { - console.log('ws onclose'); onError( new ApiError({ message: 'Error closing the connection', @@ -237,20 +253,25 @@ export const realtimeImpl: RealtimeClient = { }) ); } + ws = null; }; ws.onerror = (event) => { + // TODO handle errors once server specify them console.log('ws onerror'); - console.error(event); // if error 401, refresh token and retry // if error 403, refresh token and retry + console.error(event); + connectionManager.expireToken(app); + connectionManager.remove(connectionKey); + ws = null; // if any of those are failed again, call onError - onError(new ApiError({ message: 'error', status: 0 })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); // Drop messages that are not related to the actual result. // In the future, we might want to handle other types of messages. + // TODO: specify the fal ws protocol format if (data.status !== 'error' && data.type !== 'x-fal-message') { onResult(data); } diff --git a/libs/client/src/response.ts b/libs/client/src/response.ts index a0650d9..3f03a6d 100644 --- a/libs/client/src/response.ts +++ b/libs/client/src/response.ts @@ -56,11 +56,7 @@ export async function defaultResponseHandler( response: Response ): Promise { const { status, statusText } = response; - console.log('responseHandler'); - console.log(status, statusText); const contentType = response.headers.get('Content-Type') ?? ''; - console.log(contentType); - console.log(response.ok); if (!response.ok) { if (contentType.includes('application/json')) { const body = await response.json(); @@ -74,7 +70,6 @@ export async function defaultResponseHandler( throw new ApiError({ message: `HTTP ${status}: ${statusText}`, status }); } if (contentType.includes('application/json')) { - console.log('application/json'); return response.json() as Promise; } if (contentType.includes('text/html')) { From b420c837ae3213fe718878c1c98847262ba40df4 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Mon, 27 Nov 2023 09:34:26 -0800 Subject: [PATCH 13/14] chore: remove console.log prepare 0.6.0 --- apps/demo-nextjs-app-router/app/whisper/page.tsx | 1 - libs/client/package.json | 2 +- libs/client/src/realtime.ts | 14 ++++---------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/demo-nextjs-app-router/app/whisper/page.tsx b/apps/demo-nextjs-app-router/app/whisper/page.tsx index 58c8c41..b79c091 100644 --- a/apps/demo-nextjs-app-router/app/whisper/page.tsx +++ b/apps/demo-nextjs-app-router/app/whisper/page.tsx @@ -128,7 +128,6 @@ export default function WhisperDemo() { }, }); setResult(result); - console.log(result); } catch (error: any) { setError(error); } finally { diff --git a/libs/client/package.json b/libs/client/package.json index 31a9f4c..4c50a30 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.6.0-alpha.4", + "version": "0.6.0", "license": "MIT", "repository": { "type": "git", diff --git a/libs/client/src/realtime.ts b/libs/client/src/realtime.ts index 9b44c5c..fd19d3d 100644 --- a/libs/client/src/realtime.ts +++ b/libs/client/src/realtime.ts @@ -134,7 +134,6 @@ const connectionManager = (() => { // Very simple token expiration mechanism. // We should make it more robust in the future. setTimeout(() => { - console.log('token expired'); tokens.delete(app); }, TOKEN_EXPIRATION_SECONDS * 0.9 * 1000); return token; @@ -243,13 +242,12 @@ export const realtimeImpl: RealtimeClient = { } }; ws.onclose = (event) => { - console.log('ws onclose', event.code, event.reason); connectionManager.remove(connectionKey); if (event.code !== WebSocketErrorCodes.NORMAL_CLOSURE) { onError( new ApiError({ - message: 'Error closing the connection', - status: 0, + message: `Error closing the connection: ${event.reason}`, + status: event.code, }) ); } @@ -257,15 +255,13 @@ export const realtimeImpl: RealtimeClient = { }; ws.onerror = (event) => { // TODO handle errors once server specify them - console.log('ws onerror'); // if error 401, refresh token and retry // if error 403, refresh token and retry - console.error(event); connectionManager.expireToken(app); connectionManager.remove(connectionKey); ws = null; // if any of those are failed again, call onError - onError(new ApiError({ message: 'error', status: 0 })); + onError(new ApiError({ message: 'Unknown error', status: 500 })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); @@ -278,10 +274,8 @@ export const realtimeImpl: RealtimeClient = { }; }) .catch((error) => { - console.log('ws connection error'); - console.error(error); onError( - new ApiError({ message: 'Error opening connection', status: 0 }) + new ApiError({ message: 'Error opening connection', status: 500 }) ); }); }; From 9639e1d65fc3d15a3141626680a67c7c541c17c5 Mon Sep 17 00:00:00 2001 From: Daniel Rochetti Date: Mon, 27 Nov 2023 09:39:42 -0800 Subject: [PATCH 14/14] fix: remove unsused code --- .../app/realtime/page.tsx | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/apps/demo-nextjs-app-router/app/realtime/page.tsx b/apps/demo-nextjs-app-router/app/realtime/page.tsx index 7449c09..152f094 100644 --- a/apps/demo-nextjs-app-router/app/realtime/page.tsx +++ b/apps/demo-nextjs-app-router/app/realtime/page.tsx @@ -14,7 +14,6 @@ const PROMPT = 'a moon in a starry night sky'; export default function RealtimePage() { const [image, setImage] = useState(null); - // const { send } = fal.realtime.connect('110602490-lcm-plexed-sd15-i2i', { const { send } = fal.realtime.connect('110602490-shared-lcm-test', { connectionKey: 'realtime-demo', onResult(result) { @@ -43,26 +42,6 @@ export default function RealtimePage() { sync_mode: true, seed: 6252023, }); - // WARNING this might spam the server if you drag shapes in the canvas - // fal - // .run('110602490-shared-lcm-test', { - // // .run('110602490-lcm-plexed-sd15-i2i', { - // autoUpload: false, - // input: { - // prompt: PROMPT, - // image_url: imageData, - // sync_mode: true, - // seed: 612023, - // }, - // }) - // .then((result: any) => { - // if (result.images && result.images[0]) { - // setImage(result.images[0].url); - // } - // }) - // .catch((err: any) => { - // console.error(err); - // }); }} />