From 535a5e6120ec4fe24710cfbaf067078e5130f28c Mon Sep 17 00:00:00 2001 From: Fabian Cook Date: Mon, 4 Dec 2023 11:40:30 +1300 Subject: [PATCH] requestMethod router example --- src/events/schedule/dispatch-scheduled.ts | 1 - src/fetch/events.ts | 21 +- src/tests/worker/service-worker/index.ts | 13 +- .../worker/service-worker/routes.example.ts | 91 ++++++ .../worker/service-worker/routes.test.ts | 53 ++++ .../worker/service-worker/routes.worker.ts | 13 + src/tests/worker/service-worker/wait.ts | 18 ++ src/tests/worker/service-worker/worker.ts | 1 + src/worker/service-worker/dispatch.ts | 6 +- src/worker/service-worker/execute-fetch.ts | 7 +- src/worker/service-worker/router.ts | 300 +++++++++--------- 11 files changed, 354 insertions(+), 170 deletions(-) create mode 100644 src/tests/worker/service-worker/routes.example.ts create mode 100644 src/tests/worker/service-worker/routes.test.ts create mode 100644 src/tests/worker/service-worker/routes.worker.ts create mode 100644 src/tests/worker/service-worker/wait.ts diff --git a/src/events/schedule/dispatch-scheduled.ts b/src/events/schedule/dispatch-scheduled.ts index aab1d15..a997bc3 100644 --- a/src/events/schedule/dispatch-scheduled.ts +++ b/src/events/schedule/dispatch-scheduled.ts @@ -91,7 +91,6 @@ export async function dispatchScheduledDurableEvents(options: BackgroundSchedule undefined; // TODO detect if this event tries to dispatch again try { - let isMainThread = true; if (event.serviceWorkerId) { if (isMainThread) { const { dispatchServiceWorkerEvent } = await import("../../worker/service-worker/execute"); diff --git a/src/fetch/events.ts b/src/fetch/events.ts index 22cf930..bf38927 100644 --- a/src/fetch/events.ts +++ b/src/fetch/events.ts @@ -2,6 +2,7 @@ import {DurableEventData, DurableRequest, DurableRequestData} from "../data"; import {on} from "../events"; import {isLike, ok} from "../is"; import {FetchRespondWith} from "./dispatch"; +import {isMainThread} from "node:worker_threads"; export const FETCH = "fetch" as const; type ScheduleFetchEventType = typeof FETCH; @@ -40,11 +41,15 @@ function isFetchEvent(event: unknown): event is FetchEvent { ) } -export const removeFetchScheduledFunction = on(FETCH, async (event) => { - ok(isFetchEvent(event)); - event.respondWith( - fetch( - event.request - ) - ); -}); +export let removeFetchScheduledFunction = () => {}; + +if (isMainThread) { + removeFetchScheduledFunction = on(FETCH, async (event) => { + ok(isFetchEvent(event)); + event.respondWith( + fetch( + event.request + ) + ); + }); +} \ No newline at end of file diff --git a/src/tests/worker/service-worker/index.ts b/src/tests/worker/service-worker/index.ts index bb1281c..2bbe837 100644 --- a/src/tests/worker/service-worker/index.ts +++ b/src/tests/worker/service-worker/index.ts @@ -16,14 +16,7 @@ export {}; const pathname = fileURLToPath(import.meta.url); const worker = join(dirname(pathname), "./worker.js"); -async function waitForServiceWorker(registration: DurableServiceWorkerRegistration) { - if (registration.active) { - return registration.active; - } - await new Promise(resolve => setTimeout(resolve, 500)); - await registration.update(); - return waitForServiceWorker(registration); -} + { @@ -95,4 +88,6 @@ async function waitForServiceWorker(registration: DurableServiceWorkerRegistrati console.log(response.status, routes); ok(response.ok); -} \ No newline at end of file +} + +await import("./routes.test"); \ No newline at end of file diff --git a/src/tests/worker/service-worker/routes.example.ts b/src/tests/worker/service-worker/routes.example.ts new file mode 100644 index 0000000..2e92c31 --- /dev/null +++ b/src/tests/worker/service-worker/routes.example.ts @@ -0,0 +1,91 @@ +import {FetchEvent} from "../../../fetch"; +import {isRouteMatchCondition, RouterRule} from "../../../worker/service-worker/router"; +import {DurableServiceWorkerScope} from "../../../worker/service-worker/types"; +import {URLPattern} from "urlpattern-polyfill"; + +export interface URLPatternInit { + baseURL?: string; + username?: string; + password?: string; + protocol?: string; + hostname?: string; + port?: string; + pathname?: string; + search?: string; + hash?: string; +} + +declare var self: DurableServiceWorkerScope; + +export type RouterRequestMethodLower = "get" | "put" | "post" | "delete" | "options" | "patch" + +export interface OnRequestFn { + (request: Request, event: FetchEvent): Response | undefined | void | Promise +} + +export interface AddRouteAndHandlerFn { + (pathnameOrInit: string | URLPatternInit, onRequest: OnRequestFn): void +} + +export const requestMethod = makeRequestMethod(); + +function makeRequestMethod(): Record { + return { + get: makeAddRequestMethodRouteAndHandler("get"), + patch: makeAddRequestMethodRouteAndHandler("patch"), + put: makeAddRequestMethodRouteAndHandler("put"), + post: makeAddRequestMethodRouteAndHandler("post"), + delete: makeAddRequestMethodRouteAndHandler("delete"), + options: makeAddRequestMethodRouteAndHandler("options") + } +} + +function makeAddRequestMethodRouteAndHandler(requestMethod: RouterRequestMethodLower): AddRouteAndHandlerFn { + return function addRequestRouteAndHandler( + pathnameOrInit: string | URLPatternInit, + onRequest: OnRequestFn) { + return addRequestMethodRouteAndHandler( + requestMethod, + pathnameOrInit, + onRequest + ) + } +} + +function addRequestMethodRouteAndHandler( + requestMethod: string, + pathnameOrInit: string | URLPatternInit, + onRequest: OnRequestFn +) { + const urlPattern = new URLPattern( + typeof pathnameOrInit === "string" ? { + pathname: pathnameOrInit + } : pathnameOrInit + ); + const rule: RouterRule = { + condition: [ + { + requestMethod + }, + { + urlPattern + } + ], + source: "fetch-event" + } + self.addEventListener("install", event => { + event.addRoutes(rule) + }); + self.addEventListener("fetch", event => { + if (isRouteMatchCondition(self.registration, rule, event.request)) { + event.waitUntil(intercept()); + } + + async function intercept() { + const response = await onRequest(event.request, event); + if (response) { + event.respondWith(response); + } + } + }); +} \ No newline at end of file diff --git a/src/tests/worker/service-worker/routes.test.ts b/src/tests/worker/service-worker/routes.test.ts new file mode 100644 index 0000000..ba4f7c1 --- /dev/null +++ b/src/tests/worker/service-worker/routes.test.ts @@ -0,0 +1,53 @@ +import {serviceWorker} from "../../../worker"; +import {createRouter} from "../../../worker/service-worker/router"; +import {ok} from "../../../is"; +import {fileURLToPath} from "node:url"; +import {dirname, join} from "node:path"; +import {v4} from "uuid"; +import {waitForServiceWorker} from "./wait"; + +export {}; + +const pathname = fileURLToPath(import.meta.url); +const worker = join(dirname(pathname), "./routes.worker.js"); + +{ + const registration = await serviceWorker.register(worker); + + const fetch = await createRouter([ + registration + ]); + + // wait for activated before starting to route + // note we can create the router earlier than installing, though register needs to be done ahead of time + await waitForServiceWorker(registration); + + { + const response = await fetch("/test"); + + console.log(response.status); + ok(response.ok); + + console.log(await response.text()); + } + + { + const body = `SOMETHING RANDOM: ${v4()}` + + const response = await fetch("/test", { + method: "PUT", + body + }); + + console.log(response.status); + ok(response.ok); + + const text = await response.text(); + + console.log(text); + ok(text === body, "Expected returned body to match"); + } + + + +} \ No newline at end of file diff --git a/src/tests/worker/service-worker/routes.worker.ts b/src/tests/worker/service-worker/routes.worker.ts new file mode 100644 index 0000000..8ffafe9 --- /dev/null +++ b/src/tests/worker/service-worker/routes.worker.ts @@ -0,0 +1,13 @@ +import {requestMethod} from "./routes.example"; + +requestMethod.get("/test", () => { + console.log("In get handler"); + return new Response("Hello from test get handler"); +}) + +requestMethod.put("/test", (request) => { + console.log("In put handler"); + return new Response(request.body, { + headers: request.headers + }); +}) \ No newline at end of file diff --git a/src/tests/worker/service-worker/wait.ts b/src/tests/worker/service-worker/wait.ts new file mode 100644 index 0000000..30dcadf --- /dev/null +++ b/src/tests/worker/service-worker/wait.ts @@ -0,0 +1,18 @@ +import {DurableServiceWorkerRegistration} from "../../../worker"; +import {dispatchEvent} from "../../../events"; + +export async function waitForServiceWorker(registration: DurableServiceWorkerRegistration) { + if (registration.active) { + return registration.active; + } + // Send a push event to the worker to ensure it is activated + await dispatchEvent({ + type: "push", + serviceWorkerId: registration.durable.serviceWorkerId, + schedule: { + immediate: true + } + }); + await registration.update(); + return waitForServiceWorker(registration); +} \ No newline at end of file diff --git a/src/tests/worker/service-worker/worker.ts b/src/tests/worker/service-worker/worker.ts index 0aaa3a7..e3973f2 100644 --- a/src/tests/worker/service-worker/worker.ts +++ b/src/tests/worker/service-worker/worker.ts @@ -34,6 +34,7 @@ self.addEventListener("activate", event => { }) self.addEventListener("fetch", event => { + console.log("In fetch handler!"); event.respondWith(onFetchEvent(event)); }); diff --git a/src/worker/service-worker/dispatch.ts b/src/worker/service-worker/dispatch.ts index 544d645..7b2c51f 100644 --- a/src/worker/service-worker/dispatch.ts +++ b/src/worker/service-worker/dispatch.ts @@ -7,6 +7,7 @@ import {ServiceWorkerWorkerData} from "./worker"; import {createRespondWith, DurableFetchEventData, isDurableFetchEventData} from "../../fetch"; import {dispatchEvent} from "../../events"; import {ok} from "../../is"; +import {dispatchScheduledDurableEvents} from "../../events/schedule/dispatch-scheduled"; export interface FetchResponseMessage { type: "fetch:response"; @@ -18,8 +19,11 @@ export interface FetchResponseMessage { export async function dispatchWorkerEvent(event: DurableEventData, context: ServiceWorkerWorkerData) { if (isDurableFetchEventData(event)) { return dispatchWorkerFetchEvent(event, context); + } else { + return dispatchScheduledDurableEvents({ + event + }) } - return dispatchEvent(event); } export async function dispatchWorkerFetchEvent(event: DurableFetchEventData, context: ServiceWorkerWorkerData) { diff --git a/src/worker/service-worker/execute-fetch.ts b/src/worker/service-worker/execute-fetch.ts index 62f7f3a..7636043 100644 --- a/src/worker/service-worker/execute-fetch.ts +++ b/src/worker/service-worker/execute-fetch.ts @@ -17,7 +17,12 @@ export function createServiceWorkerFetch(registration: DurableServiceWorkerRegis if (input instanceof Request) { request = input } else if (init?.body) { - request = new Request(input, init); + request = new Request( + typeof input === "string" ? + new URL(input, getOrigin()).toString() : + input, + init + ); } else { request = { url: new URL(input, getOrigin()).toString(), diff --git a/src/worker/service-worker/router.ts b/src/worker/service-worker/router.ts index 58a57ec..19e7296 100644 --- a/src/worker/service-worker/router.ts +++ b/src/worker/service-worker/router.ts @@ -222,200 +222,200 @@ export function listRoutes(serviceWorkerId = getServiceWorkerId()) { return store.values(); } -export async function createRouter(serviceWorkers?: DurableServiceWorkerRegistration[]): Promise { - const resolveServiceWorkers = serviceWorkers ?? await listServiceWorkers(); - const serviceWorkerRoutes = await Promise.all( - resolveServiceWorkers.map( - async (serviceWorker) => { - return [ - serviceWorker, - await listRoutes(serviceWorker.durable.serviceWorkerId) - ] as const; - } - ) - ) +export function isRouteMatchCondition(serviceWorker: DurableServiceWorkerRegistration, route: RouterRule, input: RequestInfo | URL, init?: RequestInit) { - const fetchers = Object.fromEntries( - resolveServiceWorkers.map( - serviceWorker => [ - serviceWorker.durable.serviceWorkerId, - createServiceWorkerFetch(serviceWorker) - ] - ) - ) + return isConditionsMatch(route.condition); - function match(input: RequestInfo | URL, init?: RequestInit) { - for (const [serviceWorker, routes] of serviceWorkerRoutes) { - for (const route of routes) { - if (isRouteMatchCondition(serviceWorker, route, input, init)) { - return { - serviceWorker, - route - } as const - } else { - console.log(route, input); - } + function isConditionsMatch(conditions: RouterCondition | RouterCondition[]): boolean { + if (Array.isArray(conditions)) { + if (!conditions.length) { + throw new Error("Expected at least one condition"); } - } - } - - function isRouteMatchCondition(serviceWorker: DurableServiceWorkerRegistration, route: RouterRule, input: RequestInfo | URL, init?: RequestInit) { - - return isConditionsMatch(route.condition); - - function isConditionsMatch(conditions: RouterCondition | RouterCondition[]): boolean { - if (Array.isArray(conditions)) { - if (!conditions.length) { - throw new Error("Expected at least one condition"); - } - for (const condition of conditions) { - if (!isConditionMatch(condition)) { - return false; - } + for (const condition of conditions) { + if (!isConditionMatch(condition)) { + return false; } - return true; - } else { - return isConditionMatch(conditions); } + return true; + } else { + return isConditionMatch(conditions); } + } - function isRouterURLPatternConditionMatch(condition: RouterURLPatternCondition): boolean { - const url = input instanceof URL ? + function isRouterURLPatternConditionMatch(condition: RouterURLPatternCondition): boolean { + const url = input instanceof URL ? + input : + typeof input === "string" ? input : - typeof input === "string" ? - input : - input.url; - - const { urlPattern } = condition; - - if (typeof urlPattern === "string") { - // TODO, confirm this is correct - // Explainer does mention: - // - // For a USVString input, a ServiceWorker script's URL is used as a base URL. - // - // My current assumption is we are completely comparing the URL and search params here - const matchInstance = new URL(url, serviceWorker.durable.url); - const patternInstance = new URL(urlPattern, serviceWorker.durable.url); - return matchInstance.toString() === patternInstance.toString(); + input.url; + + const { urlPattern } = condition; + + if (typeof urlPattern === "string") { + // TODO, confirm this is correct + // Explainer does mention: + // + // For a USVString input, a ServiceWorker script's URL is used as a base URL. + // + // My current assumption is we are completely comparing the URL and search params here + const matchInstance = new URL(url, serviceWorker.durable.url); + const patternInstance = new URL(urlPattern, serviceWorker.durable.url); + return matchInstance.toString() === patternInstance.toString(); + } else { + if (urlPattern.test) { + return urlPattern.test(url, serviceWorker.durable.url); } else { - if (urlPattern.test) { - return urlPattern.test(url); - } else { - const pattern = new URLPattern(urlPattern); - return pattern.test(url); - } + const pattern = new URLPattern(urlPattern); + return pattern.test(url, serviceWorker.durable.url); } } + } - function isRouterRequestConditionMatch(condition: RouterRequestCondition): boolean { - if (typeof input === "string" || input instanceof URL) { - const { method, mode }: RequestInit = init || {} - if (isRouterRequestMethodCondition(condition)) { - if (method) { - if (method.toUpperCase() !== condition.requestMethod.toUpperCase()) { - return false; - } - } else { - if (condition.requestMethod.toUpperCase() !== "GET") { - return false; - } - } - } - if (isRouterRequestModeCondition(condition)) { - if (mode) { - if (mode !== condition.requestMode) { - return false; - } - } else { - // Assuming that fetch follows creating a new Request object - // From https://developer.mozilla.org/en-US/docs/Web/API/Request/mode - // For example, when a Request object is created using the Request() constructor, the value of the mode property for that Request is set to cors. - // - // undici uses new Request - // - // https://github.com/nodejs/undici/blob/8535d8c8e3937d73037272ffb411c7b14b036917/lib/fetch/index.js#L136 - // https://github.com/nodejs/undici/blob/8535d8c8e3937d73037272ffb411c7b14b036917/lib/fetch/request.js#L100 - if (condition.requestMode !== "cors") { - return false; - } - } - } - if (isRouterRequestDestinationCondition(condition)) { - // default destination is '' - // https://developer.mozilla.org/en-US/docs/Web/API/Request/destination#sect1 - if (condition.requestDestination !== "") { + function isRouterRequestConditionMatch(condition: RouterRequestCondition): boolean { + if (typeof input === "string" || input instanceof URL) { + const { method, mode }: RequestInit = init || {} + if (isRouterRequestMethodCondition(condition)) { + if (method) { + if (method.toUpperCase() !== condition.requestMethod.toUpperCase()) { return false; } - } - } else { - const { method, mode, destination } = input; - if (isRouterRequestMethodCondition(condition)) { - if (method !== condition.requestMethod) { + } else { + if (condition.requestMethod.toUpperCase() !== "GET") { return false; } } - if (isRouterRequestModeCondition(condition)) { + } + if (isRouterRequestModeCondition(condition)) { + if (mode) { if (mode !== condition.requestMode) { return false; } - } - if (isRouterRequestDestinationCondition(condition)) { - if (destination !== condition.requestDestination) { + } else { + // Assuming that fetch follows creating a new Request object + // From https://developer.mozilla.org/en-US/docs/Web/API/Request/mode + // For example, when a Request object is created using the Request() constructor, the value of the mode property for that Request is set to cors. + // + // undici uses new Request + // + // https://github.com/nodejs/undici/blob/8535d8c8e3937d73037272ffb411c7b14b036917/lib/fetch/index.js#L136 + // https://github.com/nodejs/undici/blob/8535d8c8e3937d73037272ffb411c7b14b036917/lib/fetch/request.js#L100 + if (condition.requestMode !== "cors") { return false; } } } + if (isRouterRequestDestinationCondition(condition)) { + // default destination is '' + // https://developer.mozilla.org/en-US/docs/Web/API/Request/destination#sect1 + if (condition.requestDestination !== "") { + return false; + } + } + } else { + const { method, mode, destination } = input; + if (isRouterRequestMethodCondition(condition)) { + if (method.toUpperCase() !== condition.requestMethod.toUpperCase()) { + return false; + } + } + if (isRouterRequestModeCondition(condition)) { + if (mode !== condition.requestMode) { + return false; + } + } + if (isRouterRequestDestinationCondition(condition)) { + if (destination !== condition.requestDestination) { + return false; + } + } + } - return true; + return true; + } + + function isAndConditionMatch(condition: RouterAndCondition): boolean { + return condition.and.every(isConditionMatch); + } + + function isOrConditionMatch(condition: RouterOrCondition): boolean { + const index = condition.or.findIndex(isNotConditionMatch); + return index === -1; + } + + function isNotConditionMatch(condition: RouterNotCondition): boolean { + return !isConditionsMatch(condition.not); + } + + function isConditionMatch(condition: RouterCondition): boolean { + if (isRouterURLPatternCondition(condition)) { + return isRouterURLPatternConditionMatch(condition); } - function isAndConditionMatch(condition: RouterAndCondition): boolean { - return condition.and.every(isConditionMatch); + if (isRouterRequestCondition(condition)) { + return isRouterRequestConditionMatch(condition); } - function isOrConditionMatch(condition: RouterOrCondition): boolean { - const index = condition.or.findIndex(isNotConditionMatch); - return index === -1; + if (isRouterAndCondition(condition)) { + return isAndConditionMatch(condition); } - function isNotConditionMatch(condition: RouterNotCondition): boolean { - return !isConditionsMatch(condition.not); + if (isRouterOrCondition(condition)) { + return isOrConditionMatch(condition); } - function isConditionMatch(condition: RouterCondition): boolean { - if (isRouterURLPatternCondition(condition)) { - return isRouterURLPatternConditionMatch(condition); - } + if (isRouterNotCondition(condition)) { + return isNotConditionMatch(condition); + } - if (isRouterRequestCondition(condition)) { - return isRouterRequestConditionMatch(condition); - } + return false; + } +} - if (isRouterAndCondition(condition)) { - return isAndConditionMatch(condition); - } +export async function createRouter(serviceWorkers?: DurableServiceWorkerRegistration[]): Promise { + const resolveServiceWorkers = serviceWorkers ?? await listServiceWorkers(); + const serviceWorkerRoutes = new Map(); - if (isRouterOrCondition(condition)) { - return isOrConditionMatch(condition); - } + const fetchers = new Map( + resolveServiceWorkers.map( + serviceWorker => [ + serviceWorker, + createServiceWorkerFetch(serviceWorker) + ] + ) + ) - if (isRouterNotCondition(condition)) { - return isNotConditionMatch(condition); - } + async function listServiceWorkerRoutes(serviceWorker: DurableServiceWorkerRegistration) { + const existing = serviceWorkerRoutes.get(serviceWorker); + if (existing) { + return existing; + } + const routes = await listRoutes(serviceWorker.durable.serviceWorkerId); + serviceWorkerRoutes.set(serviceWorker, routes); + return routes; + } - return false; + async function match(input: RequestInfo | URL, init?: RequestInit) { + for (const serviceWorker of resolveServiceWorkers) { + const routes = await listServiceWorkerRoutes(serviceWorker); + for (const route of routes) { + if (isRouteMatchCondition(serviceWorker, route, input, init)) { + return { + serviceWorker, + route + } as const + } + } } } return async function (input, init) { - const found = match(input, init); + const found = await match(input, init); if (!found) { throw new Error("FetchError: No match for this router"); } const { serviceWorker, route } = found; - const serviceWorkerFetch = fetchers[serviceWorker.durable.serviceWorkerId]; + const serviceWorkerFetch = fetchers.get(serviceWorker); ok(serviceWorkerFetch, "Expected to find fetcher for service worker, internal state corrupt"); return sources(route.source); @@ -473,9 +473,9 @@ export async function createRouter(serviceWorkers?: DurableServiceWorkerRegistra } if (isRouterSourceType(ruleSource, "race-network-and-fetch-handler")) { return Promise.race([ - fetch(clone(), init), - serviceWorkerFetch(clone(), init) - ]) + source("network"), + source("fetch-event") + ]); } if (isRouterSourceType(ruleSource, "cache")) { if (isRouterCacheSource(ruleSource)) {