Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support uWebSocket.js #22

Closed
wants to merge 13 commits into from
2 changes: 2 additions & 0 deletions packages/vike-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"./h3": "./dist/h3.js",
"./hono": "./dist/hono.js",
"./elysia": "./dist/elysia.js",
"./uws": "./dist/uws.js",
"./plugin": "./dist/plugin/index.js",
".": "./dist/index.js",
"./__handler": {
Expand Down Expand Up @@ -50,6 +51,7 @@
"h3": "^1.12.0",
"hono": "^4.5.5",
"typescript": "^5.5.4",
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.49.0",
"vike": "^0.4.193",
"vite": "^5.4.0"
},
Expand Down
65 changes: 65 additions & 0 deletions packages/vike-node/src/runtime/adapters/connectToWebUws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export { connectToWeb }

import { Readable } from 'node:stream'
import type { HttpResponse } from 'uWebSockets.js'
import type { ConnectMiddlewareUws, PlatformRequestUws, WebHandlerUws } from '../types.js'
import { createServerResponse } from './createServerResponseUws.js'
import { DUMMY_BASE_URL } from '../constants.js'
import { readableStreamToBuffer } from '../utils/writeHttpResponse.js'

const statusCodesWithoutBody = new Set([
100, // Continue
101, // Switching Protocols
102, // Processing (WebDAV)
103, // Early Hints
204, // No Content
205, // Reset Content
304 // Not Modified
]) as ReadonlySet<number>

/**
* Converts a Connect-style middleware to a web-compatible request handler.
*
* @param {ConnectMiddlewareUws} handler - The Connect-style middleware function to be converted.
* @returns {WebHandlerUws} A function that handles web requests and returns a Response or undefined.
*/
function connectToWeb(handler: ConnectMiddlewareUws): WebHandlerUws {
return async (response: HttpResponse, platformRequest: PlatformRequestUws): Promise<void> => {
const { res, onReadable } = createServerResponse(enrichResponse(response, platformRequest))

return new Promise<void>((resolve) => {
onReadable(async ({ readable, headers, statusCode }) => {
res.writeStatus(statusCode.toString())
for (const [key, value] of headers) {
res.writeHeader(key, value)
}
if (statusCodesWithoutBody.has(statusCode)) {
res.end()
} else {
res.end(await readableStreamToBuffer(Readable.toWeb(readable) as ReadableStream))
}
resolve()
})

Promise.resolve(handler(res, platformRequest))
})
}
}

/**
* Update the HttpResponse object from a web HttpRequest.
*
* @param {HttpResponse} res - The web Request object.
* @param {PlatformRequestUws} platformRequest
* @returns {HttpResponse} An IncomingMessage-like object compatible with Node.js HTTP module.
*/
function enrichResponse(res: HttpResponse, platformRequest: PlatformRequestUws): HttpResponse {
const parsedUrl = new URL(platformRequest.url, DUMMY_BASE_URL)
const pathnameAndQuery = (parsedUrl.pathname || '') + (parsedUrl.search || '')
// (?) TODO const body = platformRequest.body ? Readable.fromWeb(platformRequest.body as any) : Readable.from([])
res.url = pathnameAndQuery
res.method = 'GET'
res.headers = Object.fromEntries(platformRequest.headers)

return res
}
86 changes: 86 additions & 0 deletions packages/vike-node/src/runtime/adapters/createServerResponseUws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
export { createServerResponse }

import type { OutgoingHttpHeader, OutgoingHttpHeaders } from 'node:http'
import { PassThrough, Readable } from 'node:stream'
import type { HttpResponse } from 'uWebSockets.js'

type OnReadable = (
cb: (result: {
readable: Readable
headers: [string, string][]
statusCode: number
}) => void
) => void

type CreatedServerReponse = {
res: HttpResponse
onReadable: OnReadable
}

