feat(client): realtime client (#29)
* feat(client): realtime client * chore: alpha release * fix: remove os requirement * fix: check if process is defined * fix: ws connection key * fix: outgoing request throttling logic * chore: 0.6.0.alpha.4 release * chore: update realtime demo * chore: update preloaded scene * feat: auth wip * fix: compilation issue * feat: basic auth impl missing error handling * chore: remove console.log prepare 0.6.0 * fix: remove unsused code
This commit is contained in:
parent
c8ff2af189
commit
145159a12f
@ -1,2 +0,0 @@
|
|||||||
.page {
|
|
||||||
}
|
|
||||||
63
apps/demo-nextjs-app-router/app/realtime/page.tsx
Normal file
63
apps/demo-nextjs-app-router/app/realtime/page.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
'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 moon in a starry night sky';
|
||||||
|
|
||||||
|
export default function RealtimePage() {
|
||||||
|
const [image, setImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-900 text-neutral-50">
|
||||||
|
<main className="container flex flex-col items-center justify-center w-full flex-1 py-10 space-y-8">
|
||||||
|
<h1 className="text-4xl font-mono mb-8 text-neutral-50">
|
||||||
|
fal<code className="font-light text-pink-600">realtime</code>
|
||||||
|
</h1>
|
||||||
|
<div className="prose text-neutral-400">
|
||||||
|
<blockquote className="italic text-xl">{PROMPT}</blockquote>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col md:flex-row space-x-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<DrawingCanvas
|
||||||
|
onCanvasChange={({ imageData }) => {
|
||||||
|
send({
|
||||||
|
prompt: PROMPT,
|
||||||
|
image_url: imageData,
|
||||||
|
sync_mode: true,
|
||||||
|
seed: 6252023,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="w-[512px] h-[512px]">
|
||||||
|
{image && (
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={`${PROMPT} generated by fal.ai`}
|
||||||
|
className="object-contain w-full h-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -113,7 +113,7 @@ export default function WhisperDemo() {
|
|||||||
const result = await fal.subscribe('110602490-whisper', {
|
const result = await fal.subscribe('110602490-whisper', {
|
||||||
input: {
|
input: {
|
||||||
file_name: 'recording.wav',
|
file_name: 'recording.wav',
|
||||||
url: audioFile,
|
audio_url: audioFile,
|
||||||
},
|
},
|
||||||
pollInterval: 1000,
|
pollInterval: 1000,
|
||||||
logs: true,
|
logs: true,
|
||||||
@ -128,7 +128,6 @@ export default function WhisperDemo() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
setResult(result);
|
setResult(result);
|
||||||
console.log(result);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setError(error);
|
setError(error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
116
apps/demo-nextjs-app-router/components/drawing.tsx
Normal file
116
apps/demo-nextjs-app-router/components/drawing.tsx
Normal file
@ -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<string> {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
return new Promise<string>((resolve) => {
|
||||||
|
reader.onloadend = () => {
|
||||||
|
resolve(reader.result?.toString() || '');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawingCanvas({ onCanvasChange }: DrawingCanvasProps) {
|
||||||
|
const [ExcalidrawComponent, setExcalidrawComponent] = useState<
|
||||||
|
typeof Excalidraw | null
|
||||||
|
>(null);
|
||||||
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
|
useState<ExcalidrawImperativeAPI | null>(null);
|
||||||
|
const [sceneData, setSceneData] = useState<any>(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 (
|
||||||
|
<div style={{ height: '560px', width: '560px' }}>
|
||||||
|
{ExcalidrawComponent && (
|
||||||
|
<ExcalidrawComponent
|
||||||
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
|
initialData={{ elements: initialDrawing as ExcalidrawElement[] }}
|
||||||
|
onChange={handleCanvasChanges}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/demo-nextjs-app-router/components/drawingState.json
Normal file
58
apps/demo-nextjs-app-router/components/drawingState.json
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "rectangle",
|
||||||
|
"version": 240,
|
||||||
|
"versionNonce": 21728473,
|
||||||
|
"isDeleted": false,
|
||||||
|
"id": "EnLu91BTRnzWtj7m-l4Id",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 1,
|
||||||
|
"opacity": 100,
|
||||||
|
"angle": 0,
|
||||||
|
"x": -3.3853912353515625,
|
||||||
|
"y": -2.3741912841796875,
|
||||||
|
"strokeColor": "#1971c2",
|
||||||
|
"backgroundColor": "#343a40",
|
||||||
|
"width": 568.016487121582,
|
||||||
|
"height": 582.1398010253906,
|
||||||
|
"seed": 295965933,
|
||||||
|
"groupIds": [],
|
||||||
|
"frameId": null,
|
||||||
|
"roundness": null,
|
||||||
|
"boundElements": [],
|
||||||
|
"updated": 1700904828477,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "ellipse",
|
||||||
|
"version": 3545,
|
||||||
|
"versionNonce": 647409943,
|
||||||
|
"isDeleted": false,
|
||||||
|
"id": "F6oN3k42RqfCqlzJLGXXS",
|
||||||
|
"fillStyle": "solid",
|
||||||
|
"strokeWidth": 1,
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"roughness": 1,
|
||||||
|
"opacity": 100,
|
||||||
|
"angle": 0,
|
||||||
|
"x": 345.65307998657227,
|
||||||
|
"y": 81.02682495117188,
|
||||||
|
"strokeColor": "#f08c00",
|
||||||
|
"backgroundColor": "#ffec99",
|
||||||
|
"width": 124.31249999999997,
|
||||||
|
"height": 113.591796875,
|
||||||
|
"seed": 23374002,
|
||||||
|
"groupIds": [],
|
||||||
|
"frameId": null,
|
||||||
|
"roundness": {
|
||||||
|
"type": 2
|
||||||
|
},
|
||||||
|
"boundElements": [],
|
||||||
|
"updated": 1700904844024,
|
||||||
|
"link": null,
|
||||||
|
"locked": false
|
||||||
|
}
|
||||||
|
]
|
||||||
@ -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.5.4",
|
"version": "0.6.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import type { RequestMiddleware } from './middleware';
|
import {
|
||||||
|
withProxy,
|
||||||
|
type RequestMiddleware,
|
||||||
|
withMiddleware,
|
||||||
|
} from './middleware';
|
||||||
import type { ResponseHandler } from './response';
|
import type { ResponseHandler } from './response';
|
||||||
import { defaultResponseHandler } from './response';
|
import { defaultResponseHandler } from './response';
|
||||||
|
|
||||||
@ -7,6 +11,7 @@ export type CredentialsResolver = () => string | undefined;
|
|||||||
export type Config = {
|
export type Config = {
|
||||||
credentials?: undefined | string | CredentialsResolver;
|
credentials?: undefined | string | CredentialsResolver;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
proxyUrl?: string;
|
||||||
requestMiddleware?: RequestMiddleware;
|
requestMiddleware?: RequestMiddleware;
|
||||||
responseHandler?: ResponseHandler<any>;
|
responseHandler?: ResponseHandler<any>;
|
||||||
};
|
};
|
||||||
@ -21,7 +26,7 @@ export type RequiredConfig = Required<Config>;
|
|||||||
*/
|
*/
|
||||||
function hasEnvVariables(): boolean {
|
function hasEnvVariables(): boolean {
|
||||||
return (
|
return (
|
||||||
process &&
|
typeof process !== 'undefined' &&
|
||||||
process.env &&
|
process.env &&
|
||||||
(typeof process.env.FAL_KEY !== 'undefined' ||
|
(typeof process.env.FAL_KEY !== 'undefined' ||
|
||||||
(typeof process.env.FAL_KEY_ID !== 'undefined' &&
|
(typeof process.env.FAL_KEY_ID !== 'undefined' &&
|
||||||
@ -49,7 +54,7 @@ export const credentialsFromEnv: CredentialsResolver = () => {
|
|||||||
*/
|
*/
|
||||||
function getDefaultHost(): string {
|
function getDefaultHost(): string {
|
||||||
const host = 'gateway.alpha.fal.ai';
|
const host = 'gateway.alpha.fal.ai';
|
||||||
if (process && process.env) {
|
if (typeof process !== 'undefined' && process.env) {
|
||||||
return process.env.FAL_HOST || host;
|
return process.env.FAL_HOST || host;
|
||||||
}
|
}
|
||||||
return host;
|
return host;
|
||||||
@ -71,6 +76,15 @@ let configuration: RequiredConfig;
|
|||||||
*/
|
*/
|
||||||
export function config(config: Config) {
|
export function config(config: Config) {
|
||||||
configuration = { ...DEFAULT_CONFIG, ...config } as RequiredConfig;
|
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;
|
return configuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns the URL of the fal serverless rest api endpoint.
|
||||||
|
*/
|
||||||
|
export function getRestApiUrl(): string {
|
||||||
|
const { host } = getConfig();
|
||||||
|
return host.replace('gateway', 'rest');
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
export { config, getConfig } from './config';
|
export { config, getConfig } from './config';
|
||||||
export { storageImpl as storage } from './storage';
|
|
||||||
export { queue, run, subscribe } from './function';
|
export { queue, run, subscribe } from './function';
|
||||||
export { withMiddleware, withProxy } from './middleware';
|
export { withMiddleware, withProxy } from './middleware';
|
||||||
export type { RequestMiddleware } from './middleware';
|
export type { RequestMiddleware } from './middleware';
|
||||||
|
export { realtimeImpl as realtime } from './realtime';
|
||||||
export { ApiError, ValidationError } from './response';
|
export { ApiError, ValidationError } from './response';
|
||||||
export type { ResponseHandler } from './response';
|
export type { ResponseHandler } from './response';
|
||||||
|
export { storageImpl as storage } from './storage';
|
||||||
export type {
|
export type {
|
||||||
QueueStatus,
|
QueueStatus,
|
||||||
ValidationErrorInfo,
|
ValidationErrorInfo,
|
||||||
|
|||||||
295
libs/client/src/realtime.ts
Normal file
295
libs/client/src/realtime.ts
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import { getConfig, getRestApiUrl } from './config';
|
||||||
|
import { dispatchRequest } from './request';
|
||||||
|
import { ApiError } from './response';
|
||||||
|
import { isBrowser } from './runtime';
|
||||||
|
import { isReact, throttle } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A connection object that allows you to `send` request payloads to a
|
||||||
|
* realtime endpoint.
|
||||||
|
*/
|
||||||
|
export interface RealtimeConnection<Input> {
|
||||||
|
send(input: Input): void;
|
||||||
|
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultWithRequestId = {
|
||||||
|
request_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for connecting to the realtime endpoint.
|
||||||
|
*/
|
||||||
|
export interface RealtimeConnectionHandler<Output> {
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `64` milliseconds.
|
||||||
|
*/
|
||||||
|
throttleInterval?: 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<any>): 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<Input = any, Output = any>(
|
||||||
|
app: string,
|
||||||
|
handler: RealtimeConnectionHandler<Output>
|
||||||
|
): RealtimeConnection<Input>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRealtimeUrl(app: string): string {
|
||||||
|
const { host } = getConfig();
|
||||||
|
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<string> {
|
||||||
|
const [_, ...appAlias] = app.split('-');
|
||||||
|
const token: string | object = await dispatchRequest<any, string>(
|
||||||
|
'POST',
|
||||||
|
`https://${getRestApiUrl()}/tokens/`,
|
||||||
|
{
|
||||||
|
allowed_apps: [appAlias.join('-')],
|
||||||
|
token_expiration: 120,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
|
||||||
|
*/
|
||||||
|
const WebSocketErrorCodes = {
|
||||||
|
NORMAL_CLOSURE: 1000,
|
||||||
|
GOING_AWAY: 1001,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionManager = (() => {
|
||||||
|
const connections = new Map<string, WebSocket>();
|
||||||
|
const tokens = new Map<string, string>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
token(app: string) {
|
||||||
|
return tokens.get(app);
|
||||||
|
},
|
||||||
|
expireToken(app: string) {
|
||||||
|
tokens.delete(app);
|
||||||
|
},
|
||||||
|
async refreshToken(app: string) {
|
||||||
|
const token = await getToken(app);
|
||||||
|
tokens.set(app, token);
|
||||||
|
// Very simple token expiration mechanism.
|
||||||
|
// We should make it more robust in the future.
|
||||||
|
setTimeout(() => {
|
||||||
|
tokens.delete(app);
|
||||||
|
}, TOKEN_EXPIRATION_SECONDS * 0.9 * 1000);
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
has(connectionKey: string): boolean {
|
||||||
|
return connections.has(connectionKey);
|
||||||
|
},
|
||||||
|
get(connectionKey: string): WebSocket | undefined {
|
||||||
|
return connections.get(connectionKey);
|
||||||
|
},
|
||||||
|
set(connectionKey: string, ws: WebSocket) {
|
||||||
|
connections.set(connectionKey, ws);
|
||||||
|
},
|
||||||
|
remove(connectionKey: string) {
|
||||||
|
connections.delete(connectionKey);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
async function getConnection(app: string, key: string): Promise<WebSocket> {
|
||||||
|
const url = buildRealtimeUrl(app);
|
||||||
|
|
||||||
|
if (connectionManager.has(key)) {
|
||||||
|
return connectionManager.get(key) as WebSocket;
|
||||||
|
}
|
||||||
|
let token = connectionManager.token(app);
|
||||||
|
if (!token) {
|
||||||
|
token = await connectionManager.refreshToken(app);
|
||||||
|
}
|
||||||
|
const ws = new WebSocket(`${url}?fal_jwt_token=${token}`);
|
||||||
|
connectionManager.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<any> = {
|
||||||
|
send: noop,
|
||||||
|
close: noop,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default implementation of the realtime client.
|
||||||
|
*/
|
||||||
|
export const realtimeImpl: RealtimeClient = {
|
||||||
|
connect<Input, Output>(
|
||||||
|
app: string,
|
||||||
|
handler: RealtimeConnectionHandler<Output>
|
||||||
|
): RealtimeConnection<Input> {
|
||||||
|
const {
|
||||||
|
// if running on React in the server, set clientOnly to true by default
|
||||||
|
clientOnly = isReact() && !isBrowser(),
|
||||||
|
connectionKey = crypto.randomUUID(),
|
||||||
|
throttleInterval = 64,
|
||||||
|
onError = noop,
|
||||||
|
onResult,
|
||||||
|
} = handler;
|
||||||
|
if (clientOnly && typeof window === 'undefined') {
|
||||||
|
return NoOpConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueueMessages: Input[] = [];
|
||||||
|
|
||||||
|
let reconnecting = false;
|
||||||
|
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);
|
||||||
|
if (!reconnecting) {
|
||||||
|
reconnecting = true;
|
||||||
|
reconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const send =
|
||||||
|
throttleInterval > 0 ? throttle(_send, throttleInterval) : _send;
|
||||||
|
|
||||||
|
const reconnect = () => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getConnection(app, connectionKey)
|
||||||
|
.then((connection) => {
|
||||||
|
ws = connection;
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnecting = false;
|
||||||
|
if (enqueueMessages.length > 0) {
|
||||||
|
enqueueMessages.forEach((input) => send(input));
|
||||||
|
enqueueMessages.length = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
connectionManager.remove(connectionKey);
|
||||||
|
if (event.code !== WebSocketErrorCodes.NORMAL_CLOSURE) {
|
||||||
|
onError(
|
||||||
|
new ApiError({
|
||||||
|
message: `Error closing the connection: ${event.reason}`,
|
||||||
|
status: event.code,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ws = null;
|
||||||
|
};
|
||||||
|
ws.onerror = (event) => {
|
||||||
|
// TODO handle errors once server specify them
|
||||||
|
// if error 401, refresh token and retry
|
||||||
|
// if error 403, refresh token and retry
|
||||||
|
connectionManager.expireToken(app);
|
||||||
|
connectionManager.remove(connectionKey);
|
||||||
|
ws = null;
|
||||||
|
// if any of those are failed again, call onError
|
||||||
|
onError(new ApiError({ message: 'Unknown error', status: 500 }));
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
onError(
|
||||||
|
new ApiError({ message: 'Error opening connection', status: 500 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
send,
|
||||||
|
close() {
|
||||||
|
if (ws && ws.readyState === WebSocket.CLOSED) {
|
||||||
|
ws.close(
|
||||||
|
WebSocketErrorCodes.GOING_AWAY,
|
||||||
|
'Client manually closed the connection.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -56,7 +56,7 @@ export async function defaultResponseHandler<Output>(
|
|||||||
response: Response
|
response: Response
|
||||||
): Promise<Output> {
|
): Promise<Output> {
|
||||||
const { status, statusText } = response;
|
const { status, statusText } = response;
|
||||||
const contentType = response.headers.get('Content-Type') ?? "";
|
const contentType = response.headers.get('Content-Type') ?? '';
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('application/json')) {
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|||||||
@ -13,9 +13,6 @@ export function getUserAgent(): string {
|
|||||||
return memoizedUserAgent;
|
return memoizedUserAgent;
|
||||||
}
|
}
|
||||||
const packageInfo = require('../package.json');
|
const packageInfo = require('../package.json');
|
||||||
const os = require('os');
|
memoizedUserAgent = `${packageInfo.name}/${packageInfo.version}`;
|
||||||
memoizedUserAgent = `${packageInfo.name}/${
|
|
||||||
packageInfo.version
|
|
||||||
} ${os.platform()}-${os.arch()} ${process.release.name}-${process.version}`;
|
|
||||||
return memoizedUserAgent;
|
return memoizedUserAgent;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { getConfig } from './config';
|
import { getConfig, getRestApiUrl } from './config';
|
||||||
import { dispatchRequest } from './request';
|
import { dispatchRequest } from './request';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,11 +50,6 @@ type InitiateUploadData = {
|
|||||||
content_type: string | null;
|
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
|
* Get the file extension from the content type. This is used to generate
|
||||||
* a file name if the file name is not provided.
|
* a file name if the file name is not provided.
|
||||||
|
|||||||
@ -15,3 +15,52 @@ export function isValidUrl(url: string) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
limit: number
|
||||||
|
): (...funcArgs: Parameters<T>) => ReturnType<T> | void {
|
||||||
|
let lastFunc: NodeJS.Timeout | null;
|
||||||
|
let lastRan: number;
|
||||||
|
|
||||||
|
return (...args: Parameters<T>): ReturnType<T> | void => {
|
||||||
|
if (!lastRan) {
|
||||||
|
func(...args);
|
||||||
|
lastRan = Date.now();
|
||||||
|
} else {
|
||||||
|
if (lastFunc) {
|
||||||
|
clearTimeout(lastFunc);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastFunc = setTimeout(() => {
|
||||||
|
if (Date.now() - lastRan >= limit) {
|
||||||
|
func(...args);
|
||||||
|
lastRan = Date.now();
|
||||||
|
}
|
||||||
|
}, limit - (Date.now() - lastRan));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let isRunningInReact: boolean | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"outDir": "../../dist/out-tsc",
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"inlineSources": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": false,
|
"checkJs": false,
|
||||||
|
|||||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -34,6 +34,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^17.0.0",
|
"@commitlint/cli": "^17.0.0",
|
||||||
"@commitlint/config-conventional": "^17.0.0",
|
"@commitlint/config-conventional": "^17.0.0",
|
||||||
|
"@excalidraw/excalidraw": "^0.17.0",
|
||||||
"@nrwl/express": "16.10.0",
|
"@nrwl/express": "16.10.0",
|
||||||
"@nx/cypress": "16.10.0",
|
"@nx/cypress": "16.10.0",
|
||||||
"@nx/eslint-plugin": "16.10.0",
|
"@nx/eslint-plugin": "16.10.0",
|
||||||
@ -3073,6 +3074,16 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"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": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.11.13",
|
"version": "0.11.13",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
|
||||||
|
|||||||
@ -50,6 +50,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^17.0.0",
|
"@commitlint/cli": "^17.0.0",
|
||||||
"@commitlint/config-conventional": "^17.0.0",
|
"@commitlint/config-conventional": "^17.0.0",
|
||||||
|
"@excalidraw/excalidraw": "^0.17.0",
|
||||||
"@nrwl/express": "16.10.0",
|
"@nrwl/express": "16.10.0",
|
||||||
"@nx/cypress": "16.10.0",
|
"@nx/cypress": "16.10.0",
|
||||||
"@nx/eslint-plugin": "16.10.0",
|
"@nx/eslint-plugin": "16.10.0",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user