mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-02 07:43:42 +08:00
@hono/node-server replaces the global Request and Response with lightweight objects and reparents prototypes, tricks that only pay off under V8 and that native compilation cannot follow. scriptc now compiles the package from a built-in implementation (shims/hono-node-server/v2.ts) over node:http and the native Request/Response/Headers: serve, createAdaptorServer, getRequestListener and RequestError. Responses are written the way the package writes them, byte for byte on the wire: the general path (header record in Headers order, Set-Cookie gathered, default content type, content length for bodies that finish within the first reads, streamed bodies, error responses) and the package's cached-response path for a Response that is still exactly its constructor arguments. The runtime tracks that lightweight state on each constructed Response (header form, raw plain-record headers, body length; cleared by body and metadata reads, turned into Headers form by a headers read) and exposes it to the shim through scriptcResponseLight. The declaration entry of a shimmed package may now be a .d.mts/.d.cts file. The npm case hono-node-server drives buffered, streamed, cookie, header-form, HEAD, error-handler and URL cases against the real package under Node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
5.1 KiB
JavaScript
152 lines
5.1 KiB
JavaScript
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 }; |