silgi 0.52.2 → 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
@@ -1,15 +1,34 @@
1
1
  import { validateSchema } from "./core/schema.mjs";
2
2
  import { SilgiError } from "./core/error.mjs";
3
3
  import { isProcedureDef } from "./core/router-utils.mjs";
4
- import { RAW_INPUT } from "./core/ctx-symbols.mjs";
4
+ 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
  }
@@ -207,12 +187,14 @@ function _validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx
207
187
  * - Pre-computed fail function (singleton per procedure)
208
188
  * - Sync fast path when all guards are sync
209
189
  */
210
- function compileProcedure(procedure) {
190
+ function compileProcedure(procedure, rootWraps) {
211
191
  const middlewares = procedure.use ?? [];
212
192
  const guards = [];
213
- const wraps = [];
193
+ const procedureWraps = [];
214
194
  for (const mw of middlewares) if (mw.kind === "guard") guards.push(mw);
215
- 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;
216
198
  const inputSchema = procedure.input;
217
199
  const outputSchema = procedure.output;
218
200
  const resolveFn = procedure.resolve;
@@ -223,9 +205,10 @@ function compileProcedure(procedure) {
223
205
  } : guard.errors;
224
206
  const failFn = mergedErrors ? createFail(mergedErrors) : noopFail;
225
207
  const runGuards = selectGuardRunner(guards);
226
- if (wraps.length === 0 && !inputSchema && !outputSchema) return (ctx, rawInput, signal) => {
208
+ let innerHandler;
209
+ if (procedureWraps.length === 0 && !inputSchema && !outputSchema) innerHandler = (ctx, rawInput, signal) => {
227
210
  try {
228
- const guardResult = runGuards(ctx);
211
+ const guardResult = runGuards?.(ctx);
229
212
  if (guardResult && isThenable(guardResult)) return guardResult.then(() => resolveFn({
230
213
  input: rawInput,
231
214
  ctx,
@@ -244,17 +227,17 @@ function compileProcedure(procedure) {
244
227
  return Promise.reject(e);
245
228
  }
246
229
  };
247
- if (wraps.length === 0) return (ctx, rawInput, signal) => {
230
+ else if (procedureWraps.length === 0) innerHandler = (ctx, rawInput, signal) => {
248
231
  try {
249
- const guardResult = runGuards(ctx);
250
- if (guardResult && isThenable(guardResult)) return guardResult.then(() => _validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx, failFn, signal));
251
- 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);
252
235
  } catch (e) {
253
236
  return Promise.reject(e);
254
237
  }
255
238
  };
256
- return async (ctx, rawInput, signal) => {
257
- const guardResult = runGuards(ctx);
239
+ else innerHandler = async (ctx, rawInput, signal) => {
240
+ const guardResult = runGuards?.(ctx);
258
241
  if (guardResult && isThenable(guardResult)) await guardResult;
259
242
  let input;
260
243
  if (inputSchema) {
@@ -272,8 +255,8 @@ function compileProcedure(procedure) {
272
255
  params: ctx.params ?? EMPTY_PARAMS
273
256
  }));
274
257
  };
275
- for (let i = wraps.length - 1; i >= 0; i--) {
276
- const wrapFn = wraps[i].fn;
258
+ for (let i = procedureWraps.length - 1; i >= 0; i--) {
259
+ const wrapFn = procedureWraps[i].fn;
277
260
  const next = execute;
278
261
  execute = () => wrapFn(ctx, next);
279
262
  }
@@ -282,7 +265,19 @@ function compileProcedure(procedure) {
282
265
  const validated = validateSchema(outputSchema, output);
283
266
  return isThenable(validated) ? await validated : validated;
284
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
+ };
285
278
  }
279
+ /** Shared empty array for the "no root wraps" case — avoids per-call allocation. */
280
+ const EMPTY_WRAPS = /* @__PURE__ */ Object.freeze([]);
286
281
  /**
287
282
  * Compile a router tree into a rou3 radix router.
288
283
  *
@@ -290,6 +285,7 @@ function compileProcedure(procedure) {
290
285
  */
291
286
  function compileRouter(def) {
292
287
  const router = createRouter();
288
+ const rootWraps = def[ROOT_WRAPS];
293
289
  function walk(node, path) {
294
290
  if (isProcedureDef(node)) {
295
291
  const proc = node;
@@ -299,7 +295,7 @@ function compileRouter(def) {
299
295
  let cacheControl;
300
296
  if (route?.cache != null) cacheControl = typeof route.cache === "number" ? `public, max-age=${route.cache}` : route.cache;
301
297
  const compiled = {
302
- handler: compileProcedure(proc),
298
+ handler: compileProcedure(proc, rootWraps),
303
299
  cacheControl,
304
300
  passthrough: routePath.includes("**") || void 0,
305
301
  method
@@ -313,28 +309,49 @@ function compileRouter(def) {
313
309
  walk(def, []);
314
310
  return (method, path) => findRoute(router, method, path);
315
311
  }
316
- /** 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
+ */
317
324
  const CTX_POOL = [];
318
325
  const CTX_POOL_MAX = 128;
319
- /** 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
+ */
320
332
  function createContext() {
321
333
  const ctx = CTX_POOL.length > 0 ? CTX_POOL.pop() : Object.create(null);
322
334
  ctx[Symbol.dispose] = disposeContext;
323
335
  return ctx;
324
336
  }
325
- /** Mark the context as owned elsewhere so `using` won't release it. */
326
- function detachContext(ctx) {
327
- ctx[Symbol.dispose] = noopDispose;
328
- }
329
337
  function disposeContext() {
330
338
  releaseContext(this);
331
339
  }
332
- function noopDispose() {}
333
- /** 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
+ */
334
351
  function releaseContext(ctx) {
335
352
  for (const key of Object.keys(ctx)) delete ctx[key];
336
353
  for (const sym of Object.getOwnPropertySymbols(ctx)) delete ctx[sym];
337
354
  if (CTX_POOL.length < CTX_POOL_MAX) CTX_POOL.push(ctx);
338
355
  }
339
356
  //#endregion
340
- export { compileProcedure, compileRouter, createContext, detachContext, releaseContext };
357
+ export { compileProcedure, compileRouter, createContext };
@@ -17,5 +17,22 @@
17
17
  * @internal
18
18
  */
19
19
  const RAW_INPUT = Symbol.for("silgi.rawInput");
20
+ /**
21
+ * Brand stamped on a `RouterDef` by `silgi({ wraps }).router(def)` to carry
22
+ * the instance's root wraps along with the def itself.
23
+ *
24
+ * @remarks
25
+ * Every compile site (`silgi.router`, `createCaller`, `createFetchHandler`,
26
+ * WS hooks, adapter `createHandler` variants) calls `compileRouter(def)`.
27
+ * Reading the brand off `def` inside `compileRouter` means root wraps
28
+ * reach every adapter without any per-adapter plumbing, without relying
29
+ * on `routerCache`, and without a second tree walk.
30
+ *
31
+ * The brand is a non-enumerable own property on the user's def (Symbol
32
+ * keys are skipped by `Object.entries`, so router traversal is unaffected).
33
+ *
34
+ * @internal
35
+ */
36
+ const ROOT_WRAPS = Symbol.for("silgi.rootWraps");
20
37
  //#endregion
21
- export { RAW_INPUT };
38
+ export { RAW_INPUT, ROOT_WRAPS };
@@ -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>;