From 94d1c8e67071877c16d51dc0572ab576d349a13e Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:06:23 +0000 Subject: [PATCH 01/11] Added websockets playground --- playground/websockets/package.json | 19 ++++++++ playground/websockets/src/index.ts | 50 ++++++++++++++++++++++ playground/websockets/tsconfig.json | 7 +++ playground/websockets/tsconfig.node.json | 4 ++ playground/websockets/tsconfig.worker.json | 4 ++ playground/websockets/vite.config.ts | 6 +++ playground/websockets/wrangler.toml | 10 +++++ pnpm-lock.yaml | 21 +++++++++ 8 files changed, 121 insertions(+) create mode 100644 playground/websockets/package.json create mode 100644 playground/websockets/src/index.ts create mode 100644 playground/websockets/tsconfig.json create mode 100644 playground/websockets/tsconfig.node.json create mode 100644 playground/websockets/tsconfig.worker.json create mode 100644 playground/websockets/vite.config.ts create mode 100644 playground/websockets/wrangler.toml diff --git a/playground/websockets/package.json b/playground/websockets/package.json new file mode 100644 index 00000000..6f04372d --- /dev/null +++ b/playground/websockets/package.json @@ -0,0 +1,19 @@ +{ + "name": "@playground/websockets", + "private": true, + "type": "module", + "scripts": { + "build": "vite build --app", + "check:types": "tsc --build", + "dev": "vite dev", + "preview": "vite preview" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:default", + "@flarelabs-net/vite-plugin-cloudflare": "workspace:*", + "@vite-plugin-cloudflare/typescript-config": "workspace:*", + "typescript": "catalog:default", + "vite": "catalog:default", + "wrangler": "catalog:default" + } +} diff --git a/playground/websockets/src/index.ts b/playground/websockets/src/index.ts new file mode 100644 index 00000000..22083545 --- /dev/null +++ b/playground/websockets/src/index.ts @@ -0,0 +1,50 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + WEBSOCKET_SERVER: DurableObjectNamespace; +} + +export class WebSocketServer extends DurableObject { + #currentlyConnectedWebSockets = 0; + + override async fetch(request: Request) { + const { 0: client, 1: server } = new WebSocketPair(); + + server.accept(); + this.#currentlyConnectedWebSockets += 1; + + server.addEventListener('message', (event) => { + server.send( + `[Durable Object] currentlyConnectedWebSockets: ${this.#currentlyConnectedWebSockets}`, + ); + }); + + server.addEventListener('close', (event) => { + this.#currentlyConnectedWebSockets -= 1; + server.close(event.code, 'Durable Object is closing WebSocket'); + }); + + return new Response(null, { status: 101, webSocket: client }); + } +} + +export default { + async fetch(request, env) { + if (request.url.endsWith('/websocket')) { + const upgradeHeader = request.headers.get('Upgrade'); + + if (!upgradeHeader || upgradeHeader !== 'websocket') { + return new Response('Durable Object expected Upgrade: websocket', { + status: 426, + }); + } + + const id = env.WEBSOCKET_SERVER.idFromName(''); + const stub = env.WEBSOCKET_SERVER.get(id); + + return stub.fetch(request); + } + + return new Response(null, { status: 400 }); + }, +} satisfies ExportedHandler; diff --git a/playground/websockets/tsconfig.json b/playground/websockets/tsconfig.json new file mode 100644 index 00000000..b52af703 --- /dev/null +++ b/playground/websockets/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ] +} diff --git a/playground/websockets/tsconfig.node.json b/playground/websockets/tsconfig.node.json new file mode 100644 index 00000000..c24133da --- /dev/null +++ b/playground/websockets/tsconfig.node.json @@ -0,0 +1,4 @@ +{ + "extends": ["@vite-plugin-cloudflare/typescript-config/base.json"], + "include": ["vite.config.ts", "__tests__"] +} diff --git a/playground/websockets/tsconfig.worker.json b/playground/websockets/tsconfig.worker.json new file mode 100644 index 00000000..b2ab8f7a --- /dev/null +++ b/playground/websockets/tsconfig.worker.json @@ -0,0 +1,4 @@ +{ + "extends": ["@vite-plugin-cloudflare/typescript-config/worker.json"], + "include": ["src"] +} diff --git a/playground/websockets/vite.config.ts b/playground/websockets/vite.config.ts new file mode 100644 index 00000000..f7c1b369 --- /dev/null +++ b/playground/websockets/vite.config.ts @@ -0,0 +1,6 @@ +import { cloudflare } from '@flarelabs-net/vite-plugin-cloudflare'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare({ persistState: false })], +}); diff --git a/playground/websockets/wrangler.toml b/playground/websockets/wrangler.toml new file mode 100644 index 00000000..d3d793e0 --- /dev/null +++ b/playground/websockets/wrangler.toml @@ -0,0 +1,10 @@ +name = "worker" +main = "./src/index.ts" +compatibility_date = "2024-09-09" + +[durable_objects] +bindings = [{ name = "WEBSOCKET_SERVER", class_name = "WebSocketServer" }] + +[[migrations]] +tag = "v1" +new_classes = ["WebSocketServer"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16e9adce..4898c375 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -411,6 +411,27 @@ importers: specifier: catalog:default version: 3.94.0(@cloudflare/workers-types@4.20241205.0) + playground/websockets: + devDependencies: + '@cloudflare/workers-types': + specifier: catalog:default + version: 4.20241205.0 + '@flarelabs-net/vite-plugin-cloudflare': + specifier: workspace:* + version: link:../../packages/vite-plugin-cloudflare + '@vite-plugin-cloudflare/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + typescript: + specifier: catalog:default + version: 5.7.2 + vite: + specifier: catalog:default + version: 6.0.3(@types/node@22.10.1) + wrangler: + specifier: catalog:default + version: 3.94.0(@cloudflare/workers-types@4.20241205.0) + playground/worker: devDependencies: '@cloudflare/workers-types': From f3f9bde2a0130443f9f57f4b7cbe547c0f243873 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Mon, 13 Jan 2025 15:07:28 +0000 Subject: [PATCH 02/11] Initial implementation --- packages/vite-plugin-cloudflare/package.json | 4 +- packages/vite-plugin-cloudflare/src/index.ts | 55 ++++++++++++++++++++ playground/websockets/src/index.html | 40 ++++++++++++++ playground/websockets/src/index.ts | 5 +- pnpm-lock.yaml | 13 +++++ 5 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 playground/websockets/src/index.html diff --git a/packages/vite-plugin-cloudflare/package.json b/packages/vite-plugin-cloudflare/package.json index 1f5d1903..70ac030b 100644 --- a/packages/vite-plugin-cloudflare/package.json +++ b/packages/vite-plugin-cloudflare/package.json @@ -37,12 +37,14 @@ "dependencies": { "@hattip/adapter-node": "^0.0.49", "miniflare": "3.20241205.0", - "unenv": "catalog:default" + "unenv": "catalog:default", + "ws": "^8.18.0" }, "devDependencies": { "@cloudflare/workers-shared": "^0.7.0", "@cloudflare/workers-types": "catalog:default", "@types/node": "catalog:default", + "@types/ws": "^8.5.13", "@vite-plugin-cloudflare/typescript-config": "workspace:*", "magic-string": "^0.30.12", "tsup": "^8.3.0", diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index 2e7faa07..1b72eb97 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -4,6 +4,7 @@ import * as path from 'node:path'; import { createMiddleware } from '@hattip/adapter-node'; import { Miniflare } from 'miniflare'; import * as vite from 'vite'; +import ws from 'ws'; import { createCloudflareEnvironmentOptions, initRunners, @@ -20,9 +21,11 @@ import { resolveNodeCompatId, } from './node-js-compat'; import { resolvePluginConfig } from './plugin-config'; +import { UNKNOWN_HOST } from './shared'; import { getOutputDirectory, toMiniflareRequest } from './utils'; import { getWarningForWorkersConfigs } from './workers-configs'; import type { PluginConfig, ResolvedPluginConfig } from './plugin-config'; +import type { IncomingMessage } from 'node:http'; import type { Unstable_RawConfig } from 'wrangler'; export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { @@ -241,6 +244,58 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { miniflare, ); + const wss = new ws.Server({ noServer: true }); + + assert(viteDevServer.httpServer, 'No HTTP server'); + + viteDevServer.httpServer.on( + 'upgrade', + async (request: IncomingMessage, socket, head) => { + const url = new URL(request.url ?? '', UNKNOWN_HOST); + + if (url.pathname === '/__vite_hmr') { + return; + } + + const headers = new Headers(); + + for (const [key, value] of Object.entries(request.headers)) { + if (typeof value === 'string') { + headers.append(key, value); + } else if (Array.isArray(value)) { + for (const item of value) { + headers.append(key, item); + } + } + } + + const response = await entryWorker.fetch(url, { + headers, + method: request.method, + }); + const webSocket = response.webSocket; + + if (!webSocket) { + socket.destroy(); + return; + } + + wss.handleUpgrade(request, socket, head, async (ws) => { + webSocket.accept(); + webSocket.addEventListener('message', (event) => { + console.log('Vite received server message'); + ws.send(event.data); + }); + + ws.addEventListener('message', (event) => { + console.log('Vite received client message'); + webSocket.send(event.data as any); + }); + wss.emit('connection', ws, request); + }); + }, + ); + const middleware = createMiddleware(({ request }) => { return entryWorker.fetch(toMiniflareRequest(request), { redirect: 'manual', diff --git a/playground/websockets/src/index.html b/playground/websockets/src/index.html new file mode 100644 index 00000000..6ccf6c6f --- /dev/null +++ b/playground/websockets/src/index.html @@ -0,0 +1,40 @@ + + + + + + + +
+

WebSockets example

+ + +
+ + + diff --git a/playground/websockets/src/index.ts b/playground/websockets/src/index.ts index 22083545..c3af10a7 100644 --- a/playground/websockets/src/index.ts +++ b/playground/websockets/src/index.ts @@ -1,3 +1,4 @@ +import html from './index.html?raw'; import { DurableObject } from 'cloudflare:workers'; interface Env { @@ -14,12 +15,14 @@ export class WebSocketServer extends DurableObject { this.#currentlyConnectedWebSockets += 1; server.addEventListener('message', (event) => { + console.log('DO received client event', event); server.send( `[Durable Object] currentlyConnectedWebSockets: ${this.#currentlyConnectedWebSockets}`, ); }); server.addEventListener('close', (event) => { + console.log('CLOSE'); this.#currentlyConnectedWebSockets -= 1; server.close(event.code, 'Durable Object is closing WebSocket'); }); @@ -45,6 +48,6 @@ export default { return stub.fetch(request); } - return new Response(null, { status: 400 }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); }, } satisfies ExportedHandler; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4898c375..89ff7747 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: unenv: specifier: catalog:default version: unenv-nightly@2.0.0-20241218-183400-5d6aec3 + ws: + specifier: ^8.18.0 + version: 8.18.0 devDependencies: '@cloudflare/workers-shared': specifier: ^0.7.0 @@ -84,6 +87,9 @@ importers: '@types/node': specifier: catalog:default version: 22.10.1 + '@types/ws': + specifier: ^8.5.13 + version: 8.5.13 '@vite-plugin-cloudflare/typescript-config': specifier: workspace:* version: link:../typescript-config @@ -1624,6 +1630,9 @@ packages: '@types/react@19.0.1': resolution: {integrity: sha512-YW6614BDhqbpR5KtUYzTA+zlA7nayzJRA9ljz9CQoxthR0sDisYZLuvSMsil36t4EH/uAt8T52Xb4sVw17G+SQ==} + '@types/ws@8.5.13': + resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==} + '@vitejs/plugin-react@4.3.4': resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3868,6 +3877,10 @@ snapshots: dependencies: csstype: 3.1.3 + '@types/ws@8.5.13': + dependencies: + '@types/node': 22.10.1 + '@vitejs/plugin-react@4.3.4(vite@6.0.7(@types/node@22.10.1))': dependencies: '@babel/core': 7.26.0 From 8ba57d38f987ec3cd1cbd3efad99bcc723fe68a7 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Mon, 13 Jan 2025 17:40:11 +0000 Subject: [PATCH 03/11] Improved playground --- packages/vite-plugin-cloudflare/src/index.ts | 40 +++++++++++----- playground/websockets/src/index.html | 48 ++++++++++++++++---- playground/websockets/src/index.ts | 33 ++++++-------- 3 files changed, 81 insertions(+), 40 deletions(-) diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index 1b72eb97..cd3ef9d7 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -234,6 +234,8 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { } }, async configureServer(viteDevServer) { + const logger = viteDevServer.config.logger; + miniflare = new Miniflare( getDevMiniflareOptions(resolvedPluginConfig, viteDevServer), ); @@ -246,7 +248,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { const wss = new ws.Server({ noServer: true }); - assert(viteDevServer.httpServer, 'No HTTP server'); + assert(viteDevServer.httpServer, 'Unexpected error: No Vite HTTP server'); viteDevServer.httpServer.on( 'upgrade', @@ -273,25 +275,39 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { headers, method: request.method, }); - const webSocket = response.webSocket; + const workerWebSocket = response.webSocket; - if (!webSocket) { + if (!workerWebSocket) { socket.destroy(); return; } - wss.handleUpgrade(request, socket, head, async (ws) => { - webSocket.accept(); - webSocket.addEventListener('message', (event) => { - console.log('Vite received server message'); - ws.send(event.data); + wss.handleUpgrade(request, socket, head, async (clientWebSocket) => { + workerWebSocket.accept(); + + // Forward Worker events to client + workerWebSocket.addEventListener('message', (event) => { + clientWebSocket.send(event.data); + }); + workerWebSocket.addEventListener('error', (event) => { + logger.error(`WebSocket error: ${event.error}`); + }); + workerWebSocket.addEventListener('close', () => { + clientWebSocket.close(); }); - ws.addEventListener('message', (event) => { - console.log('Vite received client message'); - webSocket.send(event.data as any); + // Forward client events to Worker + clientWebSocket.on('message', (event: ArrayBuffer | string) => { + workerWebSocket.send(event); }); - wss.emit('connection', ws, request); + clientWebSocket.on('error', (error) => { + logger.error(`WebSocket error: ${error}`); + }); + clientWebSocket.on('close', () => { + workerWebSocket.close(); + }); + + wss.emit('connection', clientWebSocket, request); }); }, ); diff --git a/playground/websockets/src/index.html b/playground/websockets/src/index.html index 6ccf6c6f..bffa97f3 100644 --- a/playground/websockets/src/index.html +++ b/playground/websockets/src/index.html @@ -7,34 +7,62 @@

WebSockets example

- - + + + +

Disconnected

+

diff --git a/playground/websockets/src/index.ts b/playground/websockets/src/index.ts index c3af10a7..fc08b576 100644 --- a/playground/websockets/src/index.ts +++ b/playground/websockets/src/index.ts @@ -6,28 +6,25 @@ interface Env { } export class WebSocketServer extends DurableObject { - #currentlyConnectedWebSockets = 0; - - override async fetch(request: Request) { + override fetch() { const { 0: client, 1: server } = new WebSocketPair(); - server.accept(); - this.#currentlyConnectedWebSockets += 1; + this.ctx.acceptWebSocket(server); - server.addEventListener('message', (event) => { - console.log('DO received client event', event); - server.send( - `[Durable Object] currentlyConnectedWebSockets: ${this.#currentlyConnectedWebSockets}`, - ); - }); + return new Response(null, { status: 101, webSocket: client }); + } - server.addEventListener('close', (event) => { - console.log('CLOSE'); - this.#currentlyConnectedWebSockets -= 1; - server.close(event.code, 'Durable Object is closing WebSocket'); - }); + override async webSocketMessage(ws: WebSocket, data: string | ArrayBuffer) { + const decoder = new TextDecoder(); + const message = typeof data === 'string' ? data : decoder.decode(data); - return new Response(null, { status: 101, webSocket: client }); + console.log('message', message); + + ws.send(`Durable Object received client message: '${message}'.`); + } + + override webSocketClose(ws: WebSocket) { + ws.close(); } } @@ -42,7 +39,7 @@ export default { }); } - const id = env.WEBSOCKET_SERVER.idFromName(''); + const id = env.WEBSOCKET_SERVER.idFromName('id'); const stub = env.WEBSOCKET_SERVER.get(id); return stub.fetch(request); From 9e2386f8e8b27db1b21d9c29611ced04632d8e7b Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Mon, 13 Jan 2025 19:44:04 +0000 Subject: [PATCH 04/11] Added WebSocket tests --- packages/vite-plugin-cloudflare/src/index.ts | 13 ++++-- .../websockets/__tests__/websockets.spec.ts | 41 +++++++++++++++++++ playground/websockets/src/index.html | 14 +++---- 3 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 playground/websockets/__tests__/websockets.spec.ts diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index cd3ef9d7..c53714e2 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -255,7 +255,8 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { async (request: IncomingMessage, socket, head) => { const url = new URL(request.url ?? '', UNKNOWN_HOST); - if (url.pathname === '/__vite_hmr') { + // Ignore Vite HMR WebSockets + if (request.headers['sec-websocket-protocol']?.startsWith('vite')) { return; } @@ -290,7 +291,10 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { clientWebSocket.send(event.data); }); workerWebSocket.addEventListener('error', (event) => { - logger.error(`WebSocket error: ${event.error}`); + logger.error( + `WebSocket error:\n${event.error?.stack || event.error?.message}`, + { error: event.error }, + ); }); workerWebSocket.addEventListener('close', () => { clientWebSocket.close(); @@ -301,7 +305,10 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { workerWebSocket.send(event); }); clientWebSocket.on('error', (error) => { - logger.error(`WebSocket error: ${error}`); + logger.error( + `WebSocket error:\n${error.stack || error.message}`, + { error }, + ); }); clientWebSocket.on('close', () => { workerWebSocket.close(); diff --git a/playground/websockets/__tests__/websockets.spec.ts b/playground/websockets/__tests__/websockets.spec.ts new file mode 100644 index 00000000..e5275c11 --- /dev/null +++ b/playground/websockets/__tests__/websockets.spec.ts @@ -0,0 +1,41 @@ +import { expect, test, vi } from 'vitest'; +import { page } from '../../__test-utils__'; + +async function openWebSocket() { + const openButton = page.getByRole('button', { name: 'Open WebSocket' }); + const statusTextBefore = await page.textContent('h2'); + expect(statusTextBefore).toBe('WebSocket closed'); + await openButton.click(); + await vi.waitFor(async () => { + const statusTextAfter = await page.textContent('h2'); + expect(statusTextAfter).toBe('WebSocket open'); + }); +} + +test('opens WebSocket connection', openWebSocket); + +test('closes WebSocket connection', async () => { + await openWebSocket(); + const closeButton = page.getByRole('button', { name: 'Close WebSocket' }); + const statusTextBefore = await page.textContent('h2'); + expect(statusTextBefore).toBe('WebSocket open'); + await closeButton.click(); + await vi.waitFor(async () => { + const statusTextAfter = await page.textContent('h2'); + expect(statusTextAfter).toBe('WebSocket closed'); + }); +}); + +test('sends and receives WebSocket messages', async () => { + await openWebSocket(); + const sendButton = page.getByRole('button', { name: 'Send message' }); + const messageTextBefore = await page.textContent('p'); + expect(messageTextBefore).toBe(''); + await sendButton.click(); + await vi.waitFor(async () => { + const messageTextAfter = await page.textContent('p'); + expect(messageTextAfter).toBe( + `Durable Object received client message: 'Client event'.`, + ); + }); +}); diff --git a/playground/websockets/src/index.html b/playground/websockets/src/index.html index bffa97f3..d89a41e0 100644 --- a/playground/websockets/src/index.html +++ b/playground/websockets/src/index.html @@ -6,11 +6,11 @@
-

WebSockets example

- - - -

Disconnected

+

WebSockets playground

+ + + +

WebSocket closed

@@ -42,12 +42,12 @@

Disconnected

ws.addEventListener('open', (event) => { console.log('Client open'); - statusText.textContent = 'Connected'; + statusText.textContent = 'WebSocket open'; }); ws.addEventListener('close', (event) => { console.log('Client close'); - statusText.textContent = 'Disconnected'; + statusText.textContent = 'WebSocket closed'; }); } From 797c9b393fb9dec94ed429de6b07ea22f1656102 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Mon, 13 Jan 2025 21:43:32 +0000 Subject: [PATCH 05/11] Added support for WebSockets in preview --- packages/vite-plugin-cloudflare/src/index.ts | 89 +++---------------- .../vite-plugin-cloudflare/src/websockets.ts | 88 ++++++++++++++++++ 2 files changed, 101 insertions(+), 76 deletions(-) create mode 100644 packages/vite-plugin-cloudflare/src/websockets.ts diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index c53714e2..ef69a88e 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -4,7 +4,6 @@ import * as path from 'node:path'; import { createMiddleware } from '@hattip/adapter-node'; import { Miniflare } from 'miniflare'; import * as vite from 'vite'; -import ws from 'ws'; import { createCloudflareEnvironmentOptions, initRunners, @@ -21,11 +20,10 @@ import { resolveNodeCompatId, } from './node-js-compat'; import { resolvePluginConfig } from './plugin-config'; -import { UNKNOWN_HOST } from './shared'; import { getOutputDirectory, toMiniflareRequest } from './utils'; +import { handleWebSocket } from './websockets'; import { getWarningForWorkersConfigs } from './workers-configs'; import type { PluginConfig, ResolvedPluginConfig } from './plugin-config'; -import type { IncomingMessage } from 'node:http'; import type { Unstable_RawConfig } from 'wrangler'; export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { @@ -234,6 +232,8 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { } }, async configureServer(viteDevServer) { + assert(viteDevServer.httpServer, 'Unexpected error: No Vite HTTP server'); + const logger = viteDevServer.config.logger; miniflare = new Miniflare( @@ -246,85 +246,14 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { miniflare, ); - const wss = new ws.Server({ noServer: true }); - - assert(viteDevServer.httpServer, 'Unexpected error: No Vite HTTP server'); - - viteDevServer.httpServer.on( - 'upgrade', - async (request: IncomingMessage, socket, head) => { - const url = new URL(request.url ?? '', UNKNOWN_HOST); - - // Ignore Vite HMR WebSockets - if (request.headers['sec-websocket-protocol']?.startsWith('vite')) { - return; - } - - const headers = new Headers(); - - for (const [key, value] of Object.entries(request.headers)) { - if (typeof value === 'string') { - headers.append(key, value); - } else if (Array.isArray(value)) { - for (const item of value) { - headers.append(key, item); - } - } - } - - const response = await entryWorker.fetch(url, { - headers, - method: request.method, - }); - const workerWebSocket = response.webSocket; - - if (!workerWebSocket) { - socket.destroy(); - return; - } - - wss.handleUpgrade(request, socket, head, async (clientWebSocket) => { - workerWebSocket.accept(); - - // Forward Worker events to client - workerWebSocket.addEventListener('message', (event) => { - clientWebSocket.send(event.data); - }); - workerWebSocket.addEventListener('error', (event) => { - logger.error( - `WebSocket error:\n${event.error?.stack || event.error?.message}`, - { error: event.error }, - ); - }); - workerWebSocket.addEventListener('close', () => { - clientWebSocket.close(); - }); - - // Forward client events to Worker - clientWebSocket.on('message', (event: ArrayBuffer | string) => { - workerWebSocket.send(event); - }); - clientWebSocket.on('error', (error) => { - logger.error( - `WebSocket error:\n${error.stack || error.message}`, - { error }, - ); - }); - clientWebSocket.on('close', () => { - workerWebSocket.close(); - }); - - wss.emit('connection', clientWebSocket, request); - }); - }, - ); - const middleware = createMiddleware(({ request }) => { return entryWorker.fetch(toMiniflareRequest(request), { redirect: 'manual', }) as any; }); + handleWebSocket(viteDevServer.httpServer, entryWorker.fetch, logger); + return () => { viteDevServer.middlewares.use((req, res, next) => { middleware(req, res, next); @@ -332,6 +261,8 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { }; }, configurePreviewServer(vitePreviewServer) { + const logger = vitePreviewServer.config.logger; + const miniflare = new Miniflare( getPreviewMiniflareOptions( vitePreviewServer, @@ -345,6 +276,12 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { }) as any; }); + handleWebSocket( + vitePreviewServer.httpServer, + miniflare.dispatchFetch, + logger, + ); + return () => { vitePreviewServer.middlewares.use((req, res, next) => { middleware(req, res, next); diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/vite-plugin-cloudflare/src/websockets.ts new file mode 100644 index 00000000..5137d682 --- /dev/null +++ b/packages/vite-plugin-cloudflare/src/websockets.ts @@ -0,0 +1,88 @@ +import ws from 'ws'; +import { UNKNOWN_HOST } from './shared'; +import type { Fetcher } from '@cloudflare/workers-types/experimental'; +import type { ReplaceWorkersTypes } from 'miniflare'; +import type { IncomingMessage } from 'node:http'; +import type { Duplex } from 'node:stream'; +import type * as vite from 'vite'; + +export function handleWebSocket( + httpServer: vite.HttpServer, + fetcher: ReplaceWorkersTypes['fetch'], + logger: vite.Logger, +) { + const webSocketServer = new ws.Server({ noServer: true }); + + httpServer.on( + 'upgrade', + async (request: IncomingMessage, socket: Duplex, head: Buffer) => { + const url = new URL(request.url ?? '', UNKNOWN_HOST); + + // Ignore Vite HMR WebSockets + if (request.headers['sec-websocket-protocol']?.startsWith('vite')) { + return; + } + + const headers = new Headers(); + + for (const [key, value] of Object.entries(request.headers)) { + if (typeof value === 'string') { + headers.append(key, value); + } else if (Array.isArray(value)) { + for (const item of value) { + headers.append(key, item); + } + } + } + + const response = await fetcher(url, { + headers, + method: request.method, + }); + const workerWebSocket = response.webSocket; + + if (!workerWebSocket) { + socket.destroy(); + return; + } + + webSocketServer.handleUpgrade( + request, + socket, + head, + async (clientWebSocket) => { + workerWebSocket.accept(); + + // Forward Worker events to client + workerWebSocket.addEventListener('message', (event) => { + clientWebSocket.send(event.data); + }); + workerWebSocket.addEventListener('error', (event) => { + logger.error( + `WebSocket error:\n${event.error?.stack || event.error?.message}`, + { error: event.error }, + ); + }); + workerWebSocket.addEventListener('close', () => { + clientWebSocket.close(); + }); + + // Forward client events to Worker + clientWebSocket.on('message', (event: ArrayBuffer | string) => { + workerWebSocket.send(event); + }); + clientWebSocket.on('error', (error) => { + logger.error(`WebSocket error:\n${error.stack || error.message}`, { + error, + }); + }); + clientWebSocket.on('close', () => { + workerWebSocket.close(); + }); + + webSocketServer.emit('connection', clientWebSocket, request); + }, + ); + }, + ); +} From 0cc9339b46c70dd9d60e340187d46d3a4b4ff88c Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Tue, 14 Jan 2025 10:27:22 +0000 Subject: [PATCH 06/11] In progress. Fixing close events. --- packages/vite-plugin-cloudflare/src/websockets.ts | 7 +++++-- playground/websockets/src/index.html | 1 + playground/websockets/src/index.ts | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/vite-plugin-cloudflare/src/websockets.ts index 5137d682..22a13a17 100644 --- a/packages/vite-plugin-cloudflare/src/websockets.ts +++ b/packages/vite-plugin-cloudflare/src/websockets.ts @@ -64,7 +64,8 @@ export function handleWebSocket( ); }); workerWebSocket.addEventListener('close', () => { - clientWebSocket.close(); + console.log('Worker close'); + // clientWebSocket.close(); }); // Forward client events to Worker @@ -77,7 +78,9 @@ export function handleWebSocket( }); }); clientWebSocket.on('close', () => { - workerWebSocket.close(); + console.log('readyState', clientWebSocket.readyState); + console.log('Client close'); + // workerWebSocket.close(); }); webSocketServer.emit('connection', clientWebSocket, request); diff --git a/playground/websockets/src/index.html b/playground/websockets/src/index.html index d89a41e0..9c70cf78 100644 --- a/playground/websockets/src/index.html +++ b/playground/websockets/src/index.html @@ -59,6 +59,7 @@

WebSocket closed

} ws.close(); + ws = undefined; } function send(message) { diff --git a/playground/websockets/src/index.ts b/playground/websockets/src/index.ts index fc08b576..be685492 100644 --- a/playground/websockets/src/index.ts +++ b/playground/websockets/src/index.ts @@ -18,12 +18,11 @@ export class WebSocketServer extends DurableObject { const decoder = new TextDecoder(); const message = typeof data === 'string' ? data : decoder.decode(data); - console.log('message', message); - ws.send(`Durable Object received client message: '${message}'.`); } override webSocketClose(ws: WebSocket) { + console.log('DO close', ws.readyState); ws.close(); } } From cd2321cf4970ea0daf9373f5788785d0afc342a8 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Tue, 14 Jan 2025 16:23:08 +0000 Subject: [PATCH 07/11] Tidy up --- packages/vite-plugin-cloudflare/src/websockets.ts | 13 +++++-------- playground/websockets/src/index.ts | 5 ----- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/vite-plugin-cloudflare/src/websockets.ts index 22a13a17..d1e17c5d 100644 --- a/packages/vite-plugin-cloudflare/src/websockets.ts +++ b/packages/vite-plugin-cloudflare/src/websockets.ts @@ -11,7 +11,7 @@ export function handleWebSocket( fetcher: ReplaceWorkersTypes['fetch'], logger: vite.Logger, ) { - const webSocketServer = new ws.Server({ noServer: true }); + const nodeWebSocket = new ws.Server({ noServer: true }); httpServer.on( 'upgrade', @@ -46,7 +46,7 @@ export function handleWebSocket( return; } - webSocketServer.handleUpgrade( + nodeWebSocket.handleUpgrade( request, socket, head, @@ -64,8 +64,7 @@ export function handleWebSocket( ); }); workerWebSocket.addEventListener('close', () => { - console.log('Worker close'); - // clientWebSocket.close(); + clientWebSocket.close(); }); // Forward client events to Worker @@ -78,12 +77,10 @@ export function handleWebSocket( }); }); clientWebSocket.on('close', () => { - console.log('readyState', clientWebSocket.readyState); - console.log('Client close'); - // workerWebSocket.close(); + workerWebSocket.close(); }); - webSocketServer.emit('connection', clientWebSocket, request); + nodeWebSocket.emit('connection', clientWebSocket, request); }, ); }, diff --git a/playground/websockets/src/index.ts b/playground/websockets/src/index.ts index be685492..4426dd72 100644 --- a/playground/websockets/src/index.ts +++ b/playground/websockets/src/index.ts @@ -20,11 +20,6 @@ export class WebSocketServer extends DurableObject { ws.send(`Durable Object received client message: '${message}'.`); } - - override webSocketClose(ws: WebSocket) { - console.log('DO close', ws.readyState); - ws.close(); - } } export default { From fceaf00a6b331882b399383d85c5abea7b98ea2b Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Wed, 15 Jan 2025 16:54:54 +0000 Subject: [PATCH 08/11] Remove logger variable --- packages/vite-plugin-cloudflare/src/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/vite-plugin-cloudflare/src/index.ts b/packages/vite-plugin-cloudflare/src/index.ts index ef69a88e..37ce7dec 100644 --- a/packages/vite-plugin-cloudflare/src/index.ts +++ b/packages/vite-plugin-cloudflare/src/index.ts @@ -234,8 +234,6 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { async configureServer(viteDevServer) { assert(viteDevServer.httpServer, 'Unexpected error: No Vite HTTP server'); - const logger = viteDevServer.config.logger; - miniflare = new Miniflare( getDevMiniflareOptions(resolvedPluginConfig, viteDevServer), ); @@ -252,7 +250,11 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { }) as any; }); - handleWebSocket(viteDevServer.httpServer, entryWorker.fetch, logger); + handleWebSocket( + viteDevServer.httpServer, + entryWorker.fetch, + viteDevServer.config.logger, + ); return () => { viteDevServer.middlewares.use((req, res, next) => { @@ -261,8 +263,6 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { }; }, configurePreviewServer(vitePreviewServer) { - const logger = vitePreviewServer.config.logger; - const miniflare = new Miniflare( getPreviewMiniflareOptions( vitePreviewServer, @@ -279,7 +279,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin { handleWebSocket( vitePreviewServer.httpServer, miniflare.dispatchFetch, - logger, + vitePreviewServer.config.logger, ); return () => { From 46985a71916dc9c7c37eb7c6277d76117ad086e1 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Wed, 15 Jan 2025 17:03:43 +0000 Subject: [PATCH 09/11] Added nodeHeadersToWebHeaders function --- packages/vite-plugin-cloudflare/src/utils.ts | 19 +++++++++++++++++++ .../vite-plugin-cloudflare/src/websockets.ts | 17 +++++------------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/vite-plugin-cloudflare/src/utils.ts b/packages/vite-plugin-cloudflare/src/utils.ts index db435762..b2c0544e 100644 --- a/packages/vite-plugin-cloudflare/src/utils.ts +++ b/packages/vite-plugin-cloudflare/src/utils.ts @@ -1,6 +1,7 @@ import * as path from 'node:path'; import { Request as MiniflareRequest } from 'miniflare'; import * as vite from 'vite'; +import type { IncomingHttpHeaders } from 'node:http'; export function getOutputDirectory( userConfig: vite.UserConfig, @@ -23,4 +24,22 @@ export function toMiniflareRequest(request: Request): MiniflareRequest { }); } +export function nodeHeadersToWebHeaders( + nodeHeaders: IncomingHttpHeaders, +): Headers { + const headers = new Headers(); + + for (const [key, value] of Object.entries(nodeHeaders)) { + if (typeof value === 'string') { + headers.append(key, value); + } else if (Array.isArray(value)) { + for (const item of value) { + headers.append(key, item); + } + } + } + + return headers; +} + export type Optional = Omit & Pick, K>; diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/vite-plugin-cloudflare/src/websockets.ts index d1e17c5d..b54dc15d 100644 --- a/packages/vite-plugin-cloudflare/src/websockets.ts +++ b/packages/vite-plugin-cloudflare/src/websockets.ts @@ -1,11 +1,15 @@ import ws from 'ws'; import { UNKNOWN_HOST } from './shared'; +import { nodeHeadersToWebHeaders } from './utils'; import type { Fetcher } from '@cloudflare/workers-types/experimental'; import type { ReplaceWorkersTypes } from 'miniflare'; import type { IncomingMessage } from 'node:http'; import type { Duplex } from 'node:stream'; import type * as vite from 'vite'; +/** + * + */ export function handleWebSocket( httpServer: vite.HttpServer, fetcher: ReplaceWorkersTypes['fetch'], @@ -23,18 +27,7 @@ export function handleWebSocket( return; } - const headers = new Headers(); - - for (const [key, value] of Object.entries(request.headers)) { - if (typeof value === 'string') { - headers.append(key, value); - } else if (Array.isArray(value)) { - for (const item of value) { - headers.append(key, item); - } - } - } - + const headers = nodeHeadersToWebHeaders(request.headers); const response = await fetcher(url, { headers, method: request.method, From 1a9fc0d44b0fda4198f50e272b6bce7f4b3eda1f Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Wed, 15 Jan 2025 17:06:57 +0000 Subject: [PATCH 10/11] Add jsdoc --- packages/vite-plugin-cloudflare/src/websockets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite-plugin-cloudflare/src/websockets.ts b/packages/vite-plugin-cloudflare/src/websockets.ts index b54dc15d..e4b53b5a 100644 --- a/packages/vite-plugin-cloudflare/src/websockets.ts +++ b/packages/vite-plugin-cloudflare/src/websockets.ts @@ -8,7 +8,7 @@ import type { Duplex } from 'node:stream'; import type * as vite from 'vite'; /** - * + * This function handles 'upgrade' requests to the Vite HTTP server and forwards WebSocket events between the client and Worker environments. */ export function handleWebSocket( httpServer: vite.HttpServer, From 192f4e87ee4eb9512d13a4ec40893652f3907c80 Mon Sep 17 00:00:00 2001 From: James Opstad <13586373+jamesopstad@users.noreply.github.com> Date: Wed, 15 Jan 2025 17:11:26 +0000 Subject: [PATCH 11/11] Fix lock file --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89ff7747..ecd3e80d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -433,7 +433,7 @@ importers: version: 5.7.2 vite: specifier: catalog:default - version: 6.0.3(@types/node@22.10.1) + version: 6.0.7(@types/node@22.10.1) wrangler: specifier: catalog:default version: 3.94.0(@cloudflare/workers-types@4.20241205.0)