@bhooai/nexus-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ import type { Handler, Middleware, Params, RequestContext } from './context.js';
2
+
3
+ interface RouteNode {
4
+ static: Map<string, RouteNode>; // exact segment -> child
5
+ param?: { name: string; node: RouteNode };
6
+ wildcard?: { node: RouteNode };
7
+ handlers: Map<string, Handler>; // method -> handler
8
+ middleware: Middleware[];
9
+ }
10
+
11
+ function createNode(): RouteNode {
12
+ return { static: new Map(), handlers: new Map(), middleware: [] };
13
+ }
14
+
15
+ interface MatchResult {
16
+ handler: Handler;
17
+ params: Params;
18
+ middleware: Middleware[];
19
+ pattern: string;
20
+ }
21
+
22
+ /**
23
+ * Trie router supporting static segments, `:param` segments, and `*` wildcards.
24
+ * Per-route middleware runs before the handler; router-level middleware (via
25
+ * `use`) runs for all matched routes under its mount path.
26
+ */
27
+ export class Router {
28
+ private root = createNode();
29
+ private globalMiddleware: Middleware[] = [];
30
+ private routeCount = 0;
31
+
32
+ /** Register router-level middleware. */
33
+ use(mw: Middleware): this;
34
+ use(path: string, mw: Middleware): this;
35
+ use(pathOrMw: string | Middleware, maybeMw?: Middleware): this {
36
+ if (typeof pathOrMw === 'function') {
37
+ this.globalMiddleware.push(pathOrMw);
38
+ } else if (maybeMw) {
39
+ // path-scoped middleware: attach to a node matched by prefix
40
+ const node = this.findOrCreateNode(pathOrMw);
41
+ node.middleware.push(maybeMw);
42
+ }
43
+ return this;
44
+ }
45
+
46
+ get(path: string, handler: Handler, mw: Middleware[] = []): this { return this.add('GET', path, handler, mw); }
47
+ post(path: string, handler: Handler, mw: Middleware[] = []): this { return this.add('POST', path, handler, mw); }
48
+ put(path: string, handler: Handler, mw: Middleware[] = []): this { return this.add('PUT', path, handler, mw); }
49
+ patch(path: string, handler: Handler, mw: Middleware[] = []): this { return this.add('PATCH', path, handler, mw); }
50
+ delete(path: string, handler: Handler, mw: Middleware[] = []): this { return this.add('DELETE', path, handler, mw); }
51
+ head(path: string, handler: Handler, mw: Middleware[] = []): this { return this.add('HEAD', path, handler, mw); }
52
+ options(path: string, handler: Handler, mw: Middleware[] = []): this { return this.add('OPTIONS', path, handler, mw); }
53
+
54
+ add(method: string, path: string, handler: Handler, mw: Middleware[] = []): this {
55
+ const node = this.findOrCreateNode(path);
56
+ node.handlers.set(method.toUpperCase(), handler);
57
+ for (const m of mw) node.middleware.push(m);
58
+ this.routeCount++;
59
+ return this;
60
+ }
61
+
62
+ /** Resolve a request to a route match, or null if none. */
63
+ match(method: string, path: string): MatchResult | null {
64
+ const segments = splitPath(path);
65
+ const params: Params = {};
66
+ const collectedMw: Middleware[] = [...this.globalMiddleware];
67
+
68
+ let node: RouteNode = this.root;
69
+ for (let i = 0; i < segments.length; i++) {
70
+ const seg = segments[i]!;
71
+ if (node.static.has(seg)) {
72
+ node = node.static.get(seg)!;
73
+ } else if (node.param) {
74
+ params[node.param.name] = decodeURIComponent(seg);
75
+ node = node.param.node;
76
+ } else if (node.wildcard) {
77
+ // Wildcard is terminal: consume the rest of the path.
78
+ node = node.wildcard.node;
79
+ if (node.middleware.length) collectedMw.push(...node.middleware);
80
+ break;
81
+ } else {
82
+ return null;
83
+ }
84
+ if (node.middleware.length) collectedMw.push(...node.middleware);
85
+ }
86
+
87
+ const handler = node.handlers.get(method);
88
+ if (!handler) {
89
+ // Method not allowed on this path — surface 405 if any method matches.
90
+ if (node.handlers.size > 0) {
91
+ return { handler: methodNotAllowedHandler(node.handlers), params, middleware: collectedMw, pattern: path };
92
+ }
93
+ return null;
94
+ }
95
+ return { handler, params, middleware: collectedMw, pattern: joinPattern(path) };
96
+ }
97
+
98
+ get size(): number {
99
+ return this.routeCount;
100
+ }
101
+
102
+ /** Enumerate registered (method, path) pairs — for OPTIONS/CORS introspection. */
103
+ routes(): Array<{ method: string; path: string }> {
104
+ const out: Array<{ method: string; path: string }> = [];
105
+ const walk = (node: RouteNode, prefix: string) => {
106
+ for (const [method] of node.handlers) out.push({ method, path: prefix || '/' });
107
+ for (const [seg, child] of node.static) walk(child, `${prefix}/${seg}`);
108
+ if (node.param) walk(node.param.node, `${prefix}/:${node.param.name}`);
109
+ if (node.wildcard) walk(node.wildcard.node, `${prefix}/*`);
110
+ };
111
+ walk(this.root, '');
112
+ return out;
113
+ }
114
+
115
+ private findOrCreateNode(path: string): RouteNode {
116
+ const segments = splitPath(path);
117
+ let node = this.root;
118
+ for (const seg of segments) {
119
+ if (seg === '*') {
120
+ if (!node.wildcard) node.wildcard = { node: createNode() };
121
+ node = node.wildcard.node;
122
+ } else if (seg.startsWith(':')) {
123
+ const name = seg.slice(1);
124
+ if (!node.param) node.param = { name, node: createNode() };
125
+ node.param.name = name;
126
+ node = node.param.node;
127
+ } else {
128
+ if (!node.static.has(seg)) node.static.set(seg, createNode());
129
+ node = node.static.get(seg)!;
130
+ }
131
+ }
132
+ return node;
133
+ }
134
+ }
135
+
136
+ function splitPath(path: string): string[] {
137
+ return path.split('/').filter((s) => s.length > 0);
138
+ }
139
+
140
+ function joinPattern(path: string): string {
141
+ return path.startsWith('/') ? path : `/${path}`;
142
+ }
143
+
144
+ function methodNotAllowedHandler(handlers: Map<string, Handler>): Handler {
145
+ return (ctx) => {
146
+ ctx.setHeader('allow', [...handlers.keys()].join(', '));
147
+ ctx.status(405);
148
+ };
149
+ }
@@ -0,0 +1,145 @@
1
+ import { createServer, IncomingMessage, Server as HttpServer, ServerResponse } from 'node:http';
2
+ import { createServer as createHttpsServer } from 'node:https';
3
+ import { readFileSync } from 'node:fs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import type { Middleware, RequestContext } from './context.js';
6
+ import { createContext } from './context.js';
7
+ import type { Router } from './Router.js';
8
+ import { NexusError, toNexusError } from '../errors.js';
9
+
10
+ /** Generate a request id (overridable for tests/injection). */
11
+ export function newRequestId(): string {
12
+ return randomUUID();
13
+ }
14
+
15
+ export interface ServerOptions {
16
+ router: Router;
17
+ /** Global middleware run before routing. */
18
+ middleware?: Middleware[];
19
+ /** Trust X-Forwarded-* headers (boolean or hop count). */
20
+ trustProxy?: boolean | number;
21
+ /** Maximum request body size in bytes. */
22
+ bodyLimit?: number;
23
+ /** HTTPS cert/key paths (enables TLS when both present). */
24
+ certFile?: string;
25
+ keyFile?: string;
26
+ /** Called for each error to produce a JSON error body. */
27
+ onError?: (err: NexusError, ctx: RequestContext) => unknown;
28
+ }
29
+
30
+ /**
31
+ * Inbuilt HTTP server built on node:http (no express). Runs a middleware
32
+ * pipeline, dispatches to the router, propagates request/trace ids, and
33
+ * supports graceful shutdown.
34
+ */
35
+ export class NexusServer {
36
+ private server: HttpServer;
37
+ private middleware: Middleware[];
38
+ private opts: ServerOptions;
39
+
40
+ constructor(opts: ServerOptions) {
41
+ this.opts = { trustProxy: false, bodyLimit: 1024 * 1024, ...opts };
42
+ this.middleware = opts.middleware ?? [];
43
+ const handler = (req: IncomingMessage, res: ServerResponse) => this.handle(req, res);
44
+ if (opts.certFile && opts.keyFile) {
45
+ this.server = createHttpsServer(
46
+ { cert: readFileSync(opts.certFile), key: readFileSync(opts.keyFile) },
47
+ handler,
48
+ );
49
+ } else {
50
+ this.server = createServer(handler);
51
+ }
52
+ }
53
+
54
+ use(mw: Middleware): this {
55
+ this.middleware.push(mw);
56
+ return this;
57
+ }
58
+
59
+ listen(port: number, host: string = '0.0.0.0'): Promise<void> {
60
+ return new Promise((resolve) => {
61
+ this.server.listen(port, host, () => resolve());
62
+ });
63
+ }
64
+
65
+ get address() {
66
+ return this.server.address();
67
+ }
68
+
69
+ /** The underlying node:http(s) server (for attaching WebSocket upgrades, etc.). */
70
+ get httpServer(): HttpServer {
71
+ return this.server;
72
+ }
73
+
74
+ close(): Promise<void> {
75
+ return new Promise((resolve) => this.server.close(() => resolve()));
76
+ }
77
+
78
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
79
+ const requestId = (req.headers['x-request-id'] as string) ?? newRequestId();
80
+ res.setHeader('x-request-id', requestId);
81
+ const ctx = createContext(req, res, requestId);
82
+
83
+ try {
84
+ await this.runPipeline(ctx);
85
+ } catch (err) {
86
+ this.handleError(err, ctx);
87
+ }
88
+ }
89
+
90
+ private async runPipeline(ctx: RequestContext): Promise<void> {
91
+ const stack = [...this.middleware, this.dispatchMiddleware()];
92
+ let i = 0;
93
+ const next = async () => {
94
+ const mw = stack[i++];
95
+ if (mw) await mw(ctx, next);
96
+ };
97
+ await next();
98
+
99
+ if (!ctx.res.writableEnded) {
100
+ ctx.status(404);
101
+ }
102
+ }
103
+
104
+ private dispatchMiddleware(): Middleware {
105
+ return async (ctx, next) => {
106
+ const match = this.opts.router.match(ctx.method, ctx.path);
107
+ if (!match) {
108
+ await next();
109
+ return;
110
+ }
111
+ ctx.params = match.params;
112
+ ctx.routePattern = match.pattern;
113
+ // Run route middleware then the handler.
114
+ let i = 0;
115
+ const routeStack = [...match.middleware, match.handler];
116
+ const routeNext = async () => {
117
+ const fn = routeStack[i++];
118
+ if (fn) await fn(ctx, routeNext);
119
+ };
120
+ await routeNext();
121
+ await next();
122
+ };
123
+ }
124
+
125
+ private handleError(err: unknown, ctx: RequestContext): void {
126
+ if (ctx.res.writableEnded) return;
127
+ const ne = toNexusError(err);
128
+ const body = this.opts.onError ? this.opts.onError(ne, ctx) : defaultErrorBody(ne);
129
+ if (!ctx.res.headersSent) {
130
+ ctx.res.statusCode = ne.statusCode;
131
+ ctx.res.setHeader('content-type', 'application/json; charset=utf-8');
132
+ }
133
+ ctx.res.end(JSON.stringify(body));
134
+ }
135
+ }
136
+
137
+ function defaultErrorBody(err: NexusError): unknown {
138
+ return {
139
+ error: {
140
+ code: err.code,
141
+ message: err.message,
142
+ ...(err.details ? { details: err.details } : {}),
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,129 @@
1
+ import type { Middleware } from './context.js';
2
+ import { NexusError } from '../errors.js';
3
+
4
+ /** Read the full request body into a Buffer, capped at `limit` bytes. */
5
+ export function readBody(req: import('node:http').IncomingMessage, limit: number): Promise<Buffer> {
6
+ return new Promise((resolve, reject) => {
7
+ const chunks: Buffer[] = [];
8
+ let size = 0;
9
+ req.on('data', (chunk: Buffer) => {
10
+ size += chunk.length;
11
+ if (size > limit) {
12
+ req.destroy();
13
+ reject(new NexusError('Request body too large', { code: 'BODY_TOO_LARGE', statusCode: 413 }));
14
+ return;
15
+ }
16
+ chunks.push(chunk);
17
+ });
18
+ req.on('end', () => resolve(Buffer.concat(chunks)));
19
+ req.on('error', reject);
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Body parser middleware: parses JSON, urlencoded, and multipart/form-data
25
+ * (urlencoded + multipart use Node's querystring / a simple parser). For
26
+ * multipart file uploads, populates `ctx.state.files` with { field, filename,
27
+ * contentType, data } entries.
28
+ */
29
+ export function bodyParser(limit = 1024 * 1024): Middleware {
30
+ return async (ctx, next) => {
31
+ const type = (ctx.headers['content-type'] ?? '').toString().split(';')[0]?.trim() ?? '';
32
+ if (ctx.method === 'GET' || ctx.method === 'HEAD' || !type) {
33
+ await next();
34
+ return;
35
+ }
36
+ const buf = await readBody(ctx.req, limit);
37
+ // Preserve the raw body for webhook signature verification (the parsed form
38
+ // loses exact byte order/encoding that the gateway's signature was computed over).
39
+ ctx.state.__rawBody = buf;
40
+ try {
41
+ if (type === 'application/json') {
42
+ ctx.body = buf.length ? JSON.parse(buf.toString('utf8')) : undefined;
43
+ } else if (type === 'application/x-www-form-urlencoded') {
44
+ ctx.body = parseUrlEncoded(buf.toString('utf8'));
45
+ } else if (type === 'text/plain') {
46
+ ctx.body = buf.toString('utf8');
47
+ } else if (type === 'multipart/form-data') {
48
+ const { fields, files } = parseMultipart(buf, getBoundary(ctx.headers['content-type']?.toString() ?? ''));
49
+ ctx.body = fields;
50
+ ctx.state.files = files;
51
+ } else {
52
+ ctx.body = buf;
53
+ }
54
+ } catch {
55
+ throw new NexusError('Invalid request body', { code: 'INVALID_BODY', statusCode: 400 });
56
+ }
57
+ await next();
58
+ };
59
+ }
60
+
61
+ function getBoundary(contentType: string): string | undefined {
62
+ const match = /boundary=("?)([^";]+)\1/.exec(contentType);
63
+ return match?.[2];
64
+ }
65
+
66
+ export function parseUrlEncoded(input: string): Record<string, string | string[]> {
67
+ const out: Record<string, string | string[]> = {};
68
+ for (const pair of input.split('&')) {
69
+ if (!pair) continue;
70
+ const eq = pair.indexOf('=');
71
+ const key = decodeURIComponent(eq === -1 ? pair : pair.slice(0, eq)).replace(/\+/g, ' ');
72
+ const val = decodeURIComponent(eq === -1 ? '' : pair.slice(eq + 1)).replace(/\+/g, ' ');
73
+ const existing = out[key];
74
+ if (existing === undefined) out[key] = val;
75
+ else if (Array.isArray(existing)) existing.push(val);
76
+ else out[key] = [existing, val];
77
+ }
78
+ return out;
79
+ }
80
+
81
+ export interface UploadedFile {
82
+ field: string;
83
+ filename: string;
84
+ contentType: string;
85
+ data: Buffer;
86
+ }
87
+
88
+ /** Minimal multipart/form-data parser sufficient for file uploads. */
89
+ export function parseMultipart(buf: Buffer, boundary?: string): {
90
+ fields: Record<string, string>;
91
+ files: UploadedFile[];
92
+ } {
93
+ const fields: Record<string, string> = {};
94
+ const files: UploadedFile[] = [];
95
+ if (!boundary) return { fields, files };
96
+ const delim = Buffer.from(`--${boundary}`);
97
+ const parts = splitBuffer(buf, delim).slice(1); // drop preamble
98
+ for (const part of parts) {
99
+ if (part.length === 0 || part.toString('utf8').trim() === '--') continue;
100
+ const headerEnd = part.indexOf('\r\n\r\n');
101
+ if (headerEnd === -1) continue;
102
+ const headerStr = part.subarray(0, headerEnd).toString('utf8');
103
+ const bodyBuf = part.subarray(headerEnd + 4, part.length - 2); // strip trailing \r\n
104
+ const disposition = /Content-Disposition: form-data;[^\r\n]*/i.exec(headerStr)?.[0] ?? '';
105
+ const name = /name="([^"]+)"/.exec(disposition)?.[1];
106
+ const filename = /filename="([^"]*)"/.exec(disposition)?.[1];
107
+ const contentType = /Content-Type: ([^\r\n]+)/i.exec(headerStr)?.[1]?.trim() ?? 'text/plain';
108
+ if (!name) continue;
109
+ if (filename !== undefined) {
110
+ files.push({ field: name, filename, contentType, data: bodyBuf });
111
+ } else {
112
+ fields[name] = bodyBuf.toString('utf8');
113
+ }
114
+ }
115
+ return { fields, files };
116
+ }
117
+
118
+ function splitBuffer(buf: Buffer, delim: Buffer): Buffer[] {
119
+ const out: Buffer[] = [];
120
+ let start = 0;
121
+ let idx = buf.indexOf(delim, start);
122
+ while (idx !== -1) {
123
+ out.push(buf.subarray(start, idx));
124
+ start = idx + delim.length;
125
+ idx = buf.indexOf(delim, start);
126
+ }
127
+ out.push(buf.subarray(start));
128
+ return out;
129
+ }
@@ -0,0 +1,112 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { Socket } from 'node:net';
3
+
4
+ /** Route path parameters extracted from the URL. */
5
+ export type Params = Record<string, string>;
6
+
7
+ /** Per-request mutable state shared across middleware. */
8
+ export type State = Record<string, unknown>;
9
+
10
+ export interface RequestContext {
11
+ /** The raw Node request. */
12
+ req: IncomingMessage;
13
+ /** The raw Node response. */
14
+ res: ServerResponse;
15
+ /** HTTP method, uppercased. */
16
+ method: string;
17
+ /** URL pathname (no query). */
18
+ path: string;
19
+ /** Parsed query string. */
20
+ query: Record<string, string | string[]>;
21
+ /** Route params. */
22
+ params: Params;
23
+ /** Headers (lowercased keys). */
24
+ headers: Record<string, string | string[] | undefined>;
25
+ /** Parsed request body (set by body parser middleware). */
26
+ body: unknown;
27
+ /** Per-request state. */
28
+ state: State;
29
+ /** Request id (also in headers as x-request-id). */
30
+ requestId: string;
31
+ /** The matched route pattern, e.g. "/users/:id". */
32
+ routePattern?: string;
33
+ /** Send a JSON response. */
34
+ json(data: unknown, status?: number): void;
35
+ /** Send a text response. */
36
+ text(data: string, status?: number): void;
37
+ /** Send an HTML response. */
38
+ html(data: string, status?: number): void;
39
+ /** Send an empty/status-only response. */
40
+ status(status: number): void;
41
+ /** Redirect to a location. */
42
+ redirect(location: string, status?: number): void;
43
+ /** Set a response header. */
44
+ setHeader(name: string, value: string | string[]): void;
45
+ }
46
+
47
+ export type Next = () => Promise<void> | void;
48
+
49
+ export type Middleware = (ctx: RequestContext, next: Next) => Promise<void> | void;
50
+
51
+ export type Handler = (ctx: RequestContext) => void | Promise<void>;
52
+
53
+ /** Build a RequestContext from raw Node objects. */
54
+ export function createContext(
55
+ req: IncomingMessage,
56
+ res: ServerResponse,
57
+ requestId: string,
58
+ ): RequestContext {
59
+ const url = new URL(req.url ?? '/', 'http://localhost');
60
+ const query: Record<string, string | string[]> = {};
61
+ for (const [k, v] of url.searchParams.entries()) {
62
+ const existing = query[k];
63
+ if (existing === undefined) query[k] = v;
64
+ else if (Array.isArray(existing)) existing.push(v);
65
+ else query[k] = [existing, v];
66
+ }
67
+ const ctx: RequestContext = {
68
+ req,
69
+ res,
70
+ method: (req.method ?? 'GET').toUpperCase(),
71
+ path: url.pathname,
72
+ query,
73
+ params: {},
74
+ headers: req.headers as Record<string, string | string[] | undefined>,
75
+ body: undefined,
76
+ state: {},
77
+ requestId,
78
+ json(data, status = 200) {
79
+ send(res, JSON.stringify(data), status, 'application/json; charset=utf-8');
80
+ },
81
+ text(data, status = 200) {
82
+ send(res, data, status, 'text/plain; charset=utf-8');
83
+ },
84
+ html(data, status = 200) {
85
+ send(res, data, status, 'text/html; charset=utf-8');
86
+ },
87
+ status(s) {
88
+ res.statusCode = s;
89
+ res.end();
90
+ },
91
+ redirect(location, status = 302) {
92
+ res.statusCode = status;
93
+ res.setHeader('location', location);
94
+ res.end();
95
+ },
96
+ setHeader(name, value) {
97
+ res.setHeader(name, value);
98
+ },
99
+ };
100
+ return ctx;
101
+ }
102
+
103
+ function send(res: ServerResponse, body: string, status: number, type: string): void {
104
+ res.statusCode = status;
105
+ res.setHeader('content-type', type);
106
+ res.end(body);
107
+ }
108
+
109
+ /** True if the socket is still writable (not closed). */
110
+ export function isAlive(socket: Socket | undefined): boolean {
111
+ return !!socket && !socket.destroyed && socket.writable;
112
+ }
@@ -0,0 +1,6 @@
1
+ export * from './context.js';
2
+ export * from './Router.js';
3
+ export * from './Server.js';
4
+ export * from './bodyParser.js';
5
+ export * from './static.js';
6
+ export * from './uploads.js';
@@ -0,0 +1,85 @@
1
+ import { createReadStream, existsSync, statSync } from 'node:fs';
2
+ import { extname, join, normalize, sep } from 'node:path';
3
+ import type { Middleware } from './context.js';
4
+ import { NotFoundError } from '../errors.js';
5
+
6
+ const MIME: Record<string, string> = {
7
+ '.html': 'text/html; charset=utf-8',
8
+ '.js': 'text/javascript; charset=utf-8',
9
+ '.mjs': 'text/javascript; charset=utf-8',
10
+ '.css': 'text/css; charset=utf-8',
11
+ '.json': 'application/json; charset=utf-8',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.jpeg': 'image/jpeg',
15
+ '.gif': 'image/gif',
16
+ '.svg': 'image/svg+xml',
17
+ '.ico': 'image/x-icon',
18
+ '.woff': 'font/woff',
19
+ '.woff2': 'font/woff2',
20
+ '.ttf': 'font/ttf',
21
+ '.map': 'application/json',
22
+ '.txt': 'text/plain; charset=utf-8',
23
+ '.webp': 'image/webp',
24
+ '.wasm': 'application/wasm',
25
+ };
26
+
27
+ /** Serve static files from `root`. Safe against path traversal. */
28
+ export function serveStatic(root: string, options: { index?: string; prefix?: string } = {}): Middleware {
29
+ const index = options.index ?? 'index.html';
30
+ const prefix = normalizePrefix(options.prefix);
31
+ return async (ctx, next) => {
32
+ if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
33
+ await next();
34
+ return;
35
+ }
36
+ if (prefix && ctx.path !== prefix && !ctx.path.startsWith(`${prefix}/`)) {
37
+ await next();
38
+ return;
39
+ }
40
+ const requestPath = prefix ? ctx.path.slice(prefix.length) || '/' : ctx.path;
41
+ const safe = normalize(requestPath).replace(/^(\.\.[/\\])+/, '');
42
+ let filePath = join(root, safe);
43
+ if (isOutside(root, filePath)) {
44
+ throw new NotFoundError();
45
+ }
46
+ if (!existsSync(filePath)) {
47
+ await next();
48
+ return;
49
+ }
50
+ const initialStat = statSync(filePath);
51
+ if (initialStat.isDirectory()) {
52
+ filePath = join(filePath, index);
53
+ if (!existsSync(filePath)) {
54
+ await next();
55
+ return;
56
+ }
57
+ }
58
+ const stat = statSync(filePath);
59
+ const ext = extname(filePath).toLowerCase();
60
+ ctx.setHeader('content-type', MIME[ext] ?? 'application/octet-stream');
61
+ ctx.setHeader('content-length', String(stat.size));
62
+ ctx.setHeader('etag', `"${stat.size.toString(16)}-${stat.mtimeMs.toString(16)}"`);
63
+ if (ctx.method === 'HEAD') {
64
+ ctx.status(200);
65
+ return;
66
+ }
67
+ await new Promise<void>((resolveStream, rejectStream) => {
68
+ const stream = createReadStream(filePath);
69
+ stream.on('error', rejectStream);
70
+ stream.on('end', resolveStream);
71
+ stream.pipe(ctx.res);
72
+ });
73
+ };
74
+ }
75
+
76
+ function normalizePrefix(prefix: string | undefined): string {
77
+ if (!prefix || prefix === '/') return '';
78
+ return `/${prefix.replace(/^\/+|\/+$/g, '')}`;
79
+ }
80
+
81
+ function isOutside(root: string, target: string): boolean {
82
+ const rel = normalize(target).split(sep);
83
+ const base = normalize(root).split(sep);
84
+ return !normalize(target).startsWith(normalize(root) + sep) && normalize(target) !== normalize(root);
85
+ }