silgi 0.53.0 → 0.53.1

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.
package/dist/compile.mjs CHANGED
@@ -5,11 +5,30 @@ import { RAW_INPUT, ROOT_WRAPS } from "./core/ctx-symbols.mjs";
5
5
  import { addRoute, createRouter, findRoute } from "rou3";
6
6
  //#region src/compile.ts
7
7
  /**
8
- * Pipeline Compiler — guard unrolling, context pooling, rou3 routing.
8
+ * Pipeline Compiler
9
+ * ------------------
9
10
  *
10
- * 1. UNROLLED GUARDS — 0-4 guard specialization (no loop, V8 inlines)
11
- * 2. ZERO-ALLOC CONTEXT Object.create(null) + pool reuse
12
- * 3. ROU3 ROUTER — unjs radix tree (same as h3/nitro)
11
+ * Turns a user-authored procedure (input schema, guards, wraps,
12
+ * resolver, output schema) into a single handler that the adapters
13
+ * call once per request:
14
+ *
15
+ * (ctx, rawInput, signal) => output | Promise<output>
16
+ *
17
+ * The pipeline order is:
18
+ *
19
+ * 1. Guards — pre-steps that may mutate `ctx` or throw.
20
+ * 2. Input validation — via Standard Schema if `input` is set.
21
+ * 3. Wraps — onion middleware around the resolver (root wraps first).
22
+ * 4. Resolver — user's business logic.
23
+ * 5. Output validation — via Standard Schema if `output` is set.
24
+ *
25
+ * Everything that can be decided up-front (the merged error map, the
26
+ * guard/wrap lists, whether validation exists) is closed over at
27
+ * compile time so the per-request path stays small.
28
+ *
29
+ * Router compilation lives in `compileRouter`. It walks the nested
30
+ * router def, compiles each procedure, and registers it in a rou3
31
+ * radix tree.
13
32
  */
14
33
  function isThenable(value) {
15
34
  return value !== null && typeof value === "object" && typeof value.then === "function";
@@ -31,14 +50,30 @@ function noopFail(code, data) {
31
50
  defined: false
32
51
  });
33
52
  }
53
+ /**
54
+ * Keys forbidden anywhere in a guard's return value. Blocking them at
55
+ * every level keeps `ctx` (a plain object) safe from attacker-supplied
56
+ * payloads that could otherwise reach `Object.prototype`.
57
+ */
34
58
  const UNSAFE_KEYS = /* @__PURE__ */ new Set([
35
59
  "__proto__",
36
60
  "constructor",
37
61
  "prototype"
38
62
  ]);
39
- /** Pre-frozen empty params avoids per-request {} allocation */
63
+ /** Shared frozen empty params object. Read only, never mutated. */
40
64
  const EMPTY_PARAMS = /* @__PURE__ */ Object.freeze(Object.create(null));
