feat(client): binary chunk streaming support (#77)

* feat(client): binary chunk streaming support

* fix: buffer update

* fix: audio playing

* fix: media resource setup

* feat: allow streaming through the proxy

* fix: legacy urls env

* feat: streaming connection mode

* chore: bump client alpha version

* feat(proxy): enable response streaming when supported

* fix: queue streaming

* chore: deprecated endpoint id cleanup

* fix: client tests

* chore: demo page updates

* chore: bump version for release
This commit is contained in:
Daniel Rochetti 2024-08-06 09:09:40 -07:00 committed by GitHub
parent d9ea6c7dd3
commit 6edbf2948d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 281 additions and 185 deletions

View File

@ -25,10 +25,15 @@ function Error(props: ErrorProps) {
); );
} }
const DEFAULT_ENDPOINT_ID = 'fal-ai/fast-sdxl';
const DEFAULT_INPUT = `{
"prompt": "A beautiful sunset over the ocean"
}`;
export default function Home() { export default function Home() {
// Input state // Input state
const [endpointId, setEndpointId] = useState<string>(''); const [endpointId, setEndpointId] = useState<string>(DEFAULT_ENDPOINT_ID);
const [input, setInput] = useState<string>('{}'); const [input, setInput] = useState<string>(DEFAULT_INPUT);
// Result state // Result state
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);

View File

@ -1,7 +1,7 @@
{ {
"name": "@fal-ai/serverless-client", "name": "@fal-ai/serverless-client",
"description": "The fal serverless JS/TS client", "description": "The fal serverless JS/TS client",
"version": "0.14.0-alpha.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -1,19 +1,6 @@
import uuid from 'uuid-random';
import { buildUrl } from './function'; import { buildUrl } from './function';
describe('The function test suite', () => { describe('The function test suite', () => {
it('should build the URL with a function UUIDv4', () => {
const id = uuid();
const url = buildUrl(`12345/${id}`);
expect(url).toMatch(`trigger/12345/${id}`);
});
it('should build the URL with a function user-id-app-alias', () => {
const alias = '12345-some-alias';
const url = buildUrl(alias);
expect(url).toMatch(`fal.run/12345/some-alias`);
});
it('should build the URL with a function username/app-alias', () => { it('should build the URL with a function username/app-alias', () => {
const alias = 'fal-ai/text-to-image'; const alias = 'fal-ai/text-to-image';
const url = buildUrl(alias); const url = buildUrl(alias);

View File

@ -1,14 +1,13 @@
import { getTemporaryAuthToken } from './auth';
import { dispatchRequest } from './request'; import { dispatchRequest } from './request';
import { storageImpl } from './storage'; import { storageImpl } from './storage';
import { FalStream } from './streaming'; import { FalStream, StreamingConnectionMode } from './streaming';
import { import {
CompletedQueueStatus, CompletedQueueStatus,
EnqueueResult, EnqueueResult,
QueueStatus, QueueStatus,
RequestLog, RequestLog,
} from './types'; } from './types';
import { ensureAppIdFormat, isUUIDv4, isValidUrl, parseAppId } from './utils'; import { ensureAppIdFormat, isValidUrl, parseAppId } from './utils';
/** /**
* The function input and other configuration when running * The function input and other configuration when running
@ -80,7 +79,6 @@ export function buildUrl<Input>(
Object.keys(params).length > 0 Object.keys(params).length > 0
? `?${new URLSearchParams(params).toString()}` ? `?${new URLSearchParams(params).toString()}`
: ''; : '';
const parts = id.split('/');
// if a fal url is passed, just use it // if a fal url is passed, just use it
if (isValidUrl(id)) { if (isValidUrl(id)) {
@ -88,12 +86,6 @@ export function buildUrl<Input>(
return `${url}${path}${queryParams}`; return `${url}${path}${queryParams}`;
} }
// TODO remove this after some time, fal.run should be preferred
if (parts.length === 2 && isUUIDv4(parts[1])) {
const host = 'gateway.shark.fal.ai';
return `https://${host}/trigger/${id}/${path}${queryParams}`;
}
const appId = ensureAppIdFormat(id); const appId = ensureAppIdFormat(id);
const subdomain = options.subdomain ? `${options.subdomain}.` : ''; const subdomain = options.subdomain ? `${options.subdomain}.` : '';
const url = `https://${subdomain}fal.run/${appId}/${path}`; const url = `https://${subdomain}fal.run/${appId}/${path}`;
@ -199,6 +191,12 @@ type QueueSubscribeOptions = {
} }
| { | {
mode: 'streaming'; mode: 'streaming';
/**
* The connection mode to use for streaming updates. It defaults to `server`.
* Set to `client` if your server proxy doesn't support streaming.
*/
connectionMode?: StreamingConnectionMode;
} }
); );
@ -228,6 +226,14 @@ type QueueStatusOptions = BaseQueueOptions & {
logs?: boolean; logs?: boolean;
}; };
type QueueStatusStreamOptions = QueueStatusOptions & {
/**
* The connection mode to use for streaming updates. It defaults to `server`.
* Set to `client` if your server proxy doesn't support streaming.
*/
connectionMode?: StreamingConnectionMode;
};
/** /**
* Represents a request queue with methods for submitting requests, * Represents a request queue with methods for submitting requests,
* checking their status, retrieving results, and subscribing to updates. * checking their status, retrieving results, and subscribing to updates.
@ -263,7 +269,7 @@ interface Queue {
*/ */
streamStatus( streamStatus(
endpointId: string, endpointId: string,
options: QueueStatusOptions options: QueueStatusStreamOptions
): Promise<FalStream<unknown, QueueStatus>>; ): Promise<FalStream<unknown, QueueStatus>>;
/** /**
@ -340,24 +346,26 @@ export const queue: Queue = {
async streamStatus( async streamStatus(
endpointId: string, endpointId: string,
{ requestId, logs = false }: QueueStatusOptions { requestId, logs = false, connectionMode }: QueueStatusStreamOptions
): Promise<FalStream<unknown, QueueStatus>> { ): Promise<FalStream<unknown, QueueStatus>> {
const appId = parseAppId(endpointId); const appId = parseAppId(endpointId);
const prefix = appId.namespace ? `${appId.namespace}/` : ''; const prefix = appId.namespace ? `${appId.namespace}/` : '';
const token = await getTemporaryAuthToken(endpointId);
const queryParams = {
logs: logs ? '1' : '0',
};
const url = buildUrl(`${prefix}${appId.owner}/${appId.alias}`, { const url = buildUrl(`${prefix}${appId.owner}/${appId.alias}`, {
subdomain: 'queue', subdomain: 'queue',
path: `/requests/${requestId}/status/stream`, path: `/requests/${requestId}/status/stream`,
query: queryParams,
}); });
const queryParams = new URLSearchParams({ return new FalStream<unknown, QueueStatus>(endpointId, {
fal_jwt_token: token, url,
logs: logs ? '1' : '0',
});
return new FalStream<unknown, QueueStatus>(`${url}?${queryParams}`, {
input: {},
method: 'get', method: 'get',
connectionMode,
queryParams,
}); });
}, },
@ -375,6 +383,10 @@ export const queue: Queue = {
const status = await queue.streamStatus(endpointId, { const status = await queue.streamStatus(endpointId, {
requestId, requestId,
logs: options.logs, logs: options.logs,
connectionMode:
'connectionMode' in options
? (options.connectionMode as StreamingConnectionMode)
: undefined,
}); });
const logs: RequestLog[] = []; const logs: RequestLog[] = [];
if (timeout) { if (timeout) {
@ -390,7 +402,7 @@ export const queue: Queue = {
); );
}, timeout); }, timeout);
} }
status.on('message', (data: QueueStatus) => { status.on('data', (data: QueueStatus) => {
if (options.onQueueUpdate) { if (options.onQueueUpdate) {
// accumulate logs to match previous polling behavior // accumulate logs to match previous polling behavior
if ( if (

View File

@ -1,14 +1,20 @@
import { getConfig } from './config'; import { getConfig } from './config';
import { ResponseHandler } from './response';
import { getUserAgent, isBrowser } from './runtime'; import { getUserAgent, isBrowser } from './runtime';
const isCloudflareWorkers = const isCloudflareWorkers =
typeof navigator !== 'undefined' && typeof navigator !== 'undefined' &&
navigator?.userAgent === 'Cloudflare-Workers'; navigator?.userAgent === 'Cloudflare-Workers';
type RequestOptions = {
responseHandler?: ResponseHandler<any>;
};
export async function dispatchRequest<Input, Output>( export async function dispatchRequest<Input, Output>(
method: string, method: string,
targetUrl: string, targetUrl: string,
input: Input input: Input,
options: RequestOptions & RequestInit = {}
): Promise<Output> { ): Promise<Output> {
const { const {
credentials: credentialsValue, credentials: credentialsValue,
@ -39,14 +45,21 @@ export async function dispatchRequest<Input, Output>(
...userAgent, ...userAgent,
...(headers ?? {}), ...(headers ?? {}),
} as HeadersInit; } as HeadersInit;
const { responseHandler: customResponseHandler, ...requestInit } = options;
const response = await fetch(url, { const response = await fetch(url, {
...requestInit,
method, method,
headers: requestHeaders, headers: {
...requestHeaders,
...(requestInit.headers ?? {}),
},
...(!isCloudflareWorkers && { mode: 'cors' }), ...(!isCloudflareWorkers && { mode: 'cors' }),
body: body:
method.toLowerCase() !== 'get' && input method.toLowerCase() !== 'get' && input
? JSON.stringify(input) ? JSON.stringify(input)
: undefined, : undefined,
}); });
return await responseHandler(response); const handleResponse = customResponseHandler ?? responseHandler;
return await handleResponse(response);
} }

View File

@ -2,19 +2,33 @@ import { createParser } from 'eventsource-parser';
import { getTemporaryAuthToken } from './auth'; import { getTemporaryAuthToken } from './auth';
import { getConfig } from './config'; import { getConfig } from './config';
import { buildUrl } from './function'; import { buildUrl } from './function';
import { dispatchRequest } from './request';
import { ApiError, defaultResponseHandler } from './response'; import { ApiError, defaultResponseHandler } from './response';
import { storageImpl } from './storage'; import { storageImpl } from './storage';
export type StreamingConnectionMode = 'client' | 'server';
/** /**
* The stream API options. It requires the API input and also * The stream API options. It requires the API input and also
* offers configuration options. * offers configuration options.
*/ */
type StreamOptions<Input> = { type StreamOptions<Input> = {
/**
* The endpoint URL. If not provided, it will be generated from the
* `endpointId` and the `queryParams`.
*/
readonly url?: string;
/** /**
* The API input payload. * The API input payload.
*/ */
readonly input?: Input; readonly input?: Input;
/**
* The query parameters to be sent with the request.
*/
readonly queryParams?: Record<string, string>;
/** /**
* The maximum time interval in milliseconds between stream chunks. Defaults to 15s. * The maximum time interval in milliseconds between stream chunks. Defaults to 15s.
*/ */
@ -30,19 +44,37 @@ type StreamOptions<Input> = {
* The HTTP method, defaults to `post`; * The HTTP method, defaults to `post`;
*/ */
readonly method?: 'get' | 'post' | 'put' | 'delete' | string; readonly method?: 'get' | 'post' | 'put' | 'delete' | string;
/**
* The content type the client accepts as response.
* By default this is set to `text/event-stream`.
*/
readonly accept?: string;
/**
* The streaming connection mode. This is used to determine
* whether the streaming will be done from the browser itself (client)
* or through your own server, either when running on NodeJS or when
* using a proxy that supports streaming.
*
* It defaults to `server`. Set to `client` if your server proxy doesn't
* support streaming.
*/
readonly connectionMode?: StreamingConnectionMode;
}; };
const EVENT_STREAM_TIMEOUT = 15 * 1000; const EVENT_STREAM_TIMEOUT = 15 * 1000;
type FalStreamEventType = 'message' | 'error' | 'done'; type FalStreamEventType = 'data' | 'error' | 'done';
type EventHandler = (event: any) => void; type EventHandler<T = any> = (event: T) => void;
/** /**
* The class representing a streaming response. With t * The class representing a streaming response. With t
*/ */
export class FalStream<Input, Output> { export class FalStream<Input, Output> {
// properties // properties
endpointId: string;
url: string; url: string;
options: StreamOptions<Input>; options: StreamOptions<Input>;
@ -58,8 +90,14 @@ export class FalStream<Input, Output> {
private abortController = new AbortController(); private abortController = new AbortController();
constructor(url: string, options: StreamOptions<Input>) { constructor(endpointId: string, options: StreamOptions<Input>) {
this.url = url; this.endpointId = endpointId;
this.url =
options.url ??
buildUrl(endpointId, {
path: '/stream',
query: options.queryParams,
});
this.options = options; this.options = options;
this.donePromise = new Promise<Output>((resolve, reject) => { this.donePromise = new Promise<Output>((resolve, reject) => {
if (this.streamClosed) { if (this.streamClosed) {
@ -84,20 +122,34 @@ export class FalStream<Input, Output> {
} }
private start = async () => { private start = async () => {
const { url, options } = this; const { endpointId, options } = this;
const { input, method = 'post' } = options; const { input, method = 'post', connectionMode = 'server' } = options;
const { fetch = global.fetch } = getConfig();
try { try {
const response = await fetch(url, { if (connectionMode === 'client') {
// if we are in the browser, we need to get a temporary token
// to authenticate the request
const token = await getTemporaryAuthToken(endpointId);
const { fetch = global.fetch } = getConfig();
const parsedUrl = new URL(this.url);
parsedUrl.searchParams.set('fal_jwt_token', token);
const response = await fetch(parsedUrl.toString(), {
method: method.toUpperCase(), method: method.toUpperCase(),
headers: { headers: {
accept: 'text/event-stream', accept: options.accept ?? 'text/event-stream',
'content-type': 'application/json', 'content-type': 'application/json',
}, },
body: input && method !== 'get' ? JSON.stringify(input) : undefined, body: input && method !== 'get' ? JSON.stringify(input) : undefined,
signal: this.abortController.signal, signal: this.abortController.signal,
}); });
this.handleResponse(response); return await this.handleResponse(response);
}
return await dispatchRequest(method.toUpperCase(), this.url, input, {
headers: {
accept: options.accept ?? 'text/event-stream',
},
responseHandler: this.handleResponse,
signal: this.abortController.signal,
});
} catch (error) { } catch (error) {
this.handleError(error); this.handleError(error);
} }
@ -127,6 +179,25 @@ export class FalStream<Input, Output> {
); );
return; return;
} }
// any response that is not a text/event-stream will be handled as a binary stream
if (response.headers.get('content-type') !== 'text/event-stream') {
const reader = body.getReader();
const emitRawChunk = () => {
reader.read().then(({ done, value }) => {
if (done) {
this.emit('done', this.currentData);
return;
}
this.currentData = value as Output;
this.emit('data', value);
emitRawChunk();
});
};
emitRawChunk();
return;
}
const decoder = new TextDecoder('utf-8'); const decoder = new TextDecoder('utf-8');
const reader = response.body.getReader(); const reader = response.body.getReader();
@ -138,7 +209,10 @@ export class FalStream<Input, Output> {
const parsedData = JSON.parse(data); const parsedData = JSON.parse(data);
this.buffer.push(parsedData); this.buffer.push(parsedData);
this.currentData = parsedData; this.currentData = parsedData;
this.emit('message', parsedData); this.emit('data', parsedData);
// also emit 'message'for backwards compatibility
this.emit('message' as any, parsedData);
} catch (e) { } catch (e) {
this.emit('error', e); this.emit('error', e);
} }
@ -242,27 +316,19 @@ export class FalStream<Input, Output> {
* object as a result, that can be used to get partial results through either * object as a result, that can be used to get partial results through either
* `AsyncIterator` or through an event listener. * `AsyncIterator` or through an event listener.
* *
* @param appId the app id, e.g. `fal-ai/llavav15-13b`. * @param endpointId the endpoint id, e.g. `fal-ai/llavav15-13b`.
* @param options the request options, including the input payload. * @param options the request options, including the input payload.
* @returns the `FalStream` instance. * @returns the `FalStream` instance.
*/ */
export async function stream<Input = Record<string, any>, Output = any>( export async function stream<Input = Record<string, any>, Output = any>(
appId: string, endpointId: string,
options: StreamOptions<Input> options: StreamOptions<Input>
): Promise<FalStream<Input, Output>> { ): Promise<FalStream<Input, Output>> {
const token = await getTemporaryAuthToken(appId);
const url = buildUrl(appId, { path: '/stream' });
const input = const input =
options.input && options.autoUpload !== false options.input && options.autoUpload !== false
? await storageImpl.transformInput(options.input) ? await storageImpl.transformInput(options.input)
: options.input; : options.input;
return new FalStream<Input, Output>(endpointId, {
const queryParams = new URLSearchParams({
fal_jwt_token: token,
});
return new FalStream<Input, Output>(`${url}?${queryParams}`, {
...options, ...options,
input: input as Input, input: input as Input,
}); });

View File

@ -1,22 +1,6 @@
import uuid from 'uuid-random'; import { ensureAppIdFormat, parseAppId } from './utils';
import { ensureAppIdFormat, isUUIDv4, parseAppId } from './utils';
describe('The utils test suite', () => { describe('The utils test suite', () => {
it('should match a valid v4 uuid', () => {
const id = uuid();
expect(isUUIDv4(id)).toBe(true);
});
it('should not match invalid v4 id', () => {
const id = 'e726b886-e2c2-11ed-b5ea-0242ac120002';
expect(isUUIDv4(id)).toBe(false);
});
it('shoud match match a legacy appOwner-appId format', () => {
const id = '12345-abcde-fgh';
expect(ensureAppIdFormat(id)).toBe('12345/abcde-fgh');
});
it('shoud match a current appOwner/appId format', () => { it('shoud match a current appOwner/appId format', () => {
const id = 'fal-ai/fast-sdxl'; const id = 'fal-ai/fast-sdxl';
expect(ensureAppIdFormat(id)).toBe(id); expect(ensureAppIdFormat(id)).toBe(id);
@ -32,15 +16,6 @@ describe('The utils test suite', () => {
expect(() => ensureAppIdFormat(id)).toThrowError(); expect(() => ensureAppIdFormat(id)).toThrowError();
}); });
it('should parse a legacy app id', () => {
const id = '12345-abcde-fgh';
const parsed = parseAppId(id);
expect(parsed).toEqual({
owner: '12345',
alias: 'abcde-fgh',
});
});
it('should parse a current app id', () => { it('should parse a current app id', () => {
const id = 'fal-ai/fast-sdxl'; const id = 'fal-ai/fast-sdxl';
const parsed = parseAppId(id); const parsed = parseAppId(id);

View File

@ -1,12 +1,3 @@
export function isUUIDv4(id: string): boolean {
return (
typeof id === 'string' &&
id.length === 36 &&
id[14] === '4' &&
['8', '9', 'a', 'b'].includes(id[19])
);
}
export function ensureAppIdFormat(id: string): string { export function ensureAppIdFormat(id: string): string {
const parts = id.split('/'); const parts = id.split('/');
if (parts.length > 1) { if (parts.length > 1) {

View File

@ -1,6 +1,6 @@
{ {
"name": "@fal-ai/serverless-proxy", "name": "@fal-ai/serverless-proxy",
"version": "0.7.5", "version": "0.8.0",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -17,11 +17,37 @@ export const handler: RequestHandler = async (request, response, next) => {
await handleRequest({ await handleRequest({
id: 'express', id: 'express',
method: request.method, method: request.method,
respondWith: (status, data) => response.status(status).json(data), getRequestBody: async () => JSON.stringify(request.body),
getHeaders: () => request.headers, getHeaders: () => request.headers,
getHeader: (name) => request.headers[name], getHeader: (name) => request.headers[name],
sendHeader: (name, value) => response.setHeader(name, value), sendHeader: (name, value) => response.setHeader(name, value),
getBody: async () => JSON.stringify(request.body), respondWith: (status, data) => response.status(status).json(data),
sendResponse: async (res) => {
if (res.body instanceof ReadableStream) {
const reader = res.body.getReader();
const stream = async () => {
const { done, value } = await reader.read();
if (done) {
response.end();
return response;
}
response.write(value);
return await stream();
};
return await stream().catch((error) => {
if (!response.headersSent) {
response.status(500).send(error.message);
} else {
response.end();
}
});
}
if (res.headers.get('content-type')?.includes('application/json')) {
return response.status(res.status).json(await res.json());
}
return response.status(res.status).send(await res.text());
},
}); });
next(); next();
}; };

View File

@ -19,10 +19,11 @@ export interface ProxyBehavior<ResponseType> {
method: string; method: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
respondWith(status: number, data: string | any): ResponseType; respondWith(status: number, data: string | any): ResponseType;
sendResponse(response: Response): Promise<ResponseType>;
getHeaders(): Record<string, HeaderValue>; getHeaders(): Record<string, HeaderValue>;
getHeader(name: string): HeaderValue; getHeader(name: string): HeaderValue;
sendHeader(name: string, value: string): void; sendHeader(name: string, value: string): void;
getBody(): Promise<string | undefined>; getRequestBody(): Promise<string | undefined>;
resolveApiKey?: () => Promise<string | undefined>; resolveApiKey?: () => Promise<string | undefined>;
} }
@ -109,7 +110,7 @@ export async function handleRequest<ResponseType>(
body: body:
behavior.method?.toUpperCase() === 'GET' behavior.method?.toUpperCase() === 'GET'
? undefined ? undefined
: await behavior.getBody(), : await behavior.getRequestBody(),
}); });
// copy headers from fal to the proxied response // copy headers from fal to the proxied response
@ -119,12 +120,7 @@ export async function handleRequest<ResponseType>(
} }
}); });
if (res.headers.get('content-type')?.includes('application/json')) { return behavior.sendResponse(res);
const data = await res.json();
return behavior.respondWith(res.status, data);
}
const data = await res.text();
return behavior.respondWith(res.status, data);
} }
export function fromHeaders( export function fromHeaders(
@ -138,3 +134,5 @@ export function fromHeaders(
}); });
return result; return result;
} }
export const responsePassthrough = (res: Response) => Promise.resolve(res);

View File

@ -1,6 +1,11 @@
import { NextResponse, type NextRequest } from 'next/server'; import { NextResponse, type NextRequest } from 'next/server';
import type { NextApiHandler } from 'next/types'; import type { NextApiHandler } from 'next/types';
import { DEFAULT_PROXY_ROUTE, fromHeaders, handleRequest } from './index'; import {
DEFAULT_PROXY_ROUTE,
fromHeaders,
handleRequest,
responsePassthrough,
} from './index';
/** /**
* The default Next API route for the fal.ai client proxy. * The default Next API route for the fal.ai client proxy.
@ -11,6 +16,8 @@ export const PROXY_ROUTE = DEFAULT_PROXY_ROUTE;
* The Next API route handler for the fal.ai client proxy. * The Next API route handler for the fal.ai client proxy.
* Use it with the /pages router in Next.js. * Use it with the /pages router in Next.js.
* *
* Note: the page routers proxy doesn't support streaming responses.
*
* @param request the Next API request object. * @param request the Next API request object.
* @param response the Next API response object. * @param response the Next API response object.
* @returns a promise that resolves when the request is handled. * @returns a promise that resolves when the request is handled.
@ -19,11 +26,17 @@ export const handler: NextApiHandler = async (request, response) => {
return handleRequest({ return handleRequest({
id: 'nextjs-page-router', id: 'nextjs-page-router',
method: request.method || 'POST', method: request.method || 'POST',
respondWith: (status, data) => response.status(status).json(data), getRequestBody: async () => JSON.stringify(request.body),
getHeaders: () => request.headers, getHeaders: () => request.headers,
getHeader: (name) => request.headers[name], getHeader: (name) => request.headers[name],
sendHeader: (name, value) => response.setHeader(name, value), sendHeader: (name, value) => response.setHeader(name, value),
getBody: async () => JSON.stringify(request.body), respondWith: (status, data) => response.status(status).json(data),
sendResponse: async (res) => {
if (res.headers.get('content-type')?.includes('application/json')) {
return response.status(res.status).json(await res.json());
}
return response.status(res.status).send(await res.text());
},
}); });
}; };
@ -36,18 +49,22 @@ export const handler: NextApiHandler = async (request, response) => {
async function routeHandler(request: NextRequest) { async function routeHandler(request: NextRequest) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const responseHeaders: Record<string, any> = {}; const responseHeaders: Record<string, any> = {};
// check if response if from a streaming request
return await handleRequest({ return await handleRequest({
id: 'nextjs-app-router', id: 'nextjs-app-router',
method: request.method, method: request.method,
getRequestBody: async () => request.text(),
getHeaders: () => fromHeaders(request.headers),
getHeader: (name) => request.headers.get(name),
sendHeader: (name, value) => (responseHeaders[name] = value),
respondWith: (status, data) => respondWith: (status, data) =>
NextResponse.json(data, { NextResponse.json(data, {
status, status,
headers: responseHeaders, headers: responseHeaders,
}), }),
getHeaders: () => fromHeaders(request.headers), sendResponse: responsePassthrough,
getHeader: (name) => request.headers.get(name),
sendHeader: (name, value) => (responseHeaders[name] = value),
getBody: async () => request.text(),
}); });
} }

View File

@ -1,5 +1,5 @@
import { type RequestHandler } from '@sveltejs/kit'; import { type RequestHandler } from '@sveltejs/kit';
import { fromHeaders, handleRequest } from './index'; import { fromHeaders, handleRequest, responsePassthrough } from './index';
type RequestHandlerParams = { type RequestHandlerParams = {
/** /**
@ -28,16 +28,17 @@ export const createRequestHandler = ({
return await handleRequest({ return await handleRequest({
id: 'svelte-app-router', id: 'svelte-app-router',
method: request.method, method: request.method,
getRequestBody: async () => request.text(),
getHeaders: () => fromHeaders(request.headers),
getHeader: (name) => request.headers.get(name),
sendHeader: (name, value) => (responseHeaders[name] = value),
resolveApiKey: () => Promise.resolve(FAL_KEY),
respondWith: (status, data) => respondWith: (status, data) =>
new Response(JSON.stringify(data), { new Response(JSON.stringify(data), {
status, status,
headers: responseHeaders, headers: responseHeaders,
}), }),
getHeaders: () => fromHeaders(request.headers), sendResponse: responsePassthrough,
getHeader: (name) => request.headers.get(name),
sendHeader: (name, value) => (responseHeaders[name] = value),
getBody: async () => request.text(),
resolveApiKey: () => Promise.resolve(FAL_KEY),
}); });
}; };
return { return {

125
package-lock.json generated
View File

@ -31,7 +31,7 @@
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"http-proxy-middleware": "^2.0.6", "http-proxy-middleware": "^2.0.6",
"js-base64": "^3.7.5", "js-base64": "^3.7.5",
"next": "^14.0.3", "next": "^14.2.5",
"open": "^10.0.3", "open": "^10.0.3",
"ora": "^8.0.1", "ora": "^8.0.1",
"react": "^18.2.0", "react": "^18.2.0",
@ -4764,9 +4764,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.5.tgz",
"integrity": "sha512-7xRqh9nMvP5xrW4/+L0jgRRX+HoNRGnfJpD+5Wq6/13j3dsdzxO3BCXn7D3hMqsDb+vjZnJq+vI7+EtgrYZTeA==" "integrity": "sha512-/zZGkrTOsraVfYjGP8uM0p6r0BDT6xWpkjdVbcz66PJVSpwXX3yNiRycxAuDfBKGWBrZBXRuK/YVlkNgxHGwmA=="
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
"version": "14.0.3", "version": "14.0.3",
@ -4798,9 +4798,9 @@
} }
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.5.tgz",
"integrity": "sha512-64JbSvi3nbbcEtyitNn2LEDS/hcleAFpHdykpcnrstITFlzFgB/bW0ER5/SJJwUPj+ZPY+z3e+1jAfcczRLVGw==", "integrity": "sha512-/9zVxJ+K9lrzSGli1///ujyRfon/ZneeZ+v4ptpiPoOU+GKZnm8Wj8ELWU1Pm7GHltYRBklmXMTUqM/DqQ99FQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4813,9 +4813,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.5.tgz",
"integrity": "sha512-RkTf+KbAD0SgYdVn1XzqE/+sIxYGB7NLMZRn9I4Z24afrhUpVJx6L8hsRnIwxz3ERE2NFURNliPjJ2QNfnWicQ==", "integrity": "sha512-vXHOPCwfDe9qLDuq7U1OYM2wUY+KQ4Ex6ozwsKxp26BlJ6XXbHleOUldenM67JRyBfVjv371oneEvYd3H2gNSA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4828,9 +4828,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.5.tgz",
"integrity": "sha512-3tBWGgz7M9RKLO6sPWC6c4pAw4geujSwQ7q7Si4d6bo0l6cLs4tmO+lnSwFp1Tm3lxwfMk0SgkJT7EdwYSJvcg==", "integrity": "sha512-vlhB8wI+lj8q1ExFW8lbWutA4M2ZazQNvMWuEDqZcuJJc78iUnLdPPunBPX8rC4IgT6lIx/adB+Cwrl99MzNaA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4843,9 +4843,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.5.tgz",
"integrity": "sha512-v0v8Kb8j8T23jvVUWZeA2D8+izWspeyeDGNaT2/mTHWp7+37fiNfL8bmBWiOmeumXkacM/AB0XOUQvEbncSnHA==", "integrity": "sha512-NpDB9NUR2t0hXzJJwQSGu1IAOYybsfeB+LxpGsXrRIb7QOrYmidJz3shzY8cM6+rO4Aojuef0N/PEaX18pi9OA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4858,9 +4858,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.5.tgz",
"integrity": "sha512-VM1aE1tJKLBwMGtyBR21yy+STfl0MapMQnNrXkxeyLs0GFv/kZqXS5Jw/TQ3TSUnbv0QPDf/X8sDXuMtSgG6eg==", "integrity": "sha512-8XFikMSxWleYNryWIjiCX+gU201YS+erTUidKdyOVYi5qUQo/gRxv/3N1oZFCgqpesN6FPeqGM72Zve+nReVXQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4873,9 +4873,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.5.tgz",
"integrity": "sha512-64EnmKy18MYFL5CzLaSuUn561hbO1Gk16jM/KHznYP3iCIfF9e3yULtHaMy0D8zbHfxset9LTOv6cuYKJgcOxg==", "integrity": "sha512-6QLwi7RaYiQDcRDSU/os40r5o06b5ue7Jsk5JgdRBGGp8l37RZEh9JsLSM8QF0YDsgcosSeHjglgqi25+m04IQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -4888,9 +4888,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.5.tgz",
"integrity": "sha512-WRDp8QrmsL1bbGtsh5GqQ/KWulmrnMBgbnb+59qNTW1kVi1nG/2ndZLkcbs2GX7NpFLlToLRMWSQXmPzQm4tog==", "integrity": "sha512-1GpG2VhbspO+aYoMOQPQiqc/tG3LzmsdBH0LhnDS3JrtDx2QmzXe0B6mSZZiN3Bq7IOMXxv1nlsjzoS1+9mzZw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -4903,9 +4903,9 @@
} }
}, },
"node_modules/@next/swc-win32-ia32-msvc": { "node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.5.tgz",
"integrity": "sha512-EKffQeqCrj+t6qFFhIFTRoqb2QwX1mU7iTOvMyLbYw3QtqTw9sMwjykyiMlZlrfm2a4fA84+/aeW+PMg1MjuTg==", "integrity": "sha512-Igh9ZlxwvCDsu6438FXlQTHlRno4gFpJzqPjSIBZooD22tKeI4fE/YMRoHVJHmrQ2P5YL1DoZ0qaOKkbeFWeMg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -4918,9 +4918,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.0.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.5.tgz",
"integrity": "sha512-ERhKPSJ1vQrPiwrs15Pjz/rvDHZmkmvbf/BjPN/UCOI++ODftT0GtasDPi0j+y6PPJi5HsXw+dpRaXUaw4vjuQ==", "integrity": "sha512-tEQ7oinq1/CjSG9uSTerca3v4AZ+dFa+4Yu6ihaG8Ud8ddqLQgFGcnwYls13H5X5CPDPZJdYxyeMui6muOLd4g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -7616,17 +7616,16 @@
} }
}, },
"node_modules/@swc/counter": { "node_modules/@swc/counter": {
"version": "0.1.2", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="
"devOptional": true,
"peer": true
}, },
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.2", "version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.2.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==", "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"dependencies": { "dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
@ -10369,9 +10368,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001558", "version": "1.0.30001643",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001558.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001643.tgz",
"integrity": "sha512-/Et7DwLqpjS47JPEcz6VnxU9PwcIdVi0ciLXRWBQdj1XFye68pSQYpV0QtPTfUKWuOaEig+/Vez2l74eDc1tPQ==", "integrity": "sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -15782,7 +15781,8 @@
"node_modules/glob-to-regexp": { "node_modules/glob-to-regexp": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"dev": true
}, },
"node_modules/glob/node_modules/minimatch": { "node_modules/glob/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
@ -20608,17 +20608,17 @@
"dev": true "dev": true
}, },
"node_modules/next": { "node_modules/next": {
"version": "14.0.3", "version": "14.2.5",
"resolved": "https://registry.npmjs.org/next/-/next-14.0.3.tgz", "resolved": "https://registry.npmjs.org/next/-/next-14.2.5.tgz",
"integrity": "sha512-AbYdRNfImBr3XGtvnwOxq8ekVCwbFTv/UJoLwmaX89nk9i051AEY4/HAWzU0YpaTDw8IofUpmuIlvzWF13jxIw==", "integrity": "sha512-0f8aRfBVL+mpzfBjYfQuLWh2WyAwtJXCRfkPF4UJ5qd2YwrHczsrSzXU4tRMV0OAxR8ZJZWPFn6uhSC56UTsLA==",
"dependencies": { "dependencies": {
"@next/env": "14.0.3", "@next/env": "14.2.5",
"@swc/helpers": "0.5.2", "@swc/helpers": "0.5.5",
"busboy": "1.6.0", "busboy": "1.6.0",
"caniuse-lite": "^1.0.30001406", "caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31", "postcss": "8.4.31",
"styled-jsx": "5.1.1", "styled-jsx": "5.1.1"
"watchpack": "2.4.0"
}, },
"bin": { "bin": {
"next": "dist/bin/next" "next": "dist/bin/next"
@ -20627,18 +20627,19 @@
"node": ">=18.17.0" "node": ">=18.17.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "14.0.3", "@next/swc-darwin-arm64": "14.2.5",
"@next/swc-darwin-x64": "14.0.3", "@next/swc-darwin-x64": "14.2.5",
"@next/swc-linux-arm64-gnu": "14.0.3", "@next/swc-linux-arm64-gnu": "14.2.5",
"@next/swc-linux-arm64-musl": "14.0.3", "@next/swc-linux-arm64-musl": "14.2.5",
"@next/swc-linux-x64-gnu": "14.0.3", "@next/swc-linux-x64-gnu": "14.2.5",
"@next/swc-linux-x64-musl": "14.0.3", "@next/swc-linux-x64-musl": "14.2.5",
"@next/swc-win32-arm64-msvc": "14.0.3", "@next/swc-win32-arm64-msvc": "14.2.5",
"@next/swc-win32-ia32-msvc": "14.0.3", "@next/swc-win32-ia32-msvc": "14.2.5",
"@next/swc-win32-x64-msvc": "14.0.3" "@next/swc-win32-x64-msvc": "14.2.5"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"sass": "^1.3.0" "sass": "^1.3.0"
@ -20647,6 +20648,9 @@
"@opentelemetry/api": { "@opentelemetry/api": {
"optional": true "optional": true
}, },
"@playwright/test": {
"optional": true
},
"sass": { "sass": {
"optional": true "optional": true
} }
@ -30163,6 +30167,7 @@
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
"integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==",
"dev": true,
"dependencies": { "dependencies": {
"glob-to-regexp": "^0.4.1", "glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2" "graceful-fs": "^4.1.2"

View File

@ -47,7 +47,7 @@
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"http-proxy-middleware": "^2.0.6", "http-proxy-middleware": "^2.0.6",
"js-base64": "^3.7.5", "js-base64": "^3.7.5",
"next": "^14.0.3", "next": "^14.2.5",
"open": "^10.0.3", "open": "^10.0.3",
"ora": "^8.0.1", "ora": "^8.0.1",
"react": "^18.2.0", "react": "^18.2.0",