From e12bc1d4a62dfef8713a05109836f05446c646dd Mon Sep 17 00:00:00 2001 From: saltyaom Date: Thu, 31 Jul 2025 18:36:29 +0700 Subject: [PATCH 1/5] :broom: chore: init --- bun.lock | 22 ++++-- example/index.ts | 2 +- package.json | 8 ++- src/index.ts | 181 ++++++++++++++++++++++------------------------- 4 files changed, 104 insertions(+), 109 deletions(-) diff --git a/bun.lock b/bun.lock index 02a1168..46d2ba9 100644 --- a/bun.lock +++ b/bun.lock @@ -4,13 +4,15 @@ "": { "name": "@elysiajs/node", "dependencies": { - "@hono/node-server": "^1.14.3", + "@hono/node-server": "^1.14.4", + "crossws": "^0.4.1", + "srvx": "^0.8.2", }, "devDependencies": { "@elysiajs/cors": "^1.3.0", "@elysiajs/swagger": "^1.3.0", "@types/node": "^22.10.2", - "elysia": "^1.3.3", + "elysia": "^1.3.7", "eslint": "9.17.0", "tsup": "^8.3.5", "tsx": "^4.19.2", @@ -18,7 +20,7 @@ "vitest": "^2.1.8", }, "peerDependencies": { - "elysia": ">= 1.3.3", + "elysia": ">= 1.3.5", }, }, }, @@ -93,7 +95,7 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.8", "", { "dependencies": { "@eslint/core": "^0.13.0", "levn": "^0.4.1" } }, "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA=="], - "@hono/node-server": ["@hono/node-server@1.14.3", "", { "peerDependencies": { "hono": "^4" } }, "sha512-KuDMwwghtFYSmIpr4WrKs1VpelTrptvJ+6x6mbUcZnFcc213cumTF5BdqfHyW93B19TNI4Vaev14vOI2a0Ie3w=="], + "@hono/node-server": ["@hono/node-server@1.14.4", "", { "peerDependencies": { "hono": "^4" } }, "sha512-DnxpshhYewr2q9ZN8ez/M5mmc3sucr8CT1sIgIy1bkeUXut9XWDkqHoFHRhWIQgkYnKpVRxunyhK7WzpJeJ6qQ=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -239,8 +241,12 @@ "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crossws": ["crossws@0.4.1", "", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-E7WKBcHVhAVrY6JYD5kteNqVq1GSZxqGrdSiwXR9at+XHi43HJoCQKXcCczR5LBnBquFZPsB3o7HklulKoBU5w=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], @@ -249,7 +255,7 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "elysia": ["elysia@1.3.3", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.2", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-x6a89d4h0xX9TB0CSGkUuKNqIK776HBJw0WHtK1B3ViQqZijsLnOor6be/7TwVl8MG6m59NHGHbmbBS6lnCXSw=="], + "elysia": ["elysia@1.3.7", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.1.3", "fast-decode-uri-component": "^1.0.1" }, "optionalDependencies": { "@sinclair/typebox": "^0.34.33", "openapi-types": "^12.1.3" }, "peerDependencies": { "file-type": ">= 20.0.0", "typescript": ">= 5.0.0" } }, "sha512-UE0jEu32tQJIiDAW9Wy99EydSZcS3+9lp7AlPeA5lKBXnvSxff8sm14s+49s7RHINbBUBVEGHbmMWbYU5viT7Q=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -277,7 +283,7 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "exact-mirror": ["exact-mirror@0.1.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-wFCPCDLmHbKGUb8TOi/IS7jLsgR8WVDGtDK3CzcB4Guf/weq7G+I+DkXiRSZfbemBFOxOINKpraM6ml78vo8Zw=="], + "exact-mirror": ["exact-mirror@0.1.3", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-yI62LpSby0ItzPJF05C4DRycVAoknRiCIDOLOCCs9zaEKylOXQtOFM3flX54S44swpRz584vk3P70yWQodsLlg=="], "expect-type": ["expect-type@1.2.1", "", {}, "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw=="], @@ -319,7 +325,7 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="], + "hono": ["hono@4.7.11", "", {}, "sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ=="], "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], @@ -449,6 +455,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "srvx": ["srvx@0.8.2", "", { "dependencies": { "cookie-es": "^2.0.0" } }, "sha512-anC1+7B6tryHQd4lFVSDZIfZ1QwJwqm5h1iveKwC1E40PA8nOD50hEt7+AlUoGc9jW3OdmztWBqf4yHCdCPdRQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="], diff --git a/example/index.ts b/example/index.ts index 93d1bba..e40bf40 100644 --- a/example/index.ts +++ b/example/index.ts @@ -9,7 +9,7 @@ const app = new Elysia({ }) .use(cors()) .use(swagger()) - .get('/image', async () => file('test/kyuukurarin.mp4')) + .get('/image', async () => file('test/images/midori.png')) .get('/generator', async function* () { for (let i = 0; i < 100; i++) { await new Promise(resolve => setTimeout(resolve, 10)) diff --git a/package.json b/package.json index c75cf2a..3055fb8 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,18 @@ "release": "npm run build && npm run test && npm publish --access public" }, "dependencies": { - "@hono/node-server": "^1.14.3" + "@hono/node-server": "^1.14.4", + "crossws": "^0.4.1", + "srvx": "^0.8.2" }, "peerDependencies": { - "elysia": ">= 1.3.3" + "elysia": ">= 1.3.5" }, "devDependencies": { "@elysiajs/cors": "^1.3.0", "@elysiajs/swagger": "^1.3.0", "@types/node": "^22.10.2", - "elysia": "^1.3.3", + "elysia": "^1.3.7", "eslint": "9.17.0", "tsup": "^8.3.5", "tsx": "^4.19.2", diff --git a/src/index.ts b/src/index.ts index 96d849d..8c02931 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import type { ElysiaAdapter } from 'elysia' import { WebStandardAdapter } from 'elysia/adapter/web-standard' -import { serve } from '@hono/node-server' +import { serve, FastResponse } from 'srvx' import { isNumericString, randomId } from 'elysia/utils' import type { Server } from 'elysia/universal' @@ -19,13 +19,7 @@ export const node = () => { options = parseInt(options) } - const { promise: serverInfo, resolve: setServerInfo } = - Promise.withResolvers() - - // @ts-expect-error closest possible type - app.server = serverInfo - - const serverOptions: any = + const serverOptions = typeof options === 'number' ? { port: options, @@ -37,98 +31,89 @@ export const node = () => { host: options?.hostname } - let server = serve(serverOptions, () => { - const address = server.address() - const hostname = - typeof address === 'string' - ? address - : address - ? address.address - : 'localhost' - - const port = - typeof address === 'string' ? 0 : (address?.port ?? 0) - - const serverInfo: Server = { - ...server, - id: randomId(), - development: process.env.NODE_ENV !== 'production', - fetch: app.fetch, - hostname, - // @ts-expect-error - get pendingRequests() { - const { promise, resolve, reject } = - Promise.withResolvers() - - server.getConnections((error, total) => { - if (error) reject(error) - - resolve(total) - }) - - return promise - }, - get pendingWebSockets() { - return 0 - }, - port, - publish() { - throw new Error( - "This adapter doesn't support uWebSocket Publish method" - ) - }, - ref() { - server.ref() - }, - unref() { - server.unref() - }, - reload() { - server.close(() => { - server = serve(serverOptions) - }) - }, - requestIP() { - throw new Error( - "This adapter doesn't support Bun requestIP method" - ) - }, - stop() { - server.close() - }, - upgrade() { - throw new Error( - "This adapter doesn't support Web Standard Upgrade method" - ) - }, - url: new URL( - `http://${hostname === '::' ? 'localhost' : hostname}:${port}` - ), - [Symbol.dispose]() { - server.close() - }, - // @ts-expect-error additional property - raw: server - } satisfies Server - - setServerInfo(serverInfo) - - if (callback) callback(serverInfo) - - app.modules.then(() => { - try { - serverInfo.reload( - typeof options === 'object' - ? (options as any) - : { - port: options - } - ) - } catch {} - }) - }) + let server = serve(serverOptions) + const nodeServer = server.node?.server + + console.log(nodeServer) // @ts-ignore + const hostname = server.serveOptions.host ?? 'localhost' + const port = server.options.port + + const serverInfo: Server = { + ...server, + id: randomId(), + development: process.env.NODE_ENV !== 'production', + fetch: app.fetch, + hostname, + get pendingRequests() { + const { promise, resolve, reject } = + Promise.withResolvers() + + nodeServer?.getConnections((error, total) => { + if (error) reject(error) + + resolve(total) + }) + + return promise + }, + get pendingWebSockets() { + return 0 + }, + port, + publish() { + throw new Error( + "This adapter doesn't support uWebSocket Publish method" + ) + }, + ref() { + nodeServer?.ref() + }, + unref() { + nodeServer?.unref() + }, + reload() { + nodeServer?.close() + server = serve(serverOptions) + }, + requestIP() { + throw new Error( + "This adapter doesn't support Bun requestIP method" + ) + }, + stop() { + server.close() + }, + upgrade() { + throw new Error( + "This adapter doesn't support Web Standard Upgrade method" + ) + }, + url: new URL( + `http://${hostname === '::' ? 'localhost' : hostname}:${port}` + ), + [Symbol.dispose]() { + server.close() + }, + raw: server + } satisfies Server + + if (callback) callback(serverInfo) + + app.modules.then(() => { + try { + serverInfo.reload( + typeof options === 'object' + ? (options as any) + : { + port: options + } + ) + } catch {} + }) + + // @ts-ignore private property app.router.http.build?.() if (app.event.start) From 7a9d14706f38cd80a73856215d77dba3d0fbdb3c Mon Sep 17 00:00:00 2001 From: saltyaom Date: Thu, 31 Jul 2025 19:57:55 +0700 Subject: [PATCH 2/5] :broom: chore: ws --- example/demo.ts | 35 ++++++ example/index.ts | 12 +- src/index.ts | 299 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 324 insertions(+), 22 deletions(-) create mode 100644 example/demo.ts diff --git a/example/demo.ts b/example/demo.ts new file mode 100644 index 0000000..6fd3027 --- /dev/null +++ b/example/demo.ts @@ -0,0 +1,35 @@ +import { serve } from 'crossws/server' + +serve({ + fetch: () => new Response("A", { + status: 404 + }), + websocket: { + upgrade(request) { + console.log(`[ws] upgrading ${request.url}...`) + return { + // namespace: new URL(req.url).pathname + headers: {} + } + }, + + open(peer) { + console.log(`[ws] open: ${peer}`) + }, + + message(peer, message) { + console.log('[ws] message', peer, message) + if (message.text().includes('ping')) { + peer.send('pong') + } + }, + + close(peer, event) { + console.log('[ws] close', peer, event) + }, + + error(peer, error) { + console.log('[ws] error', peer, error) + } + } +}) diff --git a/example/index.ts b/example/index.ts index e40bf40..18793ef 100644 --- a/example/index.ts +++ b/example/index.ts @@ -9,11 +9,19 @@ const app = new Elysia({ }) .use(cors()) .use(swagger()) + .ws('/ws', { + open() { + console.log('OPENED') + }, + message(ws, message) { + ws.send(message) + } + }) .get('/image', async () => file('test/images/midori.png')) .get('/generator', async function* () { for (let i = 0; i < 100; i++) { - await new Promise(resolve => setTimeout(resolve, 10)) - yield "A" + await new Promise((resolve) => setTimeout(resolve, 10)) + yield 'A' } }) .post('/', ({ body }) => body, { diff --git a/src/index.ts b/src/index.ts index 8c02931..c186f03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,253 @@ -import type { ElysiaAdapter } from 'elysia' +import { + getSchemaValidator, + serializeCookie, + ValidationError, + type ElysiaAdapter +} from 'elysia' import { WebStandardAdapter } from 'elysia/adapter/web-standard' -import { serve, FastResponse } from 'srvx' +import { defineHooks } from 'crossws' +import { serve } from 'crossws/server' +import { FastResponse } from 'srvx' -import { isNumericString, randomId } from 'elysia/utils' +import type { + Server as NodeServer, + IncomingMessage, + ServerResponse +} from 'http' +import type { + Http2Server, + Http2ServerRequest, + Http2ServerResponse +} from 'http2' + +import { isNotEmpty, isNumericString, randomId } from 'elysia/utils' import type { Server } from 'elysia/universal' +import { + createHandleWSResponse, + createWSMessageParser, + ElysiaWS +} from 'elysia/ws' +import { ServerWebSocket } from 'elysia/ws/bun' +import { parseSetCookies } from 'elysia/adapter/utils' + +const toServerWebSocket = (ws: ServerWebSocket) => { + // @ts-ignore + ws.data = ws.context + ws.sendText = ws.send + ws.sendBinary = ws.send + ws.publishText = ws.publish + ws.publishBinary = ws.publish + ws.isSubscribed = () => false + // @ts-ignore + ws.cork = () => {} +} export const node = () => { + const wsState: Record = {} + return { ...WebStandardAdapter, name: 'node', + ws(app, path, options) { + const { parse, body, response, ...rest } = options + + const validateMessage = getSchemaValidator(body, { + // @ts-expect-error private property + modules: app.definitions.typebox, + // @ts-expect-error private property + models: app.definitions.type as Record, + normalize: app.config.normalize + }) + + const validateResponse = getSchemaValidator(response as any, { + // @ts-expect-error private property + modules: app.definitions.typebox, + // @ts-expect-error private property + models: app.definitions.type as Record, + normalize: app.config.normalize + }) + + app.route( + 'WS', + path as any, + async (context: any) => { + // ! Enable static code analysis just in case resolveUnknownFunction doesn't work, do not remove + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { set, path, qi, headers, query, params } = context + + const id = context.request.id + + // @ts-ignore + context.validator = validateResponse + + if (options.upgrade) { + if (typeof options.upgrade === 'function') { + const temp = options.upgrade(context as any) + if (temp instanceof Promise) await temp + } else if (options.upgrade) + Object.assign( + set.headers, + options.upgrade as Record + ) + } + + if (set.cookie && isNotEmpty(set.cookie)) { + const cookie = serializeCookie(set.cookie) + + if (cookie) set.headers['set-cookie'] = cookie + } + + if ( + set.headers['set-cookie'] && + Array.isArray(set.headers['set-cookie']) + ) + set.headers = parseSetCookies( + new Headers(set.headers as any) as Headers, + set.headers['set-cookie'] + ) as any + + const handleResponse = + createHandleWSResponse(validateResponse) + const parseMessage = createWSMessageParser(parse) + + let _id: string | undefined + + if (typeof options.beforeHandle === 'function') { + const result = options.beforeHandle(context) + if (result instanceof Promise) await result + } + + const errorHandlers = [ + ...(options.error + ? Array.isArray(options.error) + ? options.error + : [options.error] + : []), + ...(app.event.error ?? []).map((x) => + typeof x === 'function' ? x : x.fn + ) + ].filter((x) => x) + + const handleErrors = !errorHandlers.length + ? () => {} + : async (ws: ServerWebSocket, error: unknown) => { + for (const handleError of errorHandlers) { + let response = handleError( + Object.assign(context, { error }) + ) + if (response instanceof Promise) + response = await response + + await handleResponse(ws, response) + + if (response) break + } + } + + wsState[id] = { + context, + headers: isNotEmpty(set.headers) + ? (set.headers as Record) + : undefined, + id, + validator: validateResponse, + ping(data?: unknown) { + options.ping?.(data) + }, + pong(data?: unknown) { + options.pong?.(data) + }, + open: async (ws: ServerWebSocket) => { + toServerWebSocket(ws) + + try { + await handleResponse( + ws, + options.open?.( + new ElysiaWS(ws, context as any) + ) + ) + } catch (error) { + console.log(error) + handleErrors(ws, error) + } + }, + message: async ( + ws: ServerWebSocket, + _message: any + ) => { + const message = await parseMessage( + ws, + _message.text() + ) + + if (validateMessage?.Check(message) === false) + return void ws.send( + new ValidationError( + 'message', + validateMessage, + message + ).message as string + ) + + try { + await handleResponse( + ws, + options.message?.( + new ElysiaWS( + ws, + context as any, + message + ), + message as any + ) + ) + } catch (error) { + console.log(error) + handleErrors(ws, error) + } + }, + drain: async (ws: ServerWebSocket) => { + try { + await handleResponse( + ws, + options.drain?.( + new ElysiaWS(ws, context as any) + ) + ) + } catch (error) { + handleErrors(ws, error) + } + }, + close: async ( + ws: ServerWebSocket, + code: number, + reason: string + ) => { + try { + await handleResponse( + ws, + options.close?.( + new ElysiaWS(ws, context as any), + code, + reason + ) + ) + } catch (error) { + handleErrors(ws, error) + } + } + } + + return '' + }, + { + ...rest, + websocket: options + } as any + ) + }, listen(app) { return (options, callback) => { if (typeof options === 'string') { @@ -19,22 +257,55 @@ export const node = () => { options = parseInt(options) } + const websocket = defineHooks({ + async upgrade(request) { + // @ts-ignore + const id = (request.id = randomId()) + + const response = await app.handle(request) + if (response.status >= 300) return response + + const ws = wsState[id] + + return { + // @ts-ignore + headers: ws!.headers, + context: ws as any + } + }, + open(ws) { + // @ts-ignore + ws.context.open?.(ws) + }, + message(ws, message) { + // @ts-ignore + ws.context.message?.(ws, message) + } + }) + const serverOptions = typeof options === 'number' ? { port: options, + websocket, fetch: app.fetch } : { ...options, - // @ts-ignore - host: options?.hostname + websocket, + fetch: app.fetch } let server = serve(serverOptions) - const nodeServer = server.node?.server - - console.log(nodeServer) + const nodeServer = server.node?.server as + | NodeServer + | Http2Server< + typeof IncomingMessage, + typeof ServerResponse, + typeof Http2ServerRequest, + typeof Http2ServerResponse + > + | undefined // @ts-ignore const hostname = server.serveOptions.host ?? 'localhost' @@ -101,18 +372,6 @@ export const node = () => { if (callback) callback(serverInfo) - app.modules.then(() => { - try { - serverInfo.reload( - typeof options === 'object' - ? (options as any) - : { - port: options - } - ) - } catch {} - }) - // @ts-ignore private property app.router.http.build?.() From 0edc9132782540dd3d6ce414c7ec89cd7ec76e8c Mon Sep 17 00:00:00 2001 From: saltyaom Date: Thu, 31 Jul 2025 21:22:22 +0700 Subject: [PATCH 3/5] :tada: feat: file --- CHANGELOG.md | 8 + example/index.ts | 2 +- src/handle.ts | 718 +++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 16 +- src/utils.ts | 69 +++++ 5 files changed, 809 insertions(+), 4 deletions(-) create mode 100644 src/handle.ts create mode 100644 src/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cab4ec..80a57b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.3.1 +Change: +- use srvx, crossws + +Improvement: +- support WebSocket (cork, and isSubscribed is not implemented yet) +- support `ElysiaFile` `content-type`, and `content-range` + # 1.3.0 - 27 May 2025 Change: - use WebStandard Compatibility via `@hono/node-server` diff --git a/example/index.ts b/example/index.ts index 18793ef..585e8c6 100644 --- a/example/index.ts +++ b/example/index.ts @@ -17,7 +17,7 @@ const app = new Elysia({ ws.send(message) } }) - .get('/image', async () => file('test/images/midori.png')) + .get('/image', async () => file('test/kyuukurarin.mp4')) .get('/generator', async function* () { for (let i = 0; i < 100; i++) { await new Promise((resolve) => setTimeout(resolve, 10)) diff --git a/src/handle.ts b/src/handle.ts new file mode 100644 index 0000000..e45a222 --- /dev/null +++ b/src/handle.ts @@ -0,0 +1,718 @@ +/* eslint-disable sonarjs/no-nested-switch */ +/* eslint-disable sonarjs/no-duplicate-string */ +import { FastResponse as Response } from 'srvx' + +import { + createResponseHandler, + createStreamHandler, + handleSet, + responseToSetHeaders, + streamResponse +} from 'elysia/adapter/utils' +import { handleFile } from './utils' + +import { ElysiaFile, mime } from 'elysia/universal/file' +import { isNotEmpty } from 'elysia/utils' +import { Cookie } from 'elysia/cookies' +import { ElysiaCustomStatusResponse } from 'elysia/error' + +import type { Context } from 'elysia/context' +import type { AnyLocalHook } from 'elysia/types' + +import type { ReadStream } from 'fs' + +const handleElysiaFile = ( + file: ElysiaFile, + set: Context['set'] = { + headers: {} + } +) => { + const path = file.path + // @ts-ignore + const contentType = mime[path.slice(path.lastIndexOf('.') + 1)] + if (contentType) set.headers['content-type'] = contentType + + if ( + file.stats && + set && + set.status !== 206 && + set.status !== 304 && + set.status !== 412 && + set.status !== 416 + ) + return file.stats!.then((stat) => { + const size = stat.size as number + + if (size !== undefined) { + set.headers['content-range'] = `bytes 0-${size - 1}/${size}` + set.headers['content-length'] = size + } + + return handleFile(file.value as ReadStream, set) + }) as any + + return handleFile(file.value as ReadStream, set) +} + +export const mapResponse = ( + response: unknown, + set: Context['set'], + request?: Request +): Response => { + if (isNotEmpty(set.headers) || set.status !== 200 || set.cookie) { + handleSet(set) + + switch (response?.constructor?.name) { + case 'String': + set.headers['content-type'] = 'text/plain' + return new Response(response as string, set as any) + + case 'Array': + case 'Object': + set.headers['content-type'] = 'application/json' + return new Response(JSON.stringify(response), set as any) + + case 'ElysiaFile': + return handleElysiaFile(response as ElysiaFile, set) + + case 'File': + return handleFile(response as File, set as any) + + case 'Blob': + return handleFile(response as Blob, set as any) + + case 'ElysiaCustomStatusResponse': + set.status = (response as ElysiaCustomStatusResponse<200>).code + + return mapResponse( + (response as ElysiaCustomStatusResponse<200>).response, + set, + request + ) + + case 'ReadableStream': + if ( + !set.headers['content-type']?.startsWith( + 'text/event-stream' + ) + ) + set.headers['content-type'] = + 'text/event-stream; charset=utf-8' + + request?.signal?.addEventListener( + 'abort', + { + handleEvent() { + if (request?.signal && !request?.signal?.aborted) + (response as ReadableStream).cancel() + } + }, + { + once: true + } + ) + + return new Response(response as ReadableStream, set as any) + + case undefined: + if (!response) return new Response('', set as any) + + return new Response(JSON.stringify(response), set as any) + + case 'Response': + return handleResponse(response as Response, set, request) + + case 'Error': + return errorToResponse(response as Error, set) + + case 'Promise': + return (response as Promise).then((x) => + mapResponse(x, set, request) + ) as any + + case 'Function': + return mapResponse((response as Function)(), set, request) + + case 'Number': + case 'Boolean': + return new Response( + (response as number | boolean).toString(), + set as any + ) + + case 'Cookie': + if (response instanceof Cookie) + return new Response(response.value, set as any) + + return new Response(response?.toString(), set as any) + + case 'FormData': + return new Response(response as FormData, set as any) + + default: + // recheck Response, Promise, Error because some library may extends Response + if (response instanceof Response) + return handleResponse(response as Response, set, request) + + if (response instanceof Promise) + return response.then((x) => mapResponse(x, set)) as any + + if (response instanceof Error) + return errorToResponse(response as Error, set) + + if (response instanceof ElysiaCustomStatusResponse) { + set.status = ( + response as ElysiaCustomStatusResponse<200> + ).code + + return mapResponse( + (response as ElysiaCustomStatusResponse<200>).response, + set, + request + ) + } + + // @ts-expect-error + if (typeof response?.next === 'function') + return handleStream(response as any, set, request) as any + + // @ts-expect-error + if (typeof response?.then === 'function') + // @ts-expect-error + return response.then((x) => mapResponse(x, set)) as any + + // @ts-expect-error + if (typeof response?.toResponse === 'function') + return mapResponse((response as any).toResponse(), set) + + if ('charCodeAt' in (response as any)) { + const code = (response as any).charCodeAt(0) + + if (code === 123 || code === 91) { + if (!set.headers['Content-Type']) + set.headers['Content-Type'] = 'application/json' + + return new Response( + JSON.stringify(response), + set as any + ) as any + } + } + + return new Response(response as any, set as any) + } + } + + if ( + response instanceof Response && + !(response as Response).headers.has('content-length') && + (response as Response).headers.get('transfer-encoding') === 'chunked' + ) + return handleStream( + streamResponse(response), + responseToSetHeaders(response as Response, set), + request + ) as any + + // Stream response defers a 'set' API, assume that it may include 'set' + if ( + // @ts-expect-error + typeof response?.next === 'function' || + response instanceof ReadableStream + ) + return handleStream(response as any, set, request) as any + + return mapCompactResponse(response, request) +} + +export const mapEarlyResponse = ( + response: unknown, + set: Context['set'], + request?: Request +): Response | undefined => { + if (response === undefined || response === null) return + + if (isNotEmpty(set.headers) || set.status !== 200 || set.cookie) { + handleSet(set) + + switch (response?.constructor?.name) { + case 'String': + set.headers['content-type'] = 'text/plain' + return new Response(response as string, set as any) + + case 'Array': + case 'Object': + set.headers['content-type'] = 'application/json' + return new Response(JSON.stringify(response), set as any) + + case 'ElysiaFile': + return handleElysiaFile(response as ElysiaFile, set) + + case 'File': + return handleFile(response as File, set as any) + + case 'Blob': + return handleFile(response as File | Blob, set) + + case 'ElysiaCustomStatusResponse': + set.status = (response as ElysiaCustomStatusResponse<200>).code + + return mapEarlyResponse( + (response as ElysiaCustomStatusResponse<200>).response, + set, + request + ) + + case 'ReadableStream': + if ( + !set.headers['content-type']?.startsWith( + 'text/event-stream' + ) + ) + set.headers['content-type'] = + 'text/event-stream; charset=utf-8' + + request?.signal?.addEventListener( + 'abort', + { + handleEvent() { + if (request?.signal && !request?.signal?.aborted) + (response as ReadableStream).cancel() + } + }, + { + once: true + } + ) + + return new Response(response as ReadableStream, set as any) + + case undefined: + if (!response) return + + return new Response(JSON.stringify(response), set as any) + + case 'Response': + return handleResponse(response as Response, set, request) + + case 'Promise': + // @ts-ignore + return (response as Promise).then((x) => + mapEarlyResponse(x, set) + ) + + case 'Error': + return errorToResponse(response as Error, set) + + case 'Function': + return mapEarlyResponse((response as Function)(), set) + + case 'Number': + case 'Boolean': + return new Response( + (response as number | boolean).toString(), + set as any + ) + + case 'FormData': + return new Response(response as FormData) + + case 'Cookie': + if (response instanceof Cookie) + return new Response(response.value, set as any) + + return new Response(response?.toString(), set as any) + + default: + if (response instanceof Response) + return handleResponse(response, set, request) + + if (response instanceof Promise) + return response.then((x) => mapEarlyResponse(x, set)) as any + + if (response instanceof Error) + return errorToResponse(response as Error, set) + + if (response instanceof ElysiaCustomStatusResponse) { + set.status = ( + response as ElysiaCustomStatusResponse<200> + ).code + + return mapEarlyResponse( + (response as ElysiaCustomStatusResponse<200>).response, + set, + request + ) + } + + // @ts-expect-error + if (typeof response?.next === 'function') + return handleStream(response as any, set, request) as any + + // @ts-expect-error + if (typeof response?.then === 'function') + // @ts-expect-error + return response.then((x) => mapEarlyResponse(x, set)) as any + + // @ts-expect-error + if (typeof response?.toResponse === 'function') + return mapEarlyResponse((response as any).toResponse(), set) + + if ('charCodeAt' in (response as any)) { + const code = (response as any).charCodeAt(0) + + if (code === 123 || code === 91) { + if (!set.headers['Content-Type']) + set.headers['Content-Type'] = 'application/json' + + return new Response( + JSON.stringify(response), + set as any + ) as any + } + } + + return new Response(response as any, set as any) + } + } else + switch (response?.constructor?.name) { + case 'String': + set.headers['content-type'] = 'text/plain' + return new Response(response as string) + + case 'Array': + case 'Object': + set.headers['content-type'] = 'application/json' + return new Response(JSON.stringify(response), set as any) + + case 'ElysiaFile': + return handleElysiaFile(response as ElysiaFile, set) + + case 'File': + return handleFile(response as File, set as any) + + case 'Blob': + return handleFile(response as File | Blob, set) + + case 'ElysiaCustomStatusResponse': + set.status = (response as ElysiaCustomStatusResponse<200>).code + + return mapEarlyResponse( + (response as ElysiaCustomStatusResponse<200>).response, + set, + request + ) + + case 'ReadableStream': + request?.signal?.addEventListener( + 'abort', + { + handleEvent() { + if (request?.signal && !request?.signal?.aborted) + (response as ReadableStream).cancel() + } + }, + { + once: true + } + ) + + return new Response(response as ReadableStream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8' + } + }) + + case undefined: + if (!response) return new Response('') + + return new Response(JSON.stringify(response), { + headers: { + 'content-type': 'application/json' + } + }) + + case 'Response': + if ( + !(response as Response).headers.has('content-length') && + (response as Response).headers.get('transfer-encoding') === + 'chunked' + ) + return handleStream( + streamResponse(response as Response), + responseToSetHeaders(response as Response), + request + ) as any + + return response as Response + + case 'Promise': + // @ts-ignore + return (response as Promise).then((x) => { + const r = mapEarlyResponse(x, set) + if (r !== undefined) return r + }) + + case 'Error': + return errorToResponse(response as Error, set) + + case 'Function': + return mapCompactResponse((response as Function)(), request) + + case 'Number': + case 'Boolean': + return new Response((response as number | boolean).toString()) + + case 'Cookie': + if (response instanceof Cookie) + return new Response(response.value, set as any) + + return new Response(response?.toString(), set as any) + + case 'FormData': + return new Response(response as FormData) + + default: + if (response instanceof Response) return response + + if (response instanceof Promise) + return response.then((x) => mapEarlyResponse(x, set)) as any + + if (response instanceof Error) + return errorToResponse(response as Error, set) + + if (response instanceof ElysiaCustomStatusResponse) { + set.status = ( + response as ElysiaCustomStatusResponse<200> + ).code + + return mapEarlyResponse( + (response as ElysiaCustomStatusResponse<200>).response, + set, + request + ) + } + + // @ts-expect-error + if (typeof response?.next === 'function') + return handleStream(response as any, set, request) as any + + // @ts-expect-error + if (typeof response?.then === 'function') + // @ts-expect-error + return response.then((x) => mapEarlyResponse(x, set)) as any + + // @ts-expect-error + if (typeof response?.toResponse === 'function') + return mapEarlyResponse((response as any).toResponse(), set) + + if ('charCodeAt' in (response as any)) { + const code = (response as any).charCodeAt(0) + + if (code === 123 || code === 91) { + if (!set.headers['Content-Type']) + set.headers['Content-Type'] = 'application/json' + + return new Response( + JSON.stringify(response), + set as any + ) as any + } + } + + return new Response(response as any) + } +} + +export const mapCompactResponse = ( + response: unknown, + request?: Request +): Response => { + switch (response?.constructor?.name) { + case 'String': + return new Response(response as string, { + headers: { + 'Content-Type': 'text/plain' + } + }) + + case 'Object': + case 'Array': + return new Response(JSON.stringify(response), { + headers: { + 'Content-Type': 'application/json' + } + }) + + case 'ElysiaFile': + return handleElysiaFile(response as ElysiaFile) + + case 'File': + return handleFile(response as File) + + case 'Blob': + return handleFile(response as File | Blob) + + case 'ElysiaCustomStatusResponse': + return mapResponse( + (response as ElysiaCustomStatusResponse<200>).response, + { + status: (response as ElysiaCustomStatusResponse<200>).code, + headers: {} + } + ) + + case 'ReadableStream': + request?.signal?.addEventListener( + 'abort', + { + handleEvent() { + if (request?.signal && !request?.signal?.aborted) + (response as ReadableStream).cancel() + } + }, + { + once: true + } + ) + + return new Response(response as ReadableStream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8' + } + }) + + case undefined: + if (!response) return new Response('') + + return new Response(JSON.stringify(response), { + headers: { + 'content-type': 'application/json' + } + }) + + case 'Response': + if ( + (response as Response).headers.get('transfer-encoding') === + 'chunked' + ) + return handleStream( + streamResponse(response as Response), + responseToSetHeaders(response as Response), + request + ) as any + + return response as Response + + case 'Error': + return errorToResponse(response as Error) + + case 'Promise': + return (response as any as Promise).then((x) => + mapCompactResponse(x, request) + ) as any + + // ? Maybe response or Blob + case 'Function': + return mapCompactResponse((response as Function)(), request) + + case 'Number': + case 'Boolean': + return new Response((response as number | boolean).toString()) + + case 'FormData': + return new Response(response as FormData) + + default: + if (response instanceof Response) return response + + if (response instanceof Promise) + return response.then((x) => + mapCompactResponse(x, request) + ) as any + + if (response instanceof Error) + return errorToResponse(response as Error) + + if (response instanceof ElysiaCustomStatusResponse) + return mapResponse( + (response as ElysiaCustomStatusResponse<200>).response, + { + status: (response as ElysiaCustomStatusResponse<200>) + .code, + headers: {} + } + ) + + // @ts-expect-error + if (typeof response?.next === 'function') + return handleStream(response as any, undefined, request) as any + + // @ts-expect-error + if (typeof response?.then === 'function') + // @ts-expect-error + return response.then((x) => mapResponse(x, set)) as any + + // @ts-expect-error + if (typeof response?.toResponse === 'function') + return mapCompactResponse((response as any).toResponse()) + + if ('charCodeAt' in (response as any)) { + const code = (response as any).charCodeAt(0) + + if (code === 123 || code === 91) { + return new Response(JSON.stringify(response), { + headers: { + 'Content-Type': 'application/json' + } + }) as any + } + } + + return new Response(response as any) + } +} + +export const errorToResponse = (error: Error, set?: Context['set']) => + new Response( + JSON.stringify({ + name: error?.name, + message: error?.message, + cause: error?.cause + }), + { + status: + set?.status !== 200 ? ((set?.status as number) ?? 500) : 500, + headers: set?.headers as any + } + ) + +export const createStaticHandler = ( + handle: unknown, + hooks: Partial, + setHeaders: Context['set']['headers'] = {} +): (() => Response) | undefined => { + if (typeof handle === 'function') return + + const response = mapResponse(handle, { + headers: setHeaders + }) + + if ( + !hooks.parse?.length && + !hooks.transform?.length && + !hooks.beforeHandle?.length && + !hooks.afterHandle?.length + ) + return response.clone.bind(response) as any +} + +const handleResponse = createResponseHandler({ + mapResponse, + mapCompactResponse +}) + +const handleStream = createStreamHandler({ + mapResponse, + mapCompactResponse +}) diff --git a/src/index.ts b/src/index.ts index c186f03..6f73fdd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,13 @@ import { WebStandardAdapter } from 'elysia/adapter/web-standard' import { defineHooks } from 'crossws' import { serve } from 'crossws/server' -import { FastResponse } from 'srvx' + +import { + mapCompactResponse, + mapEarlyResponse, + mapResponse, + createStaticHandler +} from './handle' import type { Server as NodeServer, @@ -49,6 +55,12 @@ export const node = () => { return { ...WebStandardAdapter, name: 'node', + handler: { + mapCompactResponse, + mapEarlyResponse, + mapResponse, + createStaticHandler + }, ws(app, path, options) { const { parse, body, response, ...rest } = options @@ -169,7 +181,6 @@ export const node = () => { ) ) } catch (error) { - console.log(error) handleErrors(ws, error) } }, @@ -204,7 +215,6 @@ export const node = () => { ) ) } catch (error) { - console.log(error) handleErrors(ws, error) } }, diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..8364420 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,69 @@ +import type { Context } from 'elysia/context' +import { isNotEmpty } from 'elysia/utils' +import type { ReadStream } from 'fs' + +export const handleFile = ( + response: ReadStream | File | Blob, + set?: Context['set'] +): Response => { + if (response instanceof Promise) + return response.then((res) => handleFile(res, set)) as any + + // @ts-ignore + const size = response.size + const immutable = + set && + (set.status === 206 || + set.status === 304 || + set.status === 412 || + set.status === 416) + + const defaultHeader = immutable + ? { + 'transfer-encoding': 'chunked' + } + : ({ + 'accept-ranges': 'bytes', + 'content-range': size + ? `bytes 0-${size - 1}/${size}` + : undefined, + 'transfer-encoding': 'chunked' + } as any) + + if (!set && !size) return new Response(response as Blob) + + if (!set) + return new Response(response as Blob, { + headers: defaultHeader + }) + + if (set.headers instanceof Headers) { + let setHeaders: Record = defaultHeader + + setHeaders = {} + // @ts-ignore + for (const [key, value] of set.headers.entries()) + if (key in set.headers) setHeaders[key] = value + + if (immutable) { + delete set.headers['content-length'] + delete set.headers['accept-ranges'] + } + + return new Response(response as Blob, { + status: set.status as number, + headers: setHeaders + }) + } + + if (isNotEmpty(set.headers)) + return new Response(response as Blob, { + status: set.status as number, + headers: Object.assign(defaultHeader, set.headers) + }) + + return new Response(response as Blob, { + status: set.status as number, + headers: defaultHeader + }) +} From ba7018e8f0b75f9df2f2cfcea91c7b9fe83a226a Mon Sep 17 00:00:00 2001 From: saltyaom Date: Thu, 31 Jul 2025 21:26:01 +0700 Subject: [PATCH 4/5] :tada: feat: ws --- example/index.ts | 6 +- src/index.ts | 6 +- src/utils.ts | 6 +- src/ws.ts | 191 ----------------------------------------------- 4 files changed, 11 insertions(+), 198 deletions(-) delete mode 100644 src/ws.ts diff --git a/example/index.ts b/example/index.ts index 585e8c6..079211d 100644 --- a/example/index.ts +++ b/example/index.ts @@ -9,9 +9,9 @@ const app = new Elysia({ }) .use(cors()) .use(swagger()) - .ws('/ws', { - open() { - console.log('OPENED') + .ws('/ws/:id', { + open({ data }) { + console.log(data.params) }, message(ws, message) { ws.send(message) diff --git a/src/index.ts b/src/index.ts index 6f73fdd..04712fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,8 +38,10 @@ import { ServerWebSocket } from 'elysia/ws/bun' import { parseSetCookies } from 'elysia/adapter/utils' const toServerWebSocket = (ws: ServerWebSocket) => { - // @ts-ignore - ws.data = ws.context + // @ts-ignore, context.context is intentional + // first context is srvx.context (alias of bun.ws.data) + // second context is Elysia context + ws.data = ws.context.context ws.sendText = ws.send ws.sendBinary = ws.send ws.publishText = ws.publish diff --git a/src/utils.ts b/src/utils.ts index 8364420..cdff08c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,9 @@ -import type { Context } from 'elysia/context' -import { isNotEmpty } from 'elysia/utils' +import { FastResponse as Response } from 'srvx' import type { ReadStream } from 'fs' +import { isNotEmpty } from 'elysia/utils' +import type { Context } from 'elysia/context' + export const handleFile = ( response: ReadStream | File | Blob, set?: Context['set'] diff --git a/src/ws.ts b/src/ws.ts deleted file mode 100644 index 0b32fb4..0000000 --- a/src/ws.ts +++ /dev/null @@ -1,191 +0,0 @@ -// import { -// AnyElysia, -// serializeCookie, -// ValidationError, -// type TSchema -// } from 'elysia' -// import { isNotEmpty, randomId } from 'elysia/utils' -// import { getSchemaValidator } from 'elysia/schema' - -// import { createServer } from 'http' - -// import { WebSocketServer } from 'ws' -// import type { WebSocket as NodeWebSocket } from 'ws' -// import { -// createHandleWSResponse, -// createWSMessageParser, -// ElysiaWS -// } from 'elysia/ws' -// import type { ServerWebSocket } from 'elysia/ws/bun' -// import { parseSetCookies } from 'elysia/adapter/web-standard/handler' - -// import type { AnyWSLocalHook } from 'elysia/ws/types' - -// export const attachWebSocket = ( -// app: AnyElysia, -// server: ReturnType -// ) => { -// const wsServer = new WebSocketServer({ -// noServer: true -// }) - -// const staticWsRouter = app.router.static.ws -// const router = app.router.http -// const history = app.router.history - -// server.on('upgrade', (request, socket, head) => { -// wsServer.handleUpgrade(request, socket, head, async (ws) => { -// const qi = request.url!.indexOf('?') -// let path = request.url! -// if (qi !== -1) path = request.url!.substring(0, qi) - -// const index = staticWsRouter[path] -// if (index === undefined) return - -// const route = history[index] -// if (!route) { -// router.find('$INTERNALWS', path) -// return -// } - -// if (!route.websocket) return -// const websocket: AnyWSLocalHook = route.websocket - -// const validateMessage = getSchemaValidator(route.hooks.body, { -// // @ts-expect-error private property -// modules: app.definitions.typebox, -// // @ts-expect-error private property -// models: app.definitions.type as Record, -// normalize: app.config.normalize -// }) - -// const validateResponse = getSchemaValidator( -// route.hooks.response as any, -// { -// // @ts-expect-error private property -// modules: app.definitions.typebox, -// // @ts-expect-error private property -// models: app.definitions.type as Record, -// normalize: app.config.normalize -// } -// ) - -// const parseMessage = createWSMessageParser(route.hooks.parse) -// const handleResponse = createHandleWSResponse(validateResponse) - -// const context = requestToContext(app, request, undefined as any) -// const set = context.set - -// if (set.cookie && isNotEmpty(set.cookie)) { -// const cookie = serializeCookie(set.cookie) - -// if (cookie) set.headers['set-cookie'] = cookie -// } - -// if ( -// set.headers['set-cookie'] && -// Array.isArray(set.headers['set-cookie']) -// ) -// set.headers = parseSetCookies( -// new Headers(set.headers as any) as Headers, -// set.headers['set-cookie'] -// ) as any - -// if (route.hooks.upgrade) { -// if (typeof route.hooks.upgrade === 'function') { -// const temp = route.hooks.upgrade(context as any) -// if (temp instanceof Promise) await temp - -// Object.assign(set.headers, context.set.headers) -// } else if (route.hooks.upgrade) -// Object.assign( -// set.headers, -// route.hooks.upgrade as Record -// ) -// } - -// if (route.hooks.transform) -// for (let i = 0; i < route.hooks.transform.length; i++) { -// const hook = route.hooks.transform[i] -// const operation = hook.fn(context) - -// if (hook.subType === 'derive') { -// if (operation instanceof Promise) -// Object.assign(context, await operation) -// else Object.assign(context, operation) -// } else if (operation instanceof Promise) await operation -// } - -// if (route.hooks.beforeHandle) -// for (let i = 0; i < route.hooks.beforeHandle.length; i++) { -// const hook = route.hooks.beforeHandle[i] -// let response = hook.fn(context) - -// if (hook.subType === 'resolve') { -// if (response instanceof Promise) -// Object.assign(context, await response) -// else Object.assign(context, response) - -// continue -// } else if (response instanceof Promise) -// response = await response -// } - -// let _id: string | undefined -// Object.assign(context, { -// get id() { -// if (_id) return _id - -// return (_id = randomId()) -// }, -// validator: validateResponse -// }) - -// const elysiaWS = nodeWebSocketToServerWebSocket( -// ws, -// wsServer, -// context as any -// ) - -// if (websocket.open) -// handleResponse( -// elysiaWS, -// websocket.open!(new ElysiaWS(elysiaWS, context as any)) -// ) - -// if (websocket.message) -// ws.on('message', async (_message) => { -// const message = await parseMessage(elysiaWS, _message) - -// if (validateMessage?.Check(message) === false) -// return void ws.send( -// new ValidationError( -// 'message', -// validateMessage, -// message -// ).message as string -// ) - -// handleResponse( -// elysiaWS, -// websocket.message!( -// new ElysiaWS(elysiaWS, context as any, message), -// message -// ) -// ) -// }) - -// if (websocket.close) -// ws.on('close', (code, reason) => { -// handleResponse( -// elysiaWS, -// websocket.close!( -// new ElysiaWS(elysiaWS, context as any), -// code, -// reason.toString() -// ) -// ) -// }) -// }) -// }) -// } From 034b7078d8bc4ab58831649a035177743aef17c8 Mon Sep 17 00:00:00 2001 From: saltyaom Date: Thu, 31 Jul 2025 21:33:32 +0700 Subject: [PATCH 5/5] :tada: feat: silent --- CHANGELOG.md | 2 +- src/index.ts | 42 ++++++++++++++++++++++++++++-------------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a57b5..3d68e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ Change: - use srvx, crossws Improvement: -- support WebSocket (cork, and isSubscribed is not implemented yet) +- support full WebSocket - support `ElysiaFile` `content-type`, and `content-range` # 1.3.0 - 27 May 2025 diff --git a/src/index.ts b/src/index.ts index 04712fe..0393f99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,20 +37,6 @@ import { import { ServerWebSocket } from 'elysia/ws/bun' import { parseSetCookies } from 'elysia/adapter/utils' -const toServerWebSocket = (ws: ServerWebSocket) => { - // @ts-ignore, context.context is intentional - // first context is srvx.context (alias of bun.ws.data) - // second context is Elysia context - ws.data = ws.context.context - ws.sendText = ws.send - ws.sendBinary = ws.send - ws.publishText = ws.publish - ws.publishBinary = ws.publish - ws.isSubscribed = () => false - // @ts-ignore - ws.cork = () => {} -} - export const node = () => { const wsState: Record = {} @@ -82,6 +68,32 @@ export const node = () => { normalize: app.config.normalize }) + const subscribed: Record = {} + + const toServerWebSocket = (ws: ServerWebSocket) => { + // @ts-ignore, context.context is intentional + // first context is srvx.context (alias of bun.ws.data) + // second context is Elysia context + ws.data = ws.context.context + ws.sendText = ws.send + ws.sendBinary = ws.send + ws.publishText = ws.publish + ws.publishBinary = ws.publish + ws.subscribe = (topic: string) => { + subscribed[topic] = 1 + ws.subscribe(topic) + } + ws.unsubscribe = (topic: string) => { + delete subscribed[topic] + ws.unsubscribe(topic) + } + ws.isSubscribed = (topic: string) => topic in subscribed + // @ts-ignore + ws.cork = () => { + console.log('ws.cork is not supported yet') + } + } + app.route( 'WS', path as any, @@ -299,11 +311,13 @@ export const node = () => { typeof options === 'number' ? { port: options, + silent: true, websocket, fetch: app.fetch } : { ...options, + silent: true, websocket, fetch: app.fetch }