From 99cff0a98d09750f28d01116692f78a384a961f0 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 27 Oct 2024 15:09:26 +0900 Subject: [PATCH] refactor(workerd): replace websocket with worker rpc (#141) --- examples/react-ssr-workerd/public/favicon.ico | Bin 0 -> 4286 bytes packages/workerd/src/plugin.ts | 122 ++++++++---------- packages/workerd/src/shared.ts | 18 ++- packages/workerd/src/worker.ts | 119 ++++++++--------- 4 files changed, 129 insertions(+), 130 deletions(-) create mode 100644 examples/react-ssr-workerd/public/favicon.ico diff --git a/examples/react-ssr-workerd/public/favicon.ico b/examples/react-ssr-workerd/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4aff076603f8f01fb3b7d8b5e38df77cc1efdd69 GIT binary patch literal 4286 zcmc(jeOQ&{9mmi9Y5(N4wKaEHmR6cpj%~hXo13+jb7bn2986aeCEr<~nOgZmpr$E` zf`o4brE!#(M1gZu646vl&`1pxMdw?|n=m>c+@F0P;mO>tlyk0ib#r~sdCqh0=e|Gp z@BY2q2Z!Sa`s>)yVZAyw{@CGY>u@-_O1kCfFIo4mH+}bzU$dHTI2`!e8Vx4;1ZKh^ zxKi)yD84^;xC-;kZcMM8u;Hxl=0 zrl(}R$D9P?+i+ezRaK9fv=7rK8`IkjJ!J!H{(<@MS+!R~`O?{>ox=Qa3-rsRXz(63 z2OX9VwCuy2qfp=E%do9`=6nq%BuDe7!jtKI6u6hZ(Up|S59#T~^xp-0ue5F~Z+jVS zZek&3&_;~E69#PN+~91w#MclR`Z@H~e)M!gs0@p&odJ103qFqWoKTLTHzP(@mAxzj(#e6hoycovD(ij}ivB{e#lRuP=cG5k$iaRo)dtx=G zM`-QOi1(><%*tYDlHdB;$}2ES5-{NlF)zQ3nIet}%c&n1$C&sM{8FwGkZ_g`sUGg! z3T>ptZyW}H`KjW=1Y|*Rxo@*u@#a=udF@Tij3~@2VhCG&oq>tVm=kl5JGI?yq#tEV znyQW7d%s`BnUNog;R7)^G4l^7CpCJ{a9%Yn`Yp_y*_byXFtZ{t(-+7Viy(9{&V^aD zbs2uD6#Nr0*t#nRmP8cFH{WZ{6~oeCpO^7?twAxY_*(EPM7@rgXEDr!SrITj3c}}8 zG&hNdv%li*9q=>hZiT(2yL8>xDH{YUC=?&&*$uMcN6JZFoDoXp$8j^YkZG`J2F#yD zT~q{{;?mf*+{vkkRh*ogz;80kX{(g`^V}P1yAv!$FZ=}TTrvCu$T`)zJ@6=Iw#2~7 zDX=^omRbzcx$<@-Pv=~u!|o<}xvTke<^?+EHqb5)?vdJw!(HPN#foO70Yl-k>>`W6NT?SD*WDt)y&<{AiS7Soz8*XK=RiTNo5?#oDKo7j&K*4as9+{Y$WBf^0FF zhPW_7T*Y)e2oGsl4=Su|Js>7q|IHS&LZG8pDR+w@NcCf#pc|Y1m!a|mxHMN<5IFx1 z8~>pkE;}TKz}oRNCQKpRUBH8fptD@dQYW$4vY4(*To35FgLJ?22Ui7UQ&lU1W0kw( zmA{hV*u|UYZ&JK{x(u?0L*`#0{co^Y3@IiNbQp!w zO~^{sk=0O8+H8YsRd7@>zI!0-dJcAo!6iFvdtPxEsQr*FoZ{FpidEZFc~oV-Tk*|$ zOiZ@A>Uvn-1u7yJ!OXeJSCKGPdDF_>p{gGfEe6%GMd?tjc)TqC{6ur_9{_tS27fUO z62lPLLUzcOFS-?jc~K-4?ZfXl{PI|{{KR7G7oUNpZc?8j4+TjnD#%v;R^FZ=O;ZgC zQw<4K9kXibq*$#{4s=ZGD|!+NHE*G=kG!mVVn4kRE-IKSOQ7zG>Zpg*Dnk!_{PpKI z^ege$`kG68tF7N7moCV*M=`td75h4u#3o_hR4h!F4JOMLvlF2rN59`Pl%M4|o^y(g zS}=__`)A81R}DOVN=Mz3(8JOR)qGpx>fXX;*=W+gG@L^E>t@wck4JM=z=<;1T8?r+ z2K1tp)T{Jd($Pnl{u<(`)5^1QqbH<3;_5B+5|_m^P~VlR|NpaD%c*J7PrU}Yugsfq z6=KWbwaXB4Ua4MOgTyu9jE-TFWv}nL35VJirUPP1tyZ~^yM!slY|{1Zn*D!(@9X_P D`VsgZ literal 0 HcmV?d00001 diff --git a/packages/workerd/src/plugin.ts b/packages/workerd/src/plugin.ts index afa91b35..5c5542b5 100644 --- a/packages/workerd/src/plugin.ts +++ b/packages/workerd/src/plugin.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { DefaultMap, tinyassert } from "@hiogawa/utils"; +import type { Fetcher } from "@cloudflare/workers-types/experimental"; import { webToNodeHandler } from "@hiogawa/utils-node"; import { Miniflare, @@ -10,14 +10,14 @@ import { mergeWorkerOptions, } from "miniflare"; import { - type CustomPayload, DevEnvironment, type HotChannel, + type HotPayload, type Plugin, type ResolvedConfig, } from "vite"; import type { SourcelessWorkerOptions } from "wrangler"; -import { ANY_URL, type FetchMetadata, RUNNER_INIT_PATH } from "./shared"; +import { type FetchMetadata, type RunnerRpc } from "./shared"; interface WorkerdPluginOptions extends WorkerdEnvironmentOptions { entry?: string; @@ -119,12 +119,17 @@ export async function createWorkerdDevEnvironment( const args = await request.json(); try { const result = await devEnv.fetchModule(...(args as [any, any])); - return new MiniflareResponse(JSON.stringify(result)); + return MiniflareResponse.json(result); } catch (error) { console.error("[fetchModule]", args, error); throw error; } }, + __viteRunnerSend: async (request) => { + const payload = (await request.json()) as HotPayload; + hotListener.dispatch(payload); + return MiniflareResponse.json(null); + }, }, bindings: { __viteRoot: config.root, @@ -159,30 +164,23 @@ export async function createWorkerdDevEnvironment( // get durable object singleton const ns = await miniflare.getDurableObjectNamespace("__viteRunner"); - const runnerObject = ns.get(ns.idFromName("")); + const runnerObject = ns.get(ns.idFromName("")) as any as Fetcher & RunnerRpc; - // initial request to setup websocket - const initResponse = await runnerObject.fetch(ANY_URL + RUNNER_INIT_PATH, { - headers: { - Upgrade: "websocket", - }, - }); - tinyassert(initResponse.webSocket); - const { webSocket } = initResponse; - webSocket.accept(); + // init via rpc + await runnerObject.__viteInit(); - // websocket hmr channgel - const hot = createSimpleHMRChannel({ - post: (data) => webSocket.send(data), - on: (listener) => { - webSocket.addEventListener("message", listener); - return () => { - webSocket.removeEventListener("message", listener); - }; + // hmr channel + const hotListener = createHotListenerManager(); + const hot: HotChannel = { + listen: () => {}, + close: () => {}, + on: hotListener.on, + off: hotListener.off, + send(...args: any[]) { + const payload = normalizeServerSendPayload(...args); + runnerObject.__viteServerSend(payload); }, - serialize: (v) => JSON.stringify(v), - deserialize: (v) => JSON.parse(v.data), - }); + }; // TODO: move initialization code to `init`? // inheritance to extend dispose @@ -204,8 +202,7 @@ export async function createWorkerdDevEnvironment( "x-vite-fetch", JSON.stringify({ entry } satisfies FetchMetadata), ); - const fetch_ = runnerObject.fetch as any as typeof fetch; // fix web/undici types - const res = await fetch_(request.url, { + const res = await runnerObject.fetch(request.url, { method: request.method, headers, body: request.body as any, @@ -213,10 +210,10 @@ export async function createWorkerdDevEnvironment( // @ts-ignore undici duplex: "half", }); - return new Response(res.body, { + return new Response(res.body as any, { status: res.status, statusText: res.statusText, - headers: res.headers, + headers: res.headers as any, }); }, }; @@ -224,49 +221,40 @@ export async function createWorkerdDevEnvironment( return Object.assign(devEnv, { api }) as WorkerdDevEnvironment; } -// cf. -// https://github.com/vitejs/vite/blob/feat/environment-api/packages/vite/src/node/server/hmr.ts/#L909-L910 -// https://github.com/vitejs/vite/blob/feat/environment-api/packages/vite/src/node/ssr/runtime/serverHmrConnector.ts/#L33-L34 -function createSimpleHMRChannel(options: { - post: (data: any) => any; - on: (listener: (data: any) => void) => () => void; - serialize: (v: any) => any; - deserialize: (v: any) => any; -}): HotChannel { - const listerMap = new DefaultMap>(() => new Set()); - let dispose: (() => void) | undefined; +// wrapper to simplify listener management +function createHotListenerManager(): Pick & { + dispatch: (payload: HotPayload) => void; +} { + const listerMap: Record> = {}; + const getListerMap = (e: string) => (listerMap[e] ??= new Set()); return { - listen() { - dispose = options.on((data) => { - const payload = options.deserialize(data) as CustomPayload; - for (const f of listerMap.get(payload.event)) { - f(payload.data); - } - }); - }, - close() { - dispose?.(); - dispose = undefined; + on(event: string, listener: Function) { + getListerMap(event).add(listener); }, - on(event: string, listener: (...args: any[]) => any) { - listerMap.get(event).add(listener); + off(event, listener: any) { + getListerMap(event).delete(listener); }, - off(event: string, listener: (...args: any[]) => any) { - listerMap.get(event).delete(listener); - }, - send(...args: any[]) { - let payload: any; - if (typeof args[0] === "string") { - payload = { - type: "custom", - event: args[0], - data: args[1], - }; - } else { - payload = args[0]; + dispatch(payload) { + if (payload.type === "custom") { + for (const lister of getListerMap(payload.event)) { + lister(payload.data); + } } - options.post(options.serialize(payload)); }, }; } + +function normalizeServerSendPayload(...args: any[]) { + let payload: HotPayload; + if (typeof args[0] === "string") { + payload = { + type: "custom", + event: args[0], + data: args[1], + }; + } else { + payload = args[0]; + } + return payload; +} diff --git a/packages/workerd/src/shared.ts b/packages/workerd/src/shared.ts index 8d249e5d..158f5da0 100644 --- a/packages/workerd/src/shared.ts +++ b/packages/workerd/src/shared.ts @@ -1,4 +1,5 @@ -export const RUNNER_INIT_PATH = "/__viteInit"; +import type { HotPayload } from "vite"; + export const ANY_URL = "https://any.local"; export type RunnerEnv = { @@ -9,9 +10,24 @@ export type RunnerEnv = { __viteFetchModule: { fetch: (request: Request) => Promise; }; + __viteRunnerSend: { + fetch: (request: Request) => Promise; + }; __viteRunner: DurableObject; }; +export type RunnerRpc = { + __viteInit: () => Promise; + __viteServerSend: (payload: HotPayload) => Promise; +}; + export type FetchMetadata = { entry: string; }; + +export function requestJson(data: unknown) { + return new Request(ANY_URL, { + method: "POST", + body: JSON.stringify(data), + }); +} diff --git a/packages/workerd/src/worker.ts b/packages/workerd/src/worker.ts index 33974c9e..a0bce2fb 100644 --- a/packages/workerd/src/worker.ts +++ b/packages/workerd/src/worker.ts @@ -1,3 +1,4 @@ +import { DurableObject } from "cloudflare:workers"; import { objectPickBy, tinyassert } from "@hiogawa/utils"; import { ModuleRunner, @@ -5,21 +6,22 @@ import { ssrModuleExportsKey, } from "vite/module-runner"; import { - ANY_URL, type FetchMetadata, - RUNNER_INIT_PATH, type RunnerEnv, + type RunnerRpc, + requestJson, } from "./shared"; -export class RunnerObject implements DurableObject { +export class RunnerObject extends DurableObject implements RunnerRpc { #env: RunnerEnv; #runner?: ModuleRunner; - constructor(_state: DurableObjectState, env: RunnerEnv) { - this.#env = env; + constructor(...args: ConstructorParameters) { + super(...args); + this.#env = args[1] as RunnerEnv; } - async fetch(request: Request) { + override async fetch(request: Request) { try { return await this.#fetch(request); } catch (e) { @@ -33,16 +35,6 @@ export class RunnerObject implements DurableObject { } async #fetch(request: Request) { - const url = new URL(request.url); - - if (url.pathname === RUNNER_INIT_PATH) { - const pair = new WebSocketPair(); - (pair[0] as any).accept(); - tinyassert(!this.#runner); - this.#runner = createRunner(this.#env, pair[0]); - return new Response(null, { status: 101, webSocket: pair[1] }); - } - tinyassert(this.#runner); const options = JSON.parse( request.headers.get("x-vite-fetch")!, @@ -58,56 +50,59 @@ export class RunnerObject implements DurableObject { abort(_reason?: any) {}, }); } -} -function createRunner(env: RunnerEnv, webSocket: WebSocket) { - return new ModuleRunner( - { - root: env.__viteRoot, - sourcemapInterceptor: "prepareStackTrace", - transport: { - fetchModule: async (...args) => { - const response = await env.__viteFetchModule.fetch( - new Request(ANY_URL, { - method: "POST", - body: JSON.stringify(args), - }), - ); - tinyassert(response.ok); - const result = response.json(); - return result as any; - }, - }, - hmr: { - connection: { - isReady: () => true, - onUpdate(callback) { - webSocket.addEventListener("message", (event) => { - callback(JSON.parse(event.data)); - }); + async __viteInit() { + const env = this.#env; + this.#runner = new ModuleRunner( + { + root: env.__viteRoot, + sourcemapInterceptor: "prepareStackTrace", + transport: { + fetchModule: async (...args) => { + const response = await env.__viteFetchModule.fetch( + requestJson(args), + ); + tinyassert(response.ok); + return response.json(); }, - send(payload) { - webSocket.send(JSON.stringify(payload)); + }, + hmr: { + connection: { + isReady: () => true, + onUpdate: (callback) => { + this.#viteServerSendHandler = callback; + }, + send: async (payload) => { + const response = await env.__viteRunnerSend.fetch( + requestJson(payload), + ); + tinyassert(response.ok); + }, }, }, }, - }, - { - runInlinedModule: async (context, transformed) => { - const codeDefinition = `'use strict';async (${Object.keys(context).join( - ",", - )})=>{{`; - const code = `${codeDefinition}${transformed}\n}}`; - const fn = env.__viteUnsafeEval.eval( - code, - context[ssrImportMetaKey].filename, - ); - await fn(...Object.values(context)); - Object.freeze(context[ssrModuleExportsKey]); - }, - async runExternalModule(filepath) { - return import(filepath); + { + runInlinedModule: async (context, transformed) => { + const codeDefinition = `'use strict';async (${Object.keys( + context, + ).join(",")})=>{{`; + const code = `${codeDefinition}${transformed}\n}}`; + const fn = env.__viteUnsafeEval.eval( + code, + context[ssrImportMetaKey].filename, + ); + await fn(...Object.values(context)); + Object.freeze(context[ssrModuleExportsKey]); + }, + async runExternalModule(filepath) { + return import(filepath); + }, }, - }, - ); + ); + } + + #viteServerSendHandler!: (payload: any) => void; + async __viteServerSend(payload: any) { + this.#viteServerSendHandler(payload); + } }