From 1d778a795909e42b3b4e9624ed162313776191e8 Mon Sep 17 00:00:00 2001 From: ShafSpecs Date: Tue, 1 Oct 2024 04:05:01 +0100 Subject: [PATCH] feat(worker-runtime): runtime v5 release candidate - added support for SPA modec- Disabled worker runtime APIs when single fetch is enabled BREAKING CHANGE --- .../src/utils/__test__/handle-request.test.ts | 17 +++ .../src/utils/__test__/request.test.ts | 20 ++- .../src/utils/handle-request.ts | 33 +++-- packages/worker-runtime/src/utils/remix.ts | 117 ++++++++++++++++++ packages/worker-runtime/src/utils/request.ts | 17 ++- packages/worker-runtime/src/utils/unstable.ts | 81 ++++++++++++ 6 files changed, 271 insertions(+), 14 deletions(-) create mode 100644 packages/worker-runtime/src/utils/remix.ts create mode 100644 packages/worker-runtime/src/utils/unstable.ts diff --git a/packages/worker-runtime/src/utils/__test__/handle-request.test.ts b/packages/worker-runtime/src/utils/__test__/handle-request.test.ts index eae94d1f..23a3a22e 100644 --- a/packages/worker-runtime/src/utils/__test__/handle-request.test.ts +++ b/packages/worker-runtime/src/utils/__test__/handle-request.test.ts @@ -9,6 +9,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerLoader: true, module: { workerLoader: vi.fn(() => Promise.resolve({ message: 'Hello, world!' })), }, @@ -31,6 +32,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerLoader: true, module: { workerLoader: vi.fn().mockReturnValue(new Response('mock-response', { status: 200 })), }, @@ -53,6 +55,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: vi.fn(() => Promise.resolve({ message: 'Hello, world!' })), }, @@ -97,6 +100,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerLoader: true, module: { workerLoader: vi.fn(() => Promise.reject(error)), }, @@ -124,6 +128,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: vi.fn().mockReturnValue(new Response(null, { status: 302, headers: { Location: '/route2' } })), }, @@ -147,6 +152,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: vi .fn() @@ -176,6 +182,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: vi.fn().mockReturnValue(new Response('Remix response', { status: 200 })), }, @@ -200,6 +207,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: mockWorkerAction, }, @@ -225,6 +233,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: mockWorkerAction, }, @@ -254,6 +263,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: mockWorkerAction, }, @@ -283,6 +293,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: mockWorkerAction, }, @@ -326,6 +337,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: mockWorkerAction, }, @@ -364,6 +376,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: mockWorkerAction, }, @@ -392,6 +405,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerAction: true, module: { workerAction: mockWorkerAction, }, @@ -420,6 +434,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerLoader: true, module: { workerLoader: mockWorkerLoader, }, @@ -446,6 +461,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerLoader: true, module: { workerLoader: mockWorkerLoader, }, @@ -480,6 +496,7 @@ describe('handleRequest', () => { const routes = { route1: { id: 'route1', + hasWorkerLoader: true, module: { workerLoader: mockWorkerLoader, }, diff --git a/packages/worker-runtime/src/utils/__test__/request.test.ts b/packages/worker-runtime/src/utils/__test__/request.test.ts index 32dc0554..3a0e2b66 100644 --- a/packages/worker-runtime/src/utils/__test__/request.test.ts +++ b/packages/worker-runtime/src/utils/__test__/request.test.ts @@ -8,6 +8,7 @@ import { isActionRequest, stripDataParameter, stripIndexParameter, + stripRouteParameter, } from '../request.js'; describe('clone', () => { @@ -62,10 +63,20 @@ describe('stripDataParam', () => { }); }); +describe('stripRouteParam', () => { + test('should remove _route parameter from the URL', () => { + const request = new Request('https://example.com/test?_route=root'); + const stripped = stripRouteParameter(request); + + expect(stripped.url).toBe('https://example.com/test'); + expect(stripped.headers).toEqual(request.headers); + }); +}); + describe('createArgumentsFrom', () => { test('should create an object with request, params, and context properties', () => { const event = { - request: new Request('https://example.com/test?a=1&b=2&index&_data=test'), + request: new Request('https://example.com/test?a=1&_route=test&b=2&index&_data=test'), } as FetchEvent; const loadContext = {} as WorkerLoadContext; @@ -85,6 +96,13 @@ describe('isActionRequest', () => { expect(isAction).toBeTruthy(); }); + test('should return true for clientAction requests in SPA mode', () => { + const request = new Request('https://example.com/test?_route=test', { method: 'POST' }); + const isAction = isActionRequest(request, true); + + expect(isAction).toBeTruthy(); + }); + test('should return false for non-action requests', () => { const request = new Request('https://example.com/test?a=1&b=2'); const isAction = isActionRequest(request); diff --git a/packages/worker-runtime/src/utils/handle-request.ts b/packages/worker-runtime/src/utils/handle-request.ts index b0093918..99e691f7 100644 --- a/packages/worker-runtime/src/utils/handle-request.ts +++ b/packages/worker-runtime/src/utils/handle-request.ts @@ -6,20 +6,20 @@ import type { WorkerRouteManifest, } from '@remix-pwa/dev/worker-build.js'; import { isRouteErrorResponse } from '@remix-run/router'; -import { ServerMode } from '@remix-run/server-runtime/dist/mode.js'; -import type { TypedResponse } from '@remix-run/server-runtime/dist/responses.js'; + +import type { TypedResponse } from './remix.js'; import { - createDeferredReadableStream, isDeferredData, isRedirectResponse, isRedirectStatusCode, isResponse, json, redirect, -} from '@remix-run/server-runtime/dist/responses.js'; - + ServerMode, +} from './remix.js'; import { createArgumentsFrom, getURLParameters, isActionRequest, isLoaderRequest } from './request.js'; import { errorResponseToJson, isRemixResponse } from './response.js'; +import { createDeferredReadableStream } from './unstable.js'; interface HandleRequestArgs { defaultHandler: DefaultFetchHandler; @@ -47,6 +47,7 @@ interface HandleError { /** * A FetchEvent handler for Remix. + * * If the `event.request` has a worker loader/action defined, it will call it and return the response. * Otherwise, it will call the default handler... */ @@ -57,11 +58,23 @@ export async function handleRequest({ loadContext, routes, }: HandleRequestArgs): Promise { - const isSPAMode = process.env.__REMIX_PWA_SPA_MODE === 'true'; + const isSPAMode = String(process.env.__REMIX_PWA_SPA_MODE) === 'true'; + const isSingleFetchMode = String(process.env.__REMIX_SINGLE_FETCH) === 'true'; const url = new URL(event.request.url); - const routeId = url.searchParams.get('_data'); - // if the request is not a loader or action request, we call the default handler and the routeId will be undefined + + let routeId: string | null; + + if (!isSPAMode) { + routeId = url.searchParams.get('_data'); + } else { + routeId = url.searchParams.get('_route'); + } + + if (isSingleFetchMode) routeId = null; + + // if the request is not a loader or action request, we call + // the default handler and the routeId will be undefined const route = routeId ? routes[routeId] : undefined; const _arguments = { request: event.request, @@ -70,7 +83,7 @@ export async function handleRequest({ }; try { - if (isLoaderRequest(event.request, isSPAMode) && route?.module.workerLoader) { + if (isLoaderRequest(event.request, isSPAMode) && route?.hasWorkerLoader && route?.module?.workerLoader) { return await handleLoader({ event, loader: route.module.workerLoader, @@ -80,7 +93,7 @@ export async function handleRequest({ }).then(responseHandler); } - if (isActionRequest(event.request, isSPAMode) && route?.module?.workerAction) { + if (isActionRequest(event.request, isSPAMode) && route?.hasWorkerAction && route?.module?.workerAction) { return await handleAction({ event, action: route.module.workerAction, diff --git a/packages/worker-runtime/src/utils/remix.ts b/packages/worker-runtime/src/utils/remix.ts new file mode 100644 index 00000000..19ed5ffa --- /dev/null +++ b/packages/worker-runtime/src/utils/remix.ts @@ -0,0 +1,117 @@ +import type { DeferredData, TrackedPromise } from '@remix-run/router/dist/utils.js'; + +export type RedirectFunction = (url: string, init?: number | ResponseInit) => Response; +export type JsonFunction = (data: Data, init?: number | ResponseInit) => Response; +export type TypedResponse = Omit & { + json(): Promise; +}; + +/** + * The mode to use when running the server. + */ +export enum ServerMode { + Development = 'development', + Production = 'production', + Test = 'test', +} + +export function isServerMode(value: any): value is ServerMode { + return value === ServerMode.Development || value === ServerMode.Production || value === ServerMode.Test; +} + +export function isDeferredData(value: any): value is DeferredData { + const deferred: DeferredData = value; + return ( + deferred && + typeof deferred === 'object' && + typeof deferred.data === 'object' && + typeof deferred.subscribe === 'function' && + typeof deferred.cancel === 'function' && + typeof deferred.resolveData === 'function' + ); +} + +export function isResponse(value: any): value is Response { + return ( + value != null && + typeof value.status === 'number' && + typeof value.statusText === 'string' && + typeof value.headers === 'object' && + typeof value.body !== 'undefined' + ); +} + +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +export function isRedirectStatusCode(statusCode: number): boolean { + return redirectStatusCodes.has(statusCode); +} +export function isRedirectResponse(response: Response): boolean { + return isRedirectStatusCode(response.status); +} + +export function isTrackedPromise(value: any): value is TrackedPromise { + return value != null && typeof value.then === 'function' && value._tracked === true; +} + +/** + * A redirect response. Sets the status code and the `Location` header. + * Defaults to "302 Found". + */ +export const redirect: RedirectFunction = (url, init = 302) => { + let responseInit = init; + if (typeof responseInit === 'number') { + responseInit = { status: responseInit }; + } else if (typeof responseInit.status === 'undefined') { + responseInit.status = 302; + } + + const headers = new Headers(responseInit.headers); + headers.set('Location', url); + + return new Response(null, { + ...responseInit, + headers, + }); +}; + +/** + * This is a shortcut for creating `application/json` responses. Converts `data` + * to JSON and sets the `Content-Type` header. + */ +export const json: JsonFunction = (data, init = {}) => { + const responseInit = typeof init === 'number' ? { status: init } : init; + + const headers = new Headers(responseInit.headers); + if (!headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json; charset=utf-8'); + } + + return new Response(JSON.stringify(data), { + ...responseInit, + headers, + }); +}; + +export function sanitizeError(error: T, serverMode: ServerMode) { + if (error instanceof Error && serverMode !== ServerMode.Development) { + const sanitized = new Error('Unexpected Server Error'); + sanitized.stack = undefined; + return sanitized; + } + return error; +} + +// must be type alias due to inference issues on interfaces +// https://github.com/microsoft/TypeScript/issues/15300 +export type SerializedError = { + message: string; + stack?: string; +}; + +export function serializeError(error: Error, serverMode: ServerMode): SerializedError { + const sanitized = sanitizeError(error, serverMode); + return { + message: sanitized.message, + stack: sanitized.stack, + }; +} diff --git a/packages/worker-runtime/src/utils/request.ts b/packages/worker-runtime/src/utils/request.ts index 42f06f37..0b058f29 100644 --- a/packages/worker-runtime/src/utils/request.ts +++ b/packages/worker-runtime/src/utils/request.ts @@ -59,6 +59,17 @@ export function stripDataParameter(request: Request): Request { return new Request(url.href, { ...clone(request), duplex: 'half' }); } +/** + * Removes the route parameter from a request. + */ +export function stripRouteParameter(request: Request): Request { + const url = new URL(request.url); + url.searchParams.delete('_route'); + // We need to set the duplex property, otherwise the request will fail in the worker. + // @ts-expect-error The duplex property is not defined in the Request type, yet. See: https://github.com/whatwg/fetch/pull/1493 + return new Request(url.href, { ...clone(request), duplex: 'half' }); +} + /** * Creates arguments for the Worker Actions and Loaders. */ @@ -71,7 +82,7 @@ export function createArgumentsFrom({ loadContext: WorkerLoadContext; path?: string; }) { - const request = stripDataParameter(stripIndexParameter(event.request.clone())); + const request = stripRouteParameter(stripDataParameter(stripIndexParameter(event.request.clone()))); const parameters = getURLParameters(request, path); return { @@ -107,7 +118,7 @@ export function isActionMethod(request: Request) { */ export function isActionRequest(request: Request, spaMode = false) { const url = new URL(request.url); - const qualifies = spaMode ? true : url.searchParams.get('_data'); + const qualifies = spaMode ? url.searchParams.get('_route') : url.searchParams.get('_data'); return isActionMethod(request) && qualifies; } @@ -116,6 +127,6 @@ export function isActionRequest(request: Request, spaMode = false) { */ export function isLoaderRequest(request: Request, spaMode = false) { const url = new URL(request.url); - const qualifies = spaMode ? true : url.searchParams.get('_data'); + const qualifies = spaMode ? url.searchParams.get('_route') : url.searchParams.get('_data'); return isLoaderMethod(request) && qualifies; } diff --git a/packages/worker-runtime/src/utils/unstable.ts b/packages/worker-runtime/src/utils/unstable.ts new file mode 100644 index 00000000..22d0c6ad --- /dev/null +++ b/packages/worker-runtime/src/utils/unstable.ts @@ -0,0 +1,81 @@ +import type { DeferredData, TrackedPromise } from '@remix-run/router/dist/utils.js'; + +import { isTrackedPromise, serializeError, type ServerMode } from './remix.js'; + +const DEFERRED_VALUE_PLACEHOLDER_PREFIX = '__deferred_promise:'; +export function createDeferredReadableStream( + deferredData: DeferredData, + signal: AbortSignal, + serverMode: ServerMode +): any { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller: any) { + const criticalData: any = {}; + + const preresolvedKeys: string[] = []; + for (const [key, value] of Object.entries(deferredData.data)) { + if (isTrackedPromise(value)) { + criticalData[key] = `${DEFERRED_VALUE_PLACEHOLDER_PREFIX}${key}`; + if (typeof value._data !== 'undefined' || typeof value._error !== 'undefined') { + preresolvedKeys.push(key); + } + } else { + criticalData[key] = value; + } + } + + // Send the critical data + controller.enqueue(encoder.encode(JSON.stringify(criticalData) + '\n\n')); + + for (const preresolvedKey of preresolvedKeys) { + enqueueTrackedPromise( + controller, + encoder, + preresolvedKey, + deferredData.data[preresolvedKey] as TrackedPromise, + serverMode + ); + } + + const unsubscribe = deferredData.subscribe((aborted, settledKey) => { + if (settledKey) { + enqueueTrackedPromise( + controller, + encoder, + settledKey, + deferredData.data[settledKey] as TrackedPromise, + serverMode + ); + } + }); + await deferredData.resolveData(signal); + unsubscribe(); + controller.close(); + }, + }); + + return stream; +} + +function enqueueTrackedPromise( + controller: any, + encoder: TextEncoder, + settledKey: string, + promise: TrackedPromise, + serverMode: ServerMode +) { + if ('_error' in promise) { + controller.enqueue( + encoder.encode( + 'error:' + + JSON.stringify({ + [settledKey]: promise._error instanceof Error ? serializeError(promise._error, serverMode) : promise._error, + }) + + '\n\n' + ) + ); + } else { + controller.enqueue(encoder.encode('data:' + JSON.stringify({ [settledKey]: promise._data ?? null }) + '\n\n')); + } +}