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/builder.mjs +32 -6
- package/dist/caller.mjs +65 -55
- package/dist/compile.d.mts +15 -8
- package/dist/compile.mjs +157 -142
- package/dist/core/handler.d.mts +3 -3
- package/dist/core/handler.mjs +69 -73
- package/dist/core/input.mjs +95 -33
- package/dist/core/schema-converter.d.mts +68 -63
- package/dist/core/schema-converter.mjs +85 -56
- package/dist/core/serve.d.mts +18 -17
- package/dist/core/serve.mjs +154 -64
- package/dist/core/sse.d.mts +5 -6
- package/dist/core/sse.mjs +86 -46
- package/dist/core/task.d.mts +15 -4
- package/dist/core/task.mjs +160 -76
- package/dist/plugins/cache.d.mts +62 -126
- package/dist/plugins/cache.mjs +146 -128
- package/dist/scalar.d.mts +24 -13
- package/dist/scalar.mjs +292 -201
- package/dist/silgi.mjs +160 -117
- package/dist/ws.d.mts +26 -27
- package/dist/ws.mjs +126 -87
- package/package.json +1 -1
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
|
|
8
|
+
* Pipeline Compiler
|
|
9
|
+
* ------------------
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
/**
|
|
63
|
+
/** Shared frozen empty params object. Read only, never mutated. */
|
|
40
64
|
const EMPTY_PARAMS = /* @__PURE__ */ Object.freeze(Object.create(null));
|
|
41
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
/**
|
|
144
|
-
async
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
*
|
|
152
|
-
*
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
|
|
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) =>
|
|
196
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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(() =>
|
|
252
|
-
return
|
|
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
|
-
|
|
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 =
|
|
277
|
-
const wrapFn =
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
335
|
-
|
|
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
|
|
357
|
+
export { compileProcedure, compileRouter, createContext };
|
package/dist/core/handler.d.mts
CHANGED
|
@@ -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.
|
|
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
|
|
22
|
-
*
|
|
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>;
|
package/dist/core/handler.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { routerCache } from "./router-utils.mjs";
|
|
2
|
-
import { compileRouter
|
|
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
|
|
10
|
+
* Fetch API handler
|
|
11
|
+
* -------------------
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
|
77
|
+
const init = async () => {
|
|
84
78
|
try {
|
|
85
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
next = wrapWithAnalytics(next, router, options.analytics, options.schemaRegistry, options.hooks);
|
|
94
88
|
}
|
|
95
|
-
wrapped =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
};
|
|
136
|
+
const awaitHook = async (name, event) => {
|
|
135
137
|
if (!hooks) return;
|
|
136
138
|
try {
|
|
137
|
-
|
|
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
|
-
|
|
177
|
+
const ctx = Object.create(null);
|
|
177
178
|
let rawInput;
|
|
178
179
|
try {
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
return errorResponse instanceof Promise ? await errorResponse : errorResponse;
|
|
212
|
+
return await makeErrorResponse(error, format);
|
|
217
213
|
}
|
|
218
214
|
};
|
|
219
215
|
}
|