mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-04 08:04:37 +08:00
Build @hono/node-server 2.x into scriptc (#1886)
This commit is contained in:
@@ -33,6 +33,7 @@ output/
|
||||
/packages/runtime-*/runtime-pack.json
|
||||
/packages/runtime-*/.runtime-pack-*
|
||||
!tests/fixtures/npm/node_modules/*/dist/
|
||||
!tests/fixtures/npm/node_modules/@*/*/dist/
|
||||
!tests/fixtures/npm/workspace/*/dist/
|
||||
!tests/fixtures/npm/cases/workspace-copied/members/*/dist/
|
||||
!tests/fixtures/npm/cases/workspace-copied/node_modules/*/dist/
|
||||
|
||||
@@ -92,6 +92,13 @@ scriptc implements a few packages itself and compiles that implementation in pla
|
||||
- graceful-fs's retry queue, which fs-extra uses under Node.js, isn't part of the implementation, so a program that runs out of file descriptors fails at the first `EMFILE` error instead of retrying.
|
||||
- An ES module that imports fs-extra can use any of its exports by name. Node.js only exposes the names its CommonJS lexer finds in fs-extra's source, so a name like `readFile` throws a `SyntaxError` there.
|
||||
|
||||
`@hono/node-server` 2.x is built in. `serve`, `createAdaptorServer`, `getRequestListener` and `RequestError` serve a fetch handler such as Hono's `app.fetch` over `node:http` with native `Request` and `Response` objects, and write each response the way the package does under Node.js: the same status line, header names, header order and content length, for buffered and streamed bodies, including the package's shortcut for a response that is still exactly what its constructor received. Request bodies stream to the handler as it reads them, and a body the handler leaves unread is drained after the response, up to 64 MiB or 500 ms, as the package does. Known differences:
|
||||
|
||||
- Only HTTP/1.1 servers are declared: the `createServer` and `serverOptions` options for `node:https` and `node:http2`, the `websocket` option and `upgradeWebSocket` aren't declared, so using one is a type error at build time. Subpath modules such as `@hono/node-server/serve-static` compile from the package's own files.
|
||||
- The fetch handler must return a `Response` or a promise of one; the handler's second argument holds `incoming` and `outgoing`.
|
||||
- The global `Request` and `Response` stay the built-in classes, so `overrideGlobalObjects` has no effect, and a `Response` reads its default text content type as `text/plain;charset=UTF-8` where the package's replacement reads `text/plain; charset=UTF-8`. Written responses carry the package's spelling.
|
||||
- A streamed response is written without waiting for `drain`, and a failed handler answers 504 only for an error whose `name` is `TimeoutError`.
|
||||
|
||||
Small polyfill packages whose module is exactly a Node.js built-in on Node.js 24 compile as that built-in, at the `require()` that loads them, from your program's CommonJS files, from compiled packages, and from esbuild bundles whose module paths name the package and its version (as pnpm installs do). The package's own files aren't compiled.
|
||||
|
||||
<table>
|
||||
|
||||
@@ -5561,6 +5561,7 @@ declare module "http" {
|
||||
readonly trailersDistinct: { [name: string]: string[] | undefined };
|
||||
readonly rawTrailers: string[];
|
||||
resume(): void;
|
||||
pause(): void;
|
||||
destroy(): void;
|
||||
setTimeout(msecs: number, callback?: () => void): this;
|
||||
/* setEncoding('utf8'): 'data' delivers strings (other real encodings
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
// scriptc's built-in implementation of `@hono/node-server` 2.x: the Node.js
|
||||
// adapter that serves a fetch-style handler (Hono's `app.fetch`) over
|
||||
// node:http. The published package swaps the global Request and Response
|
||||
// for lightweight objects and reparents prototypes, which only pays off
|
||||
// under V8; this port builds native Request objects from the incoming
|
||||
// message, with a body streamed from it as the app reads it, and writes
|
||||
// the handler's Response the way the package's listener does (header
|
||||
// record, content length for bodies that complete within the first reads,
|
||||
// streamed bodies, error responses), so a client sees the same status
|
||||
// lines, headers and bodies. A body the app leaves unread is drained after
|
||||
// the response, within the package's limits.
|
||||
//
|
||||
// HTTP/1.1 only: the http2/https server options and WebSocket upgrades are
|
||||
// not declared. A Response that is still exactly its constructor arguments
|
||||
// takes the package's cached-response path (its lightweight Response), read
|
||||
// from the runtime through scriptcResponseLight; every other Response takes
|
||||
// the general path.
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export type HttpBindings = {
|
||||
incoming: IncomingMessage;
|
||||
outgoing: ServerResponse;
|
||||
};
|
||||
|
||||
export type ServerType = Server;
|
||||
|
||||
export type FetchCallback = (request: Request, env: HttpBindings) => Response | Promise<Response>;
|
||||
|
||||
export type CustomErrorHandler = (err: unknown) => void | Response | Promise<void | Response>;
|
||||
|
||||
export type Options = {
|
||||
fetch: FetchCallback;
|
||||
overrideGlobalObjects?: boolean;
|
||||
autoCleanupIncoming?: boolean;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
};
|
||||
|
||||
export interface RequestListenerOptions {
|
||||
hostname?: string;
|
||||
errorHandler?: CustomErrorHandler;
|
||||
overrideGlobalObjects?: boolean;
|
||||
autoCleanupIncoming?: boolean;
|
||||
}
|
||||
|
||||
export class RequestError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "RequestError";
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CONTENT_TYPE = "text/plain; charset=UTF-8";
|
||||
const X_ALREADY_SENT = "x-hono-already-sent";
|
||||
|
||||
const reValidRequestUrl = /^\/[!#$&-;=?-[\]_a-z~]*$/;
|
||||
const reDotSegment = /\/\.\.?(?:[/?#]|$)/;
|
||||
const reValidHost = /^[a-z0-9._-]+(?::(?:[1-5]\d{3,4}|[6-9]\d{3}))?$/;
|
||||
|
||||
function buildUrl(scheme: string, host: string, incomingUrl: string): string {
|
||||
const url = `${scheme}://${host}${incomingUrl}`;
|
||||
if (!reValidHost.test(host)) {
|
||||
const urlObj = new URL(url);
|
||||
if (
|
||||
urlObj.hostname.length !== host.length &&
|
||||
urlObj.hostname !== (host.includes(":") ? host.replace(/:\d+$/, "") : host).toLowerCase()
|
||||
)
|
||||
throw new RequestError("Invalid host header");
|
||||
return urlObj.href;
|
||||
}
|
||||
if (incomingUrl.length === 0) return url + "/";
|
||||
if (incomingUrl.charCodeAt(0) !== 47) throw new RequestError("Invalid URL");
|
||||
if (!reValidRequestUrl.test(incomingUrl) || reDotSegment.test(incomingUrl))
|
||||
return new URL(url).href;
|
||||
return url;
|
||||
}
|
||||
|
||||
function normalizeIncomingMethod(method: string | undefined): string {
|
||||
if (method === undefined || method.length === 0) return "GET";
|
||||
switch (method) {
|
||||
case "DELETE":
|
||||
case "GET":
|
||||
case "HEAD":
|
||||
case "OPTIONS":
|
||||
case "PATCH":
|
||||
case "POST":
|
||||
case "PUT":
|
||||
case "QUERY":
|
||||
return method;
|
||||
}
|
||||
const upper = method.toUpperCase();
|
||||
switch (upper) {
|
||||
case "DELETE":
|
||||
case "GET":
|
||||
case "HEAD":
|
||||
case "OPTIONS":
|
||||
case "POST":
|
||||
case "PUT":
|
||||
return upper;
|
||||
default:
|
||||
return method;
|
||||
}
|
||||
}
|
||||
|
||||
/** The request URL the package builds: an absolute request target as is,
|
||||
* otherwise the Host header (or the configured hostname) over http. */
|
||||
function requestUrl(incoming: IncomingMessage, defaultHostname: string | undefined): string {
|
||||
const incomingUrl = incoming.url ?? "";
|
||||
if (
|
||||
incomingUrl[0] !== "/" &&
|
||||
(incomingUrl.startsWith("http://") || incomingUrl.startsWith("https://"))
|
||||
) {
|
||||
try {
|
||||
return new URL(incomingUrl).href;
|
||||
} catch (e) {
|
||||
throw new RequestError("Invalid absolute URL", { cause: e });
|
||||
}
|
||||
}
|
||||
const hostHeader = incoming.headers.host;
|
||||
const host = hostHeader !== undefined && hostHeader !== "" ? hostHeader : (defaultHostname ?? "");
|
||||
if (host === "") throw new RequestError("Missing host header");
|
||||
try {
|
||||
return buildUrl("http", host, incomingUrl);
|
||||
} catch (e) {
|
||||
if (e instanceof RequestError) throw e;
|
||||
throw new RequestError("Invalid URL", { cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
function requestHeaders(incoming: IncomingMessage): Headers {
|
||||
const headers = new Headers();
|
||||
const raw = incoming.rawHeaders;
|
||||
for (let i = 0; i < raw.length; i += 2) {
|
||||
const name = raw[i]!;
|
||||
if (!name.startsWith(":")) headers.append(name, raw[i + 1]!);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** What the listener knows about the incoming body: whether it ended
|
||||
* (the package's `readableEnded`) and whether a drain started. */
|
||||
type IncomingState = { ended: boolean; draining: boolean };
|
||||
|
||||
/** The incoming message's readableHighWaterMark: Readable.toWeb queues
|
||||
* this many bytes before it pauses the message. */
|
||||
const BODY_HIGH_WATER_MARK = 64 * 1024;
|
||||
|
||||
/** The request body as the package streams it: Readable.toWeb over the
|
||||
* incoming message, read one chunk per pull. Chunks queue until
|
||||
* BODY_HIGH_WATER_MARK bytes wait unread, then the message pauses until a
|
||||
* pull takes them, so the socket holds the rest. The stream pulls once on
|
||||
* its own when it is constructed; the package builds its stream only when
|
||||
* the app reads the body, so until the app's first read a drain discards
|
||||
* the chunks instead of queueing them. */
|
||||
function incomingBody(incoming: IncomingMessage, state: IncomingState): ReadableStream<Uint8Array> {
|
||||
let pulls = 0;
|
||||
let listening = false;
|
||||
let ended = false;
|
||||
let failure: Error | undefined = undefined;
|
||||
const queue: Buffer[] = [];
|
||||
let queued = 0;
|
||||
let wake: (() => void) | undefined = undefined;
|
||||
const woken = (): void => {
|
||||
const resolve = wake;
|
||||
wake = undefined;
|
||||
if (resolve !== undefined) resolve();
|
||||
};
|
||||
const listen = (): void => {
|
||||
listening = true;
|
||||
incoming.on("data", (chunk: Buffer) => {
|
||||
if (state.draining && pulls === 1) return;
|
||||
queue.push(chunk);
|
||||
queued += chunk.length;
|
||||
if (queued >= BODY_HIGH_WATER_MARK) incoming.pause();
|
||||
woken();
|
||||
});
|
||||
incoming.on("end", () => {
|
||||
ended = true;
|
||||
woken();
|
||||
});
|
||||
incoming.on("error", (err: Error) => {
|
||||
failure = err;
|
||||
woken();
|
||||
});
|
||||
};
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
pulls++;
|
||||
if (!listening) listen();
|
||||
for (;;) {
|
||||
const chunk = queue.shift();
|
||||
if (chunk !== undefined) {
|
||||
queued -= chunk.length;
|
||||
if (queued < BODY_HIGH_WATER_MARK) incoming.resume();
|
||||
controller.enqueue(chunk);
|
||||
return;
|
||||
}
|
||||
if (failure !== undefined) {
|
||||
controller.error(failure);
|
||||
return;
|
||||
}
|
||||
if (ended) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function newRequest(
|
||||
incoming: IncomingMessage,
|
||||
defaultHostname: string | undefined,
|
||||
state: IncomingState,
|
||||
): Request {
|
||||
const method = normalizeIncomingMethod(incoming.method);
|
||||
const url = requestUrl(incoming, defaultHostname);
|
||||
const headers = requestHeaders(incoming);
|
||||
if (method === "GET" || method === "HEAD") return new Request(url, { method, headers });
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
body: incomingBody(incoming, state),
|
||||
duplex: "half",
|
||||
});
|
||||
}
|
||||
|
||||
const DRAIN_TIMEOUT_MS = 500;
|
||||
const MAX_DRAIN_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** The package's drain of a body the app left unread: read and discard
|
||||
* up to MAX_DRAIN_BYTES or DRAIN_TIMEOUT_MS, then close the connection
|
||||
* once its writes are out. */
|
||||
function drainIncoming(incoming: IncomingMessage, state: IncomingState): void {
|
||||
const socket = incoming.socket;
|
||||
if (socket.destroyed || state.draining) return;
|
||||
state.draining = true;
|
||||
let bytesRead = 0;
|
||||
let done = false;
|
||||
const cleanup = (): void => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
const forceClose = (): void => {
|
||||
if (done) return;
|
||||
cleanup();
|
||||
if (!socket.destroyed) socket.destroySoon();
|
||||
};
|
||||
const timer = setTimeout(forceClose, DRAIN_TIMEOUT_MS);
|
||||
timer.unref();
|
||||
incoming.on("data", (chunk: Buffer) => {
|
||||
if (done) return;
|
||||
bytesRead += chunk.length;
|
||||
if (bytesRead > MAX_DRAIN_BYTES) forceClose();
|
||||
});
|
||||
incoming.on("end", cleanup);
|
||||
incoming.on("error", cleanup);
|
||||
incoming.resume();
|
||||
}
|
||||
|
||||
/** The package's close handler cleanup: an unread body drains two timer
|
||||
* turns after the response closes. */
|
||||
function drainAfterClose(incoming: IncomingMessage, state: IncomingState): void {
|
||||
if (state.ended) return;
|
||||
setTimeout(() => {
|
||||
if (!state.ended)
|
||||
setTimeout(() => {
|
||||
drainIncoming(incoming, state);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The package's outgoing header record: Headers order (lowercase,
|
||||
* sorted), Set-Cookie values gathered into one array entry at the end,
|
||||
* and the default content type for a response with a body. */
|
||||
function buildOutgoingHttpHeaders(
|
||||
headers: Headers,
|
||||
hasBody: boolean,
|
||||
): Record<string, string | string[]> {
|
||||
const res: Record<string, string | string[]> = {};
|
||||
if (headers.has("set-cookie")) {
|
||||
const cookies: string[] = [];
|
||||
headers.forEach((v, k) => {
|
||||
if (k === "set-cookie") cookies.push(v);
|
||||
else res[k] = v;
|
||||
});
|
||||
// Headers.forEach joins nothing but set-cookie, which getSetCookie
|
||||
// lists value by value, as the package's iteration does.
|
||||
if (cookies.length > 0) res["set-cookie"] = headers.getSetCookie();
|
||||
} else {
|
||||
headers.forEach((v, k) => {
|
||||
res[k] = v;
|
||||
});
|
||||
}
|
||||
if (hasBody && res["content-type"] === undefined) res["content-type"] = DEFAULT_CONTENT_TYPE;
|
||||
return res;
|
||||
}
|
||||
|
||||
/* Spelled out rather than ReadableStreamReadResult<Uint8Array>, whose
|
||||
* declaration depends on the program's lib and @types/node settings: the
|
||||
* shim compiles inside every program that imports the package. */
|
||||
type ReadResult = { done: boolean; value?: Uint8Array };
|
||||
|
||||
function readWithoutBlocking(read: Promise<ReadResult>): Promise<ReadResult | undefined> {
|
||||
return Promise.race([read, Promise.resolve().then(() => Promise.resolve(undefined))]);
|
||||
}
|
||||
|
||||
function nextTick(): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/** Writes the rest of a body stream. As in the package, a response that
|
||||
* closes before the stream ends (the client disconnected) cancels the
|
||||
* stream, so its producer stops; the pending read then answers done. A
|
||||
* response that fails closes too, and its stream is cancelled the same way
|
||||
* (the package passes the error as the reason). The listener stays attached
|
||||
* and does nothing once the stream has ended. */
|
||||
async function pump(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
outgoing: ServerResponse,
|
||||
pending: Promise<ReadResult> | undefined,
|
||||
): Promise<void> {
|
||||
let finished = false;
|
||||
outgoing.on("close", () => {
|
||||
if (!finished) reader.cancel().catch(() => {});
|
||||
});
|
||||
try {
|
||||
let read = pending ?? reader.read();
|
||||
for (;;) {
|
||||
const { done, value } = await read;
|
||||
if (done) {
|
||||
outgoing.end();
|
||||
return;
|
||||
}
|
||||
outgoing.write(value!);
|
||||
read = reader.read();
|
||||
}
|
||||
} finally {
|
||||
finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResponseError(e: unknown, outgoing: ServerResponse): void {
|
||||
const err = e instanceof Error ? e : new Error("unknown error", { cause: e });
|
||||
console.error(e);
|
||||
if (!outgoing.headersSent) outgoing.writeHead(500, { "Content-Type": "text/plain" });
|
||||
outgoing.end(`Error: ${err.message}`);
|
||||
outgoing.destroy();
|
||||
}
|
||||
|
||||
function emptyResponse(status: number): Response {
|
||||
return new Response(null, { status });
|
||||
}
|
||||
|
||||
/** The lightweight state of a constructed Response (the runtime's
|
||||
* scr_fetch_response_light): [form, flags, byte length, ...record pairs]. */
|
||||
declare function scriptcResponseLight(res: Response): string[];
|
||||
|
||||
const UNDICI_TEXT_TYPE = "text/plain;charset=UTF-8";
|
||||
|
||||
/** Content-Length as Node's implicit header: set the headers one by one and
|
||||
* let end(body) compute the length, which Node then writes after its own
|
||||
* Date/Connection headers, as the package's preset length does. */
|
||||
function endWithImplicitLength(
|
||||
outgoing: ServerResponse,
|
||||
status: number,
|
||||
header: Record<string, string | string[]>,
|
||||
body: Uint8Array,
|
||||
): void {
|
||||
outgoing.statusCode = status;
|
||||
for (const name of Object.keys(header)) outgoing.setHeader(name, header[name]!);
|
||||
outgoing.end(body);
|
||||
}
|
||||
|
||||
/** The package's cached-response path: a Response that is still exactly
|
||||
* its constructor arguments (no body or metadata read since) is written
|
||||
* from them. Form 1 has no init headers, form 2 Headers, form 3 a plain
|
||||
* record kept verbatim. */
|
||||
async function writeLightResponse(
|
||||
res: Response,
|
||||
outgoing: ServerResponse,
|
||||
light: string[],
|
||||
head: boolean,
|
||||
): Promise<void> {
|
||||
const form = light[0]!;
|
||||
const flags = light[1]!;
|
||||
const length = Number(light[2]!);
|
||||
const status = res.status;
|
||||
const sized = length >= 0;
|
||||
if (form === "1") {
|
||||
if (length === -1) {
|
||||
outgoing.writeHead(status);
|
||||
outgoing.end();
|
||||
} else if (sized) {
|
||||
const body = await res.bytes();
|
||||
if (head) {
|
||||
outgoing.writeHead(status, {
|
||||
"Content-Type": DEFAULT_CONTENT_TYPE,
|
||||
"Content-Length": String(length),
|
||||
});
|
||||
outgoing.end(body);
|
||||
} else
|
||||
endWithImplicitLength(outgoing, status, { "Content-Type": DEFAULT_CONTENT_TYPE }, body);
|
||||
} else {
|
||||
outgoing.writeHead(status, { "Content-Type": DEFAULT_CONTENT_TYPE });
|
||||
outgoing.flushHeaders();
|
||||
await pump(res.body!.getReader(), outgoing, undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let header: Record<string, string | string[]>;
|
||||
let hasContentLength = false;
|
||||
let plain = false;
|
||||
let canAutoLength = true;
|
||||
if (form === "2") {
|
||||
const headers = new Headers(res.headers);
|
||||
if (flags.includes("a") && headers.get("content-type") === UNDICI_TEXT_TYPE) {
|
||||
if (flags.includes("d")) headers.set("content-type", DEFAULT_CONTENT_TYPE);
|
||||
else headers.delete("content-type");
|
||||
} else if (flags.includes("d") && !headers.has("content-type")) {
|
||||
headers.set("content-type", DEFAULT_CONTENT_TYPE);
|
||||
}
|
||||
hasContentLength = headers.has("content-length");
|
||||
header = buildOutgoingHttpHeaders(headers, length !== -1);
|
||||
} else {
|
||||
plain = true;
|
||||
header = {};
|
||||
for (let i = 3; i + 1 < light.length; i += 2) {
|
||||
const name = light[i]!;
|
||||
const value = light[i + 1]!;
|
||||
const previous = header[name];
|
||||
if (previous === undefined) header[name] = value;
|
||||
else if (typeof previous === "string") header[name] = [previous, value];
|
||||
else previous.push(value);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === "content-length") hasContentLength = true;
|
||||
else if (lower === "transfer-encoding" || lower === "trailer") canAutoLength = false;
|
||||
}
|
||||
}
|
||||
const body = sized ? await res.bytes() : undefined;
|
||||
if (!hasContentLength && body !== undefined) {
|
||||
if (plain && canAutoLength && !head && status !== 204 && status !== 304) {
|
||||
endWithImplicitLength(outgoing, status, header, body);
|
||||
return;
|
||||
}
|
||||
header["Content-Length"] = String(length);
|
||||
}
|
||||
outgoing.writeHead(status, header);
|
||||
if (length === -1) outgoing.end();
|
||||
else if (body !== undefined) outgoing.end(body);
|
||||
else {
|
||||
outgoing.flushHeaders();
|
||||
await pump(res.body!.getReader(), outgoing, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeResponse(
|
||||
res: Response,
|
||||
outgoing: ServerResponse,
|
||||
head: boolean,
|
||||
): Promise<void> {
|
||||
const light = scriptcResponseLight(res);
|
||||
if (light[0] !== "0") return writeLightResponse(res, outgoing, light, head);
|
||||
const body = res.body;
|
||||
const record = buildOutgoingHttpHeaders(res.headers, body !== null);
|
||||
if (body !== null) {
|
||||
const reader: ReadableStreamDefaultReader<Uint8Array> = body.getReader();
|
||||
const values: Uint8Array[] = [];
|
||||
let done = false;
|
||||
let pending: Promise<ReadResult> | undefined = undefined;
|
||||
if (record["transfer-encoding"] !== "chunked") {
|
||||
let maxReadCount = 2;
|
||||
for (let i = 0; i < maxReadCount; i++) {
|
||||
pending ??= reader.read();
|
||||
let chunk: ReadResult | undefined = undefined;
|
||||
try {
|
||||
chunk = await readWithoutBlocking(pending);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
done = true;
|
||||
}
|
||||
if (chunk === undefined) {
|
||||
if (i === 1 && !done) {
|
||||
await nextTick();
|
||||
maxReadCount = 3;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
pending = undefined;
|
||||
if (chunk.value !== undefined) values.push(chunk.value);
|
||||
if (chunk.done) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (done && record["content-length"] === undefined) {
|
||||
let length = 0;
|
||||
for (const value of values) length += value.length;
|
||||
record["content-length"] = String(length);
|
||||
}
|
||||
}
|
||||
outgoing.writeHead(res.status, record);
|
||||
for (const value of values) outgoing.write(value);
|
||||
if (done) outgoing.end();
|
||||
else {
|
||||
if (values.length === 0) outgoing.flushHeaders();
|
||||
await pump(reader, outgoing, pending);
|
||||
}
|
||||
} else if (record[X_ALREADY_SENT] === undefined) {
|
||||
outgoing.writeHead(res.status, record);
|
||||
outgoing.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function respond(
|
||||
pending: Response | Promise<Response>,
|
||||
outgoing: ServerResponse,
|
||||
errorHandler: CustomErrorHandler | undefined,
|
||||
head: boolean,
|
||||
): Promise<void> {
|
||||
let res: Response;
|
||||
if (pending instanceof Promise) {
|
||||
try {
|
||||
res = await pending;
|
||||
} catch (err) {
|
||||
if (errorHandler === undefined) {
|
||||
res = handleFetchError(err);
|
||||
} else {
|
||||
const errRes = await errorHandler(err);
|
||||
if (errRes === undefined) return;
|
||||
res = errRes;
|
||||
}
|
||||
}
|
||||
} else res = pending;
|
||||
await writeResponse(res, outgoing, head);
|
||||
}
|
||||
|
||||
/** A Response the package writes from its cache without waiting: still
|
||||
* exactly its constructor arguments, with no body or a string or bytes
|
||||
* body. */
|
||||
function immediateCacheable(res: Response): boolean {
|
||||
const light = scriptcResponseLight(res);
|
||||
return light[0] !== "0" && Number(light[2]!) >= -1;
|
||||
}
|
||||
|
||||
function handleFetchError(e: unknown): Response {
|
||||
return emptyResponse(e instanceof Error && e.name === "TimeoutError" ? 504 : 500);
|
||||
}
|
||||
|
||||
async function handleRequest(
|
||||
fetchCallback: FetchCallback,
|
||||
options: RequestListenerOptions,
|
||||
incoming: IncomingMessage,
|
||||
outgoing: ServerResponse,
|
||||
): Promise<void> {
|
||||
let req: Request | undefined = undefined;
|
||||
let res: Response | Promise<Response> | undefined = undefined;
|
||||
const state: IncomingState = { ended: false, draining: false };
|
||||
let needsBodyCleanup = false;
|
||||
let closeHandlerAttached = false;
|
||||
const ensureCloseHandler = (): void => {
|
||||
if (req === undefined || closeHandlerAttached) return;
|
||||
closeHandlerAttached = true;
|
||||
if (needsBodyCleanup)
|
||||
outgoing.on("close", () => {
|
||||
drainAfterClose(incoming, state);
|
||||
});
|
||||
};
|
||||
try {
|
||||
req = newRequest(incoming, options.hostname, state);
|
||||
needsBodyCleanup =
|
||||
options.autoCleanupIncoming !== false &&
|
||||
!(incoming.method === "GET" || incoming.method === "HEAD");
|
||||
if (needsBodyCleanup)
|
||||
incoming.on("end", () => {
|
||||
state.ended = true;
|
||||
});
|
||||
res = fetchCallback(req, { incoming, outgoing });
|
||||
if (!(res instanceof Promise) && immediateCacheable(res)) {
|
||||
if (needsBodyCleanup)
|
||||
outgoing.once("finish", () => {
|
||||
if (!state.ended) drainIncoming(incoming, state);
|
||||
});
|
||||
} else ensureCloseHandler();
|
||||
} catch (e) {
|
||||
if (res !== undefined) return handleResponseError(e, outgoing);
|
||||
const errorHandler = options.errorHandler;
|
||||
if (errorHandler !== undefined) {
|
||||
ensureCloseHandler();
|
||||
const handled = await errorHandler(
|
||||
req !== undefined ? e : e instanceof RequestError ? e : toRequestError(e),
|
||||
);
|
||||
if (handled === undefined) return;
|
||||
res = handled;
|
||||
} else if (req === undefined) res = emptyResponse(400);
|
||||
else res = handleFetchError(e);
|
||||
}
|
||||
try {
|
||||
await respond(res, outgoing, options.errorHandler, incoming.method === "HEAD");
|
||||
} catch (e) {
|
||||
handleResponseError(e, outgoing);
|
||||
}
|
||||
}
|
||||
|
||||
function toRequestError(e: unknown): RequestError {
|
||||
return new RequestError(e instanceof Error ? e.message : String(e), { cause: e });
|
||||
}
|
||||
|
||||
export function getRequestListener(
|
||||
fetchCallback: FetchCallback,
|
||||
options: RequestListenerOptions = {},
|
||||
): (incoming: IncomingMessage, outgoing: ServerResponse) => Promise<void> {
|
||||
return (incoming, outgoing) => handleRequest(fetchCallback, options, incoming, outgoing);
|
||||
}
|
||||
|
||||
export function createAdaptorServer(options: Options): ServerType {
|
||||
const listener = getRequestListener(options.fetch, {
|
||||
hostname: options.hostname,
|
||||
overrideGlobalObjects: options.overrideGlobalObjects,
|
||||
autoCleanupIncoming: options.autoCleanupIncoming,
|
||||
});
|
||||
return createServer((incoming, outgoing) => {
|
||||
void listener(incoming, outgoing);
|
||||
});
|
||||
}
|
||||
|
||||
export function serve(
|
||||
options: Options,
|
||||
listeningListener?: (info: AddressInfo) => void,
|
||||
): ServerType {
|
||||
const server = createAdaptorServer(options);
|
||||
server.listen(options.port ?? 3000, options.hostname, () => {
|
||||
const info = server.address() as AddressInfo;
|
||||
if (listeningListener !== undefined) listeningListener(info);
|
||||
});
|
||||
return server;
|
||||
}
|
||||
@@ -896,6 +896,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
// ambient-this read never throw. The fs dyn read is the sync-fs story.
|
||||
"fetch.start": "scr_fetch_static",
|
||||
"fetch.responseNew": "scr_fetch_response_new",
|
||||
"fetch.responseLight": "scr_fetch_response_light",
|
||||
"fetch.responseJsonStatic": "scr_fetch_response_json_static",
|
||||
"fetch.input": "scr_fetch_input",
|
||||
"abort.controllerNew": "scr_fetch_abort_controller_new",
|
||||
@@ -1322,6 +1323,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"http.reqPipeClient": "scr_http_req_pipe_client",
|
||||
"http.reqPipeSock": "scr_http_req_pipe_sock",
|
||||
"http.reqResume": "scr_http_req_resume",
|
||||
"http.reqPause": "scr_http_req_pause",
|
||||
"http.reqDestroy": "scr_http_req_destroy",
|
||||
"http.reqSetEncoding": "scr_http_req_set_encoding",
|
||||
"http.resSetHeader": "scr_http_res_set_header",
|
||||
|
||||
@@ -11262,6 +11262,7 @@ const SHIM_INTRINSICS: ReadonlyMap<string, { fn: IrLibFn; args: IrType[]; ret: I
|
||||
["scriptcUnixDgramConnect", { fn: "dgram.unixConnect", args: [F64, STRING], ret: F64 }],
|
||||
["scriptcUnixDgramSend", { fn: "dgram.unixSend", args: [F64, BYTES_U8], ret: F64 }],
|
||||
["scriptcUnixDgramClose", { fn: "dgram.unixClose", args: [F64], ret: F64 }],
|
||||
["scriptcResponseLight", { fn: "fetch.responseLight", args: [DYN], ret: arrayOf(STRING) }],
|
||||
]);
|
||||
|
||||
function lowerShimIntrinsicCall(
|
||||
|
||||
@@ -7581,7 +7581,7 @@ function lowerHttpReqMethodCall(
|
||||
loc,
|
||||
};
|
||||
}
|
||||
if (name === "resume" || name === "destroy") {
|
||||
if (name === "resume" || name === "pause" || name === "destroy") {
|
||||
requireStatementPosition(lowerer, call, `req.${name}()`);
|
||||
if (args.length !== 0) {
|
||||
lowerer.noLowering(
|
||||
@@ -7591,7 +7591,8 @@ function lowerHttpReqMethodCall(
|
||||
);
|
||||
}
|
||||
const receiver = coerceToHandle(lowerer, access.expression, HTTPREQ_T);
|
||||
const fn: IrLibFn = name === "resume" ? "http.reqResume" : "http.reqDestroy";
|
||||
const fn: IrLibFn =
|
||||
name === "resume" ? "http.reqResume" : name === "pause" ? "http.reqPause" : "http.reqDestroy";
|
||||
return { kind: "libCall", fn, args: [receiver], type: VOID, loc };
|
||||
}
|
||||
if (name === "setEncoding") {
|
||||
|
||||
@@ -63,7 +63,7 @@ interface ImplementationShim {
|
||||
* and this file) is the package itself. */
|
||||
mainFile: string;
|
||||
/** The package's declaration entry relative to its directory, when it
|
||||
* is not `index.d.ts`. */
|
||||
* is not `index.d.ts` (a `.d.mts` or `.d.cts` entry included). */
|
||||
typesFile?: string;
|
||||
/** An alternative entry for this installation (relative to the shim
|
||||
* root), or undefined for the version's own entry. */
|
||||
@@ -117,6 +117,15 @@ const IMPLEMENTATION_SHIMS: ReadonlyMap<string, ImplementationShim> = new Map<
|
||||
variants: ["hot-shots/v8-unix-dgram.ts"],
|
||||
},
|
||||
],
|
||||
[
|
||||
"@hono/node-server",
|
||||
{
|
||||
name: "@hono/node-server",
|
||||
mainFile: "dist/index.mjs",
|
||||
typesFile: "dist/index.d.mts",
|
||||
versions: new Map([[2, "hono-node-server/v2.ts"]]),
|
||||
},
|
||||
],
|
||||
[
|
||||
"undici",
|
||||
{
|
||||
@@ -324,7 +333,7 @@ export function isShimmedImport(fromFile: string, specifier: string): boolean {
|
||||
/** The shimmed package whose declaration entry `path` is, with the
|
||||
* package directory: `<pkg>/index.d.ts`, or the entry's own `typesFile`. */
|
||||
function shimOfDeclarationPath(path: string): { pkgDir: string; shim: ImplementationShim } | null {
|
||||
if (!path.endsWith(".d.ts")) return null;
|
||||
if (!/\.d\.[cm]?ts$/.test(path)) return null;
|
||||
if (path.endsWith("/index.d.ts")) {
|
||||
const pkgDir = dirname(path);
|
||||
const shim = IMPLEMENTATION_SHIMS.get(pkgDir.slice(pkgDir.lastIndexOf("/") + 1));
|
||||
@@ -377,7 +386,7 @@ export function isPackageShimFile(fileName: string): boolean {
|
||||
/** True for a name a shipped shim may declare as a bodiless runtime
|
||||
* intrinsic (lower-calls.ts lowers its calls to runtime calls). */
|
||||
export function isShimIntrinsicName(name: string): boolean {
|
||||
return name.startsWith("scriptcUnixDgram");
|
||||
return name.startsWith("scriptcUnixDgram") || name === "scriptcResponseLight";
|
||||
}
|
||||
|
||||
/** Resets per-compile caches. */
|
||||
|
||||
@@ -66,6 +66,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
|
||||
"fetch.start": { argTypes: [STRING, DYN], result: { kind: "promise", inner: DYN } },
|
||||
"fetch.responseNew": { argTypes: [DYN, DYN], result: DYN },
|
||||
"fetch.responseJsonStatic": { argTypes: [DYN, DYN], result: DYN },
|
||||
"fetch.responseLight": { argTypes: [DYN], result: arrayOf(STRING) },
|
||||
"fetch.input": { argTypes: [DYN, DYN], result: { kind: "promise", inner: DYN } },
|
||||
"abort.controllerNew": { argTypes: [], result: DYN },
|
||||
/** EventTarget / Event / CustomEvent construction (scr_event_target.c):
|
||||
@@ -1835,7 +1836,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
|
||||
* connection (+1, the same handle net.connect would give);
|
||||
* sockRemoteAddress answers `string | undefined` (NULL after the fd
|
||||
* closed, Node's destroyed-socket undefined; a dual-stack accept of an
|
||||
* IPv4 peer reads "::ffff:a.b.c.d" like Node). reqResume/reqDestroy and
|
||||
* IPv4 peer reads "::ffff:a.b.c.d" like Node). reqResume/reqPause/reqDestroy and
|
||||
* the req error/close listener slots complete the IncomingMessage
|
||||
* surface portless's client responses use; resDestroy/resOnClose and
|
||||
* resWriteHeadPairs ([k0,v0,k1,v1,...] — the env.pairs helper's flat
|
||||
@@ -1902,6 +1903,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
|
||||
"http.reqPipeClient": { argTypes: [HTTPREQ_T, HTTPCLIENTREQ_T], result: VOID },
|
||||
"http.reqPipeSock": { argTypes: [HTTPREQ_T, NETSOCKET_T], result: VOID },
|
||||
"http.reqResume": { argTypes: [HTTPREQ_T], result: VOID },
|
||||
"http.reqPause": { argTypes: [HTTPREQ_T], result: VOID },
|
||||
"http.reqDestroy": { argTypes: [HTTPREQ_T], result: VOID },
|
||||
"http.reqOnError": { argTypes: [HTTPREQ_T, null, BOOL], result: VOID },
|
||||
"http.reqOnClose": {
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("package shims compile without refusals", () => {
|
||||
expect(entries.map((e) => e.slice(shimRoot.length + 1))).toEqual([
|
||||
"chalk/v5.ts",
|
||||
"fs-extra/index.ts",
|
||||
"hono-node-server/v2.ts",
|
||||
"hot-shots/v8-unix-dgram.ts",
|
||||
"hot-shots/v8.ts",
|
||||
"undici/v5.ts",
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/7287-http-held-request-backpressure.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/packages/compiler/shims/hono-node-server/v2.ts",
|
||||
"<repo>/tests/fixtures/npm/cases/hono-node-server/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -184,6 +184,23 @@ struct SfResponse {
|
||||
int status;
|
||||
bool redirected;
|
||||
bool null_body;
|
||||
/* @hono/node-server's lightweight-response state (scr_fetch_response_light):
|
||||
* whether a constructed Response is still one the package would write
|
||||
* from its constructor arguments, and in which header form. 0: not (it
|
||||
* was materialized by a body/metadata read, or never qualified); 1: no
|
||||
* init headers; 2: a Headers (or pair list) form; 3: a plain record,
|
||||
* whose raw entries light_record keeps in order. */
|
||||
int8_t light;
|
||||
/* Form 2 reached from form 1 by a headers read: the package's Headers
|
||||
* start with its default text content type when the body is not null. */
|
||||
bool light_default_ct;
|
||||
/* The constructor added the text content type (a string body without
|
||||
* one), which the package's lightweight headers never hold. */
|
||||
bool auto_ct;
|
||||
/* The body's byte length (string or Uint8Array), -1 for a null body,
|
||||
* -2 for a stream. */
|
||||
double light_length;
|
||||
ScrArr *light_record;
|
||||
};
|
||||
|
||||
#ifndef SCR_FETCH_SIGNAL_ONLY
|
||||
@@ -2660,6 +2677,7 @@ static void sf_response_release(SfResponse *r) {
|
||||
sf_headers_release(r->headers);
|
||||
scr_str_release(r->url);
|
||||
scr_str_release(r->status_text);
|
||||
scr_arr_release(r->light_record);
|
||||
free(r);
|
||||
}
|
||||
|
||||
@@ -2670,6 +2688,13 @@ static void sf_response_release_v(void *p) {
|
||||
sf_response_release((SfResponse *)p);
|
||||
}
|
||||
|
||||
/* A read the package's lightweight Response answers from its native
|
||||
* response materializes it; status, ok and headers keep it light, and a
|
||||
* headers read turns the header form into a Headers object. */
|
||||
static void sf_response_materialize(SfResponse *r) {
|
||||
r->light = 0;
|
||||
}
|
||||
|
||||
static ScrPromise *sf_response_collect(SfResponse *r, int mode) {
|
||||
if (!r->null_body) return sf_stream_collect(r->body, mode);
|
||||
|
||||
@@ -2708,6 +2733,7 @@ static ScrDyn *sf_response_invoke(void *ptr, ScrDyn *self,
|
||||
ScrDyn *const *args, size_t argc,
|
||||
const char *what) {
|
||||
SfResponse *r = ptr;
|
||||
sf_response_materialize(r);
|
||||
int mode = -1;
|
||||
if (strcmp(method, "json") == 0) mode = SF_COLLECT_JSON;
|
||||
else if (strcmp(method, "text") == 0) mode = SF_COLLECT_TEXT;
|
||||
@@ -2731,6 +2757,19 @@ static ScrDyn *sf_response_get(void *ptr, const char *key, size_t len) {
|
||||
if (sf_name(key, len, "ok")) {
|
||||
return scr_dyn_new_bool(r->status >= 200 && r->status <= 299);
|
||||
}
|
||||
if (sf_name(key, len, "headers")) {
|
||||
if (r->light == 1) {
|
||||
r->light = 2;
|
||||
r->light_default_ct = !r->null_body;
|
||||
} else if (r->light == 3) {
|
||||
r->light = 2;
|
||||
}
|
||||
return scr_dyn_new_handle(r->headers, SCR_DYNH_FETCH_HEADERS);
|
||||
}
|
||||
if (sf_name(key, len, "body") || sf_name(key, len, "bodyUsed") ||
|
||||
sf_name(key, len, "url") || sf_name(key, len, "redirected") ||
|
||||
sf_name(key, len, "statusText"))
|
||||
sf_response_materialize(r);
|
||||
if (sf_name(key, len, "bodyUsed")) {
|
||||
return scr_dyn_new_bool(!r->null_body && r->body->disturbed);
|
||||
}
|
||||
@@ -2746,9 +2785,6 @@ static ScrDyn *sf_response_get(void *ptr, const char *key, size_t len) {
|
||||
if (sf_name(key, len, "statusText")) {
|
||||
return scr_dyn_new_str(r->status_text);
|
||||
}
|
||||
if (sf_name(key, len, "headers")) {
|
||||
return scr_dyn_new_handle(r->headers, SCR_DYNH_FETCH_HEADERS);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
@@ -2760,6 +2796,7 @@ static ScrPromise *sf_response_collect_dyn(ScrDyn *response, int mode) {
|
||||
&scr_dyn_release_v, NULL);
|
||||
}
|
||||
SfResponse *r = response->v.handle.ptr;
|
||||
sf_response_materialize(r);
|
||||
return sf_response_collect(r, mode);
|
||||
}
|
||||
|
||||
@@ -4752,6 +4789,65 @@ static ScrDyn *sf_response_json_serialize(const ScrDyn *value) {
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/* The lightweight header form of a constructor's init headers (see
|
||||
* SfResponse.light): absent headers are form 1 (Response.json's plain
|
||||
* {"content-type": "application/json"} record is form 3), a Headers object
|
||||
* or pair list form 2, and a plain record form 3, whose names and values
|
||||
* *record receives in order (an array value contributes one entry per
|
||||
* element, as writeHead writes it). Response.json with init headers is
|
||||
* form 2 whatever their shape: the package's Response.json passes them on
|
||||
* as a Headers object, adding the JSON content type when they lack one.
|
||||
* 0 for any other init. */
|
||||
static int8_t sf_light_header_form(const ScrDyn *headers, bool json_body,
|
||||
ScrArr **record) {
|
||||
if (!headers || headers->kind == SCR_DYN_UNDEF) {
|
||||
if (!json_body) return 1;
|
||||
*record = scr_arr_new(SCR_ELEM_STR, 2);
|
||||
sf_push_header_text(*record, "content-type", "application/json", 16);
|
||||
return 3;
|
||||
}
|
||||
if (json_body) return 2;
|
||||
if (headers->kind == SCR_DYN_ARR ||
|
||||
(headers->kind == SCR_DYN_HANDLE &&
|
||||
headers->v.handle.tag == SCR_DYNH_FETCH_HEADERS))
|
||||
return 2;
|
||||
ScrDyn *materialized = NULL;
|
||||
if (headers->kind == SCR_DYN_TYPED_REF) {
|
||||
materialized = scr_dyn_typed_ref_materialize(headers);
|
||||
if (!materialized) return 0;
|
||||
headers = materialized;
|
||||
}
|
||||
int8_t form = 0;
|
||||
if (headers->kind == SCR_DYN_OBJ) {
|
||||
ScrArr *out = scr_arr_new(SCR_ELEM_STR, 2 * headers->v.obj.len);
|
||||
form = 3;
|
||||
for (size_t i = 0; i < headers->v.obj.len && form == 3; i++) {
|
||||
const ScrDynEntry *e = &headers->v.obj.entries[i];
|
||||
const ScrDyn *value = e->value;
|
||||
size_t count = value->kind == SCR_DYN_ARR ? value->v.arr.len : 1;
|
||||
for (size_t j = 0; j < count; j++) {
|
||||
const ScrDyn *item = value->kind == SCR_DYN_ARR ? value->v.arr.items[j] : value;
|
||||
if (item->kind != SCR_DYN_STR && item->kind != SCR_DYN_NUM &&
|
||||
item->kind != SCR_DYN_BOOL) {
|
||||
form = 0;
|
||||
break;
|
||||
}
|
||||
ScrStr *text = scr_dyn_string_coerce_js(item);
|
||||
if (!text) {
|
||||
form = 0;
|
||||
break;
|
||||
}
|
||||
scr_arr_push_ref(out, scr_str_new(e->key, e->key_len));
|
||||
scr_arr_push_ref(out, text);
|
||||
}
|
||||
}
|
||||
if (form == 3) *record = out;
|
||||
else scr_arr_release(out);
|
||||
}
|
||||
scr_dyn_release(materialized);
|
||||
return form;
|
||||
}
|
||||
|
||||
static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init,
|
||||
const ScrDyn *json_data,
|
||||
SfJsonSerialize serialize) {
|
||||
@@ -4766,6 +4862,23 @@ static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init,
|
||||
bool text_body = false;
|
||||
int status = 200;
|
||||
bool status_range_error = false;
|
||||
/* The lightweight-response classification (see SfResponse.light): a
|
||||
* Response init qualifies only while it is itself still light. */
|
||||
bool light_ok = !(init && init->kind == SCR_DYN_HANDLE &&
|
||||
init->v.handle.tag == SCR_DYNH_FETCH_RESPONSE &&
|
||||
((SfResponse *)init->v.handle.ptr)->light == 0);
|
||||
if (!json_body && !null_body) {
|
||||
if (body->kind == SCR_DYN_BYTES)
|
||||
light_ok = light_ok && body->v.bytes->elem == SCR_BYTES_U8 &&
|
||||
!body->v.bytes->is_data_view;
|
||||
else if (!(body->kind == SCR_DYN_STR ||
|
||||
(body->kind == SCR_DYN_HANDLE &&
|
||||
body->v.handle.tag == SCR_DYNH_WEB_STREAM)))
|
||||
light_ok = false;
|
||||
}
|
||||
int8_t light_form = 0;
|
||||
ScrArr *light_record = NULL;
|
||||
size_t light_length_bytes = 0;
|
||||
|
||||
/* WebIDL converts constructor arguments from left to right. BodyInit's
|
||||
* string arm therefore runs before ResponseInit's dictionary conversion,
|
||||
@@ -4803,6 +4916,7 @@ static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init,
|
||||
* coercions precede status and statusText. Header syntax validation is a
|
||||
* later response-initialization step, after body extraction. */
|
||||
ScrDyn *init_headers = sf_response_init_get(init, "headers", 7);
|
||||
if (light_ok) light_form = sf_light_header_form(init_headers, json_body, &light_record);
|
||||
converted_headers = sf_headers_convert_init(init_headers);
|
||||
scr_dyn_release(init_headers);
|
||||
if (!converted_headers) goto fail;
|
||||
@@ -4859,6 +4973,7 @@ static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init,
|
||||
scr_str_release(encoding);
|
||||
scr_dyn_release(serialized);
|
||||
stream = sf_stream_new_native();
|
||||
light_length_bytes = body_bytes->len;
|
||||
sf_stream_enqueue_bytes(stream, body_bytes);
|
||||
sf_stream_close(stream);
|
||||
scr_bytes_release(body_bytes);
|
||||
@@ -4877,6 +4992,7 @@ static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init,
|
||||
sf_stream_retain(stream);
|
||||
} else {
|
||||
stream = sf_stream_new_native();
|
||||
light_length_bytes = body_bytes->len;
|
||||
sf_stream_enqueue_bytes(stream, body_bytes);
|
||||
sf_stream_close(stream);
|
||||
scr_bytes_release(body_bytes);
|
||||
@@ -4910,9 +5026,11 @@ static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init,
|
||||
sf_type_error(message);
|
||||
goto fail;
|
||||
}
|
||||
bool auto_ct = false;
|
||||
if (text_body && !sf_pairs_have(header_pairs, "content-type")) {
|
||||
sf_push_header_text(header_pairs, "content-type",
|
||||
"text/plain;charset=UTF-8", 24);
|
||||
auto_ct = true;
|
||||
}
|
||||
if (json_body && !sf_pairs_have(header_pairs, "content-type")) {
|
||||
static const char json_type[16] = "application/json";
|
||||
@@ -4930,6 +5048,14 @@ static ScrDyn *sf_response_construct(ScrDyn *body, ScrDyn *init,
|
||||
response->status = status;
|
||||
response->redirected = false;
|
||||
response->null_body = null_body;
|
||||
response->light = light_form;
|
||||
response->auto_ct = auto_ct;
|
||||
response->light_record = light_record;
|
||||
light_record = NULL;
|
||||
response->light_length =
|
||||
null_body ? -1.0
|
||||
: (!json_body && body->kind == SCR_DYN_HANDLE) ? -2.0
|
||||
: (double)light_length_bytes;
|
||||
ScrDyn *out =
|
||||
scr_dyn_new_handle(response, SCR_DYNH_FETCH_RESPONSE);
|
||||
sf_response_release(response);
|
||||
@@ -4941,9 +5067,43 @@ fail:
|
||||
scr_str_release(status_text);
|
||||
scr_arr_release(header_pairs);
|
||||
scr_arr_release(converted_headers);
|
||||
scr_arr_release(light_record);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* @hono/node-server's built-in implementation reads a Response's
|
||||
* lightweight state to write it as the package does: [form, flags,
|
||||
* byte length, ...raw record name/value pairs], with form as in
|
||||
* SfResponse.light ("0" alone when the response is not light), flags "d"
|
||||
* (the package's Headers hold its default content type) and "a" (the
|
||||
* constructor added the text content type), and the length -1 for a null
|
||||
* body or -2 for a stream. */
|
||||
ScrArr *scr_fetch_response_light(ScrDyn *response) {
|
||||
ScrArr *out = scr_arr_new(SCR_ELEM_STR, 4);
|
||||
if (!response || response->kind != SCR_DYN_HANDLE ||
|
||||
response->v.handle.tag != SCR_DYNH_FETCH_RESPONSE) {
|
||||
scr_arr_push_ref(out, scr_str_new("0", 1));
|
||||
return out;
|
||||
}
|
||||
SfResponse *r = response->v.handle.ptr;
|
||||
char buf[32];
|
||||
int n = snprintf(buf, sizeof buf, "%d", (int)r->light);
|
||||
scr_arr_push_ref(out, scr_str_new(buf, (size_t)n));
|
||||
if (r->light == 0) return out;
|
||||
char flags[3];
|
||||
size_t f = 0;
|
||||
if (r->light_default_ct) flags[f++] = 'd';
|
||||
if (r->auto_ct) flags[f++] = 'a';
|
||||
scr_arr_push_ref(out, scr_str_new(flags, f));
|
||||
n = snprintf(buf, sizeof buf, "%.0f", r->light_length);
|
||||
scr_arr_push_ref(out, scr_str_new(buf, (size_t)n));
|
||||
if (r->light == 3 && r->light_record) {
|
||||
for (size_t i = 0; i < r->light_record->len; i++)
|
||||
scr_arr_push_ref(out, scr_arr_get_ref(r->light_record, (double)i));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init) {
|
||||
return sf_response_construct(body, init, NULL, NULL);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ ScrDyn *scr_fetch_headers_new(ScrDyn *init);
|
||||
ScrPromise *scr_fetch_static(ScrStr *url, ScrDyn *init); /* +1 promise<Response handle> */
|
||||
ScrDyn *scr_fetch_response_new(ScrDyn *body, ScrDyn *init); /* borrowed args; +1 Response handle or NULL pending */
|
||||
ScrDyn *scr_fetch_response_json_static(ScrDyn *data, ScrDyn *init); /* Response.json; borrowed args; +1 Response handle or NULL pending */
|
||||
ScrArr *scr_fetch_response_light(ScrDyn *response); /* @hono/node-server shim: borrowed; +1 string[] */
|
||||
ScrPromise *scr_fetch_response_json(ScrDyn *response); /* +1 promise<dyn> */
|
||||
ScrPromise *scr_fetch_response_text(ScrDyn *response); /* +1 promise<dyn> */
|
||||
ScrPromise *scr_fetch_response_array_buffer(ScrDyn *response); /* +1 promise<dyn> */
|
||||
|
||||
@@ -239,10 +239,11 @@ struct ScrHttpReq {
|
||||
bool close_queued;
|
||||
bool close_emitted; /* settled: err/close listeners dropped */
|
||||
bool join_dup; /* joinDuplicateHeaders: repeated names read joined ", " */
|
||||
/* pause(): delivery holds — arrived body bytes buffer in pend (the
|
||||
* parser keeps consuming; memory is the buffer, a documented bound)
|
||||
* and 'end' defers (end_pending) until resume() drains through the
|
||||
* emit queue, never the resuming stack. */
|
||||
/* pause(): delivery holds — arrived body bytes buffer in pend and
|
||||
* 'end' defers (end_pending) until resume() drains through the emit
|
||||
* queue, never the resuming stack. A held HTTP/1 server request stops
|
||||
* its socket's reads once pend reaches SCR_HTTP_REQ_HOLD_BYTES
|
||||
* (sock_held), as Node's readStop does. */
|
||||
bool paused;
|
||||
bool end_pending;
|
||||
bool drain_queued;
|
||||
@@ -257,6 +258,7 @@ struct ScrHttpReq {
|
||||
bool received; /* the whole body arrived (req.complete) */
|
||||
bool body_seen; /* body bytes arrived */
|
||||
bool destroyed; /* req.destroy()/the teardown ran — req.destroyed */
|
||||
bool sock_held; /* this request stopped its socket's reads */
|
||||
ScrNetLs aborted_ls; /* req.on('aborted') — the h2 compat event */
|
||||
};
|
||||
|
||||
@@ -330,7 +332,10 @@ void scr_http_req_release(ScrHttpReq *r) {
|
||||
scr_http_client_release(r->pipe_client);
|
||||
scr_net_sock_release(r->pipe_sock);
|
||||
free(r->pend);
|
||||
if (r->sock) scr_net_sock_release(r->sock);
|
||||
if (r->sock) {
|
||||
if (r->sock_held) scr_net_sock_hold(r->sock, false);
|
||||
scr_net_sock_release(r->sock);
|
||||
}
|
||||
if (r->h2_stream) scr_http_h2_ops->release(r->h2_stream);
|
||||
#ifdef SCR_RC_AUDIT
|
||||
scr_http_live--;
|
||||
@@ -638,6 +643,22 @@ static bool scr_http_req_holding(const ScrHttpReq *r) {
|
||||
return r->paused || (r->gated && !r->flowing);
|
||||
}
|
||||
|
||||
/* IncomingMessage's readableHighWaterMark (its socket's, 64 KiB): a held
|
||||
* request buffers this many body bytes before its socket stops reading. */
|
||||
#define SCR_HTTP_REQ_HOLD_BYTES 65536u
|
||||
|
||||
/* Stops the socket's reads while an HTTP/1 server request holds a full
|
||||
* buffer, and restarts them once it flows, ends or is dumped, as Node's
|
||||
* readStop when push() returns false. Client responses and h2 compat
|
||||
* requests keep reading. */
|
||||
static void scr_http_req_update_hold(ScrHttpReq *r) {
|
||||
bool hold = r->sock != NULL && r->status == -1 && !r->http2 && !r->ended &&
|
||||
scr_http_req_holding(r) && r->pend_len >= SCR_HTTP_REQ_HOLD_BYTES;
|
||||
if (hold == r->sock_held) return;
|
||||
r->sock_held = hold;
|
||||
scr_net_sock_hold(r->sock, hold);
|
||||
}
|
||||
|
||||
static void scr_http_req_queue_drain(ScrHttpReq *r) {
|
||||
if (r->drain_queued) return;
|
||||
r->drain_queued = true;
|
||||
@@ -650,6 +671,7 @@ static void scr_http_req_flow(ScrHttpReq *r) {
|
||||
r->paused = false;
|
||||
r->flowing = true;
|
||||
if (r->pend_len > 0 || r->end_pending) scr_http_req_queue_drain(r);
|
||||
scr_http_req_update_hold(r);
|
||||
}
|
||||
|
||||
/* A 'data' listener or pipe() starts the flow unless pause() stopped it,
|
||||
@@ -658,9 +680,7 @@ static void scr_http_req_consume(ScrHttpReq *r) {
|
||||
if (r->gated && !r->paused && !r->flowing) scr_http_req_flow(r);
|
||||
}
|
||||
|
||||
/* resume(): releases what pause() or the initial paused state held. This
|
||||
* parser always consumes body bytes from the socket (SEMANTICS.md); held
|
||||
* bytes buffer in memory. */
|
||||
/* resume(): releases what pause() or the initial paused state held. */
|
||||
void scr_http_req_resume(ScrHttpReq *r) {
|
||||
if (!scr_http_req_holding(r)) return;
|
||||
scr_http_req_flow(r);
|
||||
@@ -676,6 +696,7 @@ static void scr_http_req_dump(ScrHttpReq *r) {
|
||||
r->pend_len = r->pend_cap = 0;
|
||||
r->paused = false;
|
||||
r->flowing = true;
|
||||
scr_http_req_update_hold(r);
|
||||
}
|
||||
|
||||
/* pause(): delivery holds until resume() (scr_http_req_deliver buffers;
|
||||
@@ -759,6 +780,7 @@ static void scr_http_req_finish(ScrHttpReq *r, bool fire) {
|
||||
return;
|
||||
}
|
||||
r->ended = true;
|
||||
scr_http_req_update_hold(r);
|
||||
r->end_consumed = fire && (r->data_ls.n > 0 || r->pipe_res || r->pipe_client || r->pipe_sock);
|
||||
if (fire) scr_net_fire0_this(&r->end_ls, r, SCR_DYNH_HTTP_REQ);
|
||||
scr_net_ls_drop(&r->data_ls);
|
||||
@@ -2305,6 +2327,7 @@ static void scr_http_proto_sweep(void) {
|
||||
req->pend_len = req->pend_cap = 0;
|
||||
if (held_len > 0) scr_http_req_deliver(req, held, held_len);
|
||||
free(held);
|
||||
scr_http_req_update_hold(req);
|
||||
if (!scr_exc_pending() && !req->paused && req->end_pending) {
|
||||
req->end_pending = false;
|
||||
scr_http_req_finish(req, true);
|
||||
@@ -2758,8 +2781,8 @@ static void scr_http_req_deliver(ScrHttpReq *r, const char *data, size_t n) {
|
||||
if (!r || n == 0 || r->ended) return;
|
||||
r->body_seen = true;
|
||||
if (scr_http_req_holding(r)) {
|
||||
/* paused: the parser keeps consuming (memory is the buffer — a
|
||||
* documented bound), delivery waits for the drain */
|
||||
/* held: delivery waits for the drain; a full buffer stops the
|
||||
* socket's reads */
|
||||
if (r->pend_len + n > r->pend_cap) {
|
||||
size_t cap = r->pend_cap ? r->pend_cap : 4096;
|
||||
while (cap < r->pend_len + n) cap *= 2;
|
||||
@@ -2769,6 +2792,7 @@ static void scr_http_req_deliver(ScrHttpReq *r, const char *data, size_t n) {
|
||||
}
|
||||
memcpy(r->pend + r->pend_len, data, n);
|
||||
r->pend_len += n;
|
||||
scr_http_req_update_hold(r);
|
||||
return;
|
||||
}
|
||||
bool piped = r->pipe_res != NULL || r->pipe_client != NULL || r->pipe_sock != NULL;
|
||||
|
||||
@@ -556,6 +556,10 @@ struct ScrNetSocket {
|
||||
* unconsumed stream). resume() clears user_paused and sets flowing. */
|
||||
bool user_paused;
|
||||
bool flowing;
|
||||
/* scr_net_sock_hold: a protocol consumer (an HTTP/1 server request
|
||||
* holding unread body bytes) stops reads without touching the user's
|
||||
* pause state */
|
||||
bool hold;
|
||||
/* EOF arrived while the socket buffered for a consumer it does not have
|
||||
* yet: 'end' waits until one drains the buffer (Node's readable state) */
|
||||
bool eof_pending;
|
||||
@@ -978,7 +982,7 @@ static ScrNetSocket *scr_net_sock_new(void) {
|
||||
#define SCR_NET_READ_AHEAD 65536u
|
||||
|
||||
static void scr_net_sock_update_read(ScrNetSocket *s) {
|
||||
bool want = s->fd >= 0 && !s->connecting && !s->rd_eof && !s->user_paused &&
|
||||
bool want = s->fd >= 0 && !s->connecting && !s->rd_eof && !s->user_paused && !s->hold &&
|
||||
(s->data_ls.n > 0 || s->pipe_dst != NULL || s->native_data != NULL ||
|
||||
(s->tunnel != NULL && !s->tunnel->send_only) /* the proxy's CONNECT answer */ ||
|
||||
s->flowing /* resume(): flow (and discard sans listeners) */ ||
|
||||
@@ -1303,7 +1307,7 @@ static void scr_net_sock_read(ScrNetSocket *s) {
|
||||
if (s->tunnel != NULL) return; /* readiness feeds the tunnel pump instead */
|
||||
if (s->tops && !s->t_est) return; /* readiness feeds the handshake pump instead */
|
||||
for (;;) {
|
||||
if (s->fd < 0 || s->user_paused) return;
|
||||
if (s->fd < 0 || s->user_paused || s->hold) return;
|
||||
bool flowing = s->data_ls.n > 0 || s->pipe_dst || s->native_data || s->flowing;
|
||||
/* buffered (peeked/unshifted) bytes first — they precede the
|
||||
* kernel's in the stream; a TLS engine drains them through its bio */
|
||||
@@ -3102,6 +3106,15 @@ ScrNetSocket *scr_net_sock_pause(ScrNetSocket *s) {
|
||||
return scr_net_sock_retain(s);
|
||||
}
|
||||
|
||||
/* Stops (hold) or restarts reads for a protocol consumer that has
|
||||
* buffered as much as it accepts, as Node's readStop when push() returns
|
||||
* false. Separate from pause()/resume(), which stay the user's. */
|
||||
void scr_net_sock_hold(ScrNetSocket *s, bool hold) {
|
||||
if (s->hold == hold) return;
|
||||
s->hold = hold;
|
||||
scr_net_sock_update_read(s);
|
||||
}
|
||||
|
||||
/* socket.resume(): flowing mode — a consumer even with no 'data'
|
||||
* listener (arrived bytes discard, so 'end' can be reached, Node's
|
||||
* resumed-but-unconsumed stream). Buffered bytes deliver from the next
|
||||
@@ -5271,7 +5284,7 @@ static bool scr_net_flags_pending(void) {
|
||||
* plaintext inside a transport, or unshifted bytes in the receive
|
||||
* buffer: the poller can't re-signal them, the sweep must deliver */
|
||||
bool consumers = (s->data_ls.n > 0 || s->pipe_dst || s->native_data || s->flowing) &&
|
||||
!s->user_paused;
|
||||
!s->user_paused && !s->hold;
|
||||
if (consumers && s->fd >= 0) {
|
||||
if (s->tops && s->t_est && s->tops->pending && s->tops->pending(s->tctx)) return true;
|
||||
if (!s->tops && s->rlen > s->rhead) return true;
|
||||
@@ -5377,7 +5390,7 @@ static void scr_net_sweep(void) {
|
||||
while (sock) {
|
||||
ScrNetSocket *next = sock->next; /* may unregister below */
|
||||
scr_net_sock_retain(sock); /* callbacks may drop every other ref */
|
||||
if (sock->fd >= 0 && !sock->user_paused &&
|
||||
if (sock->fd >= 0 && !sock->user_paused && !sock->hold &&
|
||||
(sock->data_ls.n > 0 || sock->pipe_dst || sock->native_data || sock->flowing) &&
|
||||
((sock->tops && sock->t_est && sock->tops->pending && sock->tops->pending(sock->tctx)) ||
|
||||
(!sock->tops && sock->rlen > sock->rhead))) {
|
||||
|
||||
@@ -232,6 +232,7 @@ void scr_net_sock_destroy(ScrNetSocket *s);
|
||||
* counters/flags the compat surface reads. */
|
||||
ScrNetSocket *scr_net_sock_pause(ScrNetSocket *s); /* +1: chaining */
|
||||
ScrNetSocket *scr_net_sock_resume(ScrNetSocket *s); /* +1: chaining */
|
||||
void scr_net_sock_hold(ScrNetSocket *s, bool hold); /* protocol read hold (not pause) */
|
||||
ScrNetSocket *scr_net_sock_set_nodelay(ScrNetSocket *s, bool enable); /* +1: chaining */
|
||||
void scr_net_sock_set_keepalive(ScrNetSocket *s, bool enable, double delay_ms);
|
||||
void scr_net_sock_destroy_soon(ScrNetSocket *s);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// A server request that holds its body (nothing reads it yet, or its
|
||||
// reader paused it) stops reading its socket once its buffer is full, so
|
||||
// a client that keeps uploading is held back by TCP. Each client writes
|
||||
// 64 KiB chunks, one after the previous write's callback, and stops when
|
||||
// the response arrives. An unread body followed by a pipelined request is
|
||||
// discarded once the response finishes, and the second request is served.
|
||||
import * as http from "node:http";
|
||||
import * as net from "node:net";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
function upload(port: number, path: string): Promise<number> {
|
||||
return new Promise<number>((resolve) => {
|
||||
const socket = net.connect(port, "127.0.0.1");
|
||||
const frame = Buffer.concat([Buffer.from("10000\r\n"), Buffer.alloc(65536, 98), Buffer.from("\r\n")]);
|
||||
let sent = 0;
|
||||
let sentBeforeResponse = -1;
|
||||
let response = "";
|
||||
const writeNext = (): void => {
|
||||
if (sentBeforeResponse >= 0 || socket.destroyed || sent >= 256 * MiB) return;
|
||||
sent += 65536;
|
||||
socket.write(frame, () => {
|
||||
writeNext();
|
||||
});
|
||||
};
|
||||
socket.on("connect", () => {
|
||||
socket.write(`POST ${path} HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n`);
|
||||
writeNext();
|
||||
});
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
response += chunk.toString("latin1");
|
||||
if (sentBeforeResponse < 0 && response.includes("\r\n\r\n")) {
|
||||
sentBeforeResponse = sent;
|
||||
socket.destroy();
|
||||
resolve(sentBeforeResponse);
|
||||
}
|
||||
});
|
||||
socket.on("error", () => {});
|
||||
});
|
||||
}
|
||||
|
||||
function pipelined(port: number): Promise<string[]> {
|
||||
return new Promise<string[]>((resolve) => {
|
||||
const socket = net.connect(port, "127.0.0.1");
|
||||
const body = "c".repeat(300000);
|
||||
let response = "";
|
||||
socket.on("connect", () => {
|
||||
socket.write(
|
||||
`POST /unread HTTP/1.1\r\nHost: localhost\r\nContent-Length: ${body.length}\r\n\r\n${body}` +
|
||||
"GET /second HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
});
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
response += chunk.toString("latin1");
|
||||
});
|
||||
socket.on("end", () => {
|
||||
const lines = response.split("\r\n").filter((line) => line.startsWith("HTTP/") || line.startsWith("served"));
|
||||
resolve(lines);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let pausedRequest: http.IncomingMessage | undefined;
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/unconsumed") {
|
||||
setTimeout(() => {
|
||||
res.writeHead(413, { "content-type": "text/plain" });
|
||||
res.end("unconsumed");
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
if (req.url === "/paused") {
|
||||
let seen = 0;
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
seen += chunk.length;
|
||||
if (pausedRequest === undefined) {
|
||||
pausedRequest = req;
|
||||
req.pause();
|
||||
setTimeout(() => {
|
||||
res.writeHead(413, { "content-type": "text/plain" });
|
||||
res.end(`paused after ${seen > 0}`);
|
||||
req.resume();
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end(`served ${req.url}`);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
void (async () => {
|
||||
const before = process.memoryUsage().rss;
|
||||
const unconsumed = await upload(port, "/unconsumed");
|
||||
console.log("unconsumed: client stopped before 32 MiB:", unconsumed < 32 * MiB);
|
||||
const paused = await upload(port, "/paused");
|
||||
console.log("paused: client stopped before 32 MiB:", paused < 32 * MiB);
|
||||
console.log("memory grew under 64 MiB:", process.memoryUsage().rss - before < 64 * MiB);
|
||||
for (const line of await pipelined(port)) console.log(line);
|
||||
server.close(() => console.log("closed"));
|
||||
})();
|
||||
});
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
// @hono/node-server 2.x (compiled from scriptc's built-in implementation)
|
||||
// serving a fetch handler: a raw HTTP client prints each exchange's status
|
||||
// line, wire-order headers (Date dropped) and body, covering buffered and
|
||||
// streamed bodies, header forms, cookies, request bodies, URL handling,
|
||||
// HEAD, empty statuses, handler errors, a client that disconnects during
|
||||
// a streamed body, and request bodies the handler reads, leaves unread or
|
||||
// rejects while the upload is still arriving.
|
||||
import * as http from "node:http";
|
||||
import * as net from "node:net";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { getRequestListener, RequestError, serve } from "@hono/node-server";
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function slowStream(parts: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
let index = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
await delay(5);
|
||||
if (index < parts.length) controller.enqueue(encoder.encode(parts[index++]!));
|
||||
else controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readyStream(parts: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const part of parts) controller.enqueue(encoder.encode(part));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// A stream that produces until it is cancelled (bounded, so a missed cancel
|
||||
// ends the program instead of hanging it).
|
||||
let reportCancel: (reason: string) => void = () => {};
|
||||
const endlessCancelled = new Promise<string>((resolve) => {
|
||||
reportCancel = resolve;
|
||||
});
|
||||
function endlessStream(): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
let pulls = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
await delay(5);
|
||||
if (++pulls > 400) controller.close();
|
||||
else controller.enqueue(encoder.encode("tick\n"));
|
||||
},
|
||||
cancel(reason) {
|
||||
reportCancel(`cancelled with ${String(reason)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handler(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
switch (url.pathname) {
|
||||
case "/echo": {
|
||||
const body = await request.text();
|
||||
const headers = new Headers({ "content-type": "application/json" });
|
||||
headers.set("x-echo", "yes");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
method: request.method,
|
||||
url: request.url.replace(/:\d+\//, ":PORT/"),
|
||||
type: request.headers.get("content-type"),
|
||||
agent: request.headers.get("x-agent"),
|
||||
body,
|
||||
}),
|
||||
{ status: 201, headers },
|
||||
);
|
||||
}
|
||||
case "/headers-object": {
|
||||
const headers = new Headers();
|
||||
headers.set("x-b", "2");
|
||||
headers.set("X-A", "1");
|
||||
headers.set("content-type", "text/html; charset=utf-8");
|
||||
return new Response("<p>hi</p>", { status: 202, headers });
|
||||
}
|
||||
case "/cookies": {
|
||||
const headers = new Headers();
|
||||
headers.append("set-cookie", "a=1; Path=/");
|
||||
headers.set("x-before", "b");
|
||||
headers.append("set-cookie", "b=2; HttpOnly");
|
||||
headers.set("content-type", "text/plain");
|
||||
return new Response("cookies", { headers });
|
||||
}
|
||||
case "/no-content":
|
||||
return new Response(null, { status: 204, headers: new Headers({ "x-empty": "1" }) });
|
||||
case "/declared-length": {
|
||||
const headers = new Headers({ "content-length": "5", "content-type": "text/plain" });
|
||||
return new Response("12345", { headers });
|
||||
}
|
||||
case "/ready-stream": {
|
||||
const headers = new Headers({ "content-type": "text/plain" });
|
||||
return new Response(readyStream(["one ", "two"]), { headers });
|
||||
}
|
||||
case "/slow-stream": {
|
||||
const headers = new Headers({ "content-type": "application/x-ndjson" });
|
||||
return new Response(slowStream(['{"n":1}\n', '{"n":2}\n', '{"n":3}\n']), { headers });
|
||||
}
|
||||
case "/chunked-stream": {
|
||||
const headers = new Headers({ "content-type": "text/event-stream", "transfer-encoding": "chunked" });
|
||||
return new Response(readyStream(["data: a\n\n", "data: b\n\n"]), { headers });
|
||||
}
|
||||
case "/bytes": {
|
||||
const headers = new Headers({ "content-type": "application/octet-stream" });
|
||||
return new Response(new Uint8Array([104, 105, 33]), { headers });
|
||||
}
|
||||
case "/null-body": {
|
||||
return new Response(null, { status: 404, headers: new Headers({ "x-missing": "yes" }) });
|
||||
}
|
||||
case "/reject":
|
||||
throw new Error("handler failed");
|
||||
case "/light-text":
|
||||
return new Response("light");
|
||||
case "/light-record":
|
||||
return new Response("rec", { status: 201, headers: { "X-Custom": "a", "Content-Type": "text/html" } });
|
||||
case "/light-json":
|
||||
return Response.json({ ok: true, n: [1, 2] });
|
||||
case "/light-json-headers":
|
||||
return Response.json({ ok: true }, { status: 201, headers: { "X-Test": "yes" } });
|
||||
case "/light-json-typed":
|
||||
return Response.json([1], { headers: { "Content-Type": "application/vnd.test+json" } });
|
||||
case "/endless":
|
||||
return new Response(endlessStream(), { headers: new Headers({ "content-type": "text/plain" }) });
|
||||
case "/light-touched": {
|
||||
const r = new Response("touched");
|
||||
r.headers.set("x-touched", "1");
|
||||
return r;
|
||||
}
|
||||
case "/light-record-touched": {
|
||||
const r = new Response("rt", { headers: { "X-Rec": "1" } });
|
||||
r.headers.append("x-more", "2");
|
||||
return r;
|
||||
}
|
||||
case "/materialized": {
|
||||
const r = new Response("m", { headers: { "X-M": "1" } });
|
||||
return new Response(r.body, r);
|
||||
}
|
||||
case "/light-stream":
|
||||
return new Response(readyStream(["s1 ", "s2"]));
|
||||
case "/light-null":
|
||||
return new Response(null, { status: 202 });
|
||||
case "/light-length":
|
||||
return new Response("abc", { headers: { "Content-Length": "3" } });
|
||||
case "/light-bytes":
|
||||
return new Response(new Uint8Array([111, 107]));
|
||||
default: {
|
||||
const headers = new Headers({ "content-type": "text/plain; charset=UTF-8" });
|
||||
return new Response(`path ${url.pathname}${url.search}`, { headers });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Exchange {
|
||||
method: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
function exchange(port: number, ex: Exchange): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const headers: Record<string, string> = { ...(ex.headers ?? {}) };
|
||||
if (ex.body !== undefined) headers["content-length"] = String(Buffer.byteLength(ex.body));
|
||||
const req = http.request(
|
||||
{ host: "127.0.0.1", port, method: ex.method, path: ex.path, headers: headers },
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on("end", () => {
|
||||
const lines: string[] = [`HTTP/${res.httpVersion} ${res.statusCode} ${res.statusMessage}`];
|
||||
for (let i = 0; i < res.rawHeaders.length; i += 2) {
|
||||
const name = res.rawHeaders[i]!;
|
||||
if (name.toLowerCase() === "date") continue;
|
||||
lines.push(`${name}: ${res.rawHeaders[i + 1]}`);
|
||||
}
|
||||
resolve(`### ${ex.method} ${ex.path}\n${lines.join("\n")}\n\n${Buffer.concat(chunks).toString("utf8")}\n`);
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (ex.body !== undefined) req.write(ex.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const exchanges: Exchange[] = [
|
||||
{ method: "GET", path: "/plain?q=1" },
|
||||
{ method: "POST", path: "/echo", headers: { "content-type": "text/plain", "x-agent": "probe" }, body: "payload body" },
|
||||
{ method: "PUT", path: "/echo", headers: { "content-type": "application/json" }, body: '{"k":[1,2]}' },
|
||||
{ method: "GET", path: "/headers-object" },
|
||||
{ method: "GET", path: "/cookies" },
|
||||
{ method: "GET", path: "/no-content" },
|
||||
{ method: "GET", path: "/declared-length" },
|
||||
{ method: "GET", path: "/ready-stream" },
|
||||
{ method: "GET", path: "/slow-stream" },
|
||||
{ method: "GET", path: "/chunked-stream" },
|
||||
{ method: "GET", path: "/bytes" },
|
||||
{ method: "GET", path: "/null-body" },
|
||||
{ method: "HEAD", path: "/plain" },
|
||||
{ method: "GET", path: "/light-text" },
|
||||
{ method: "HEAD", path: "/light-text" },
|
||||
{ method: "GET", path: "/light-record" },
|
||||
{ method: "HEAD", path: "/light-record" },
|
||||
{ method: "GET", path: "/light-json" },
|
||||
{ method: "GET", path: "/light-json-headers" },
|
||||
{ method: "GET", path: "/light-json-typed" },
|
||||
{ method: "GET", path: "/light-touched" },
|
||||
{ method: "GET", path: "/light-record-touched" },
|
||||
{ method: "GET", path: "/materialized" },
|
||||
{ method: "GET", path: "/light-stream" },
|
||||
{ method: "GET", path: "/light-null" },
|
||||
{ method: "GET", path: "/light-length" },
|
||||
{ method: "GET", path: "/light-bytes" },
|
||||
{ method: "GET", path: "/reject" },
|
||||
{ method: "GET", path: "/a/../b/./c?x=%20y" },
|
||||
{ method: "GET", path: "/upper", headers: { host: "Example.COM:8080" } },
|
||||
{ method: "GET", path: "/bad-host", headers: { host: "bad host" } },
|
||||
{ method: "get", path: "/lower-method" },
|
||||
];
|
||||
|
||||
/** Reads the first chunk of /endless, then destroys the response: the
|
||||
* server cancels the response's stream. */
|
||||
function disconnectMidStream(port: number): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
const req = http.request({ host: "127.0.0.1", port, method: "GET", path: "/endless" }, (res) => {
|
||||
res.once("data", () => {
|
||||
res.destroy();
|
||||
});
|
||||
});
|
||||
req.end();
|
||||
const timeout = new Promise<string>((done) => {
|
||||
setTimeout(() => done("not cancelled"), 3000);
|
||||
});
|
||||
void Promise.race([endlessCancelled, timeout]).then((outcome) => {
|
||||
resolve(`### disconnect GET /endless\n${outcome}\n`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const server = serve({ fetch: handler, port: 0, hostname: "127.0.0.1" }, (info: AddressInfo) => {
|
||||
console.log("listening", info.family, info.address, typeof info.port);
|
||||
void (async () => {
|
||||
for (const ex of exchanges) console.log(await exchange(info.port, ex));
|
||||
console.log(await disconnectMidStream(info.port));
|
||||
server.close(() => {
|
||||
console.log("closed");
|
||||
startSecond();
|
||||
});
|
||||
})();
|
||||
});
|
||||
|
||||
// getRequestListener with an error handler, on a plain node:http server.
|
||||
const listener = getRequestListener(
|
||||
async (request) => {
|
||||
if (request.url.endsWith("/fail")) throw new RequestError("listener failure");
|
||||
return new Response("ok", { headers: new Headers({ "content-type": "text/plain" }) });
|
||||
},
|
||||
{
|
||||
errorHandler: (err) =>
|
||||
new Response(`handled: ${err instanceof Error ? `${err.name} ${err.message}` : "?"}`, {
|
||||
status: 503,
|
||||
headers: new Headers({ "content-type": "text/plain" }),
|
||||
}),
|
||||
},
|
||||
);
|
||||
function startSecond(): void {
|
||||
const second = http.createServer((req, res) => {
|
||||
void listener(req, res);
|
||||
});
|
||||
second.listen(0, "127.0.0.1", () => {
|
||||
const port = (second.address() as AddressInfo).port;
|
||||
void (async () => {
|
||||
console.log(await exchange(port, { method: "GET", path: "/ok" }));
|
||||
console.log(await exchange(port, { method: "GET", path: "/fail" }));
|
||||
second.close(() => {
|
||||
console.log("second closed");
|
||||
startUploads();
|
||||
});
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// Request bodies stream to the handler. /size reads the whole body. The
|
||||
// other routes leave it unread: /unread answers at once (the package's
|
||||
// cached-response path) and /unread-async through a promise, and /reject
|
||||
// answers 413 after 300 ms while the client keeps uploading, so the server
|
||||
// must stop reading the socket.
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
interface UploadResult {
|
||||
status: string;
|
||||
body: string;
|
||||
sentBeforeResponse: number;
|
||||
}
|
||||
|
||||
/** A chunked POST over a raw socket: 64 KiB chunks, each written after
|
||||
* the previous write's callback, until `limit` bytes are out (then the
|
||||
* last chunk, when `finish` is set) or the response arrives. */
|
||||
function chunkedUpload(port: number, path: string, limit: number, finish: boolean): Promise<UploadResult> {
|
||||
return new Promise<UploadResult>((resolve) => {
|
||||
const socket = net.connect(port, "127.0.0.1");
|
||||
const frame = Buffer.concat([Buffer.from("10000\r\n"), Buffer.alloc(65536, 97), Buffer.from("\r\n")]);
|
||||
let sent = 0;
|
||||
let sentBeforeResponse = -1;
|
||||
let response = "";
|
||||
let settled = false;
|
||||
const settle = (): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const headEnd = response.indexOf("\r\n\r\n");
|
||||
const status = response.slice(0, response.indexOf("\r\n"));
|
||||
resolve({ status, body: headEnd < 0 ? "" : response.slice(headEnd + 4), sentBeforeResponse });
|
||||
socket.destroy();
|
||||
};
|
||||
const writeNext = (): void => {
|
||||
if (sentBeforeResponse >= 0 || socket.destroyed) return;
|
||||
if (sent >= limit) {
|
||||
if (finish) socket.write("0\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
sent += 65536;
|
||||
socket.write(frame, () => {
|
||||
writeNext();
|
||||
});
|
||||
};
|
||||
socket.on("connect", () => {
|
||||
socket.write(`POST ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\nTransfer-Encoding: chunked\r\n\r\n`);
|
||||
writeNext();
|
||||
});
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
response += chunk.toString("latin1");
|
||||
if (sentBeforeResponse < 0 && response.includes("\r\n\r\n")) sentBeforeResponse = sent;
|
||||
const match = /content-length: (\d+)/i.exec(response);
|
||||
const headEnd = response.indexOf("\r\n\r\n");
|
||||
if (match !== null && headEnd >= 0 && response.length - headEnd - 4 >= Number(match[1])) settle();
|
||||
});
|
||||
socket.on("error", () => settle());
|
||||
socket.on("close", () => settle());
|
||||
});
|
||||
}
|
||||
|
||||
function startUploads(): void {
|
||||
const server = serve(
|
||||
{
|
||||
fetch: (request: Request): Response | Promise<Response> => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/size")
|
||||
return request.arrayBuffer().then((body) => new Response(`size ${body.byteLength}`));
|
||||
if (url.pathname === "/reject")
|
||||
return delay(300).then(() => new Response("too large", { status: 413 }));
|
||||
if (url.pathname === "/unread-async") return Promise.resolve(new Response("unread async"));
|
||||
return new Response(`unread ${url.pathname}`);
|
||||
},
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
},
|
||||
(info: AddressInfo) => {
|
||||
void (async () => {
|
||||
const read = await chunkedUpload(info.port, "/size", 3 * MiB, true);
|
||||
console.log(`### chunked POST /size\n${read.status}\n${read.body}\n`);
|
||||
console.log(await exchange(info.port, { method: "POST", path: "/unread", body: "x".repeat(300000) }));
|
||||
console.log(await exchange(info.port, { method: "GET", path: "/after-unread" }));
|
||||
console.log(await exchange(info.port, { method: "POST", path: "/unread-async", body: "y".repeat(300000) }));
|
||||
console.log(await exchange(info.port, { method: "GET", path: "/after-unread-async" }));
|
||||
const before = process.memoryUsage().rss;
|
||||
const rejected = await chunkedUpload(info.port, "/reject", 512 * MiB, false);
|
||||
const grown = process.memoryUsage().rss - before;
|
||||
console.log(`### chunked POST /reject\n${rejected.status}\n${rejected.body}`);
|
||||
console.log("client stopped before 32 MiB:", rejected.sentBeforeResponse >= 0 && rejected.sentBeforeResponse < 32 * MiB);
|
||||
console.log("server memory grew under 64 MiB:", grown < 64 * MiB);
|
||||
server.close(() => console.log("uploads closed"));
|
||||
})();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "type": "module" }
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 - present, Yusuke Wada and Hono contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
# Node.js Adapter for Hono
|
||||
|
||||
This adapter `@hono/node-server` allows you to run your Hono application on Node.js.
|
||||
Initially, Hono wasn't designed for Node.js, but with this adapter, you can now use Hono on Node.js. It utilizes web standard APIs implemented in Node.js.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Hono is 3.1 times faster than Express.
|
||||
|
||||
Express v5:
|
||||
|
||||
```txt
|
||||
$ bombardier -d 10s --fasthttp http://localhost:3000/
|
||||
|
||||
Statistics Avg Stdev Max
|
||||
Reqs/sec 24380.41 1409.74 26604.88
|
||||
Latency 5.12ms 2.95ms 305.21ms
|
||||
HTTP codes:
|
||||
1xx - 0, 2xx - 243915, 3xx - 0, 4xx - 0, 5xx - 0
|
||||
others - 0
|
||||
Throughput: 6.98MB/s
|
||||
```
|
||||
|
||||
Hono + `@hono/node-server`:
|
||||
|
||||
```txt
|
||||
$ bombardier -d 10s --fasthttp http://localhost:3000/
|
||||
|
||||
Statistics Avg Stdev Max
|
||||
Reqs/sec 74428.96 3815.27 88228.32
|
||||
Latency 1.68ms 621.25us 106.20ms
|
||||
HTTP codes:
|
||||
1xx - 0, 2xx - 744378, 3xx - 0, 4xx - 0, 5xx - 0
|
||||
others - 0
|
||||
Throughput: 16.82MB/s
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
It works on Node.js versions greater than 20.x.
|
||||
|
||||
## Installation
|
||||
|
||||
You can install it from the npm registry:
|
||||
|
||||
```sh
|
||||
npm install @hono/node-server
|
||||
pnpm add @hono/node-server
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Just import `@hono/node-server` at the top and write the code as usual.
|
||||
The same code that runs on Cloudflare Workers, Deno, and Bun will work.
|
||||
|
||||
```ts
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono()
|
||||
app.get('/', (c) => c.text('Hono meets Node.js'))
|
||||
|
||||
serve(app, (info) => {
|
||||
console.log(`Listening on http://localhost:${info.port}`) // Listening on http://localhost:3000
|
||||
})
|
||||
```
|
||||
|
||||
## WebSocket
|
||||
|
||||
You can upgrade WebSocket connections with `upgradeWebSocket` from `@hono/node-server`.
|
||||
To enable this, install `ws` (and `@types/ws`) in your project, then create and provide a `WebSocketServer` as shown in the example below.
|
||||
|
||||
```ts
|
||||
import { serve, upgradeWebSocket } from '@hono/node-server'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get(
|
||||
'/ws',
|
||||
upgradeWebSocket(() => ({
|
||||
onMessage(event, ws) {
|
||||
ws.send(event.data)
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
websocket: { server: wss },
|
||||
})
|
||||
```
|
||||
|
||||
For example, run it using `ts-node`. Then an HTTP server will be launched. The default port is `3000`.
|
||||
|
||||
```sh
|
||||
ts-node ./index.ts
|
||||
```
|
||||
|
||||
Open `http://localhost:3000` with your browser.
|
||||
|
||||
## Options
|
||||
|
||||
### `port`
|
||||
|
||||
```ts
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: 8787, // Port number, default is 3000
|
||||
})
|
||||
```
|
||||
|
||||
### `createServer`
|
||||
|
||||
```ts
|
||||
import { createServer } from 'node:https'
|
||||
import fs from 'node:fs'
|
||||
|
||||
//...
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
createServer: createServer,
|
||||
serverOptions: {
|
||||
key: fs.readFileSync('test/fixtures/keys/agent1-key.pem'),
|
||||
cert: fs.readFileSync('test/fixtures/keys/agent1-cert.pem'),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### `overrideGlobalObjects`
|
||||
|
||||
The default value is `true`. The Node.js Adapter rewrites the global Request/Response and uses a lightweight Request/Response to improve performance. If you don't want to do that, set `false`.
|
||||
|
||||
```ts
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
overrideGlobalObjects: false,
|
||||
})
|
||||
```
|
||||
|
||||
### `autoCleanupIncoming`
|
||||
|
||||
The default value is `true`. The Node.js Adapter automatically cleans up (explicitly call `destroy()` method) if application is not finished to consume the incoming request. If you don't want to do that, set `false`.
|
||||
|
||||
If the application accepts connections from arbitrary clients, this cleanup must be done otherwise incomplete requests from clients may cause the application to stop responding. If your application only accepts connections from trusted clients, such as in a reverse proxy environment and there is no process that returns a response without reading the body of the POST request all the way through, you can improve performance by setting it to `false`.
|
||||
|
||||
```ts
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
autoCleanupIncoming: false,
|
||||
})
|
||||
```
|
||||
|
||||
### `websocket`
|
||||
|
||||
provide a websocket server to enable websocket support.
|
||||
|
||||
```ts
|
||||
import { serve, upgradeWebSocket } from '@hono/node-server'
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
// ...
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
websocket: { server: wss },
|
||||
})
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
Most built-in middleware also works with Node.js.
|
||||
Read [the documentation](https://hono.dev/middleware/builtin/basic-auth) and use the Middleware of your liking.
|
||||
|
||||
```ts
|
||||
import { serve } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
import { prettyJSON } from 'hono/pretty-json'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.get('*', prettyJSON())
|
||||
app.get('/', (c) => c.json({ 'Hono meets': 'Node.js' }))
|
||||
|
||||
serve(app)
|
||||
```
|
||||
|
||||
## Serve Static Middleware
|
||||
|
||||
Use Serve Static Middleware that has been created for Node.js.
|
||||
|
||||
```ts
|
||||
import { serveStatic } from '@hono/node-server/serve-static'
|
||||
|
||||
//...
|
||||
|
||||
app.use('/static/*', serveStatic({ root: './' }))
|
||||
```
|
||||
|
||||
If using a relative path, `root` will be relative to the current working directory from which the app was started.
|
||||
|
||||
This can cause confusion when running your application locally.
|
||||
|
||||
Imagine your project structure is:
|
||||
|
||||
```
|
||||
my-hono-project/
|
||||
src/
|
||||
index.ts
|
||||
static/
|
||||
index.html
|
||||
```
|
||||
|
||||
Typically, you would run your app from the project's root directory (`my-hono-project`),
|
||||
so you would need the following code to serve the `static` folder:
|
||||
|
||||
```ts
|
||||
app.use('/static/*', serveStatic({ root: './static' }))
|
||||
```
|
||||
|
||||
Notice that `root` here is not relative to `src/index.ts`, rather to `my-hono-project`.
|
||||
|
||||
Only `GET` and `HEAD` requests are served. Requests with other methods are passed to the next middleware.
|
||||
|
||||
### Options
|
||||
|
||||
#### `allowPercentInPath`
|
||||
|
||||
By default, `serveStatic` skips request paths that still contain `%` after routing. This prevents a second decoding step from serving a file under a different path than the one checked by route middleware. Set `allowPercentInPath: true` to keep the previous behavior when serving files whose names contain percent signs.
|
||||
|
||||
If you restrict access to only some paths under `root` (for example, `/static/admin/*`), enabling this option may let requests bypass those restrictions. Apply access control to the same route pattern as `serveStatic` (for example, `/static/*`), before the static middleware:
|
||||
|
||||
```ts
|
||||
app.use('/static/*', authMiddleware)
|
||||
app.use('/static/*', serveStatic({ root: './static', allowPercentInPath: true }))
|
||||
```
|
||||
|
||||
#### `rewriteRequestPath`
|
||||
|
||||
If you want to serve files in `./.foojs` with the request path `/__foo/*`, you can write like the following.
|
||||
|
||||
```ts
|
||||
app.use(
|
||||
'/__foo/*',
|
||||
serveStatic({
|
||||
root: './.foojs/',
|
||||
rewriteRequestPath: (path: string) => path.replace(/^\/__foo/, ''),
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
#### `onFound`
|
||||
|
||||
You can specify handling when the requested file is found with `onFound`.
|
||||
|
||||
```ts
|
||||
app.use(
|
||||
'/static/*',
|
||||
serveStatic({
|
||||
// ...
|
||||
onFound: (_path, c) => {
|
||||
c.header('Cache-Control', `public, immutable, max-age=31536000`)
|
||||
},
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
#### `onNotFound`
|
||||
|
||||
The `onNotFound` is useful for debugging. You can write a handle for when a file is not found.
|
||||
|
||||
```ts
|
||||
app.use(
|
||||
'/static/*',
|
||||
serveStatic({
|
||||
root: './non-existent-dir',
|
||||
onNotFound: (path, c) => {
|
||||
console.log(`${path} is not found, request to ${c.req.path}`)
|
||||
},
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
#### `precompressed`
|
||||
|
||||
The `precompressed` option checks if files with extensions like `.br` or `.gz` are available and serves them based on the `Accept-Encoding` header. It prioritizes Brotli, then Zstd, and Gzip. If none are available, it serves the original file.
|
||||
|
||||
```ts
|
||||
app.use(
|
||||
'/static/*',
|
||||
serveStatic({
|
||||
precompressed: true,
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
## ConnInfo Helper
|
||||
|
||||
You can use the [ConnInfo Helper](https://hono.dev/docs/helpers/conninfo) by importing `getConnInfo` from `@hono/node-server/conninfo`.
|
||||
|
||||
```ts
|
||||
import { getConnInfo } from '@hono/node-server/conninfo'
|
||||
|
||||
app.get('/', (c) => {
|
||||
const info = getConnInfo(c) // info is `ConnInfo`
|
||||
return c.text(`Your remote address is ${info.remote.address}`)
|
||||
})
|
||||
```
|
||||
|
||||
## Accessing Node.js API
|
||||
|
||||
You can access the Node.js API from `c.env` in Node.js. For example, if you want to specify a type, you can write the following.
|
||||
|
||||
```ts
|
||||
import { serve } from '@hono/node-server'
|
||||
import type { HttpBindings } from '@hono/node-server'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono<{ Bindings: HttpBindings }>()
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.json({
|
||||
remoteAddress: c.env.incoming.socket.remoteAddress,
|
||||
})
|
||||
})
|
||||
|
||||
serve(app)
|
||||
```
|
||||
|
||||
The APIs that you can get from `c.env` are as follows.
|
||||
|
||||
```ts
|
||||
type HttpBindings = {
|
||||
incoming: IncomingMessage
|
||||
outgoing: ServerResponse
|
||||
}
|
||||
|
||||
type Http2Bindings = {
|
||||
incoming: Http2ServerRequest
|
||||
outgoing: Http2ServerResponse
|
||||
}
|
||||
```
|
||||
|
||||
## Early Hints Middleware
|
||||
|
||||
You can send HTTP 103 Early Hints to instruct browsers to preload or preconnect resources before the final response is prepared. The middleware is supported under Node.js bindings (HTTP/1.1 and HTTP/2).
|
||||
|
||||
### Usage
|
||||
|
||||
Import `earlyHints` from `@hono/node-server/early-hints`:
|
||||
|
||||
#### Static links
|
||||
|
||||
```ts
|
||||
import { serve } from '@hono/node-server'
|
||||
import { earlyHints } from '@hono/node-server/early-hints'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.use(
|
||||
earlyHints({
|
||||
link: '</styles.css>; rel=preload; as=style',
|
||||
})
|
||||
)
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.html('<!DOCTYPE html><html><body><h1>Hello Hono!</h1></body></html>')
|
||||
})
|
||||
|
||||
serve(app)
|
||||
```
|
||||
|
||||
#### Dynamic links
|
||||
|
||||
```ts
|
||||
app.use(
|
||||
earlyHints({
|
||||
link: (c) =>
|
||||
c.req.query('theme') === 'dark'
|
||||
? '</dark.css>; rel=preload; as=style'
|
||||
: '</light.css>; rel=preload; as=style',
|
||||
})
|
||||
)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Early Hints are sent only for requests that look like document navigations. If `Sec-Fetch-Mode` or `Sec-Fetch-Dest` is present with a value other than `navigate` or `document`, for example a `fetch()` or XHR call from a browser, a subresource request, or an iframe navigation, the middleware skips the hints and continues to the handler. Requests without these headers, such as `curl` or `fetch()` from a JavaScript runtime, are treated as navigations and do receive Early Hints.
|
||||
|
||||
## Direct response from Node.js API
|
||||
|
||||
You can directly respond to the client from the Node.js API.
|
||||
In that case, the response from Hono should be ignored, so return `RESPONSE_ALREADY_SENT`.
|
||||
|
||||
> [!NOTE]
|
||||
> This feature can be used when migrating existing Node.js applications to Hono, but we recommend using Hono's API for new applications.
|
||||
|
||||
```ts
|
||||
import { serve } from '@hono/node-server'
|
||||
import type { HttpBindings } from '@hono/node-server'
|
||||
import { RESPONSE_ALREADY_SENT } from '@hono/node-server/utils/response'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
const app = new Hono<{ Bindings: HttpBindings }>()
|
||||
|
||||
app.get('/', (c) => {
|
||||
const { outgoing } = c.env
|
||||
outgoing.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||
outgoing.end('Hello World\n')
|
||||
|
||||
return RESPONSE_ALREADY_SENT
|
||||
})
|
||||
|
||||
serve(app)
|
||||
```
|
||||
|
||||
## Listen to a UNIX domain socket
|
||||
|
||||
You can configure the HTTP server to listen to a UNIX domain socket instead of a TCP port.
|
||||
|
||||
```ts
|
||||
import { createAdaptorServer } from '@hono/node-server'
|
||||
|
||||
// ...
|
||||
|
||||
const socketPath = '/tmp/example.sock'
|
||||
|
||||
const server = createAdaptorServer(app)
|
||||
server.listen(socketPath, () => {
|
||||
console.log(`Listening on ${socketPath}`)
|
||||
})
|
||||
```
|
||||
|
||||
## Related projects
|
||||
|
||||
- Hono - <https://hono.dev>
|
||||
- Hono GitHub repository - <https://github.com/honojs/hono>
|
||||
|
||||
## Authors
|
||||
|
||||
- Yusuke Wada <https://github.com/yusukebe>
|
||||
- Taku Amano <https://github.com/usualoma>
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
|
||||
//#region src/conninfo.ts
|
||||
/**
|
||||
* ConnInfo Helper for Node.js
|
||||
* @param c Context
|
||||
* @returns ConnInfo
|
||||
*/
|
||||
const getConnInfo = (c) => {
|
||||
const bindings = c.env.server ? c.env.server : c.env;
|
||||
const address = bindings.incoming.socket.remoteAddress;
|
||||
const port = bindings.incoming.socket.remotePort;
|
||||
const family = bindings.incoming.socket.remoteFamily;
|
||||
return { remote: {
|
||||
address,
|
||||
port,
|
||||
addressType: family === "IPv4" ? "IPv4" : family === "IPv6" ? "IPv6" : void 0
|
||||
} };
|
||||
};
|
||||
|
||||
//#endregion
|
||||
exports.getConnInfo = getConnInfo;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { GetConnInfo } from "hono/conninfo";
|
||||
|
||||
//#region src/conninfo.d.ts
|
||||
/**
|
||||
* ConnInfo Helper for Node.js
|
||||
* @param c Context
|
||||
* @returns ConnInfo
|
||||
*/
|
||||
declare const getConnInfo: GetConnInfo;
|
||||
//#endregion
|
||||
export { getConnInfo };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { GetConnInfo } from "hono/conninfo";
|
||||
|
||||
//#region src/conninfo.d.ts
|
||||
/**
|
||||
* ConnInfo Helper for Node.js
|
||||
* @param c Context
|
||||
* @returns ConnInfo
|
||||
*/
|
||||
declare const getConnInfo: GetConnInfo;
|
||||
//#endregion
|
||||
export { getConnInfo };
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
//#region src/conninfo.ts
|
||||
/**
|
||||
* ConnInfo Helper for Node.js
|
||||
* @param c Context
|
||||
* @returns ConnInfo
|
||||
*/
|
||||
const getConnInfo = (c) => {
|
||||
const bindings = c.env.server ? c.env.server : c.env;
|
||||
const address = bindings.incoming.socket.remoteAddress;
|
||||
const port = bindings.incoming.socket.remotePort;
|
||||
const family = bindings.incoming.socket.remoteFamily;
|
||||
return { remote: {
|
||||
address,
|
||||
port,
|
||||
addressType: family === "IPv4" ? "IPv4" : family === "IPv6" ? "IPv6" : void 0
|
||||
} };
|
||||
};
|
||||
|
||||
//#endregion
|
||||
export { getConnInfo };
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
//#region src/utils/response/constants.ts
|
||||
const X_ALREADY_SENT = "x-hono-already-sent";
|
||||
|
||||
//#endregion
|
||||
export { X_ALREADY_SENT as t };
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
|
||||
//#region src/utils/response/constants.ts
|
||||
const X_ALREADY_SENT = "x-hono-already-sent";
|
||||
|
||||
//#endregion
|
||||
Object.defineProperty(exports, 'X_ALREADY_SENT', {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return X_ALREADY_SENT;
|
||||
}
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
|
||||
//#region src/early-hints.ts
|
||||
/**
|
||||
* Early Hints middleware for Node.js
|
||||
* Automatically sends a 103 Early Hints informational response with the specified Link header(s).
|
||||
*
|
||||
* @param options EarlyHintsOptions
|
||||
* @returns MiddlewareHandler
|
||||
*/
|
||||
const earlyHints = (options) => {
|
||||
let warned = false;
|
||||
return async (c, next) => {
|
||||
const mode = c.req.header("Sec-Fetch-Mode");
|
||||
const dest = c.req.header("Sec-Fetch-Dest");
|
||||
if (mode && mode !== "navigate" || dest && dest !== "document") return next();
|
||||
const env = c.env || {};
|
||||
const outgoing = ("server" in env ? env.server : env)?.outgoing;
|
||||
if (typeof outgoing?.writeEarlyHints !== "function") {
|
||||
if (!warned) {
|
||||
console.warn("Early Hints Middleware is not supported because writeEarlyHints is not defined.");
|
||||
warned = true;
|
||||
}
|
||||
return await next();
|
||||
}
|
||||
if (!outgoing.headersSent) {
|
||||
const link = typeof options.link === "function" ? options.link(c) : options.link;
|
||||
if (link !== void 0 && (Array.isArray(link) ? link.length > 0 : Boolean(link))) outgoing.writeEarlyHints({ link });
|
||||
}
|
||||
await next();
|
||||
};
|
||||
};
|
||||
|
||||
//#endregion
|
||||
exports.earlyHints = earlyHints;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Context, Env, MiddlewareHandler } from "hono";
|
||||
|
||||
//#region src/early-hints.d.ts
|
||||
type EarlyHintsOptions<E extends Env = Env> = {
|
||||
link: string | string[] | ((c: Context<E>) => string | string[] | undefined);
|
||||
};
|
||||
/**
|
||||
* Early Hints middleware for Node.js
|
||||
* Automatically sends a 103 Early Hints informational response with the specified Link header(s).
|
||||
*
|
||||
* @param options EarlyHintsOptions
|
||||
* @returns MiddlewareHandler
|
||||
*/
|
||||
declare const earlyHints: <E extends Env = any>(options: EarlyHintsOptions<E>) => MiddlewareHandler<E>;
|
||||
//#endregion
|
||||
export { EarlyHintsOptions, earlyHints };
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Context, Env, MiddlewareHandler } from "hono";
|
||||
|
||||
//#region src/early-hints.d.ts
|
||||
type EarlyHintsOptions<E extends Env = Env> = {
|
||||
link: string | string[] | ((c: Context<E>) => string | string[] | undefined);
|
||||
};
|
||||
/**
|
||||
* Early Hints middleware for Node.js
|
||||
* Automatically sends a 103 Early Hints informational response with the specified Link header(s).
|
||||
*
|
||||
* @param options EarlyHintsOptions
|
||||
* @returns MiddlewareHandler
|
||||
*/
|
||||
declare const earlyHints: <E extends Env = any>(options: EarlyHintsOptions<E>) => MiddlewareHandler<E>;
|
||||
//#endregion
|
||||
export { EarlyHintsOptions, earlyHints };
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
//#region src/early-hints.ts
|
||||
/**
|
||||
* Early Hints middleware for Node.js
|
||||
* Automatically sends a 103 Early Hints informational response with the specified Link header(s).
|
||||
*
|
||||
* @param options EarlyHintsOptions
|
||||
* @returns MiddlewareHandler
|
||||
*/
|
||||
const earlyHints = (options) => {
|
||||
let warned = false;
|
||||
return async (c, next) => {
|
||||
const mode = c.req.header("Sec-Fetch-Mode");
|
||||
const dest = c.req.header("Sec-Fetch-Dest");
|
||||
if (mode && mode !== "navigate" || dest && dest !== "document") return next();
|
||||
const env = c.env || {};
|
||||
const outgoing = ("server" in env ? env.server : env)?.outgoing;
|
||||
if (typeof outgoing?.writeEarlyHints !== "function") {
|
||||
if (!warned) {
|
||||
console.warn("Early Hints Middleware is not supported because writeEarlyHints is not defined.");
|
||||
warned = true;
|
||||
}
|
||||
return await next();
|
||||
}
|
||||
if (!outgoing.headersSent) {
|
||||
const link = typeof options.link === "function" ? options.link(c) : options.link;
|
||||
if (link !== void 0 && (Array.isArray(link) ? link.length > 0 : Boolean(link))) outgoing.writeEarlyHints({ link });
|
||||
}
|
||||
await next();
|
||||
};
|
||||
};
|
||||
|
||||
//#endregion
|
||||
export { earlyHints };
|
||||
+1340
File diff suppressed because it is too large
Load Diff
+101
@@ -0,0 +1,101 @@
|
||||
import { AddressInfo } from "node:net";
|
||||
import { IncomingMessage, Server, ServerOptions, ServerResponse, createServer } from "node:http";
|
||||
import { Http2SecureServer, Http2Server, Http2ServerRequest, Http2ServerResponse, SecureServerOptions, ServerOptions as ServerOptions$1, createSecureServer, createServer as createServer$1 } from "node:http2";
|
||||
import { ServerOptions as ServerOptions$2, createServer as createServer$2 } from "node:https";
|
||||
import { Duplex } from "node:stream";
|
||||
import { UpgradeWebSocket } from "hono/ws";
|
||||
|
||||
//#region src/websocket-types.d.ts
|
||||
type WSReadyState = 0 | 1 | 2 | 3;
|
||||
type WebSocketData = string | ArrayBuffer | Uint8Array | readonly Uint8Array[];
|
||||
type WebSocketSendOptions = {
|
||||
compress?: boolean;
|
||||
};
|
||||
interface WebSocketLike {
|
||||
protocol: string;
|
||||
readyState: WSReadyState;
|
||||
close(code?: number, reason?: string): void;
|
||||
send(data: string | ArrayBuffer | ArrayBufferView, options?: WebSocketSendOptions): void;
|
||||
on(event: 'message', listener: (data: WebSocketData, isBinary: boolean) => void): this;
|
||||
on(event: 'close', listener: (code: number, reason: Uint8Array) => void): this;
|
||||
on(event: 'error', listener: (error: unknown) => void): this;
|
||||
off(event: 'message', listener: (data: WebSocketData, isBinary: boolean) => void): this;
|
||||
}
|
||||
interface WebSocketServerLike {
|
||||
options: {
|
||||
noServer?: boolean;
|
||||
};
|
||||
on(event: 'connection', listener: (ws: WebSocketLike, request: IncomingMessage) => void): this;
|
||||
on(event: 'headers', listener: (headers: string[]) => void): this;
|
||||
off(event: 'headers', listener: (headers: string[]) => void): this;
|
||||
emit(event: 'connection', ws: WebSocketLike, request: IncomingMessage): boolean;
|
||||
handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer, callback: (ws: WebSocketLike) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
//#endregion
|
||||
//#region src/types.d.ts
|
||||
type HttpBindings = {
|
||||
incoming: IncomingMessage;
|
||||
outgoing: ServerResponse;
|
||||
};
|
||||
type Http2Bindings = {
|
||||
incoming: Http2ServerRequest;
|
||||
outgoing: Http2ServerResponse;
|
||||
};
|
||||
type FetchCallback = (request: Request, env: HttpBindings | Http2Bindings) => Promise<unknown> | unknown;
|
||||
type ServerType = Server | Http2Server | Http2SecureServer;
|
||||
type createHttpOptions = {
|
||||
serverOptions?: ServerOptions;
|
||||
createServer?: typeof createServer;
|
||||
};
|
||||
type createHttpsOptions = {
|
||||
serverOptions?: ServerOptions$2;
|
||||
createServer?: typeof createServer$2;
|
||||
};
|
||||
type createHttp2Options = {
|
||||
serverOptions?: ServerOptions$1;
|
||||
createServer?: typeof createServer$1;
|
||||
};
|
||||
type createSecureHttp2Options = {
|
||||
serverOptions?: SecureServerOptions;
|
||||
createServer?: typeof createSecureServer;
|
||||
};
|
||||
type ServerOptions$3 = createHttpOptions | createHttpsOptions | createHttp2Options | createSecureHttp2Options;
|
||||
type Options = {
|
||||
fetch: FetchCallback;
|
||||
overrideGlobalObjects?: boolean;
|
||||
autoCleanupIncoming?: boolean;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
websocket?: {
|
||||
server: WebSocketServerLike;
|
||||
};
|
||||
} & ServerOptions$3;
|
||||
type CustomErrorHandler = (err: unknown) => void | Response | Promise<void | Response>;
|
||||
//#endregion
|
||||
//#region src/server.d.ts
|
||||
declare const createAdaptorServer: (options: Options) => ServerType;
|
||||
declare const serve: (options: Options, listeningListener?: (info: AddressInfo) => void) => ServerType;
|
||||
//#endregion
|
||||
//#region src/websocket.d.ts
|
||||
type UpgradeWebSocketOptions = {
|
||||
onError: (err: unknown) => void;
|
||||
};
|
||||
declare const upgradeWebSocket: UpgradeWebSocket<WebSocketLike, UpgradeWebSocketOptions>;
|
||||
//#endregion
|
||||
//#region src/listener.d.ts
|
||||
declare const getRequestListener: (fetchCallback: FetchCallback, options?: {
|
||||
hostname?: string;
|
||||
errorHandler?: CustomErrorHandler;
|
||||
overrideGlobalObjects?: boolean;
|
||||
autoCleanupIncoming?: boolean;
|
||||
}) => (incoming: IncomingMessage | Http2ServerRequest, outgoing: ServerResponse | Http2ServerResponse) => Promise<void>;
|
||||
//#endregion
|
||||
//#region src/error.d.ts
|
||||
declare class RequestError extends Error {
|
||||
constructor(message: string, options?: {
|
||||
cause?: unknown;
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
export { type Http2Bindings, type HttpBindings, RequestError, type ServerType, type WebSocketData, type WebSocketLike, type WebSocketServerLike, createAdaptorServer, getRequestListener, serve, upgradeWebSocket };
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { IncomingMessage, Server, ServerOptions, ServerResponse, createServer } from "node:http";
|
||||
import { Http2SecureServer, Http2Server, Http2ServerRequest, Http2ServerResponse, SecureServerOptions, ServerOptions as ServerOptions$1, createSecureServer, createServer as createServer$1 } from "node:http2";
|
||||
import { Duplex } from "node:stream";
|
||||
import { UpgradeWebSocket } from "hono/ws";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { ServerOptions as ServerOptions$2, createServer as createServer$2 } from "node:https";
|
||||
|
||||
//#region src/websocket-types.d.ts
|
||||
type WSReadyState = 0 | 1 | 2 | 3;
|
||||
type WebSocketData = string | ArrayBuffer | Uint8Array | readonly Uint8Array[];
|
||||
type WebSocketSendOptions = {
|
||||
compress?: boolean;
|
||||
};
|
||||
interface WebSocketLike {
|
||||
protocol: string;
|
||||
readyState: WSReadyState;
|
||||
close(code?: number, reason?: string): void;
|
||||
send(data: string | ArrayBuffer | ArrayBufferView, options?: WebSocketSendOptions): void;
|
||||
on(event: 'message', listener: (data: WebSocketData, isBinary: boolean) => void): this;
|
||||
on(event: 'close', listener: (code: number, reason: Uint8Array) => void): this;
|
||||
on(event: 'error', listener: (error: unknown) => void): this;
|
||||
off(event: 'message', listener: (data: WebSocketData, isBinary: boolean) => void): this;
|
||||
}
|
||||
interface WebSocketServerLike {
|
||||
options: {
|
||||
noServer?: boolean;
|
||||
};
|
||||
on(event: 'connection', listener: (ws: WebSocketLike, request: IncomingMessage) => void): this;
|
||||
on(event: 'headers', listener: (headers: string[]) => void): this;
|
||||
off(event: 'headers', listener: (headers: string[]) => void): this;
|
||||
emit(event: 'connection', ws: WebSocketLike, request: IncomingMessage): boolean;
|
||||
handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer, callback: (ws: WebSocketLike) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
//#endregion
|
||||
//#region src/types.d.ts
|
||||
type HttpBindings = {
|
||||
incoming: IncomingMessage;
|
||||
outgoing: ServerResponse;
|
||||
};
|
||||
type Http2Bindings = {
|
||||
incoming: Http2ServerRequest;
|
||||
outgoing: Http2ServerResponse;
|
||||
};
|
||||
type FetchCallback = (request: Request, env: HttpBindings | Http2Bindings) => Promise<unknown> | unknown;
|
||||
type ServerType = Server | Http2Server | Http2SecureServer;
|
||||
type createHttpOptions = {
|
||||
serverOptions?: ServerOptions;
|
||||
createServer?: typeof createServer;
|
||||
};
|
||||
type createHttpsOptions = {
|
||||
serverOptions?: ServerOptions$2;
|
||||
createServer?: typeof createServer$2;
|
||||
};
|
||||
type createHttp2Options = {
|
||||
serverOptions?: ServerOptions$1;
|
||||
createServer?: typeof createServer$1;
|
||||
};
|
||||
type createSecureHttp2Options = {
|
||||
serverOptions?: SecureServerOptions;
|
||||
createServer?: typeof createSecureServer;
|
||||
};
|
||||
type ServerOptions$3 = createHttpOptions | createHttpsOptions | createHttp2Options | createSecureHttp2Options;
|
||||
type Options = {
|
||||
fetch: FetchCallback;
|
||||
overrideGlobalObjects?: boolean;
|
||||
autoCleanupIncoming?: boolean;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
websocket?: {
|
||||
server: WebSocketServerLike;
|
||||
};
|
||||
} & ServerOptions$3;
|
||||
type CustomErrorHandler = (err: unknown) => void | Response | Promise<void | Response>;
|
||||
//#endregion
|
||||
//#region src/server.d.ts
|
||||
declare const createAdaptorServer: (options: Options) => ServerType;
|
||||
declare const serve: (options: Options, listeningListener?: (info: AddressInfo) => void) => ServerType;
|
||||
//#endregion
|
||||
//#region src/websocket.d.ts
|
||||
type UpgradeWebSocketOptions = {
|
||||
onError: (err: unknown) => void;
|
||||
};
|
||||
declare const upgradeWebSocket: UpgradeWebSocket<WebSocketLike, UpgradeWebSocketOptions>;
|
||||
//#endregion
|
||||
//#region src/listener.d.ts
|
||||
declare const getRequestListener: (fetchCallback: FetchCallback, options?: {
|
||||
hostname?: string;
|
||||
errorHandler?: CustomErrorHandler;
|
||||
overrideGlobalObjects?: boolean;
|
||||
autoCleanupIncoming?: boolean;
|
||||
}) => (incoming: IncomingMessage | Http2ServerRequest, outgoing: ServerResponse | Http2ServerResponse) => Promise<void>;
|
||||
//#endregion
|
||||
//#region src/error.d.ts
|
||||
declare class RequestError extends Error {
|
||||
constructor(message: string, options?: {
|
||||
cause?: unknown;
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
export { type Http2Bindings, type HttpBindings, RequestError, type ServerType, type WebSocketData, type WebSocketLike, type WebSocketServerLike, createAdaptorServer, getRequestListener, serve, upgradeWebSocket };
|
||||
+1335
File diff suppressed because it is too large
Load Diff
+153
@@ -0,0 +1,153 @@
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
const require_utils_stream = require('./utils/stream.cjs');
|
||||
let hono_utils_mime = require("hono/utils/mime");
|
||||
let node_fs = require("node:fs");
|
||||
let node_path = require("node:path");
|
||||
|
||||
//#region src/serve-static.ts
|
||||
const COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i;
|
||||
const ENCODINGS = {
|
||||
br: ".br",
|
||||
zstd: ".zst",
|
||||
gzip: ".gz"
|
||||
};
|
||||
const ENCODINGS_ORDERED_KEYS = Object.keys(ENCODINGS);
|
||||
const getStats = (path) => {
|
||||
let stats;
|
||||
try {
|
||||
stats = (0, node_fs.statSync)(path);
|
||||
} catch {}
|
||||
return stats;
|
||||
};
|
||||
const BYTE_RANGE_PATTERN = /^(?:bytes=)?(?!-$)(\d*)-(\d*)$/;
|
||||
const parseByteRange = (range) => {
|
||||
const match = range.match(BYTE_RANGE_PATTERN);
|
||||
if (!match) return;
|
||||
const [, start, end] = match;
|
||||
if (start === "") return {
|
||||
type: "suffix",
|
||||
length: Number(end)
|
||||
};
|
||||
if (end === "") return {
|
||||
type: "open-ended",
|
||||
start: Number(start)
|
||||
};
|
||||
return {
|
||||
type: "bounded",
|
||||
start: Number(start),
|
||||
end: Number(end)
|
||||
};
|
||||
};
|
||||
const resolveByteRange = (spec, size) => {
|
||||
if (size === 0) return;
|
||||
if (spec.type === "suffix") {
|
||||
if (spec.length === 0) return;
|
||||
return {
|
||||
start: Math.max(size - spec.length, 0),
|
||||
end: size - 1
|
||||
};
|
||||
}
|
||||
const end = spec.type === "bounded" ? Math.min(spec.end, size - 1) : size - 1;
|
||||
if (spec.start >= size || spec.start > end) return;
|
||||
return {
|
||||
start: spec.start,
|
||||
end
|
||||
};
|
||||
};
|
||||
const tryDecode = (str, decoder) => {
|
||||
try {
|
||||
return decoder(str);
|
||||
} catch {
|
||||
return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match) => {
|
||||
try {
|
||||
return decoder(match);
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const tryDecodeURI = (str) => tryDecode(str, decodeURI);
|
||||
const serveStatic = (options = { root: "" }) => {
|
||||
const root = options.root || "";
|
||||
const optionPath = options.path;
|
||||
if (root !== "" && !(0, node_fs.existsSync)(root)) console.error(`serveStatic: root path '${root}' is not found, are you sure it's correct?`);
|
||||
return async (c, next) => {
|
||||
if (c.finalized) return next();
|
||||
if (c.req.method !== "GET" && c.req.method !== "HEAD") return next();
|
||||
let filename;
|
||||
if (optionPath) filename = optionPath;
|
||||
else try {
|
||||
if (!options.allowPercentInPath && c.req.path.includes("%")) throw new Error();
|
||||
filename = tryDecodeURI(c.req.path);
|
||||
if (/(?:^|[\/\\])\.{1,2}(?:$|[\/\\])|[\/\\]{2,}|\\/.test(filename)) throw new Error();
|
||||
} catch {
|
||||
await options.onNotFound?.(c.req.path, c);
|
||||
return next();
|
||||
}
|
||||
let path = (0, node_path.join)(root, !optionPath && options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename);
|
||||
let stats = getStats(path);
|
||||
if (stats && stats.isDirectory()) {
|
||||
const indexFile = options.index ?? "index.html";
|
||||
path = (0, node_path.join)(path, indexFile);
|
||||
stats = getStats(path);
|
||||
}
|
||||
if (!stats) {
|
||||
await options.onNotFound?.(path, c);
|
||||
return next();
|
||||
}
|
||||
const mimeType = (0, hono_utils_mime.getMimeType)(path);
|
||||
c.header("Content-Type", mimeType || "application/octet-stream");
|
||||
if (options.precompressed && (!mimeType || mimeType === "application/octet-stream" || COMPRESSIBLE_CONTENT_TYPE_REGEX.test(mimeType))) {
|
||||
const acceptEncodingSet = new Set(c.req.header("Accept-Encoding")?.split(",").map((encoding) => encoding.trim()));
|
||||
for (const encoding of ENCODINGS_ORDERED_KEYS) {
|
||||
if (!acceptEncodingSet.has(encoding)) continue;
|
||||
const precompressedStats = getStats(path + ENCODINGS[encoding]);
|
||||
if (precompressedStats) {
|
||||
c.header("Content-Encoding", encoding);
|
||||
c.header("Vary", "Accept-Encoding", { append: true });
|
||||
stats = precompressedStats;
|
||||
path = path + ENCODINGS[encoding];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let result;
|
||||
const size = stats.size;
|
||||
const range = c.req.header("range") || "";
|
||||
c.header("Last-Modified", stats.mtime.toUTCString());
|
||||
if (c.req.method === "HEAD") {
|
||||
c.header("Content-Length", size.toString());
|
||||
c.status(200);
|
||||
result = c.body(null);
|
||||
} else if (!range) {
|
||||
c.header("Content-Length", size.toString());
|
||||
result = c.body(require_utils_stream.createStreamBody((0, node_fs.createReadStream)(path)), 200);
|
||||
} else {
|
||||
c.header("Accept-Ranges", "bytes");
|
||||
const resolvedRange = resolveByteRange(parseByteRange(range) ?? {
|
||||
type: "open-ended",
|
||||
start: 0
|
||||
}, size);
|
||||
if (!resolvedRange) {
|
||||
c.header("Content-Range", `bytes */${size}`);
|
||||
result = c.body(null, 416);
|
||||
} else {
|
||||
const { start, end } = resolvedRange;
|
||||
const chunkSize = end - start + 1;
|
||||
const stream = (0, node_fs.createReadStream)(path, {
|
||||
start,
|
||||
end
|
||||
});
|
||||
c.header("Content-Length", chunkSize.toString());
|
||||
c.header("Content-Range", `bytes ${start}-${end}/${size}`);
|
||||
result = c.body(require_utils_stream.createStreamBody(stream), 206);
|
||||
}
|
||||
}
|
||||
await options.onFound?.(path, c);
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
//#endregion
|
||||
exports.serveStatic = serveStatic;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Context, Env, MiddlewareHandler } from "hono";
|
||||
|
||||
//#region src/serve-static.d.ts
|
||||
type ServeStaticOptions<E extends Env = Env> = {
|
||||
/**
|
||||
* Root path. Relative path is based on current working directory from which the app was started.
|
||||
*/
|
||||
root?: string;
|
||||
path?: string;
|
||||
index?: string;
|
||||
precompressed?: boolean; /** Allow percent signs in the routed request path. Defaults to false. */
|
||||
allowPercentInPath?: boolean;
|
||||
rewriteRequestPath?: (path: string, c: Context<E>) => string;
|
||||
onFound?: (path: string, c: Context<E>) => void | Promise<void>;
|
||||
onNotFound?: (path: string, c: Context<E>) => void | Promise<void>;
|
||||
};
|
||||
declare const serveStatic: <E extends Env = any>(options?: ServeStaticOptions<E>) => MiddlewareHandler<E>;
|
||||
//#endregion
|
||||
export { ServeStaticOptions, serveStatic };
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { Context, Env, MiddlewareHandler } from "hono";
|
||||
|
||||
//#region src/serve-static.d.ts
|
||||
type ServeStaticOptions<E extends Env = Env> = {
|
||||
/**
|
||||
* Root path. Relative path is based on current working directory from which the app was started.
|
||||
*/
|
||||
root?: string;
|
||||
path?: string;
|
||||
index?: string;
|
||||
precompressed?: boolean; /** Allow percent signs in the routed request path. Defaults to false. */
|
||||
allowPercentInPath?: boolean;
|
||||
rewriteRequestPath?: (path: string, c: Context<E>) => string;
|
||||
onFound?: (path: string, c: Context<E>) => void | Promise<void>;
|
||||
onNotFound?: (path: string, c: Context<E>) => void | Promise<void>;
|
||||
};
|
||||
declare const serveStatic: <E extends Env = any>(options?: ServeStaticOptions<E>) => MiddlewareHandler<E>;
|
||||
//#endregion
|
||||
export { ServeStaticOptions, serveStatic };
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { createStreamBody } from "./utils/stream.mjs";
|
||||
import { getMimeType } from "hono/utils/mime";
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
//#region src/serve-static.ts
|
||||
const COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i;
|
||||
const ENCODINGS = {
|
||||
br: ".br",
|
||||
zstd: ".zst",
|
||||
gzip: ".gz"
|
||||
};
|
||||
const ENCODINGS_ORDERED_KEYS = Object.keys(ENCODINGS);
|
||||
const getStats = (path) => {
|
||||
let stats;
|
||||
try {
|
||||
stats = statSync(path);
|
||||
} catch {}
|
||||
return stats;
|
||||
};
|
||||
const BYTE_RANGE_PATTERN = /^(?:bytes=)?(?!-$)(\d*)-(\d*)$/;
|
||||
const parseByteRange = (range) => {
|
||||
const match = range.match(BYTE_RANGE_PATTERN);
|
||||
if (!match) return;
|
||||
const [, start, end] = match;
|
||||
if (start === "") return {
|
||||
type: "suffix",
|
||||
length: Number(end)
|
||||
};
|
||||
if (end === "") return {
|
||||
type: "open-ended",
|
||||
start: Number(start)
|
||||
};
|
||||
return {
|
||||
type: "bounded",
|
||||
start: Number(start),
|
||||
end: Number(end)
|
||||
};
|
||||
};
|
||||
const resolveByteRange = (spec, size) => {
|
||||
if (size === 0) return;
|
||||
if (spec.type === "suffix") {
|
||||
if (spec.length === 0) return;
|
||||
return {
|
||||
start: Math.max(size - spec.length, 0),
|
||||
end: size - 1
|
||||
};
|
||||
}
|
||||
const end = spec.type === "bounded" ? Math.min(spec.end, size - 1) : size - 1;
|
||||
if (spec.start >= size || spec.start > end) return;
|
||||
return {
|
||||
start: spec.start,
|
||||
end
|
||||
};
|
||||
};
|
||||
const tryDecode = (str, decoder) => {
|
||||
try {
|
||||
return decoder(str);
|
||||
} catch {
|
||||
return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match) => {
|
||||
try {
|
||||
return decoder(match);
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
const tryDecodeURI = (str) => tryDecode(str, decodeURI);
|
||||
const serveStatic = (options = { root: "" }) => {
|
||||
const root = options.root || "";
|
||||
const optionPath = options.path;
|
||||
if (root !== "" && !existsSync(root)) console.error(`serveStatic: root path '${root}' is not found, are you sure it's correct?`);
|
||||
return async (c, next) => {
|
||||
if (c.finalized) return next();
|
||||
if (c.req.method !== "GET" && c.req.method !== "HEAD") return next();
|
||||
let filename;
|
||||
if (optionPath) filename = optionPath;
|
||||
else try {
|
||||
if (!options.allowPercentInPath && c.req.path.includes("%")) throw new Error();
|
||||
filename = tryDecodeURI(c.req.path);
|
||||
if (/(?:^|[\/\\])\.{1,2}(?:$|[\/\\])|[\/\\]{2,}|\\/.test(filename)) throw new Error();
|
||||
} catch {
|
||||
await options.onNotFound?.(c.req.path, c);
|
||||
return next();
|
||||
}
|
||||
let path = join(root, !optionPath && options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename);
|
||||
let stats = getStats(path);
|
||||
if (stats && stats.isDirectory()) {
|
||||
const indexFile = options.index ?? "index.html";
|
||||
path = join(path, indexFile);
|
||||
stats = getStats(path);
|
||||
}
|
||||
if (!stats) {
|
||||
await options.onNotFound?.(path, c);
|
||||
return next();
|
||||
}
|
||||
const mimeType = getMimeType(path);
|
||||
c.header("Content-Type", mimeType || "application/octet-stream");
|
||||
if (options.precompressed && (!mimeType || mimeType === "application/octet-stream" || COMPRESSIBLE_CONTENT_TYPE_REGEX.test(mimeType))) {
|
||||
const acceptEncodingSet = new Set(c.req.header("Accept-Encoding")?.split(",").map((encoding) => encoding.trim()));
|
||||
for (const encoding of ENCODINGS_ORDERED_KEYS) {
|
||||
if (!acceptEncodingSet.has(encoding)) continue;
|
||||
const precompressedStats = getStats(path + ENCODINGS[encoding]);
|
||||
if (precompressedStats) {
|
||||
c.header("Content-Encoding", encoding);
|
||||
c.header("Vary", "Accept-Encoding", { append: true });
|
||||
stats = precompressedStats;
|
||||
path = path + ENCODINGS[encoding];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let result;
|
||||
const size = stats.size;
|
||||
const range = c.req.header("range") || "";
|
||||
c.header("Last-Modified", stats.mtime.toUTCString());
|
||||
if (c.req.method === "HEAD") {
|
||||
c.header("Content-Length", size.toString());
|
||||
c.status(200);
|
||||
result = c.body(null);
|
||||
} else if (!range) {
|
||||
c.header("Content-Length", size.toString());
|
||||
result = c.body(createStreamBody(createReadStream(path)), 200);
|
||||
} else {
|
||||
c.header("Accept-Ranges", "bytes");
|
||||
const resolvedRange = resolveByteRange(parseByteRange(range) ?? {
|
||||
type: "open-ended",
|
||||
start: 0
|
||||
}, size);
|
||||
if (!resolvedRange) {
|
||||
c.header("Content-Range", `bytes */${size}`);
|
||||
result = c.body(null, 416);
|
||||
} else {
|
||||
const { start, end } = resolvedRange;
|
||||
const chunkSize = end - start + 1;
|
||||
const stream = createReadStream(path, {
|
||||
start,
|
||||
end
|
||||
});
|
||||
c.header("Content-Length", chunkSize.toString());
|
||||
c.header("Content-Range", `bytes ${start}-${end}/${size}`);
|
||||
result = c.body(createStreamBody(stream), 206);
|
||||
}
|
||||
}
|
||||
await options.onFound?.(path, c);
|
||||
return result;
|
||||
};
|
||||
};
|
||||
|
||||
//#endregion
|
||||
export { serveStatic };
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
const require_constants = require('../constants-BXAKTxRC.cjs');
|
||||
|
||||
//#region src/utils/response.ts
|
||||
const RESPONSE_ALREADY_SENT = new Response(null, { headers: { [require_constants.X_ALREADY_SENT]: "true" } });
|
||||
|
||||
//#endregion
|
||||
exports.RESPONSE_ALREADY_SENT = RESPONSE_ALREADY_SENT;
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
//#region src/utils/response.d.ts
|
||||
declare const RESPONSE_ALREADY_SENT: Response;
|
||||
//#endregion
|
||||
export { RESPONSE_ALREADY_SENT };
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
//#region src/utils/response.d.ts
|
||||
declare const RESPONSE_ALREADY_SENT: Response;
|
||||
//#endregion
|
||||
export { RESPONSE_ALREADY_SENT };
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { t as X_ALREADY_SENT } from "../constants-BLSFu_RU.mjs";
|
||||
|
||||
//#region src/utils/response.ts
|
||||
const RESPONSE_ALREADY_SENT = new Response(null, { headers: { [X_ALREADY_SENT]: "true" } });
|
||||
|
||||
//#endregion
|
||||
export { RESPONSE_ALREADY_SENT };
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
let node_stream = require("node:stream");
|
||||
let node_process = require("node:process");
|
||||
|
||||
//#region src/utils/stream.ts
|
||||
const pr54206Applied = () => {
|
||||
const [major, minor] = node_process.versions.node.split(".").map((component) => parseInt(component));
|
||||
return major >= 23 || major === 22 && minor >= 7 || major === 20 && minor >= 18;
|
||||
};
|
||||
const useReadableToWeb = pr54206Applied();
|
||||
const createStreamBody = (stream, useNativeReadableToWeb = useReadableToWeb) => {
|
||||
if (useNativeReadableToWeb) return node_stream.Readable.toWeb(stream);
|
||||
let controller;
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
stream.off("data", onData);
|
||||
stream.off("error", onError);
|
||||
stream.off("end", onTerminate);
|
||||
stream.off("close", onTerminate);
|
||||
};
|
||||
const settle = (callback) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
callback?.();
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
if (settled || !controller) return;
|
||||
controller.enqueue(chunk);
|
||||
if ((controller.desiredSize ?? 0) <= 0) stream.pause();
|
||||
};
|
||||
const onError = (error) => {
|
||||
settle(() => {
|
||||
controller?.error(error);
|
||||
});
|
||||
};
|
||||
const onTerminate = () => {
|
||||
settle(() => {
|
||||
controller?.close();
|
||||
});
|
||||
};
|
||||
return new ReadableStream({
|
||||
start(streamController) {
|
||||
controller = streamController;
|
||||
stream.on("data", onData);
|
||||
stream.on("error", onError);
|
||||
stream.on("end", onTerminate);
|
||||
stream.on("close", onTerminate);
|
||||
stream.pause();
|
||||
},
|
||||
pull() {
|
||||
if (!settled) stream.resume();
|
||||
},
|
||||
cancel() {
|
||||
settle();
|
||||
const ignoreError = () => {};
|
||||
stream.on("error", ignoreError);
|
||||
stream.once("close", () => stream.off("error", ignoreError));
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//#endregion
|
||||
exports.createStreamBody = createStreamBody;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { ReadStream } from "node:fs";
|
||||
|
||||
//#region src/utils/stream.d.ts
|
||||
declare const createStreamBody: (stream: ReadStream, useNativeReadableToWeb?: boolean) => ReadableStream<Uint8Array>;
|
||||
//#endregion
|
||||
export { createStreamBody };
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { ReadStream } from "node:fs";
|
||||
|
||||
//#region src/utils/stream.d.ts
|
||||
declare const createStreamBody: (stream: ReadStream, useNativeReadableToWeb?: boolean) => ReadableStream<Uint8Array>;
|
||||
//#endregion
|
||||
export { createStreamBody };
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { versions } from "node:process";
|
||||
|
||||
//#region src/utils/stream.ts
|
||||
const pr54206Applied = () => {
|
||||
const [major, minor] = versions.node.split(".").map((component) => parseInt(component));
|
||||
return major >= 23 || major === 22 && minor >= 7 || major === 20 && minor >= 18;
|
||||
};
|
||||
const useReadableToWeb = pr54206Applied();
|
||||
const createStreamBody = (stream, useNativeReadableToWeb = useReadableToWeb) => {
|
||||
if (useNativeReadableToWeb) return Readable.toWeb(stream);
|
||||
let controller;
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
stream.off("data", onData);
|
||||
stream.off("error", onError);
|
||||
stream.off("end", onTerminate);
|
||||
stream.off("close", onTerminate);
|
||||
};
|
||||
const settle = (callback) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
callback?.();
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
if (settled || !controller) return;
|
||||
controller.enqueue(chunk);
|
||||
if ((controller.desiredSize ?? 0) <= 0) stream.pause();
|
||||
};
|
||||
const onError = (error) => {
|
||||
settle(() => {
|
||||
controller?.error(error);
|
||||
});
|
||||
};
|
||||
const onTerminate = () => {
|
||||
settle(() => {
|
||||
controller?.close();
|
||||
});
|
||||
};
|
||||
return new ReadableStream({
|
||||
start(streamController) {
|
||||
controller = streamController;
|
||||
stream.on("data", onData);
|
||||
stream.on("error", onError);
|
||||
stream.on("end", onTerminate);
|
||||
stream.on("close", onTerminate);
|
||||
stream.pause();
|
||||
},
|
||||
pull() {
|
||||
if (!settled) stream.resume();
|
||||
},
|
||||
cancel() {
|
||||
settle();
|
||||
const ignoreError = () => {};
|
||||
stream.on("error", ignoreError);
|
||||
stream.once("close", () => stream.off("error", ignoreError));
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
//#endregion
|
||||
export { createStreamBody };
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"name": "@hono/node-server",
|
||||
"version": "2.1.5",
|
||||
"description": "Node.js Adapter for Hono",
|
||||
"main": "dist/index.mjs",
|
||||
"type": "module",
|
||||
"types": "dist/index.d.mts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./serve-static": {
|
||||
"import": {
|
||||
"types": "./dist/serve-static.d.mts",
|
||||
"default": "./dist/serve-static.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/serve-static.d.cts",
|
||||
"default": "./dist/serve-static.cjs"
|
||||
}
|
||||
},
|
||||
"./utils/*": {
|
||||
"import": {
|
||||
"types": "./dist/utils/*.d.mts",
|
||||
"default": "./dist/utils/*.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/*.d.cts",
|
||||
"default": "./dist/utils/*.cjs"
|
||||
}
|
||||
},
|
||||
"./conninfo": {
|
||||
"import": {
|
||||
"types": "./dist/conninfo.d.mts",
|
||||
"default": "./dist/conninfo.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/conninfo.d.cts",
|
||||
"default": "./dist/conninfo.cjs"
|
||||
}
|
||||
},
|
||||
"./early-hints": {
|
||||
"import": {
|
||||
"types": "./dist/early-hints.d.mts",
|
||||
"default": "./dist/early-hints.mjs"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/early-hints.d.cts",
|
||||
"default": "./dist/early-hints.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
".": [
|
||||
"./dist/index.d.mts"
|
||||
],
|
||||
"serve-static": [
|
||||
"./dist/serve-static.d.mts"
|
||||
],
|
||||
"utils/*": [
|
||||
"./dist/utils/*.d.mts"
|
||||
],
|
||||
"conninfo": [
|
||||
"./dist/conninfo.d.mts"
|
||||
],
|
||||
"early-hints": [
|
||||
"./dist/early-hints.d.mts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/honojs/node-server.git"
|
||||
},
|
||||
"homepage": "https://github.com/honojs/node-server",
|
||||
"author": "Yusuke Wada <yusuke@kamawada.com> (https://github.com/yusukebe)",
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org",
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hono/eslint-config": "^1.0.1",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@whatwg-node/fetch": "^0.9.14",
|
||||
"eslint": "^9.10.0",
|
||||
"hono": "^4.13.9",
|
||||
"np": "^11.2.1",
|
||||
"prettier": "^3.2.4",
|
||||
"publint": "^0.3.18",
|
||||
"tsdown": "^0.20.3",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4 || ^5.0.0-0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"build": "tsdown --external hono",
|
||||
"watch": "tsdown --watch",
|
||||
"postbuild": "publint",
|
||||
"prerelease": "pnpm run build && pnpm run test --run",
|
||||
"release": "np --no-publish",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src test",
|
||||
"lint:fix": "eslint src test --fix",
|
||||
"format": "prettier --check \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\"",
|
||||
"format:fix": "prettier --write \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\""
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 - present, Yusuke Wada and Hono contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
//#region src/helper/websocket/index.ts
|
||||
/**
|
||||
* A context for controlling WebSockets
|
||||
*/
|
||||
var WSContext = class {
|
||||
#init;
|
||||
constructor(init) {
|
||||
this.#init = init;
|
||||
this.raw = init.raw;
|
||||
this.url = init.url ? new URL(init.url) : null;
|
||||
this.protocol = init.protocol ?? null;
|
||||
}
|
||||
send(source, options) {
|
||||
this.#init.send(source, options ?? {});
|
||||
}
|
||||
raw;
|
||||
binaryType = "arraybuffer";
|
||||
get readyState() {
|
||||
return this.#init.readyState;
|
||||
}
|
||||
url;
|
||||
protocol;
|
||||
close(code, reason) {
|
||||
this.#init.close(code, reason);
|
||||
}
|
||||
};
|
||||
const createWSMessageEvent = (source) => {
|
||||
return new MessageEvent("message", { data: source });
|
||||
};
|
||||
/**
|
||||
* Create a WebSocket adapter/helper
|
||||
*/
|
||||
const defineWebSocketHelper = (handler) => {
|
||||
return ((...args) => {
|
||||
if (typeof args[0] === "function") {
|
||||
const [createEvents, options] = args;
|
||||
return async function upgradeWebSocket(c, next) {
|
||||
const result = await handler(c, await createEvents(c), options);
|
||||
if (result) return result;
|
||||
await next();
|
||||
};
|
||||
} else {
|
||||
const [c, events, options] = args;
|
||||
return (async () => {
|
||||
const upgraded = await handler(c, events, options);
|
||||
if (!upgraded) throw new Error("Failed to upgrade WebSocket");
|
||||
return upgraded;
|
||||
})();
|
||||
}
|
||||
});
|
||||
};
|
||||
//#endregion
|
||||
export { WSContext, createWSMessageEvent, defineWebSocketHelper };
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "hono",
|
||||
"version": "4.13.13",
|
||||
"description": "Test fixture: only the hono/ws helper that @hono/node-server's index imports.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"./ws": {
|
||||
"import": "./dist/helper/websocket/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 5600, "san": 5600 }
|
||||
Reference in New Issue
Block a user