/**
* Creates a custom ServerResponse object that allows for intercepting and streaming the response.
*
* @param {HttpResponse} res - The incoming HTTP request message.
* @returns {CreatedServerReponse}
* An object containing:
* - res: The custom ServerResponse object.
* - onReadable: A function that takes a callback. The callback is invoked when the response is readable,
* providing an object with the readable stream, headers, and status code.
*/
function createServerResponse(res: HttpResponse): CreatedServerReponse {
const passThrough = new PassThrough()
let handled = false

const onReadable: OnReadable = (cb) => {
const handleReadable = () => {
if (handled) return
handled = true
cb({
readable: Readable.from(passThrough),
headers: res.headers as [string, string][],
statusCode: res.statusCode as number
})
}

passThrough.once('readable', handleReadable)
passThrough.once('end', handleReadable)
}

passThrough.once('finish', () => {
res.emit('finish')
})
passThrough.once('close', () => {
res.destroy()
res.emit('close')
})
passThrough.on('drain', () => {
res.emit('drain')
})

res.write = passThrough.write.bind(passThrough)
res.end = (passThrough as any).end.bind(passThrough)

res.writeHead = function writeHead(
statusCode: number,
statusMessage?: string | OutgoingHttpHeaders | OutgoingHttpHeader[],
headers?: OutgoingHttpHeaders | OutgoingHttpHeader[]
): void {
res.writeStatus(statusCode + '')
if (typeof statusMessage === 'object') {
headers = statusMessage
statusMessage = undefined
}
if (headers) {
Object.entries(headers).forEach(([key, value]) => {
if (value !== undefined) {
res.writeHeader(key, value.toString())
}
})
}
}

return {
res,
onReadable
}
}
36 changes: 36 additions & 0 deletions packages/vike-node/src/runtime/frameworks/uws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export { vike }

import type { TemplatedApp, HttpRequest } from 'uWebSockets.js'
import { createHandler } from '../handler-web-and-node-uws.js'
import type { PlatformRequestUws, VikeOptions } from '../types.js'

/**
* Creates an uWebSockets.js plugin to handle Vike requests.
*
* @param {VikeOptions} [options] - Configuration options for Vike.
*
* @returns {TemplatedApp} An uWebSockets.js plugin that handles all GET requests and processes them with Vike.
*
* @description
* The plugin:
* 1. Set up a catch-all GET route handler that processes requests using Vike's handler.
* 2. Catch internal errors.
*
* @example
* ```js
* import { App } from 'uWebSockets.js'
* import { vike } from 'vike-node/uws'
*
* const app = vike(App())
* app.listen(3000)
* ```
*/
function vike(app: TemplatedApp, options?: VikeOptions<HttpRequest>): TemplatedApp {
const handler = createHandler(options)
return app.get('*', (response, request) =>
handler({ response, request, platformRequest: request as PlatformRequestUws }).catch((error: Error) => {
console.error(error)
response.writeStatus('500').end('Internal Server Error: ' + error.message)
})
)
}
124 changes: 124 additions & 0 deletions packages/vike-node/src/runtime/handler-node-only-uws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
// import { dirname, isAbsolute, join } from 'node:path'
// import { fileURLToPath } from 'node:url'
import type { HttpResponse } from 'uWebSockets.js'

import { assert } from '../utils/assert.js'
import { globalStore } from './globalStore.js'
import type { ConnectMiddleware, PlatformRequestUws, VikeOptions } from './types.js'
import { writeHttpResponseUws } from './utils/writeHttpResponse.js'
import { renderPage } from './vike-handler.js'
// import { isVercel } from '../utils/isVercel.js'

export function createHandler(options: VikeOptions<PlatformRequestUws> = {}) {
// TODO
// const staticConfig = resolveStaticConfig(options.static)
// const shouldCache = staticConfig && staticConfig.cache
// const compressionType = options.compress ?? !isVercel()
// let staticMiddleware: ConnectMiddleware | undefined
// let compressMiddleware: ConnectMiddleware | undefined

return async function handler({
res,
platformRequest
}: {
res: HttpResponse
platformRequest: PlatformRequestUws
}): Promise<void> {
if (globalStore.isPluginLoaded) {
const handled = await handleViteDevServer(res, platformRequest)
if (handled) {
res.end()
return
}
} else {
// TODO
// const isAsset = platformRequest.url?.startsWith('/assets/')
// const shouldCompressResponse = compressionType === true || (compressionType === 'static' && isAsset)
// if (shouldCompressResponse) {
// await applyCompression(req, res, shouldCache)
// }
// if (staticConfig) {
// const handled = await serveStaticFiles(req, res, staticConfig)
// if (handled) return true
// }
}

const httpResponse = await renderPage({
url: platformRequest.url,
headers: platformRequest.headers,
platformRequest,
options
})
if (!httpResponse) {
res.writeStatus('404').end()
return
}
await writeHttpResponseUws(httpResponse, res)
return
}

// TODO
// async function applyCompression(req: IncomingMessage, res: ServerResponse, shouldCache: boolean) {
// if (!compressMiddleware) {
// const { default: shrinkRay } = await import('@nitedani/shrink-ray-current')
// compressMiddleware = shrinkRay({ cacheSize: shouldCache ? '128mB' : false }) as ConnectMiddleware
// }
// compressMiddleware(req, res, () => {})
// }

// TODO
// async function serveStaticFiles(
// req: IncomingMessage,
// res: ServerResponse,
// config: { root: string; cache: boolean }
// ): Promise<boolean> {
// if (!staticMiddleware) {
// const { default: sirv } = await import('sirv')
// staticMiddleware = sirv(config.root, { etag: true })
// }

// return new Promise<boolean>((resolve) => {
// res.once('close', () => resolve(true))
// staticMiddleware!(req, res, () => resolve(false))
// })
// }
}