41
- /** Sanitize a value to prevent prototype pollution from nested __proto__ keys */
65
+ /**
66
+ * Recursively scrub a value produced by a guard so it cannot reach
67
+ * `Object.prototype` through nested `__proto__` / `constructor` /
68
+ * `prototype` keys.
69
+ *
70
+ * Arrays are scrubbed in place (they cannot be prototype-polluted
71
+ * themselves, but their elements might). Class instances are left
72
+ * alone — they already have a non-literal prototype, so merging them
73
+ * into `ctx` does not mutate `Object.prototype`. Plain objects get a
74
+ * shallow rebuild when they carry a forbidden key, otherwise their
75
+ * values are scrubbed in place.
76
+ */
42
77
  function sanitizeValue(value) {
43
78
  if (typeof value !== "object" || value === null) return value;
44
79
  if (Array.isArray(value)) {
@@ -50,131 +85,71 @@ function sanitizeValue(value) {
50
85
  const obj = value;
51
86
  if (Object.prototype.hasOwnProperty.call(obj, "__proto__")) {
52
87
  const clean = Object.create(null);
53
- const keys = Object.keys(obj);
54
- for (let i = 0; i < keys.length; i++) {
55
- const k = keys[i];
56
- if (!UNSAFE_KEYS.has(k)) clean[k] = sanitizeValue(obj[k]);
57
- }
88
+ for (const key of Object.keys(obj)) if (!UNSAFE_KEYS.has(key)) clean[key] = sanitizeValue(obj[key]);
58
89
  return clean;
59
90
  }
60
- const keys = Object.keys(obj);
61
- for (let i = 0; i < keys.length; i++) {
62
- const k = keys[i];
63
- obj[k] = sanitizeValue(obj[k]);
64
- }
91
+ for (const key of Object.keys(obj)) obj[key] = sanitizeValue(obj[key]);
65
92
  return value;
66
93
  }
67
- /** Apply a single guard result to context — direct property set */
94
+ /**
95
+ * Merge a single guard's return value into the live context. Guards
96
+ * typically return a partial patch (e.g. `{ user }`); returning
97
+ * nothing is fine and is how guards that only validate are expressed.
98
+ */
68
99
  function applyGuardResult(ctx, result) {
69
100
  if (result === null || result === void 0 || typeof result !== "object") return;
70
- const keys = Object.keys(result);
71
- for (let i = 0; i < keys.length; i++) {
72
- const k = keys[i];
73
- if (UNSAFE_KEYS.has(k)) continue;
74
- ctx[k] = sanitizeValue(result[k]);
101
+ for (const key of Object.keys(result)) {
102
+ if (UNSAFE_KEYS.has(key)) continue;
103
+ ctx[key] = sanitizeValue(result[key]);
75
104
  }
76
105
  }
77
- /** Apply a single guard (sync fast-path, async fallback) */
78
- async function applyGuard(ctx, guard) {
79
- const result = guard.fn(ctx);
80
- applyGuardResult(ctx, isThenable(result) ? await result : result);
81
- }
82
- function runGuards0() {}
83
- function runGuards1(ctx, g0) {
84
- const r0 = g0.fn(ctx);
85
- if (isThenable(r0)) return r0.then((v) => applyGuardResult(ctx, v));
86
- applyGuardResult(ctx, r0);
87
- }
88
- function runGuards2(ctx, g0, g1) {
89
- const r0 = g0.fn(ctx);
90
- if (isThenable(r0)) return r0.then((v) => {
91
- applyGuardResult(ctx, v);
92
- return applyGuard(ctx, g1);
93
- });
94
- applyGuardResult(ctx, r0);
95
- const r1 = g1.fn(ctx);
96
- if (isThenable(r1)) return r1.then((v) => applyGuardResult(ctx, v));
97
- applyGuardResult(ctx, r1);
98
- }
99
- function runGuards3(ctx, g0, g1, g2) {
100
- const r0 = g0.fn(ctx);
101
- if (isThenable(r0)) return r0.then(async (v) => {
102
- applyGuardResult(ctx, v);
103
- await applyGuard(ctx, g1);
104
- await applyGuard(ctx, g2);
105
- });
106
- applyGuardResult(ctx, r0);
107
- const r1 = g1.fn(ctx);
108
- if (isThenable(r1)) return r1.then(async (v) => {
109
- applyGuardResult(ctx, v);
110
- await applyGuard(ctx, g2);
111
- });
112
- applyGuardResult(ctx, r1);
113
- const r2 = g2.fn(ctx);
114
- if (isThenable(r2)) return r2.then((v) => applyGuardResult(ctx, v));
115
- applyGuardResult(ctx, r2);
116
- }
117
- function runGuards4(ctx, g0, g1, g2, g3) {
118
- const r0 = g0.fn(ctx);
119
- if (isThenable(r0)) return r0.then(async (v) => {
120
- applyGuardResult(ctx, v);
121
- await applyGuard(ctx, g1);
122
- await applyGuard(ctx, g2);
123
- await applyGuard(ctx, g3);
124
- });
125
- applyGuardResult(ctx, r0);
126
- const r1 = g1.fn(ctx);
127
- if (isThenable(r1)) return r1.then(async (v) => {
128
- applyGuardResult(ctx, v);
129
- await applyGuard(ctx, g2);
130
- await applyGuard(ctx, g3);
131
- });
132
- applyGuardResult(ctx, r1);
133
- const r2 = g2.fn(ctx);
134
- if (isThenable(r2)) return r2.then(async (v) => {
135
- applyGuardResult(ctx, v);
136
- await applyGuard(ctx, g3);
137
- });
138
- applyGuardResult(ctx, r2);
139
- const r3 = g3.fn(ctx);
140
- if (isThenable(r3)) return r3.then((v) => applyGuardResult(ctx, v));
141
- applyGuardResult(ctx, r3);
106
+ /**
107
+ * Run every guard in order, applying each result to `ctx` before the
108
+ * next guard runs.
109
+ *
110
+ * Sync-first: when a guard returns synchronously we stay on the sync
111
+ * path — only the first guard that returns a `Promise` forces us onto
112
+ * the async branch. That keeps the common case of all-sync guards from
113
+ * allocating a Promise at all.
114
+ *
115
+ * Empty-guards path is short-circuited at the call site (the returned
116
+ * runner is `undefined` when `guards.length === 0`).
117
+ */
118
+ function runGuardsSequential(ctx, guards) {
119
+ for (let i = 0; i < guards.length; i++) {
120
+ const result = guards[i].fn(ctx);
121
+ if (isThenable(result)) return finishGuardsAsync(ctx, guards, i, result);
122
+ applyGuardResult(ctx, result);
123
+ }
142
124
  }
143
- /** Fallback for 5+ guards — loop */
144
- async function runGuardsN(ctx, guards) {
145
- for (const guard of guards) {
146
- const result = guard.fn(ctx);
125
+ /**
126
+ * Complete the guard chain on the async branch once a guard returned a
127
+ * `Promise`. The remaining guards are awaited in order so their results
128
+ * land on `ctx` in the same order a sync run would have produced.
129
+ */
130
+ async function finishGuardsAsync(ctx, guards, resumeIndex, firstPromise) {
131
+ applyGuardResult(ctx, await firstPromise);
132
+ for (let i = resumeIndex + 1; i < guards.length; i++) {
133
+ const result = guards[i].fn(ctx);
147
134
  applyGuardResult(ctx, isThenable(result) ? await result : result);
148
135
  }
149
136
  }
150
137
  /**
151
- * Select the optimal guard runner based on count.
152
- * Returns a function that applies all guards to a context.
138
+ * Pre-bind the guard list to a runner. Returning `undefined` for the
139
+ * zero-guard case means the call site can skip the call entirely with
140
+ * a cheap null check.
153
141
  */
154
142
  function selectGuardRunner(guards) {
155
- switch (guards.length) {
156
- case 0: return runGuards0;
157
- case 1: {
158
- const g0 = guards[0];
159
- return (ctx) => runGuards1(ctx, g0);
160
- }
161
- case 2: {
162
- const [g0, g1] = guards;
163
- return (ctx) => runGuards2(ctx, g0, g1);
164
- }
165
- case 3: {
166
- const [g0, g1, g2] = guards;
167
- return (ctx) => runGuards3(ctx, g0, g1, g2);
168
- }
169
- case 4: {
170
- const [g0, g1, g2, g3] = guards;
171
- return (ctx) => runGuards4(ctx, g0, g1, g2, g3);
172
- }
173
- default: return (ctx) => runGuardsN(ctx, guards);
174
- }
143
+ if (guards.length === 0) return void 0;
144
+ return (ctx) => runGuardsSequential(ctx, guards);
175
145
  }
176
146
  /** Call resolve, then validate output (sync-first, async fallback) */
177
- function _resolveWithOutput(resolveFn, input, ctx, failFn, signal, outputSchema) {
147
+ /**
148
+ * Call the resolver, then validate the output. Stays sync when the
149
+ * resolver is sync and there is no output schema; switches to
150
+ * `.then()` chaining only once an async boundary appears.
151
+ */
152
+ function resolveWithOutput(resolveFn, input, ctx, failFn, signal, outputSchema) {
178
153
  const output = resolveFn({
179
154
  input,
180
155
  ctx,
@@ -186,14 +161,19 @@ function _resolveWithOutput(resolveFn, input, ctx, failFn, signal, outputSchema)
186
161
  if (isThenable(output)) return output.then((o) => validateSchema(outputSchema, o));
187
162
  return validateSchema(outputSchema, output);
188
163
  }
189
- /** Validate input, resolve, validate output — sync-first with rejected Promise fallback.
190
- * All sync throws (validation errors, fail() calls, resolver errors) are converted
191
- * to rejected Promises for consistent error handling in .then().catch() chains. */
192
- function _validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx, failFn, signal) {
164
+ /**
165
+ * Validate input, call the resolver, validate output.
166
+ *
167
+ * Everything that throws synchronously (input validation errors,
168
+ * `fail()` calls inside the resolver, the resolver itself) is turned
169
+ * into a rejected `Promise` so callers can rely on a single
170
+ * `.then().catch()` chain no matter which branch the pipeline took.
171
+ */
172
+ function validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx, failFn, signal) {
193
173
  try {
194
174
  const input = inputSchema ? validateSchema(inputSchema, rawInput ?? {}) : rawInput;
195
- if (isThenable(input)) return input.then((resolvedInput) => _resolveWithOutput(resolveFn, resolvedInput, ctx, failFn, signal, outputSchema));
196
- return _resolveWithOutput(resolveFn, input, ctx, failFn, signal, outputSchema);
175
+ if (isThenable(input)) return input.then((resolvedInput) => resolveWithOutput(resolveFn, resolvedInput, ctx, failFn, signal, outputSchema));
176
+ return resolveWithOutput(resolveFn, input, ctx, failFn, signal, outputSchema);
197
177
  } catch (e) {
198
178
  return Promise.reject(e);
199
179
  }
@@ -210,10 +190,11 @@ function _validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx
210
190
  function compileProcedure(procedure, rootWraps) {
211
191
  const middlewares = procedure.use ?? [];
212
192
  const guards = [];
213
- const wraps = [];
214
- if (rootWraps && rootWraps.length > 0) for (let i = 0; i < rootWraps.length; i++) wraps.push(rootWraps[i]);
193
+ const procedureWraps = [];
215
194
  for (const mw of middlewares) if (mw.kind === "guard") guards.push(mw);
216
- else wraps.push(mw);
195
+ else procedureWraps.push(mw);
196
+ const rootWrapList = rootWraps && rootWraps.length > 0 ? rootWraps : EMPTY_WRAPS;
197
+ const hasRootWraps = rootWrapList.length > 0;
217
198
  const inputSchema = procedure.input;
218
199
  const outputSchema = procedure.output;
219
200
  const resolveFn = procedure.resolve;
@@ -224,9 +205,10 @@ function compileProcedure(procedure, rootWraps) {
224
205
  } : guard.errors;
225
206
  const failFn = mergedErrors ? createFail(mergedErrors) : noopFail;
226
207
  const runGuards = selectGuardRunner(guards);
227
- if (wraps.length === 0 && !inputSchema && !outputSchema) return (ctx, rawInput, signal) => {
208
+ let innerHandler;
209
+ if (procedureWraps.length === 0 && !inputSchema && !outputSchema) innerHandler = (ctx, rawInput, signal) => {
228
210
  try {
229
- const guardResult = runGuards(ctx);
211
+ const guardResult = runGuards?.(ctx);
230
212
  if (guardResult && isThenable(guardResult)) return guardResult.then(() => resolveFn({
231
213
  input: rawInput,
232
214
  ctx,
@@ -245,17 +227,17 @@ function compileProcedure(procedure, rootWraps) {
245
227
  return Promise.reject(e);
246
228
  }
247
229
  };
248
- if (wraps.length === 0) return (ctx, rawInput, signal) => {
230
+ else if (procedureWraps.length === 0) innerHandler = (ctx, rawInput, signal) => {
249
231
  try {
250
- const guardResult = runGuards(ctx);
251
- if (guardResult && isThenable(guardResult)) return guardResult.then(() => _validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx, failFn, signal));
252
- return _validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx, failFn, signal);
232
+ const guardResult = runGuards?.(ctx);
233
+ if (guardResult && isThenable(guardResult)) return guardResult.then(() => validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx, failFn, signal));
234
+ return validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx, failFn, signal);
253
235
  } catch (e) {
254
236
  return Promise.reject(e);
255
237
  }
256
238
  };
257
- return async (ctx, rawInput, signal) => {
258
- const guardResult = runGuards(ctx);
239
+ else innerHandler = async (ctx, rawInput, signal) => {
240
+ const guardResult = runGuards?.(ctx);
259
241
  if (guardResult && isThenable(guardResult)) await guardResult;
260
242
  let input;
261
243
  if (inputSchema) {
@@ -273,8 +255,8 @@ function compileProcedure(procedure, rootWraps) {
273
255
  params: ctx.params ?? EMPTY_PARAMS
274
256
  }));
275
257
  };
276
- for (let i = wraps.length - 1; i >= 0; i--) {
277
- const wrapFn = wraps[i].fn;
258
+ for (let i = procedureWraps.length - 1; i >= 0; i--) {
259
+ const wrapFn = procedureWraps[i].fn;
278
260
  const next = execute;
279
261
  execute = () => wrapFn(ctx, next);
280
262
  }
@@ -283,7 +265,19 @@ function compileProcedure(procedure, rootWraps) {
283
265
  const validated = validateSchema(outputSchema, output);
284
266
  return isThenable(validated) ? await validated : validated;
285
267
  };
268
+ if (!hasRootWraps) return innerHandler;
269
+ return async (ctx, rawInput, signal) => {
270
+ let execute = async () => innerHandler(ctx, rawInput, signal);
271
+ for (let i = rootWrapList.length - 1; i >= 0; i--) {
272
+ const wrapFn = rootWrapList[i].fn;
273
+ const next = execute;
274
+ execute = () => Promise.resolve(wrapFn(ctx, next));
275
+ }
276
+ return execute();
277
+ };
286
278
  }
279
+ /** Shared empty array for the "no root wraps" case — avoids per-call allocation. */
280
+ const EMPTY_WRAPS = /* @__PURE__ */ Object.freeze([]);
287
281
  /**
288
282
  * Compile a router tree into a rou3 radix router.
289
283
  *
@@ -315,28 +309,49 @@ function compileRouter(def) {
315
309
  walk(def, []);
316
310
  return (method, path) => findRoute(router, method, path);
317
311
  }
318
- /** Pool of pre-allocated null-prototype context objects — eliminates per-request GC pressure. */
312
+ /**
313
+ * Small pool of recyclable context objects.
314
+ *
315
+ * Each request allocates a context; rather than let every one become
316
+ * GC pressure, released contexts with their properties wiped are
317
+ * parked here and re-used on the next `createContext()` call. Capped
318
+ * to prevent unbounded growth under burst traffic.
319
+ *
320
+ * Externally-visible: `test/core/context-release.test.ts` relies on
321
+ * the recycling behaviour to verify that `releaseContext` runs
322
+ * exactly once on every request exit path.
323
+ */
319
324
  const CTX_POOL = [];
320
325
  const CTX_POOL_MAX = 128;
321
- /** Acquire a context object from the pool (or create one). */
326
+ /**
327
+ * Acquire a context object — from the pool when one is available,
328
+ * otherwise a fresh null-prototype object. Null-prototype keeps user
329
+ * keys from colliding with `Object.prototype` members and avoids a
330
+ * prototype-chain walk on every property lookup.
331
+ */
322
332
  function createContext() {
323
333
  const ctx = CTX_POOL.length > 0 ? CTX_POOL.pop() : Object.create(null);
324
334
  ctx[Symbol.dispose] = disposeContext;
325
335
  return ctx;
326
336
  }
327
- /** Mark the context as owned elsewhere so `using` won't release it. */
328
- function detachContext(ctx) {
329
- ctx[Symbol.dispose] = noopDispose;
330
- }
331
337
  function disposeContext() {
332
338
  releaseContext(this);
333
339
  }
334
- function noopDispose() {}
335
- /** Return a context object to the pool after request completes. */
340
+ /**
341
+ * Release a context. Called automatically at `using` scope exit and
342
+ * explicitly by stream handlers when their stream ends.
343
+ *
344
+ * With the pool gone the object itself will be GC'd as soon as its
345
+ * last reference drops, but we still wipe its properties here.
346
+ * Callers (and tests) use "properties were cleared" as the observable
347
+ * signal that release ran exactly once — notably
348
+ * `test/core/context-release.test.ts` tags a context before handing
349
+ * it off and checks the tag is gone once the request completes.
350
+ */
336
351
  function releaseContext(ctx) {
337
352
  for (const key of Object.keys(ctx)) delete ctx[key];
338
353
  for (const sym of Object.getOwnPropertySymbols(ctx)) delete ctx[sym];
339
354
  if (CTX_POOL.length < CTX_POOL_MAX) CTX_POOL.push(ctx);
340
355
  }
341
356
  //#endregion
342
- export { compileProcedure, compileRouter, createContext, detachContext, releaseContext };
357
+ export { compileProcedure, compileRouter, createContext };
@@ -9,7 +9,7 @@ type FetchHandler = (request: Request) => Response | Promise<Response>;
9
9
  interface WrapHandlerOptions {
10
10
  analytics?: AnalyticsOptions;
11
11
  scalar?: boolean | ScalarOptions;
12
- /** URL path prefix for the handler (e.g. "/api"). Requests not matching this prefix return 404. */
12
+ /** URL path prefix for the handler (e.g. `/api`). Requests outside the prefix return 404. */
13
13
  basePath?: string;
14
14
  /**
15
15
  * Schema registry for OpenAPI / analytics schema conversion. Built from
@@ -18,8 +18,8 @@ interface WrapHandlerOptions {
18
18
  */
19
19
  schemaRegistry?: SchemaRegistry;
20
20
  /**
21
- * Hookable instance threaded so `wrapWithAnalytics` can register
22
- * lifecycle listeners on `request:prepare` / `response:finalize`.
21
+ * Hookable instance threaded through so `wrapWithAnalytics` can register
22
+ * listeners on `request:prepare` / `response:finalize`.
23
23
  * @internal
24
24
  */
25
25
  hooks?: Hookable<SilgiHooks>;
@@ -1,5 +1,5 @@
1
1
  import { routerCache } from "./router-utils.mjs";
2
- import { compileRouter, createContext, detachContext, releaseContext } from "../compile.mjs";
2
+ import { compileRouter } from "../compile.mjs";
3
3
  import { applyContext } from "./dispatch.mjs";
4
4
  import { detectResponseFormat, encodeResponse, makeErrorResponse } from "./codec.mjs";
5
5
  import { parseInput } from "./input.mjs";
@@ -7,58 +7,45 @@ import { iteratorToEventStream } from "./sse.mjs";
7
7
  import { parseUrlPath } from "./url.mjs";
8
8
  //#region src/core/handler.ts
9
9
  /**
10
- * Fetch API handler — single unified request handler.
10
+ * Fetch API handler
11
+ * -------------------
11
12
  *
12
- * Orchestrates: routing context input parsing pipeline response encoding.
13
- * Each concern lives in its own module (codec.ts, input.ts, sse.ts).
13
+ * Every adapter that speaks the Fetch API (Next.js App Router, SvelteKit,
14
+ * srvx, Bun, Cloudflare Workers, Deno) ends up calling the handler built
15
+ * here. It is the single place that turns a `Request` into a `Response`
16
+ * by running the compiled pipeline produced by `compileRouter`.
14
17
  *
15
- * Analytics / Scalar are NOT here — they wrap the handler externally
16
- * (see wrapWithAnalytics / wrapWithScalar in their respective modules).
18
+ * Responsibilities, in order:
19
+ *
20
+ * 1. URL parsing and `basePath` stripping.
21
+ * 2. Route lookup + HTTP method enforcement.
22
+ * 3. Context construction (factory + optional `AsyncLocalStorage` bridge).
23
+ * 4. `request:prepare` hook so plugins (analytics, etc.) can seed `ctx`.
24
+ * 5. Input parsing (body / query / URL params).
25
+ * 6. Pipeline execution — the compiled handler from `compileProcedure`.
26
+ * 7. Response encoding (JSON, msgpack, stream, SSE, raw `Response`).
27
+ *
28
+ * Analytics and the Scalar UI are layered on top via `wrapHandler` — they
29
+ * do not live inside the hot path.
17
30
  */
18
- /** Wrap a stream to release pooled context on completion or cancellation. */
19
- function wrapStreamWithRelease(source, ctx) {
20
- let released = false;
21
- const release = () => {
22
- if (!released) {
23
- released = true;
24
- releaseContext(ctx);
25
- }
26
- };
27
- const reader = source.getReader();
28
- return new ReadableStream({
29
- async pull(controller) {
30
- try {
31
- const { done, value } = await reader.read();
32
- if (done) {
33
- release();
34
- controller.close();
35
- } else controller.enqueue(value);
36
- } catch (err) {
37
- release();
38
- controller.error(err);
39
- }
40
- },
41
- cancel() {
42
- release();
43
- reader.cancel();
44
- }
45
- });
46
- }
47
31
  /**
48
- * Build the Response for a handler's output. The pooled `ctx` is released
49
- * by the caller's `using` scope; for streaming outputs we detach `ctx` first
50
- * so the stream becomes the sole owner (releases on stream end/cancel).
32
+ * Convert a pipeline output into an HTTP `Response`.
33
+ *
34
+ * We handle four shapes:
35
+ * - `Response` — user returned one directly; pass through.
36
+ * - `ReadableStream` — wrap in a binary response.
37
+ * - Async iterator — render as Server-Sent Events.
38
+ * - Plain value — encode as JSON (or msgpack when the client asked for it).
39
+ *
40
+ * The function is async so callers have a single `await` point and do not
41
+ * have to branch on sync-vs-async encoders underneath.
51
42
  */
52
- function makeResponse(output, route, format, ctx) {
43
+ async function makeResponse(output, route, format) {
53
44
  if (output instanceof Response) return output;
54
- if (output instanceof ReadableStream) {
55
- detachContext(ctx);
56
- return new Response(wrapStreamWithRelease(output, ctx), { headers: { "content-type": "application/octet-stream" } });
57
- }
45
+ if (output instanceof ReadableStream) return new Response(output, { headers: { "content-type": "application/octet-stream" } });
58
46
  if (output && typeof output === "object" && Symbol.asyncIterator in output) {
59
- detachContext(ctx);
60
47
  const stream = iteratorToEventStream(output);
61
- return new Response(wrapStreamWithRelease(stream, ctx), { headers: {
48
+ return new Response(stream, { headers: {
62
49
  "content-type": "text/event-stream",
63
50
  "cache-control": "no-cache"
64
51
  } });
@@ -71,35 +58,42 @@ function makeResponse(output, route, format, ctx) {
71
58
  } : { "content-type": "application/json" } });
72
59
  }
73
60
  /**
74
- * Lazily wrap a FetchHandler with analytics and/or scalar.
75
- * Returns a new handler that applies wrappers on first request (async import).
76
- * If no wrappers are needed, returns the original handler as-is.
61
+ * Wrap a `FetchHandler` with Scalar UI and/or analytics, if configured.
62
+ *
63
+ * Both wrappers have non-trivial imports (Scalar pulls in the API
64
+ * reference, analytics pulls in the dashboard). We defer those imports
65
+ * until the first request so that a handler you never hit does not pay
66
+ * the cost.
67
+ *
68
+ * If the lazy init fails (network blip, broken import) we fall back to
69
+ * the raw handler and log once — one failed init must not wedge every
70
+ * subsequent request.
77
71
  */
78
72
  function wrapHandler(handler, router, options, prefix) {
79
73
  if (!options?.scalar && !options?.analytics) return handler;
80
74
  let wrapped = handler;
81
75
  let initDone = false;
82
76
  let initPromise;
83
- async function init() {
77
+ const init = async () => {
84
78
  try {
85
- let h = handler;
79
+ let next = handler;
86
80
  if (options.scalar) {
87
81
  const { wrapWithScalar } = await import("../scalar.mjs");
88
82
  const scalarOpts = typeof options.scalar === "object" ? options.scalar : {};
89
- h = wrapWithScalar(h, router, scalarOpts, prefix, options.schemaRegistry);
83
+ next = wrapWithScalar(next, router, scalarOpts, prefix, options.schemaRegistry);
90
84
  }
91
85
  if (options.analytics) {
92
86
  const { wrapWithAnalytics } = await import("../plugins/analytics.mjs");
93
- h = wrapWithAnalytics(h, router, options.analytics, options.schemaRegistry, options.hooks);
87
+ next = wrapWithAnalytics(next, router, options.analytics, options.schemaRegistry, options.hooks);
94
88
  }
95
- wrapped = h;
89
+ wrapped = next;
96
90
  } catch (err) {
97
91
  console.error("[silgi] Failed to initialise scalar/analytics wrapper:", err);
98
92
  wrapped = handler;
99
93
  } finally {
100
94
  initDone = true;
101
95
  }
102
- }
96
+ };
103
97
  return (request) => {
104
98
  if (initDone) return wrapped(request);
105
99
  initPromise ??= init();
@@ -119,10 +113,18 @@ function createFetchHandler(routerDef, contextFactory, hooks, prefix, bridge) {
119
113
  status: 404,
120
114
  message: "Procedure not found"
121
115
  });
122
- function reportHookError(name, err) {
116
+ /**
117
+ * Hook dispatch helpers.
118
+ *
119
+ * Hook errors never fail the request. But we do log them: silently
120
+ * swallowing a hook throw hides genuine user bugs (a typo'd field, a
121
+ * thrown assertion) and the dashboard / trace / logging pipeline just
122
+ * stops working with no visible signal.
123
+ */
124
+ const reportHookError = (name, err) => {
123
125
  console.error(`[silgi] hook "${name}" threw:`, err);
124
- }
125
- function callHook(name, event) {
126
+ };
127
+ const callHook = (name, event) => {
126
128
  if (!hooks) return;
127
129
  try {
128
130
  const result = hooks.callHook(name, event);
@@ -130,16 +132,15 @@ function createFetchHandler(routerDef, contextFactory, hooks, prefix, bridge) {
130
132
  } catch (err) {
131
133
  reportHookError(name, err);
132
134
  }
133
- }
134
- function awaitHook(name, event) {
135
+ };
136
+ const awaitHook = async (name, event) => {
135
137
  if (!hooks) return;
136
138
  try {
137
- const result = hooks.callHook(name, event);
138
- if (result instanceof Promise) return result.catch((err) => reportHookError(name, err));
139
+ await hooks.callHook(name, event);
139
140
  } catch (err) {
140
141
  reportHookError(name, err);
141
142
  }
142
- }
143
+ };
143
144
  return async function handleRequest(request) {
144
145
  const url = request.url;
145
146
  let fullPath = parseUrlPath(url);
@@ -173,17 +174,15 @@ function createFetchHandler(routerDef, contextFactory, hooks, prefix, bridge) {
173
174
  });
174
175
  }
175
176
  const format = detectResponseFormat(request);
176
- using ctx = createContext();
177
+ const ctx = Object.create(null);
177
178
  let rawInput;
178
179
  try {
179
- const baseCtxResult = contextFactory(request);
180
- applyContext(ctx, baseCtxResult instanceof Promise ? await baseCtxResult : baseCtxResult);
180
+ applyContext(ctx, await contextFactory(request));
181
181
  if (match.params) ctx.params = match.params;
182
- const prepareResult = awaitHook("request:prepare", {
182
+ await awaitHook("request:prepare", {
183
183
  request,
184
184
  ctx
185
185
  });
186
- if (prepareResult) await prepareResult;
187
186
  if (!route.passthrough) rawInput = await parseInput(request, url, qMark);
188
187
  if (match.params) rawInput = rawInput != null && typeof rawInput === "object" ? {
189
188
  ...match.params,
@@ -193,8 +192,7 @@ function createFetchHandler(routerDef, contextFactory, hooks, prefix, bridge) {
193
192
  path: pathname,
194
193
  input: rawInput
195
194
  });
196
- const pipelineResult = bridge ? bridge.run(ctx, () => route.handler(ctx, rawInput, request.signal)) : route.handler(ctx, rawInput, request.signal);
197
- const output = pipelineResult instanceof Promise ? await pipelineResult : pipelineResult;
195
+ const output = await (bridge ? bridge.run(ctx, () => route.handler(ctx, rawInput, request.signal)) : route.handler(ctx, rawInput, request.signal));
198
196
  callHook("response", {
199
197
  path: pathname,
200
198
  output,
@@ -205,15 +203,13 @@ function createFetchHandler(routerDef, contextFactory, hooks, prefix, bridge) {
205
203
  ctx,
206
204
  output
207
205
  });
208
- const response = makeResponse(output, route, format, ctx);
209
- return response instanceof Promise ? await response : response;
206
+ return await makeResponse(output, route, format);
210
207
  } catch (error) {
211
208
  callHook("error", {
212
209
  path: pathname,
213
210
  error
214
211
  });
215
- const errorResponse = makeErrorResponse(error, format);
216
- return errorResponse instanceof Promise ? await errorResponse : errorResponse;
212
+ return await makeErrorResponse(error, format);
217
213
  }
218
214
  };
219
215
  }