From 396aa4b02ed126d0667892bf4bf1eee7a7567207 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 26 Jun 2024 12:31:39 +0200 Subject: [PATCH] refactor!: overhaul app event handler (#792) --- docs/2.utils/2.response.md | 52 ++--- docs/2.utils/98.advanced.md | 14 +- src/adapters/node/_internal.ts | 63 ++++-- src/adapters/node/event.ts | 15 +- src/adapters/node/utils.ts | 55 ++---- src/adapters/web/_internal.ts | 59 ++---- src/adapters/web/event.ts | 17 +- src/adapters/web/index.ts | 3 - src/adapters/web/utils.ts | 36 +--- src/app.ts | 305 ----------------------------- src/app/_handler.ts | 69 +++++++ src/app/_response.ts | 143 ++++++++++++++ src/app/_utils.ts | 124 ++++++++++++ src/app/app.ts | 40 ++++ src/deprecated.ts | 26 ++- src/error.ts | 26 --- src/event.ts | 8 +- src/handler.ts | 3 - src/index.ts | 9 +- src/types/app.ts | 32 ++- src/types/event.ts | 13 +- src/types/handler.ts | 7 + src/types/index.ts | 1 + src/types/utils/static.ts | 4 +- src/utils/cache.ts | 3 - src/utils/cors.ts | 18 +- src/utils/internal/consts.ts | 1 + src/utils/internal/event-stream.ts | 10 +- src/utils/proxy.ts | 24 +-- src/utils/response.ts | 112 ++++------- src/utils/static.ts | 17 +- test/_playground.ts | 16 ++ test/app.test.ts | 2 +- test/proxy.test.ts | 8 +- test/status.test.ts | 4 +- 35 files changed, 654 insertions(+), 685 deletions(-) delete mode 100644 src/app.ts create mode 100644 src/app/_handler.ts create mode 100644 src/app/_response.ts create mode 100644 src/app/_utils.ts create mode 100644 src/app/app.ts create mode 100644 test/_playground.ts diff --git a/docs/2.utils/2.response.md b/docs/2.utils/2.response.md index d2f187f5..598756a3 100644 --- a/docs/2.utils/2.response.md +++ b/docs/2.utils/2.response.md @@ -91,19 +91,7 @@ export default defineEventHandler((event) => { }); ``` -### `removeResponseHeader(event, name)` - -Remove a response header by name. - -**Example:** - -```ts -export default defineEventHandler((event) => { - removeResponseHeader(event, "content-type"); // Remove content-type header -}); -``` - -### `sendIterable(event, iterable)` +### `iterable(iterable)` Iterate a source of chunks and send back each chunk in order. Supports mixing async work together with emitting chunks. @@ -114,8 +102,7 @@ For generator (yielding) functions, the returned value is treated the same as yi **Example:** ```ts -sendIterable(event, work()); -async function* work() { +return iterable(async function* work() { // Open document body yield "\n

Executing...

    \n"; // Do work ... @@ -128,36 +115,25 @@ async function* work() { } // Close out the report return `
`; -} +}) async function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } ``` -### `sendNoContent(event, code?)` +### `noContent(event, code?)` Respond with an empty payload.
-Note that calling this function will close the connection and no other data can be sent to the client afterwards. - **Example:** ```ts export default defineEventHandler((event) => { - return sendNoContent(event); + return noContent(event); }); ``` -**Example:** - -```ts -export default defineEventHandler((event) => { - sendNoContent(event); // Close the connection - console.log("This will not be executed"); -}); -``` - -### `sendRedirect(event, location, code)` +### `redirect(event, location, code)` Send a redirect response to the client. @@ -169,7 +145,7 @@ In the body, it sends a simple HTML page with a meta refresh tag to redirect the ```ts export default defineEventHandler((event) => { - return sendRedirect(event, "https://example.com"); + return redirect(event, "https://example.com"); }); ``` @@ -177,13 +153,21 @@ export default defineEventHandler((event) => { ```ts export default defineEventHandler((event) => { - return sendRedirect(event, "https://example.com", 301); // Permanent redirect + return redirect(event, "https://example.com", 301); // Permanent redirect }); ``` -### `sendWebResponse(event, response)` +### `removeResponseHeader(event, name)` + +Remove a response header by name. -Send a Web besponse object to the client. +**Example:** + +```ts +export default defineEventHandler((event) => { + removeResponseHeader(event, "content-type"); // Remove content-type header +}); +``` ### `setResponseHeader(event, name, value)` diff --git a/docs/2.utils/98.advanced.md b/docs/2.utils/98.advanced.md index 7d42b051..e4ac94c8 100644 --- a/docs/2.utils/98.advanced.md +++ b/docs/2.utils/98.advanced.md @@ -145,13 +145,13 @@ Make a fetch request with the event's context and headers. Get the request headers object without headers known to cause issues when proxying. -### `proxyRequest(event, target, opts)` +### `proxy(event, target, opts)` -Proxy the incoming request to a target URL. +Make a proxy request to a target URL and send the response back to the client. -### `sendProxy(event, target, opts)` +### `proxyRequest(event, target, opts)` -Make a proxy request to a target URL and send the response back to the client. +Proxy the incoming request to a target URL. @@ -182,15 +182,15 @@ const app = createApp(); const router = createRouter(); router.use('/', defineEventHandler(async (event) => { - const didHandleCors = handleCors(event, { + const corsRes = handleCors(event, { origin: '*', preflight: { statusCode: 204, }, methods: '*', }); - if (didHandleCors) { - return; + if (corsRes) { + return corsRes; } // Your code here }) diff --git a/src/adapters/node/_internal.ts b/src/adapters/node/_internal.ts index 62337a12..00b66bbd 100644 --- a/src/adapters/node/_internal.ts +++ b/src/adapters/node/_internal.ts @@ -1,13 +1,18 @@ import type { Readable as NodeReadableStream } from "node:stream"; -import type { RawResponse } from "../../types/event"; import type { NodeHandler, NodeIncomingMessage, NodeMiddleware, NodeServerResponse, } from "../../types/node"; +import type { ResponseBody } from "../../types"; import { _kRaw } from "../../event"; import { createError } from "../../error"; +import { splitCookiesString } from "../../utils/cookie"; +import { + sanitizeStatusCode, + sanitizeStatusMessage, +} from "../../utils/sanitize"; export function _getBodyStream( req: NodeIncomingMessage, @@ -28,47 +33,71 @@ export function _getBodyStream( } export function _sendResponse( - res: NodeServerResponse, - data: RawResponse, + nodeRes: NodeServerResponse, + handlerRes: ResponseBody, ): Promise { + // Web Response + if (handlerRes instanceof Response) { + for (const [key, value] of handlerRes.headers) { + if (key === "set-cookie") { + for (const setCookie of splitCookiesString(value)) { + nodeRes.appendHeader(key, setCookie); + } + } else { + nodeRes.setHeader(key, value); + } + } + + if (handlerRes.status) { + nodeRes.statusCode = sanitizeStatusCode(handlerRes.status); + } + if (handlerRes.statusText) { + nodeRes.statusMessage = sanitizeStatusMessage(handlerRes.statusText); + } + if (handlerRes.redirected) { + nodeRes.setHeader("location", handlerRes.url); + } + handlerRes = handlerRes.body; // Next step will send body as stream! + } + // Native Web Streams // https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream - if (typeof (data as ReadableStream)?.pipeTo === "function") { - return (data as ReadableStream) + if (typeof (handlerRes as ReadableStream)?.pipeTo === "function") { + return (handlerRes as ReadableStream) .pipeTo( new WritableStream({ write: (chunk) => { - res.write(chunk); + nodeRes.write(chunk); }, }), ) - .then(() => _endResponse(res)); + .then(() => _endResponse(nodeRes)); } // Node.js Readable Streams // https://nodejs.org/api/stream.html#readable-streams - if (typeof (data as NodeReadableStream)?.pipe === "function") { + if (typeof (handlerRes as NodeReadableStream)?.pipe === "function") { return new Promise((resolve, reject) => { // Pipe stream to response - (data as NodeReadableStream).pipe(res); + (handlerRes as NodeReadableStream).pipe(nodeRes); // Handle stream events (if supported) - if ((data as NodeReadableStream).on) { - (data as NodeReadableStream).on("end", resolve); - (data as NodeReadableStream).on("error", reject); + if ((handlerRes as NodeReadableStream).on) { + (handlerRes as NodeReadableStream).on("end", resolve); + (handlerRes as NodeReadableStream).on("error", reject); } // Handle request aborts - res.once("close", () => { - (data as NodeReadableStream).destroy?.(); + nodeRes.once("close", () => { + (handlerRes as NodeReadableStream).destroy?.(); // https://react.dev/reference/react-dom/server/renderToPipeableStream - (data as any).abort?.(); + (handlerRes as any).abort?.(); }); - }).then(() => _endResponse(res)); + }).then(() => _endResponse(nodeRes)); } // Send as string or buffer - return _endResponse(res, data); + return _endResponse(nodeRes, handlerRes); } export function _endResponse( diff --git a/src/adapters/node/event.ts b/src/adapters/node/event.ts index 7751647f..99108471 100644 --- a/src/adapters/node/event.ts +++ b/src/adapters/node/event.ts @@ -1,5 +1,5 @@ import type { HTTPMethod } from "../../types"; -import { RawEvent, type RawResponse } from "../../types/event"; +import type { RawEvent } from "../../types/event"; import { splitCookiesString } from "../../utils/cookie"; import { NodeHeadersProxy } from "./_headers"; import { @@ -17,8 +17,6 @@ export class NodeEvent implements RawEvent { _req: NodeIncomingMessage; _res: NodeServerResponse; - _handled?: boolean; - _originalPath?: string | undefined; _rawBody?: Promise; @@ -125,7 +123,7 @@ export class NodeEvent implements RawEvent { // -- response -- get handled() { - return this._handled || this._res.writableEnded || this._res.headersSent; + return this._res.writableEnded || this._res.headersSent; } get responseCode() { @@ -195,13 +193,4 @@ export class NodeEvent implements RawEvent { }); } } - - sendResponse(data: RawResponse) { - this._handled = true; - return _sendResponse(this._res, data).catch((error) => { - // TODO: better way? - this._handled = false; - throw error; - }); - } } diff --git a/src/adapters/node/utils.ts b/src/adapters/node/utils.ts index 43fb585d..29e09172 100644 --- a/src/adapters/node/utils.ts +++ b/src/adapters/node/utils.ts @@ -11,11 +11,11 @@ import type { NodeServerResponse, } from "../../types/node"; import { _kRaw } from "../../event"; -import { createError, errorToResponse, isError } from "../../error"; import { defineEventHandler, isEventHandler } from "../../handler"; import { EventWrapper } from "../../event"; import { NodeEvent } from "./event"; -import { callNodeHandler } from "./_internal"; +import { _sendResponse, callNodeHandler } from "./_internal"; +import { errorToAppResponse } from "../../app/_response"; /** * Convert H3 app instance to a NodeHandler with (IncomingMessage, ServerResponse) => void signature. @@ -24,48 +24,23 @@ export function toNodeHandler(app: App): NodeHandler { const nodeHandler: NodeHandler = async function (req, res) { const rawEvent = new NodeEvent(req, res); const event = new EventWrapper(rawEvent); - try { - await app.handler(event); - } catch (_error: any) { - const error = createError(_error); - if (!isError(_error)) { - error.unhandled = true; - } - - // #754 Make sure hooks see correct status code and message - event[_kRaw].responseCode = error.statusCode; - event[_kRaw].responseMessage = error.statusMessage; - - if (app.options.onError) { - await app.options.onError(error, event); - } - - if (error.unhandled || error.fatal) { - console.error("[h3]", error.fatal ? "[fatal]" : "[unhandled]", error); - } - - if (event[_kRaw].handled) { + const appResponse = await app.handler(event); + await _sendResponse(res, appResponse).catch((sendError) => { + // Possible cases: Stream canceled, headers already sent, etc. + if (res.headersSent || res.writableEnded) { return; } - - if (app.options.onBeforeResponse && !event._onBeforeResponseCalled) { - await app.options.onBeforeResponse(event, { body: error }); + const errRes = errorToAppResponse(sendError, app.options); + if (errRes.status) { + res.statusCode = errRes.status; } - - const response = errorToResponse(error, app.options.debug); - - event[_kRaw].responseCode = response.status; - event[_kRaw].responseMessage = response.statusText; - - for (const [key, value] of Object.entries(response.headers)) { - event[_kRaw].setResponseHeader(key, value); - } - - await event[_kRaw].sendResponse(response.body); - - if (app.options.onAfterResponse && !event._onAfterResponseCalled) { - await app.options.onAfterResponse(event, { body: error }); + if (errRes.statusText) { + res.statusMessage = errRes.statusText; } + res.end(errRes.body); + }); + if (app.options.onAfterResponse) { + await app.options.onAfterResponse(event, { body: appResponse }); } }; return nodeHandler; diff --git a/src/adapters/web/_internal.ts b/src/adapters/web/_internal.ts index dc6eb961..aa388c5a 100644 --- a/src/adapters/web/_internal.ts +++ b/src/adapters/web/_internal.ts @@ -1,11 +1,13 @@ import type { Readable as NodeReadableStream } from "node:stream"; -import type { App, EventHandler, H3EventContext } from "../../types"; -import type { RawResponse } from "../../types/event"; +import type { App, H3EventContext, ResponseBody } from "../../types"; import { EventWrapper, _kRaw } from "../../event"; import { WebEvent } from "./event"; -import { createError, errorToResponse, isError } from "../../error"; -export function _normalizeResponse(data: RawResponse) { +type WebNormalizedResponseBody = Exclude; + +export function _normalizeResponse( + data: ResponseBody, +): WebNormalizedResponseBody { // Node.js Readable Streams // https://nodejs.org/api/stream.html#readable-streams if (typeof (data as NodeReadableStream)?.pipe === "function") { @@ -21,7 +23,7 @@ export function _normalizeResponse(data: RawResponse) { }, }); } - return data as Exclude; + return data as WebNormalizedResponseBody; } export function _pathToRequestURL(path: string, headers?: HeadersInit): string { @@ -36,51 +38,26 @@ export function _pathToRequestURL(path: string, headers?: HeadersInit): string { export const nullBodyResponses = new Set([101, 204, 205, 304]); -export async function _callWithWebRequest( - handler: EventHandler, +export async function appFetch( + app: App, request: Request, context?: H3EventContext, - app?: App, -) { +): Promise<{ + body: WebNormalizedResponseBody; + status: Response["status"]; + statusText: Response["statusText"]; + headers: Headers; +}> { const rawEvent = new WebEvent(request); - const event = new EventWrapper(rawEvent); - - if (context) { - Object.assign(event.context, context); - } - - let error; + const event = new EventWrapper(rawEvent, context); - try { - await handler(event); - } catch (_error: any) { - error = createError(_error); - if (!isError(_error)) { - error.unhandled = true; - } - } - - if (error) { - if (error.unhandled || error.fatal) { - console.error("[h3]", error.fatal ? "[fatal]" : "[unhandled]", error); - } - if (app?.options.onError) { - await app?.options.onError(error, event); - } - const errRes = errorToResponse(error, app?.options.debug); - return { - status: errRes.status, - statusText: errRes.statusText, - headers: new Headers(errRes.headers), - body: errRes.body, - }; - } + const _appResponseBody = await app.handler(event); // https://developer.mozilla.org/en-US/docs/Web/API/Response/body const responseBody = nullBodyResponses.has(rawEvent.responseCode!) || request.method === "HEAD" ? null - : _normalizeResponse(rawEvent._responseBody); + : _normalizeResponse(_appResponseBody); return { status: rawEvent.responseCode, diff --git a/src/adapters/web/event.ts b/src/adapters/web/event.ts index 11770183..82efc54d 100644 --- a/src/adapters/web/event.ts +++ b/src/adapters/web/event.ts @@ -1,17 +1,14 @@ -import { RawEvent, type RawResponse } from "../../types/event"; -import { HTTPMethod } from "../../types"; +import type { RawEvent } from "../../types/event"; +import type { HTTPMethod } from "../../types"; export class WebEvent implements RawEvent { static isWeb = true; _req: Request; - _handled?: boolean; - _path?: string; _originalPath?: string | undefined; - _responseBody?: RawResponse; _responseCode?: number; _responseMessage?: string; _responseHeaders: Headers = new Headers(); @@ -107,10 +104,6 @@ export class WebEvent implements RawEvent { // -- response -- - get handled() { - return this._handled; - } - get responseCode() { return this._responseCode || 200; } @@ -156,7 +149,6 @@ export class WebEvent implements RawEvent { } writeHead(code: number, message?: string) { - this._handled = true; if (code) { this.responseCode = code; } @@ -168,9 +160,4 @@ export class WebEvent implements RawEvent { writeEarlyHints(_hints: Record) { // noop } - - sendResponse(data: RawResponse) { - this._handled = true; - this._responseBody = data; - } } diff --git a/src/adapters/web/index.ts b/src/adapters/web/index.ts index 7465d26b..9c123561 100644 --- a/src/adapters/web/index.ts +++ b/src/adapters/web/index.ts @@ -12,9 +12,6 @@ export { // Web Context getWebContext, - // Call - callWithWebRequest, - // --Plain-- // Plain Handler diff --git a/src/adapters/web/utils.ts b/src/adapters/web/utils.ts index 55fc01f6..d8f064f7 100644 --- a/src/adapters/web/utils.ts +++ b/src/adapters/web/utils.ts @@ -8,18 +8,16 @@ import type { import { defineEventHandler } from "../../handler"; import { EventWrapper, _kRaw } from "../../event"; import { WebEvent } from "./event"; -import { - _callWithWebRequest, - _normalizeResponse, - _pathToRequestURL, -} from "./_internal"; +import { _normalizeResponse, _pathToRequestURL, appFetch } from "./_internal"; /** * Convert H3 app instance to a WebHandler with (Request, H3EventContext) => Promise signature. */ export function toWebHandler(app: App): WebHandler { - const webHandler: WebHandler = async (request, context) => - callWithWebRequest(app.handler, request, context, app); + const webHandler: WebHandler = async (request, context) => { + const res = await appFetch(app, request, context); + return new Response(res.body, res); + }; return webHandler; } @@ -74,20 +72,6 @@ export function getWebContext( return raw.getContext(); } -export async function callWithWebRequest( - handler: EventHandler, - request: Request, - context?: H3EventContext, - app?: App, -) { - const res = await _callWithWebRequest(handler, request, context, app); - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: res.headers, - }); -} - // ---------------------------- // Plain // ---------------------------- @@ -97,7 +81,7 @@ export async function callWithWebRequest( */ export function toPlainHandler(app: App) { const handler: PlainHandler = async (request, context) => { - return callWithPlainRequest(app.handler, request, context, app); + return callWithPlainRequest(app, request, context); }; return handler; } @@ -162,20 +146,18 @@ export function fromPlainRequest( } export async function callWithPlainRequest( - handler: EventHandler, + app: App, request: PlainRequest, context?: H3EventContext, - app?: App, ): Promise { - const res = await _callWithWebRequest( - handler, + const res = await appFetch( + app, new Request(_pathToRequestURL(request.path, request.headers), { method: request.method, headers: request.headers, body: request.body, }), context, - app, ); const setCookie = res.headers.getSetCookie(); diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index b6a56af5..00000000 --- a/src/app.ts +++ /dev/null @@ -1,305 +0,0 @@ -import type { - App, - Stack, - H3Event, - EventHandler, - EventHandlerResolver, - LazyEventHandler, - AppOptions, - InputLayer, - WebSocketOptions, - Layer, -} from "./types"; -import { _kRaw } from "./event"; -import { - defineLazyEventHandler, - toEventHandler, - isEventHandler, - defineEventHandler, -} from "./handler"; -import { createError } from "./error"; -import { - sendWebResponse, - sendNoContent, - defaultContentType, -} from "./utils/response"; -import { isJSONSerializable } from "./utils/internal/object"; -import { - joinURL, - getPathname, - withoutTrailingSlash, -} from "./utils/internal/path"; -import { MIMES } from "./utils/internal/consts"; - -/** - * Create a new H3 app instance. - */ -export function createApp(options: AppOptions = {}): App { - const stack: Stack = []; - - const handler = createAppEventHandler(stack, options); - - const resolve = createResolver(stack); - handler.__resolve__ = resolve; - - const getWebsocket = cachedFn(() => websocketOptions(resolve, options)); - - const app: App = { - // @ts-expect-error - use: (arg1, arg2, arg3) => use(app as App, arg1, arg2, arg3), - resolve, - handler, - stack, - options, - get websocket() { - return getWebsocket(); - }, - }; - - return app; -} - -export function use( - app: App, - arg1: string | EventHandler | InputLayer | InputLayer[], - arg2?: Partial | EventHandler | EventHandler[], - arg3?: Partial, -) { - if (Array.isArray(arg1)) { - for (const i of arg1) { - use(app, i, arg2, arg3); - } - } else if (Array.isArray(arg2)) { - for (const i of arg2) { - use(app, arg1, i, arg3); - } - } else if (typeof arg1 === "string") { - app.stack.push( - normalizeLayer({ ...arg3, route: arg1, handler: arg2 as EventHandler }), - ); - } else if (typeof arg1 === "function") { - app.stack.push(normalizeLayer({ ...arg2, handler: arg1 as EventHandler })); - } else { - app.stack.push(normalizeLayer({ ...arg1 })); - } - return app; -} - -export function createAppEventHandler(stack: Stack, options: AppOptions) { - const spacing = options.debug ? 2 : undefined; - - return defineEventHandler(async (event) => { - // Keep a copy of incoming url - const _reqPath = event[_kRaw].path || "/"; - - // Layer path is the path without the prefix - let _layerPath: string; - - // Call onRequest hook - if (options.onRequest) { - await options.onRequest(event); - } - - for (const layer of stack) { - // 1. Remove prefix from path - if (layer.route.length > 1) { - if (!_reqPath.startsWith(layer.route)) { - continue; - } - _layerPath = _reqPath.slice(layer.route.length) || "/"; - } else { - _layerPath = _reqPath; - } - - // 2. Custom matcher - if (layer.match && !layer.match(_layerPath, event)) { - continue; - } - - // 3. Update event path with layer path - event[_kRaw].path = _layerPath; - - // 4. Handle request - const val = await layer.handler(event); - - // 5. Try to handle return value - const _body = val === undefined ? undefined : await val; - if (_body !== undefined) { - const _response = { body: _body }; - if (options.onBeforeResponse) { - event._onBeforeResponseCalled = true; - await options.onBeforeResponse(event, _response); - } - await handleHandlerResponse(event, _response.body, spacing); - if (options.onAfterResponse) { - event._onAfterResponseCalled = true; - await options.onAfterResponse(event, _response); - } - return; - } - - // Already handled - if (event[_kRaw].handled) { - if (options.onAfterResponse) { - event._onAfterResponseCalled = true; - await options.onAfterResponse(event, undefined); - } - return; - } - } - - if (!event[_kRaw].handled) { - throw createError({ - statusCode: 404, - statusMessage: `Cannot find any path matching ${event.path || "/"}.`, - }); - } - - if (options.onAfterResponse) { - event._onAfterResponseCalled = true; - await options.onAfterResponse(event, undefined); - } - }); -} - -function createResolver(stack: Stack): EventHandlerResolver { - return async (path: string) => { - let _layerPath: string; - for (const layer of stack) { - if (layer.route === "/" && !layer.handler.__resolve__) { - continue; - } - if (!path.startsWith(layer.route)) { - continue; - } - _layerPath = path.slice(layer.route.length) || "/"; - if (layer.match && !layer.match(_layerPath, undefined)) { - continue; - } - let res = { route: layer.route, handler: layer.handler }; - if (res.handler.__resolve__) { - const _res = await res.handler.__resolve__(_layerPath); - if (!_res) { - continue; - } - res = { - ...res, - ..._res, - route: joinURL(res.route || "/", _res.route || "/"), - }; - } - return res; - } - }; -} - -function normalizeLayer(input: InputLayer) { - let handler = input.handler; - // @ts-ignore - if (handler.handler) { - // @ts-ignore - handler = handler.handler; - } - - if (input.lazy) { - handler = defineLazyEventHandler(handler as LazyEventHandler); - } else if (!isEventHandler(handler)) { - handler = toEventHandler(handler, undefined, input.route); - } - - return { - route: withoutTrailingSlash(input.route), - match: input.match, - handler, - } as Layer; -} - -function handleHandlerResponse(event: H3Event, val: any, jsonSpace?: number) { - // Empty Content - if (val === null) { - return sendNoContent(event); - } - - const valType = typeof val; - - // Undefined - if (valType === "undefined") { - return sendNoContent(event); - } - - // Text - if (valType === "string") { - defaultContentType(event, MIMES.html); - return event[_kRaw].sendResponse(val); - } - - // Buffer (should be before JSON) - if (val.buffer) { - return event[_kRaw].sendResponse(val); - } - - // Error (should be before JSON) - if (val instanceof Error) { - throw createError(val); - } - - // JSON - if (isJSONSerializable(val, valType)) { - defaultContentType(event, MIMES.json); - return event[_kRaw].sendResponse(JSON.stringify(val, undefined, jsonSpace)); - } - - // BigInt - if (valType === "bigint") { - defaultContentType(event, MIMES.json); - return event[_kRaw].sendResponse(val.toString()); - } - - // Web Response - if (val instanceof Response) { - return sendWebResponse(event, val); - } - - // Blob - if (val.arrayBuffer && typeof val.arrayBuffer === "function") { - return (val as Blob).arrayBuffer().then((arrayBuffer) => { - defaultContentType(event, val.type); - return event[_kRaw].sendResponse(Buffer.from(arrayBuffer)); - }); - } - - // Symbol or Function is not supported - if (valType === "symbol" || valType === "function") { - throw createError({ - statusCode: 500, - statusMessage: `[h3] Cannot send ${valType} as response.`, - }); - } - - // Other values: direct send - return event[_kRaw].sendResponse(val); -} - -function cachedFn(fn: () => T): () => T { - let cache: T; - return () => { - if (!cache) { - cache = fn(); - } - return cache; - }; -} - -function websocketOptions( - evResolver: EventHandlerResolver, - appOptions: AppOptions, -): WebSocketOptions { - return { - ...appOptions.websocket, - async resolve(info) { - const pathname = getPathname(info.url || "/"); - const resolved = await evResolver(pathname); - return resolved?.handler?.__websocket__ || {}; - }, - }; -} diff --git a/src/app/_handler.ts b/src/app/_handler.ts new file mode 100644 index 00000000..f55dcfce --- /dev/null +++ b/src/app/_handler.ts @@ -0,0 +1,69 @@ +import type { + AppOptions, + EventHandler, + EventHandlerRequest, + ResponseBody, + Stack, +} from "../types"; +import { defineEventHandler } from "../handler"; +import { _kRaw } from "../event"; +import { createError } from "../error"; +import { handleAppResponse } from "./_response"; + +export function createAppEventHandler( + stack: Stack, + options: AppOptions, +): EventHandler> { + return defineEventHandler(async (event) => { + try { + // Keep a copy of incoming url + const _reqPath = event[_kRaw].path || "/"; + + // Layer path is the path without the prefix + let _layerPath: string; + + // Call onRequest hook + if (options.onRequest) { + await options.onRequest(event); + } + + // Run through stack + for (const layer of stack) { + // 1. Remove prefix from path + if (layer.route.length > 1) { + if (!_reqPath.startsWith(layer.route)) { + continue; + } + _layerPath = _reqPath.slice(layer.route.length) || "/"; + } else { + _layerPath = _reqPath; + } + + // 2. Custom matcher + if (layer.match && !layer.match(_layerPath, event)) { + continue; + } + + // 3. Update event path with layer path + event[_kRaw].path = _layerPath; + + // 4. Handle request + const val = await layer.handler(event); + + // 5. Handle response + const _body = val === undefined ? undefined : await val; + if (_body !== undefined) { + return handleAppResponse(event, _body, options); + } + } + + // Throw 404 is no handler in the stack responded + throw createError({ + statusCode: 404, + statusMessage: `Cannot find any path matching ${event.path || "/"}.`, + }); + } catch (error: unknown) { + return handleAppResponse(event, error, options); + } + }); +} diff --git a/src/app/_response.ts b/src/app/_response.ts new file mode 100644 index 00000000..ec337375 --- /dev/null +++ b/src/app/_response.ts @@ -0,0 +1,143 @@ +import type { AppOptions, H3Event, ResponseBody } from "../types"; +import type { AppResponse, H3Error } from "../types/app"; +import { createError } from "../error"; +import { isJSONSerializable } from "../utils/internal/object"; +import { MIMES } from "../utils/internal/consts"; +import { _kRaw } from "../event"; + +type MaybePromise = T | Promise; + +export async function handleAppResponse( + event: H3Event, + body: unknown, + options: AppOptions, +) { + const res = await _normalizeResponseBody(body, options); + if (res.error) { + if (res.error.unhandled) { + console.error("[h3] Unhandled Error:", res.error); + } + if (options.onError) { + try { + await options.onError(res.error, event); + } catch (hookError) { + console.error("[h3] Error while calling `onError` hook:", hookError); + } + } + } + if (options.onBeforeResponse) { + await options.onBeforeResponse(event, res); + } + if (res.contentType && !event[_kRaw].getResponseHeader("content-type")) { + event[_kRaw].setResponseHeader("content-type", res.contentType); + } + if (res.headers) { + for (const [key, value] of res.headers.entries()) { + event[_kRaw].setResponseHeader(key, value); + } + } + if (res.status) { + event[_kRaw].responseCode = res.status; + } + if (res.statusText) { + event[_kRaw].responseMessage = res.statusText; + } + return res.body; +} + +function _normalizeResponseBody( + val: unknown, + options: AppOptions, +): MaybePromise { + // Empty Content + if (val === null || val === undefined) { + return { body: "", status: 204 }; + } + + const valType = typeof val; + + // Text + if (valType === "string") { + return { body: val as string, contentType: MIMES.html }; + } + + // Buffer (should be before JSON) + if (val instanceof Uint8Array) { + return { body: val, contentType: MIMES.octetStream }; + } + + // Error (should be before JSON) + if (val instanceof Error) { + return errorToAppResponse(val, options); + } + + // JSON + if (isJSONSerializable(val, valType)) { + return { + body: JSON.stringify(val, undefined, options.debug ? 2 : undefined), + contentType: MIMES.json, + }; + } + + // BigInt + if (valType === "bigint") { + return { body: val.toString(), contentType: MIMES.json }; + } + + // Web Response + if (val instanceof Response) { + return { + body: val.body, + headers: val.headers, + status: val.status, + statusText: val.statusText, + }; + } + + // Blob + if (val instanceof Blob) { + return val.arrayBuffer().then((arrayBuffer) => { + return { + contentType: val.type, + body: new Uint8Array(arrayBuffer), + }; + }); + } + + // Symbol or Function is not supported + if (valType === "symbol" || valType === "function") { + return errorToAppResponse( + { + statusCode: 500, + statusMessage: `[h3] Cannot send ${valType} as response.`, + }, + options, + ); + } + + return { + body: val as ResponseBody, + }; +} + +export function errorToAppResponse( + _error: Partial | Error, + options: AppOptions, +): AppResponse { + const error = createError(_error as H3Error); + return { + error, + status: error.statusCode, + statusText: error.statusMessage, + contentType: MIMES.json, + body: JSON.stringify({ + statusCode: error.statusCode, + statusMessage: error.statusMessage, + data: error.data, + stack: + options.debug && error.stack + ? error.stack.split("\n").map((l) => l.trim()) + : undefined, + }), + }; +} diff --git a/src/app/_utils.ts b/src/app/_utils.ts new file mode 100644 index 00000000..9617b132 --- /dev/null +++ b/src/app/_utils.ts @@ -0,0 +1,124 @@ +import type { + Stack, + EventHandlerResolver, + LazyEventHandler, + InputLayer, + Layer, + AppOptions, + WebSocketOptions, + App, + EventHandler, +} from "../types"; +import { _kRaw } from "../event"; +import { + defineLazyEventHandler, + toEventHandler, + isEventHandler, +} from "../handler"; +import { + getPathname, + joinURL, + withoutTrailingSlash, +} from "../utils/internal/path"; + +export function use( + app: App, + arg1: string | EventHandler | InputLayer | InputLayer[], + arg2?: Partial | EventHandler | EventHandler[], + arg3?: Partial, +) { + if (Array.isArray(arg1)) { + for (const i of arg1) { + use(app, i, arg2, arg3); + } + } else if (Array.isArray(arg2)) { + for (const i of arg2) { + use(app, arg1, i, arg3); + } + } else if (typeof arg1 === "string") { + app.stack.push( + normalizeLayer({ ...arg3, route: arg1, handler: arg2 as EventHandler }), + ); + } else if (typeof arg1 === "function") { + app.stack.push(normalizeLayer({ ...arg2, handler: arg1 as EventHandler })); + } else { + app.stack.push(normalizeLayer({ ...arg1 })); + } + return app; +} + +export function createResolver(stack: Stack): EventHandlerResolver { + return async (path: string) => { + let _layerPath: string; + for (const layer of stack) { + if (layer.route === "/" && !layer.handler.__resolve__) { + continue; + } + if (!path.startsWith(layer.route)) { + continue; + } + _layerPath = path.slice(layer.route.length) || "/"; + if (layer.match && !layer.match(_layerPath, undefined)) { + continue; + } + let res = { route: layer.route, handler: layer.handler }; + if (res.handler.__resolve__) { + const _res = await res.handler.__resolve__(_layerPath); + if (!_res) { + continue; + } + res = { + ...res, + ..._res, + route: joinURL(res.route || "/", _res.route || "/"), + }; + } + return res; + } + }; +} + +export function normalizeLayer(input: InputLayer) { + let handler = input.handler; + // @ts-ignore + if (handler.handler) { + // @ts-ignore + handler = handler.handler; + } + + if (input.lazy) { + handler = defineLazyEventHandler(handler as unknown as LazyEventHandler); + } else if (!isEventHandler(handler)) { + handler = toEventHandler(handler, undefined, input.route); + } + + return { + route: withoutTrailingSlash(input.route), + match: input.match, + handler, + } as Layer; +} + +export function resolveWebsocketOptions( + evResolver: EventHandlerResolver, + appOptions: AppOptions, +): WebSocketOptions { + return { + ...appOptions.websocket, + async resolve(info) { + const pathname = getPathname(info.url || "/"); + const resolved = await evResolver(pathname); + return resolved?.handler?.__websocket__ || {}; + }, + }; +} + +export function cachedFn(fn: () => T): () => T { + let cache: T; + return () => { + if (!cache) { + cache = fn(); + } + return cache; + }; +} diff --git a/src/app/app.ts b/src/app/app.ts new file mode 100644 index 00000000..8fab4e65 --- /dev/null +++ b/src/app/app.ts @@ -0,0 +1,40 @@ +import type { App, Stack, AppOptions } from "../types"; +import { _kRaw } from "../event"; +import { createAppEventHandler } from "./_handler"; +import { + cachedFn, + createResolver, + resolveWebsocketOptions, + use, +} from "./_utils"; + +/** + * Create a new H3 app instance. + */ +export function createApp(options: AppOptions = {}): App { + const stack: Stack = []; + + const handler = createAppEventHandler(stack, options); + + const resolve = createResolver(stack); + handler.__resolve__ = resolve; + + const getWebsocket = cachedFn(() => + resolveWebsocketOptions(resolve, options), + ); + + const _use = (arg1: any, arg2: any, arg3: any) => use(app, arg1, arg2, arg3); + + const app: App = { + use: _use as App["use"], + resolve, + handler, + stack, + options, + get websocket() { + return getWebsocket(); + }, + }; + + return app; +} diff --git a/src/deprecated.ts b/src/deprecated.ts index 71f5beb7..4d99dd27 100644 --- a/src/deprecated.ts +++ b/src/deprecated.ts @@ -15,6 +15,9 @@ import { getBodyStream } from "./utils/body"; import { appendResponseHeader, appendResponseHeaders, + iterable, + noContent, + redirect, setResponseHeader, setResponseHeaders, } from "./utils/response"; @@ -30,6 +33,7 @@ import { readValidatedJSONBody, } from "./utils/body"; import { defineEventHandler, defineLazyEventHandler } from "./handler"; +import { proxy } from "./utils/proxy"; /** @deprecated Please use `getRequestHeader` */ export const getHeader = getRequestHeader; @@ -39,10 +43,10 @@ export const getHeaders = getRequestHeaders; /** @deprecated Directly return stream */ export function sendStream( - event: H3Event, + _event: H3Event, value: ReadableStream | NodeReadableStream, ) { - return event[_kRaw].sendResponse(value); + return value; } /** Please use `defineEventHandler` */ @@ -90,6 +94,24 @@ export const getRequestWebStream = getBodyStream; /** @deprecated Please use `event.path` instead */ export const getRequestPath = (event: H3Event) => event.path; +/** @deprecated Use `return iterable()` */ +export const sendIterable = ( + _event: H3Event, + ...args: Parameters +) => iterable(...args); + +/** @deprecated Use `return noContent()` */ +export const sendNoContent = noContent; + +/** @deprecated Use `return redirect()` */ +export const sendRedirect = redirect; + +/** @deprecated Use `return response` */ +export const sendWebResponse = (response: Response) => response; + +/** @deprecated Use `return proxy()` */ +export const sendProxy = proxy; + // --- Types --- /** @deprecated Please use `RequestMiddleware` */ diff --git a/src/error.ts b/src/error.ts index b7ca6849..956e3f9d 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,7 +1,6 @@ import { _kRaw } from "./event"; import { hasProp } from "./utils/internal/object"; import { sanitizeStatusMessage, sanitizeStatusCode } from "./utils/sanitize"; -import { MIMES } from "./utils/internal/consts"; /** * H3 Runtime Error @@ -142,31 +141,6 @@ export function createError( return err; } -export function errorToResponse(error: Error | H3Error, debug?: boolean) { - const h3Error = isError(error) ? error : createError(error); - const response = { - error: h3Error, - status: h3Error.statusCode, - statusText: h3Error.statusMessage, - headers: { - "content-type": MIMES.json, - }, - body: JSON.stringify( - { - statusCode: h3Error.statusCode, - statusMessage: h3Error.statusMessage, - data: h3Error.data, - stack: debug - ? (h3Error.stack || "").split("\n").map((l) => l.trim()) - : undefined, - }, - undefined, - 2, - ), - }; - return response; -} - /** * Checks if the given input is an instance of H3Error. * diff --git a/src/event.ts b/src/event.ts index 2fc47608..9d83a869 100644 --- a/src/event.ts +++ b/src/event.ts @@ -1,4 +1,5 @@ -import type { H3Event, RawEvent } from "./types/event"; +import type { H3EventContext, H3Event } from "./types"; +import { RawEvent } from "./types/event"; export const _kRaw: unique symbol = Symbol.for("h3.internal.raw"); @@ -12,8 +13,11 @@ export class EventWrapper implements H3Event { _onBeforeResponseCalled: boolean | undefined; _onAfterResponseCalled: boolean | undefined; - constructor(raw: RawEvent) { + constructor(raw: RawEvent, initialContext?: H3EventContext) { this[_kRaw] = raw; + if (initialContext) { + Object.assign(this.context, initialContext); + } } get method() { diff --git a/src/handler.ts b/src/handler.ts index c83838c9..f5b22f95 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -86,9 +86,6 @@ async function _callHandler< if (hooks.onRequest) { for (const hook of hooks.onRequest) { await hook(event); - if (event[_kRaw].handled) { - return; - } } } const body = await handler(event); diff --git a/src/index.ts b/src/index.ts index d7174339..83652262 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export * from "./types"; // App -export { createApp, use, createAppEventHandler } from "./app"; +export { createApp } from "./app/app"; // Event export { isEvent } from "./event"; @@ -46,7 +46,6 @@ export { fromPlainHandler, toPlainHandler, fromPlainRequest, - callWithWebRequest, callWithPlainRequest, } from "./adapters/web"; @@ -74,10 +73,6 @@ export { export { appendResponseHeader, appendResponseHeaders, - sendIterable, - sendNoContent, - sendRedirect, - sendWebResponse, setResponseHeader, setResponseHeaders, setResponseStatus, @@ -93,7 +88,7 @@ export { // Proxy export { - sendProxy, + proxy, getProxyRequestHeaders, proxyRequest, fetchWithEvent, diff --git a/src/types/app.ts b/src/types/app.ts index e8a7aca3..cf39b2c9 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,8 +1,15 @@ import type { AdapterOptions as WSOptions } from "crossws"; import type { H3Event } from "./event"; -import type { EventHandler, EventHandlerResolver } from "./handler"; +import type { + EventHandler, + EventHandlerRequest, + EventHandlerResolver, + ResponseBody, +} from "./handler"; import type { H3Error } from "../error"; +type MaybePromise = T | Promise; + export type { H3Error } from "../error"; export interface Layer { @@ -24,6 +31,15 @@ export type InputStack = InputLayer[]; export type Matcher = (url: string, event?: H3Event) => boolean; +export interface AppResponse { + error?: H3Error; + body: ResponseBody; + contentType?: string; + headers?: Headers; + status?: number; + statusText?: string; +} + export interface AppUse { ( route: string | string[], @@ -38,22 +54,22 @@ export type WebSocketOptions = WSOptions; export interface AppOptions { debug?: boolean; - onError?: (error: H3Error, event: H3Event) => any; - onRequest?: (event: H3Event) => void | Promise; + onError?: (error: H3Error, event: H3Event) => MaybePromise; + onRequest?: (event: H3Event) => MaybePromise; onBeforeResponse?: ( event: H3Event, - response: { body?: unknown }, - ) => void | Promise; + response: AppResponse, + ) => MaybePromise; onAfterResponse?: ( event: H3Event, - response?: { body?: unknown }, - ) => void | Promise; + response?: AppResponse, + ) => MaybePromise; websocket?: WebSocketOptions; } export interface App { stack: Stack; - handler: EventHandler; + handler: EventHandler>; options: AppOptions; use: AppUse; resolve: EventHandlerResolver; diff --git a/src/types/event.ts b/src/types/event.ts index 4e986d2b..84acd6b4 100644 --- a/src/types/event.ts +++ b/src/types/event.ts @@ -1,4 +1,3 @@ -import type { Readable as NodeReadableStream } from "node:stream"; import type { EventHandlerRequest, H3EventContext, HTTPMethod } from "."; import type { _kRaw } from "../event"; @@ -26,14 +25,6 @@ export interface H3Event< _onAfterResponseCalled: boolean | undefined; } -export type RawResponse = - | undefined - | null - | Uint8Array - | string - | ReadableStream - | NodeReadableStream; - export interface RawEvent { // -- Context -- getContext: () => Record; @@ -57,8 +48,6 @@ export interface RawEvent { // -- Response -- - readonly handled: boolean | undefined; - responseCode: number | undefined; responseMessage: string | undefined; @@ -69,6 +58,6 @@ export interface RawEvent { getResponseSetCookie: () => string[]; removeResponseHeader: (key: string) => void; writeHead: (code: number, message?: string) => void; - sendResponse: (data?: RawResponse) => void | Promise; + // sendResponse: (data?: RawResponse) => void | Promise; writeEarlyHints: (hints: Record) => void | Promise; } diff --git a/src/types/handler.ts b/src/types/handler.ts index 8692e4b7..9bd11a26 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -1,7 +1,14 @@ +import type { Readable as NodeReadableStream } from "node:stream"; import type { QueryObject } from "ufo"; import type { H3Event } from "./event"; import type { Hooks as WSHooks } from "crossws"; +export type ResponseBody = + | undefined // middleware pass + | null // empty content + | BodyInit + | NodeReadableStream; + export type EventHandlerResponse = T | Promise; export interface EventHandlerRequest { diff --git a/src/types/index.ts b/src/types/index.ts index 711afb53..b19cab50 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,6 +23,7 @@ export type { EventHandlerResolver, EventHandlerResponse, DynamicEventHandler, + ResponseBody, LazyEventHandler, InferEventInput, RequestMiddleware, diff --git a/src/types/utils/static.ts b/src/types/utils/static.ts index f6cfc678..b478c9b2 100644 --- a/src/types/utils/static.ts +++ b/src/types/utils/static.ts @@ -1,4 +1,4 @@ -import type { RawResponse } from "../event"; +import type { ResponseBody } from "../handler"; export interface StaticAssetMeta { type?: string; @@ -20,7 +20,7 @@ export interface ServeStaticOptions { /** * This function should resolve asset content */ - getContents: (id: string) => RawResponse | Promise; + getContents: (id: string) => ResponseBody | Promise; /** * Map of supported encodings (compressions) and their file extensions. diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 924b2c86..601f19e4 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -38,9 +38,6 @@ export function handleCacheHeaders( if (cacheMatched) { event[_kRaw].responseCode = 304; - if (!event[_kRaw].handled) { - event[_kRaw].sendResponse(); - } return true; } diff --git a/src/utils/cors.ts b/src/utils/cors.ts index 5ba2c45b..b7f2a7f4 100644 --- a/src/utils/cors.ts +++ b/src/utils/cors.ts @@ -1,7 +1,7 @@ -import type { H3Event } from "../types"; +import type { H3Event, ResponseBody } from "../types"; import type { H3CorsOptions } from "../types/utils/cors"; import { _kRaw } from "../event"; -import { sendNoContent, appendResponseHeaders } from "./response"; +import { noContent, appendResponseHeaders } from "./response"; import { createAllowHeaderHeaders, createCredentialsHeaders, @@ -60,26 +60,28 @@ export function appendCorsHeaders(event: H3Event, options: H3CorsOptions) { * const router = createRouter(); * router.use('/', * defineEventHandler(async (event) => { - * const didHandleCors = handleCors(event, { + * const corsRes = handleCors(event, { * origin: '*', * preflight: { * statusCode: 204, * }, * methods: '*', * }); - * if (didHandleCors) { - * return; + * if (corsRes) { + * return corsRes; * } * // Your code here * }) * ); */ -export function handleCors(event: H3Event, options: H3CorsOptions): boolean { +export function handleCors( + event: H3Event, + options: H3CorsOptions, +): false | ResponseBody { const _options = resolveCorsOptions(options); if (isPreflightRequest(event)) { appendCorsPreflightHeaders(event, options); - sendNoContent(event, _options.preflight.statusCode); - return true; + return noContent(event, _options.preflight.statusCode); } appendCorsHeaders(event, options); return false; diff --git a/src/utils/internal/consts.ts b/src/utils/internal/consts.ts index e4f30b52..3cfd5e57 100644 --- a/src/utils/internal/consts.ts +++ b/src/utils/internal/consts.ts @@ -1,4 +1,5 @@ export const MIMES = { html: "text/html", json: "application/json", + octetStream: "application/octet-stream", } as const; diff --git a/src/utils/internal/event-stream.ts b/src/utils/internal/event-stream.ts index 2e2363a3..d88cc545 100644 --- a/src/utils/internal/event-stream.ts +++ b/src/utils/internal/event-stream.ts @@ -1,4 +1,4 @@ -import type { H3Event } from "../../types"; +import type { H3Event, ResponseBody } from "../../types"; import type { ResponseHeaders } from "../../types/http"; import type { NodeEvent } from "../../types/node"; import type { @@ -139,10 +139,6 @@ export class EventStream { // Ignore } } - // check if the stream has been given to the client before closing the connection - if (this._event[_kRaw].handled && this._handled) { - this._event[_kRaw].sendResponse(); - } this._disposed = true; } @@ -154,11 +150,11 @@ export class EventStream { this._writer.closed.then(cb); } - async send() { + async send(): Promise { setEventStreamHeaders(this._event); setResponseStatus(this._event, 200); this._handled = true; - this._event[_kRaw].sendResponse(this._transformStream.readable); + return this._transformStream.readable; } } diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 47a24ed5..ae9937df 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -1,4 +1,10 @@ -import type { H3EventContext, H3Event, ProxyOptions, Duplex } from "../types"; +import type { + H3EventContext, + H3Event, + ProxyOptions, + Duplex, + ResponseBody, +} from "../types"; import { splitCookiesString } from "./cookie"; import { sanitizeStatusMessage, sanitizeStatusCode } from "./sanitize"; import { _kRaw } from "../event"; @@ -41,7 +47,7 @@ export async function proxyRequest( opts.headers, ); - return sendProxy(event, target, { + return proxy(event, target, { ...opts, fetchOptions: { method, @@ -56,11 +62,11 @@ export async function proxyRequest( /** * Make a proxy request to a target URL and send the response back to the client. */ -export async function sendProxy( +export async function proxy( event: H3Event, target: string, opts: ProxyOptions = {}, -) { +): Promise { let response: Response | undefined; try { response = await getFetch(opts.fetch)(target, { @@ -125,19 +131,13 @@ export async function sendProxy( return (response as any)._data; } - // Ensure event is not handled - if (event[_kRaw].handled) { - return; - } - // Send at once if (opts.sendStream === false) { - const data = new Uint8Array(await response.arrayBuffer()); - return event[_kRaw].sendResponse(data); + return new Uint8Array(await response.arrayBuffer()); } // Send as stream - return event[_kRaw].sendResponse(response.body); + return response.body; } /** diff --git a/src/utils/response.ts b/src/utils/response.ts index 7acf63b5..7de2937d 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -1,10 +1,13 @@ -import type { H3Event } from "../types"; -import type { ResponseHeaders, ResponseHeaderName } from "../types/http"; -import type { MimeType, StatusCode } from "../types"; +import type { + H3Event, + ResponseHeaders, + ResponseHeaderName, + MimeType, + StatusCode, +} from "../types"; import { _kRaw } from "../event"; import { MIMES } from "./internal/consts"; import { sanitizeStatusCode, sanitizeStatusMessage } from "./sanitize"; -import { splitCookiesString } from "./cookie"; import { serializeIterableValue, coerceIterable, @@ -15,26 +18,15 @@ import { /** * Respond with an empty payload.
* - * Note that calling this function will close the connection and no other data can be sent to the client afterwards. - * - * @example - * export default defineEventHandler((event) => { - * return sendNoContent(event); - * }); * @example * export default defineEventHandler((event) => { - * sendNoContent(event); // Close the connection - * console.log("This will not be executed"); + * return noContent(event); * }); * * @param event H3 event * @param code status code to be send. By default, it is `204 No Content`. */ -export function sendNoContent(event: H3Event, code?: StatusCode) { - if (event[_kRaw].handled) { - return; - } - +export function noContent(event: H3Event, code?: StatusCode) { if (!code && event[_kRaw].responseCode !== 200) { // status code was set with setResponseStatus code = event[_kRaw].responseCode; @@ -46,7 +38,7 @@ export function sendNoContent(event: H3Event, code?: StatusCode) { event[_kRaw].removeResponseHeader("content-length"); } event[_kRaw].writeHead(_code); - event[_kRaw].sendResponse(); + return ""; } /** @@ -122,15 +114,15 @@ export function defaultContentType(event: H3Event, type?: MimeType) { * * @example * export default defineEventHandler((event) => { - * return sendRedirect(event, "https://example.com"); + * return redirect(event, "https://example.com"); * }); * * @example * export default defineEventHandler((event) => { - * return sendRedirect(event, "https://example.com", 301); // Permanent redirect + * return redirect(event, "https://example.com", 301); // Permanent redirect * }); */ -export function sendRedirect( +export function redirect( event: H3Event, location: string, code: StatusCode = 302, @@ -143,7 +135,7 @@ export function sendRedirect( const encodedLoc = location.replace(/"/g, "%22"); const html = ``; defaultContentType(event, MIMES.html); - return event[_kRaw].sendResponse(html); + return html; } /** @@ -298,38 +290,6 @@ export function writeEarlyHints( return event[_kRaw].writeEarlyHints(hints); } -/** - * Send a Web besponse object to the client. - */ -export function sendWebResponse( - event: H3Event, - response: Response, -): void | Promise { - for (const [key, value] of response.headers) { - if (key === "set-cookie") { - for (const setCookie of splitCookiesString(value)) { - event[_kRaw].appendResponseHeader(key, setCookie); - } - } else { - event[_kRaw].setResponseHeader(key, value); - } - } - - if (response.status) { - event[_kRaw].responseCode = sanitizeStatusCode( - response.status, - event[_kRaw].responseCode, - ); - } - if (response.statusText) { - event[_kRaw].responseMessage = sanitizeStatusMessage(response.statusText); - } - if (response.redirected) { - event[_kRaw].setResponseHeader("location", response.url); - } - return event[_kRaw].sendResponse(response.body); -} - /** * Iterate a source of chunks and send back each chunk in order. * Supports mixing async work together with emitting chunks. @@ -344,8 +304,7 @@ export function sendWebResponse( * @template Value - Test * * @example - * sendIterable(event, work()); - * async function* work() { + * return iterable(async function* work() { * // Open document body * yield "\n

Executing...

    \n"; * // Do work ... @@ -358,37 +317,34 @@ export function sendWebResponse( * } * // Close out the report * return `
`; - * } + * }) * async function delay(ms) { * return new Promise(resolve => setTimeout(resolve, ms)); * } */ -export function sendIterable( - event: H3Event, +export function iterable( iterable: IterationSource, options?: { serializer: IteratorSerializer; }, -): void | Promise { +): ReadableStream { const serializer = options?.serializer ?? serializeIterableValue; const iterator = coerceIterable(iterable); - event[_kRaw].sendResponse( - new ReadableStream({ - async pull(controller) { - const { value, done } = await iterator.next(); - if (value !== undefined) { - const chunk = serializer(value); - if (chunk !== undefined) { - controller.enqueue(chunk); - } - } - if (done) { - controller.close(); + return new ReadableStream({ + async pull(controller) { + const { value, done } = await iterator.next(); + if (value !== undefined) { + const chunk = serializer(value); + if (chunk !== undefined) { + controller.enqueue(chunk); } - }, - cancel() { - iterator.return?.(); - }, - }), - ); + } + if (done) { + controller.close(); + } + }, + cancel() { + iterator.return?.(); + }, + }); } diff --git a/src/utils/static.ts b/src/utils/static.ts index 2e79a01f..ae14d4bb 100644 --- a/src/utils/static.ts +++ b/src/utils/static.ts @@ -1,4 +1,9 @@ -import type { H3Event, StaticAssetMeta, ServeStaticOptions } from "../types"; +import type { + H3Event, + StaticAssetMeta, + ServeStaticOptions, + ResponseBody, +} from "../types"; import { decodePath } from "ufo"; import { _kRaw } from "../event"; import { createError } from "../error"; @@ -14,7 +19,7 @@ import { export async function serveStatic( event: H3Event, options: ServeStaticOptions, -): Promise { +): Promise { if (event.method !== "GET" && event.method !== "HEAD") { if (!options.fallthrough) { throw createError({ @@ -75,7 +80,7 @@ export async function serveStatic( if (ifNotMatch) { event[_kRaw].responseCode = 304; event[_kRaw].responseMessage = "Not Modified"; - return event?.[_kRaw]?.sendResponse(""); + return ""; } if (meta.mtime) { @@ -85,7 +90,7 @@ export async function serveStatic( if (ifModifiedSinceH && new Date(ifModifiedSinceH) >= mtimeDate) { event[_kRaw].responseCode = 304; event[_kRaw].responseMessage = "Not Modified"; - return event?.[_kRaw]?.sendResponse(""); + return ""; } if (!event[_kRaw].getResponseHeader("last-modified")) { @@ -110,11 +115,11 @@ export async function serveStatic( } if (event.method === "HEAD") { - return event?.[_kRaw]?.sendResponse(); + return ""; } const contents = await options.getContents(id); - return event?.[_kRaw]?.sendResponse(contents); + return contents; } // --- Internal Utils --- diff --git a/test/_playground.ts b/test/_playground.ts new file mode 100644 index 00000000..9101cf21 --- /dev/null +++ b/test/_playground.ts @@ -0,0 +1,16 @@ +import { createServer } from "node:http"; +import { createApp, createRouter, eventHandler, toNodeHandler } from "../src"; + +export const app = createApp(); + +const router = createRouter(); +app.use(router); + +router.get( + "/", + eventHandler(() => Buffer.from("

Hello world!

", "utf8")), +); + +createServer(toNodeHandler(app)).listen(3000, () => { + console.log("Server listening on http://localhost:3000"); +}); diff --git a/test/app.test.ts b/test/app.test.ts index 4983c986..6023a37c 100644 --- a/test/app.test.ts +++ b/test/app.test.ts @@ -117,7 +117,7 @@ describe("app", () => { ); const res = await ctx.request.get("/"); - expect(res.text).toBe("

Hello world!

"); + expect(res.body.toString("utf8")).toBe("

Hello world!

"); }); it("Node.js Readable Stream", async () => { diff --git a/test/proxy.test.ts b/test/proxy.test.ts index bc16b14d..6c7c4bc0 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -10,7 +10,7 @@ import { readRawBody, appendResponseHeader, } from "../src"; -import { sendProxy, proxyRequest } from "../src/utils/proxy"; +import { proxy, proxyRequest } from "../src/utils/proxy"; import { setupTest } from "./_utils"; const spy = vi.spyOn(console, "error"); @@ -18,7 +18,7 @@ const spy = vi.spyOn(console, "error"); describe("proxy", () => { const ctx = setupTest(); - describe("sendProxy", () => { + describe("proxy()", () => { it("works", async () => { ctx.app.use( "/hello", @@ -27,7 +27,7 @@ describe("proxy", () => { ctx.app.use( "/", eventHandler((event) => { - return sendProxy(event, ctx.url + "/hello", { fetch }); + return proxy(event, ctx.url + "/hello", { fetch }); }), ); @@ -251,7 +251,7 @@ describe("proxy", () => { ctx.app.use( "/", eventHandler((event) => { - return sendProxy(event, ctx.url + "/setcookies", { fetch }); + return proxy(event, ctx.url + "/setcookies", { fetch }); }), ); diff --git a/test/status.test.ts b/test/status.test.ts index b20174e2..b5d02795 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -89,7 +89,7 @@ describe("setResponseStatus", () => { "/test", eventHandler((event) => { setResponseStatus(event, 418, "status-text"); - return null; + return ""; }), ); @@ -103,7 +103,7 @@ describe("setResponseStatus", () => { expect(res).toMatchObject({ status: 418, statusText: "status-text", - body: undefined, + body: "", headers: {}, }); });