function handleViteDevServer(res: HttpResponse, platformRequest: PlatformRequestUws): Promise<boolean> {
return new Promise<boolean>((resolve) => {
res.once('close', () => resolve(true))
assert(globalStore.viteDevServer)
globalStore.viteDevServer.middlewares(
platformRequest as unknown as IncomingMessage,
res as unknown as ServerResponse,
() => resolve(false)
)
})
}

// TODO
// function resolveStaticConfig(static_: VikeOptions['static']): false | { root: string; cache: boolean } {
// // Disable static file serving for Vercel
// // Vercel will serve static files on its own
// // See vercel.json > outputDirectory
// if (isVercel()) return false
// if (static_ === false) return false

// const argv1 = process.argv[1]
// const entrypointDirAbs = argv1
// ? dirname(isAbsolute(argv1) ? argv1 : join(process.cwd(), argv1))
// : dirname(fileURLToPath(import.meta.url))
// const defaultStaticDir = join(entrypointDirAbs, '..', 'client')

// if (static_ === true || static_ === undefined) {
// return { root: defaultStaticDir, cache: true }
// }
// if (typeof static_ === 'string') {
// return { root: static_, cache: true }
// }
// return {
// root: static_.root ?? defaultStaticDir,
// cache: static_.cache ?? true
// }
// }
51 changes: 51 additions & 0 deletions packages/vike-node/src/runtime/handler-web-and-node-uws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { HttpRequest, HttpResponse } from 'uWebSockets.js'
import { isNodeLike } from '../utils/isNodeLike.js'
import { connectToWeb } from './adapters/connectToWebUws.js'
import { createHandler as createHandlerNode } from './handler-node-only-uws.js'
import { createHandler as createHandlerWeb } from './handler-web-only-uws.js'
import type { HandlerUws, PlatformRequestUws, VikeOptions } from './types.js'

const getHeaders = (req: HttpRequest): [string, string][] => {
const headers: [string, string][] = []

req.forEach((key, value) => {
headers.push([key, value])
})

return headers
}

type Handler<PlatformRequestUws> = (params: {
response: HttpResponse
request: HttpRequest
platformRequest: PlatformRequestUws
}) => Promise<void>

export function createHandler<HttpRequest>(options: VikeOptions<HttpRequest> = {}): Handler<PlatformRequestUws> {
return async function handler({ response, request, platformRequest }) {
response.onAborted(() => {
response.isAborted = true
})

if (request.getMethod() !== 'get') {
response.writeStatus('405').end()
return
}

platformRequest.url = request.getUrl()
platformRequest.headers = getHeaders(request)

if (await isNodeLike()) {
const nodeOnlyHandler = createHandlerNode(options)
const nodeHandler: HandlerUws<PlatformRequestUws> = ({ platformRequest }) => {
const connectedHandler = connectToWeb((res, platformRequest) => nodeOnlyHandler({ res, platformRequest }))
return connectedHandler(response, platformRequest)
}

await nodeHandler({ res: response, platformRequest })
} else {
const webHandler: HandlerUws<PlatformRequestUws> = createHandlerWeb(options)
await webHandler({ res: response, platformRequest })
}
}
}
9 changes: 9 additions & 0 deletions packages/vike-node/src/runtime/handler-web-only-uws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { HttpRequest } from 'uWebSockets.js'
import type { HandlerUws, PlatformRequestUws, VikeOptions } from './types.js'
import { renderPageWeb } from './vike-handler-uws.js'

export function createHandler(options: VikeOptions<HttpRequest> = {}): HandlerUws<PlatformRequestUws> {
return async function handler({ res, platformRequest }) {
return renderPageWeb({ res, url: platformRequest.url, headers: platformRequest.headers, platformRequest, options })
}
}
Loading
Loading