silgi 0.53.1 → 0.53.3

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.
@@ -1,5 +1,6 @@
1
1
  import { SilgiError, fromSilgiErrorJSON, isErrorStatus, isSilgiErrorJSON } from "../../../core/error.mjs";
2
2
  import { parseEmptyableJSON, stringifyJSON } from "../../../core/utils.mjs";
3
+ import { eventStreamToIterator } from "../../../core/sse.mjs";
3
4
  //#region src/client/adapters/fetch/index.ts
4
5
  /**
5
6
  * Fetch transport — HTTP client for browser and Node.js.
@@ -29,6 +30,7 @@ var RPCLink = class {
29
30
  signal: options.signal
30
31
  });
31
32
  const contentType = response.headers.get("content-type") ?? "";
33
+ if (contentType.includes("text/event-stream") && response.body) return eventStreamToIterator(response.body);
32
34
  let responseBody;
33
35
  if (contentType.includes("msgpack")) {
34
36
  const { decode } = await import("../../../codec/msgpack.mjs");
@@ -1,4 +1,5 @@
1
1
  import { SilgiError, fromSilgiErrorJSON, isSilgiErrorJSON } from "../../../core/error.mjs";
2
+ import { eventStreamToIterator } from "../../../core/sse.mjs";
2
3
  import { MSGPACK_CONTENT_TYPE, decode, encode } from "../../../codec/msgpack.mjs";
3
4
  import { FetchError, ofetch } from "ofetch";
4
5
  //#region src/client/adapters/ofetch/index.ts
@@ -42,7 +43,7 @@ function createLink(options) {
42
43
  body = input !== void 0 && input !== null ? devalueEncode(input) : void 0;
43
44
  } else body = input !== void 0 && input !== null ? input : void 0;
44
45
  try {
45
- const data = await ofetch(url, {
46
+ const response = await ofetch.raw(url, {
46
47
  method: "POST",
47
48
  headers,
48
49
  body,
@@ -55,21 +56,28 @@ function createLink(options) {
55
56
  onResponse: options.onResponse,
56
57
  onRequestError: options.onRequestError,
57
58
  onResponseError: options.onResponseError,
58
- ...resolvedProtocol === "messagepack" ? { responseType: "arrayBuffer" } : resolvedProtocol === "devalue" ? { responseType: "text" } : { parseResponse(text) {
59
- if (!text) return void 0;
60
- try {
61
- return JSON.parse(text);
62
- } catch {
63
- return text;
64
- }
65
- } }
59
+ responseType: "stream"
66
60
  });
61
+ if ((response.headers.get("content-type") ?? "").includes("text/event-stream") && response.body) return eventStreamToIterator(response.body);
67
62
  let decoded;
68
- if (resolvedProtocol === "messagepack") decoded = decode(new Uint8Array(data));
69
- else if (resolvedProtocol === "devalue") {
70
- const { decode: devalueDecode } = await import("../../../codec/devalue.mjs");
71
- decoded = data ? devalueDecode(data) : void 0;
72
- } else decoded = data;
63
+ if (resolvedProtocol === "messagepack") {
64
+ const buf = new Uint8Array(await new Response(response.body).arrayBuffer());
65
+ decoded = buf.length > 0 ? decode(buf) : void 0;
66
+ } else if (resolvedProtocol === "devalue") {
67
+ const text = await new Response(response.body).text();
68
+ if (text) {
69
+ const { decode: devalueDecode } = await import("../../../codec/devalue.mjs");
70
+ decoded = devalueDecode(text);
71
+ } else decoded = void 0;
72
+ } else {
73
+ const text = await new Response(response.body).text();
74
+ if (!text) decoded = void 0;
75
+ else try {
76
+ decoded = JSON.parse(text);
77
+ } catch {
78
+ decoded = text;
79
+ }
80
+ }
73
81
  if (isSilgiErrorJSON(decoded)) throw fromSilgiErrorJSON(decoded);
74
82
  return decoded;
75
83
  } catch (error) {
@@ -9,8 +9,13 @@ interface ClientOptions<TContext extends ClientContext = ClientContext> {
9
9
  }
10
10
  /** A single procedure client — callable function */
11
11
  type Client<TClientContext extends ClientContext, TInput, TOutput, _TError = SilgiError> = (...args: ClientRest<TClientContext, TInput>) => Promise<TOutput>;
12
- /** A subscription client — returns async iterator */
13
- type SubscriptionClient<TClientContext extends ClientContext, TInput, TOutput> = (...args: ClientRest<TClientContext, TInput>) => AsyncIterableIterator<TOutput>;
12
+ /**
13
+ * A subscription client resolves to an async iterator. The Promise
14
+ * boundary is the network round-trip (open the SSE/WS connection, see
15
+ * the response headers); the resolved iterator yields each event until
16
+ * the stream ends.
17
+ */
18
+ type SubscriptionClient<TClientContext extends ClientContext, TInput, TOutput> = (...args: ClientRest<TClientContext, TInput>) => Promise<AsyncIterableIterator<TOutput>>;
14
19
  /** Determine argument shape based on input and context optionality */
15
20
  type ClientRest<TClientContext extends ClientContext, TInput> = undefined extends TInput ? Record<never, never> extends TClientContext ? [input?: TInput, options?: ClientOptions<TClientContext>] : [input: TInput | undefined, options: ClientOptions<TClientContext>] : Record<never, never> extends TClientContext ? [input: TInput, options?: ClientOptions<TClientContext>] : [input: TInput, options: ClientOptions<TClientContext>];
16
21
  /** Recursive nested client — mirrors the router structure */
@@ -8,13 +8,30 @@ import { ProcedureDef, WrapDef } from "./types.mjs";
8
8
  */
9
9
  type CompiledHandler = (ctx: Record<string, unknown>, rawInput: unknown, signal: AbortSignal) => unknown | Promise<unknown>;
10
10
  /**
11
- * Compile a procedure into the fastest possible handler.
11
+ * Compile a procedure into its request handler.
12
12
  *
13
- * Optimizations applied:
14
- * - Guard count specialization (unrolled for 0-4)
15
- * - Separate fast path for no-wrap case (zero closures per request)
16
- * - Pre-computed fail function (singleton per procedure)
17
- * - Sync fast path when all guards are sync
13
+ * The generated handler is built as two nested onions:
14
+ *
15
+ * root-wrap onion (outer)
16
+ * └─ guards input validation procedure-wrap onion → resolver
17
+ * output validation (inner)
18
+ *
19
+ * The *inner* handler still selects one of three shapes up front:
20
+ *
21
+ * - fully sync fast-path when there are no procedure wraps and no
22
+ * input/output schemas;
23
+ * - semi-sync when validation is present but no procedure wraps;
24
+ * - full async onion when any procedure wrap is attached.
25
+ *
26
+ * The *outer* handler is only built when `rootWraps` is non-empty.
27
+ * When no root wraps are configured we return the inner handler as
28
+ * is — zero extra closures per request, every fast-path preserved.
29
+ *
30
+ * @param procedure The procedure definition to compile.
31
+ * @param rootWraps Instance-level wraps (from `silgi({ wraps })`).
32
+ * These wrap the *whole* inner pipeline, including
33
+ * guards — that is the contract documented on
34
+ * `SilgiConfig.wraps` (issue #14).
18
35
  */
19
36
  declare function compileProcedure(procedure: ProcedureDef, rootWraps?: readonly WrapDef[] | null): CompiledHandler;
20
37
  interface CompiledRoute {
@@ -40,20 +57,35 @@ type CompiledRouterFn = (method: string, path: string) => MatchedRoute<CompiledR
40
57
  */
41
58
  declare function compileRouter(def: Record<string, unknown>): CompiledRouterFn;
42
59
  /**
43
- * Disposable wrapper around the pipeline context.
60
+ * Disposable context object handed to the pipeline.
44
61
  *
45
- * Adapters use `using ctx = createContext()` so the context is released
46
- * automatically at scope exit — unless ownership has been transferred
47
- * elsewhere (e.g. to a streaming `Response` that keeps reading from
48
- * `ctx` after the handler returns). In that case the handler calls
49
- * `detachContext(ctx)` and the new owner is responsible for cleanup.
62
+ * Adapters use `using ctx = createContext()` so the context is
63
+ * disposed automatically at scope exit — unless ownership has been
64
+ * transferred elsewhere (e.g. to a streaming `Response` that keeps
65
+ * reading from `ctx` after the handler returns). In that case the
66
+ * handler calls `detachContext(ctx)` and the new owner is responsible
67
+ * for cleanup.
50
68
  */
51
69
  type PooledContext = Record<string, unknown> & Disposable;
52
70
  /**
53
- * Acquire a context object — from the pool when one is available,
54
- * otherwise a fresh null-prototype object. Null-prototype keeps user
55
- * keys from colliding with `Object.prototype` members and avoids a
56
- * prototype-chain walk on every property lookup.
71
+ * Allocate a fresh pipeline context.
72
+ *
73
+ * The object has a `null` prototype so user-supplied keys cannot
74
+ * accidentally shadow `Object.prototype` members and property lookups
75
+ * stay on the object itself.
76
+ *
77
+ * A `Symbol.dispose` slot is attached so `using ctx = createContext()`
78
+ * runs `releaseContext(ctx)` at scope exit. Streaming responses that
79
+ * outlive the handler scope swap that slot for a no-op via
80
+ * `detachContext` and take ownership.
81
+ *
82
+ * This used to draw from a recycled pool. The pool has been removed —
83
+ * the win was marginal, the indirection was loud, and the tests that
84
+ * pinned "pool readback" behaviour were observing an implementation
85
+ * detail, not a user-visible guarantee. The public API
86
+ * (`createContext` / `detachContext` / `releaseContext` /
87
+ * `PooledContext`) is preserved so existing call sites keep working;
88
+ * only the internals changed.
57
89
  */
58
90
  declare function createContext(): PooledContext;
59
91
  //#endregion
package/dist/compile.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { validateSchema } from "./core/schema.mjs";
1
+ import { SchemaValidatorCrash, validateSchema } from "./core/schema.mjs";
2
2
  import { SilgiError } from "./core/error.mjs";
3
3
  import { isProcedureDef } from "./core/router-utils.mjs";
4
4
  import { RAW_INPUT, ROOT_WRAPS } from "./core/ctx-symbols.mjs";
@@ -33,6 +33,87 @@ import { addRoute, createRouter, findRoute } from "rou3";
33
33
  function isThenable(value) {
34
34
  return value !== null && typeof value === "object" && typeof value.then === "function";
35
35
  }
36
+ /**
37
+ * Defaults to `true` when `process` is unavailable (Workers/browser) so
38
+ * crashes stay loud by default. Read per-crash so tests can flip
39
+ * `NODE_ENV` at runtime — the crash path is cold, the cost is moot.
40
+ */
41
+ function isDevEnv() {
42
+ if (typeof process === "undefined") return true;
43
+ return process.env?.NODE_ENV !== "production";
44
+ }
45
+ /**
46
+ * Validate input. A validator that crashes (e.g. a misconstructed
47
+ * Zod v4 schema) becomes a `BAD_REQUEST` with the original throw on
48
+ * `cause` — the client sent something the validator couldn't even
49
+ * evaluate. Soft `ValidationError` results pass through unchanged.
50
+ */
51
+ function validateInput(schema, value) {
52
+ let validated;
53
+ try {
54
+ validated = validateSchema(schema, value);
55
+ } catch (e) {
56
+ if (e instanceof SchemaValidatorCrash) throw new SilgiError("BAD_REQUEST", {
57
+ message: "Input schema validator crashed",
58
+ cause: e.cause
59
+ });
60
+ throw e;
61
+ }
62
+ if (isThenable(validated)) return validated.catch((e) => {
63
+ if (e instanceof SchemaValidatorCrash) throw new SilgiError("BAD_REQUEST", {
64
+ message: "Input schema validator crashed",
65
+ cause: e.cause
66
+ });
67
+ throw e;
68
+ });
69
+ return validated;
70
+ }
71
+ /**
72
+ * Validate output. A validator that crashes becomes an
73
+ * `INTERNAL_SERVER_ERROR` (server bug, not user input) with the original
74
+ * throw on `cause`. In dev the cause is also `console.error`'d so the
75
+ * stack shows up in the server log — the only signal otherwise is a
76
+ * generic 500.
77
+ */
78
+ function validateOutput(schema, value) {
79
+ let validated;
80
+ try {
81
+ validated = validateSchema(schema, value);
82
+ } catch (e) {
83
+ if (e instanceof SchemaValidatorCrash) {
84
+ if (isDevEnv()) console.error("[silgi] output schema validator crashed:", e.cause);
85
+ throw new SilgiError("INTERNAL_SERVER_ERROR", {
86
+ message: "Output schema validator crashed",
87
+ cause: e.cause
88
+ });
89
+ }
90
+ throw e;
91
+ }
92
+ if (isThenable(validated)) return validated.catch((e) => {
93
+ if (e instanceof SchemaValidatorCrash) {
94
+ if (isDevEnv()) console.error("[silgi] output schema validator crashed:", e.cause);
95
+ throw new SilgiError("INTERNAL_SERVER_ERROR", {
96
+ message: "Output schema validator crashed",
97
+ cause: e.cause
98
+ });
99
+ }
100
+ throw e;
101
+ });
102
+ return validated;
103
+ }
104
+ /**
105
+ * Wrap a subscription's iterator so each yielded item runs through
106
+ * `validateOutput`. The pre-#26 path validated the iterator object
107
+ * itself, which always 400'd because schemas can't find their declared
108
+ * keys on an iterator. A schema crash mid-stream propagates as a thrown
109
+ * error that the SSE encoder turns into an `error` event.
110
+ */
111
+ async function* validateIteratorOutput(iterator, schema) {
112
+ for await (const item of iterator) {
113
+ const validated = validateOutput(schema, item);
114
+ yield isThenable(validated) ? await validated : validated;
115
+ }
116
+ }
36
117
  function createFail(errors) {
37
118
  return (code, data) => {
38
119
  const def = errors[code];
@@ -143,49 +224,31 @@ function selectGuardRunner(guards) {
143
224
  if (guards.length === 0) return void 0;
144
225
  return (ctx) => runGuardsSequential(ctx, guards);
145
226
  }
146
- /** Call resolve, then validate output (sync-first, async fallback) */
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) {
153
- const output = resolveFn({
154
- input,
155
- ctx,
156
- fail: failFn,
157
- signal,
158
- params: ctx.params ?? EMPTY_PARAMS
159
- });
160
- if (!outputSchema) return output;
161
- if (isThenable(output)) return output.then((o) => validateSchema(outputSchema, o));
162
- return validateSchema(outputSchema, output);
163
- }
164
227
  /**
165
- * Validate input, call the resolver, validate output.
228
+ * Compile a procedure into its request handler.
166
229
  *
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) {
173
- try {
174
- const input = inputSchema ? validateSchema(inputSchema, rawInput ?? {}) : rawInput;
175
- if (isThenable(input)) return input.then((resolvedInput) => resolveWithOutput(resolveFn, resolvedInput, ctx, failFn, signal, outputSchema));
176
- return resolveWithOutput(resolveFn, input, ctx, failFn, signal, outputSchema);
177
- } catch (e) {
178
- return Promise.reject(e);
179
- }
180
- }
181
- /**
182
- * Compile a procedure into the fastest possible handler.
230
+ * The generated handler is built as two nested onions:
231
+ *
232
+ * root-wrap onion (outer)
233
+ * └─ guards input validation procedure-wrap onion resolver
234
+ * → output validation (inner)
235
+ *
236
+ * The *inner* handler still selects one of three shapes up front:
183
237
  *
184
- * Optimizations applied:
185
- * - Guard count specialization (unrolled for 0-4)
186
- * - Separate fast path for no-wrap case (zero closures per request)
187
- * - Pre-computed fail function (singleton per procedure)
188
- * - Sync fast path when all guards are sync
238
+ * - fully sync fast-path when there are no procedure wraps and no
239
+ * input/output schemas;
240
+ * - semi-sync when validation is present but no procedure wraps;
241
+ * - full async onion when any procedure wrap is attached.
242
+ *
243
+ * The *outer* handler is only built when `rootWraps` is non-empty.
244
+ * When no root wraps are configured we return the inner handler as
245
+ * is — zero extra closures per request, every fast-path preserved.
246
+ *
247
+ * @param procedure The procedure definition to compile.
248
+ * @param rootWraps Instance-level wraps (from `silgi({ wraps })`).
249
+ * These wrap the *whole* inner pipeline, including
250
+ * guards — that is the contract documented on
251
+ * `SilgiConfig.wraps` (issue #14).
189
252
  */
190
253
  function compileProcedure(procedure, rootWraps) {
191
254
  const middlewares = procedure.use ?? [];
@@ -198,6 +261,7 @@ function compileProcedure(procedure, rootWraps) {
198
261
  const inputSchema = procedure.input;
199
262
  const outputSchema = procedure.output;
200
263
  const resolveFn = procedure.resolve;
264
+ const isSubscription = procedure.type === "subscription";
201
265
  let mergedErrors = procedure.errors;
202
266
  for (const guard of guards) if (guard.errors) mergedErrors = mergedErrors ? {
203
267
  ...mergedErrors,
@@ -205,43 +269,14 @@ function compileProcedure(procedure, rootWraps) {
205
269
  } : guard.errors;
206
270
  const failFn = mergedErrors ? createFail(mergedErrors) : noopFail;
207
271
  const runGuards = selectGuardRunner(guards);
208
- let innerHandler;
209
- if (procedureWraps.length === 0 && !inputSchema && !outputSchema) innerHandler = (ctx, rawInput, signal) => {
210
- try {
211
- const guardResult = runGuards?.(ctx);
212
- if (guardResult && isThenable(guardResult)) return guardResult.then(() => resolveFn({
213
- input: rawInput,
214
- ctx,
215
- fail: failFn,
216
- signal,
217
- params: ctx.params ?? EMPTY_PARAMS
218
- }));
219
- return resolveFn({
220
- input: rawInput,
221
- ctx,
222
- fail: failFn,
223
- signal,
224
- params: ctx.params ?? EMPTY_PARAMS
225
- });
226
- } catch (e) {
227
- return Promise.reject(e);
272
+ const innerHandler = async (ctx, rawInput, signal) => {
273
+ if (runGuards) {
274
+ const guardResult = runGuards(ctx);
275
+ if (guardResult && isThenable(guardResult)) await guardResult;
228
276
  }
229
- };
230
- else if (procedureWraps.length === 0) innerHandler = (ctx, rawInput, signal) => {
231
- try {
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);
235
- } catch (e) {
236
- return Promise.reject(e);
237
- }
238
- };
239
- else innerHandler = async (ctx, rawInput, signal) => {
240
- const guardResult = runGuards?.(ctx);
241
- if (guardResult && isThenable(guardResult)) await guardResult;
242
277
  let input;
243
278
  if (inputSchema) {
244
- const validated = validateSchema(inputSchema, rawInput ?? {});
279
+ const validated = validateInput(inputSchema, rawInput ?? {});
245
280
  input = isThenable(validated) ? await validated : validated;
246
281
  } else input = rawInput;
247
282
  ctx[RAW_INPUT] = input;
@@ -262,12 +297,13 @@ function compileProcedure(procedure, rootWraps) {
262
297
  }
263
298
  const output = await execute();
264
299
  if (!outputSchema) return output;
265
- const validated = validateSchema(outputSchema, output);
300
+ if (isSubscription && output && typeof output === "object" && Symbol.asyncIterator in output) return validateIteratorOutput(output, outputSchema);
301
+ const validated = validateOutput(outputSchema, output);
266
302
  return isThenable(validated) ? await validated : validated;
267
303
  };
268
304
  if (!hasRootWraps) return innerHandler;
269
305
  return async (ctx, rawInput, signal) => {
270
- let execute = async () => innerHandler(ctx, rawInput, signal);
306
+ let execute = () => Promise.resolve(innerHandler(ctx, rawInput, signal));
271
307
  for (let i = rootWrapList.length - 1; i >= 0; i--) {
272
308
  const wrapFn = rootWrapList[i].fn;
273
309
  const next = execute;
@@ -310,48 +346,30 @@ function compileRouter(def) {
310
346
  return (method, path) => findRoute(router, method, path);
311
347
  }
312
348
  /**
313
- * Small pool of recyclable context objects.
349
+ * Allocate a fresh pipeline context.
314
350
  *
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.
351
+ * The object has a `null` prototype so user-supplied keys cannot
352
+ * accidentally shadow `Object.prototype` members and property lookups
353
+ * stay on the object itself.
319
354
  *
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
- */
324
- const CTX_POOL = [];
325
- const CTX_POOL_MAX = 128;
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.
355
+ * A `Symbol.dispose` slot is attached so `using ctx = createContext()`
356
+ * runs `releaseContext(ctx)` at scope exit. Streaming responses that
357
+ * outlive the handler scope swap that slot for a no-op via
358
+ * `detachContext` and take ownership.
359
+ *
360
+ * This used to draw from a recycled pool. The pool has been removed —
361
+ * the win was marginal, the indirection was loud, and the tests that
362
+ * pinned "pool readback" behaviour were observing an implementation
363
+ * detail, not a user-visible guarantee. The public API
364
+ * (`createContext` / `detachContext` / `releaseContext` /
365
+ * `PooledContext`) is preserved so existing call sites keep working;
366
+ * only the internals changed.
331
367
  */
332
368
  function createContext() {
333
- const ctx = CTX_POOL.length > 0 ? CTX_POOL.pop() : Object.create(null);
369
+ const ctx = Object.create(null);
334
370
  ctx[Symbol.dispose] = disposeContext;
335
371
  return ctx;
336
372
  }
337
- function disposeContext() {
338
- releaseContext(this);
339
- }
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
- */
351
- function releaseContext(ctx) {
352
- for (const key of Object.keys(ctx)) delete ctx[key];
353
- for (const sym of Object.getOwnPropertySymbols(ctx)) delete ctx[sym];
354
- if (CTX_POOL.length < CTX_POOL_MAX) CTX_POOL.push(ctx);
355
- }
373
+ function disposeContext() {}
356
374
  //#endregion
357
375
  export { compileProcedure, compileRouter, createContext };
@@ -13,8 +13,21 @@ declare class ValidationError extends Error {
13
13
  issues: readonly SchemaIssue[];
14
14
  });
15
15
  }
16
+ /**
17
+ * Thrown when a Standard Schema validator itself crashes (e.g. a
18
+ * misconstructed Zod v4 schema). Distinct from `ValidationError` —
19
+ * the *schema* is broken, not the value. `cause` holds the original
20
+ * throw. Only observable on direct `validateSchema()` calls; the
21
+ * pipeline rebrands these as `SilgiError` with `cause` preserved.
22
+ */
23
+ declare class SchemaValidatorCrash extends Error {
24
+ constructor(options: {
25
+ message?: string;
26
+ cause: unknown;
27
+ });
28
+ }
16
29
  /** Sync fast-path: Zod 4 validate() returns sync result — avoid Promise allocation */
17
30
  declare function validateSchema(schema: AnySchema, value: unknown): unknown;
18
31
  declare function type<TInput, TOutput = TInput>(...args: TInput extends TOutput ? TOutput extends TInput ? [map?: (input: TInput) => TOutput] : [map: (input: TInput) => TOutput] : [map: (input: TInput) => TOutput]): Schema<TInput, TOutput>;
19
32
  //#endregion
20
- export { AnySchema, InferSchemaInput, InferSchemaOutput, Schema, ValidationError, type, validateSchema };
33
+ export { AnySchema, InferSchemaInput, InferSchemaOutput, Schema, SchemaValidatorCrash, ValidationError, type, validateSchema };
@@ -7,9 +7,27 @@ var ValidationError = class extends Error {
7
7
  this.issues = options.issues;
8
8
  }
9
9
  };
10
+ /**
11
+ * Thrown when a Standard Schema validator itself crashes (e.g. a
12
+ * misconstructed Zod v4 schema). Distinct from `ValidationError` —
13
+ * the *schema* is broken, not the value. `cause` holds the original
14
+ * throw. Only observable on direct `validateSchema()` calls; the
15
+ * pipeline rebrands these as `SilgiError` with `cause` preserved.
16
+ */
17
+ var SchemaValidatorCrash = class extends Error {
18
+ constructor(options) {
19
+ super(options.message ?? "Schema validator crashed", { cause: options.cause });
20
+ this.name = "SchemaValidatorCrash";
21
+ }
22
+ };
10
23
  /** Sync fast-path: Zod 4 validate() returns sync result — avoid Promise allocation */
11
24
  function validateSchema(schema, value) {
12
- const result = schema["~standard"].validate(value);
25
+ let result;
26
+ try {
27
+ result = schema["~standard"].validate(value);
28
+ } catch (e) {
29
+ throw new SchemaValidatorCrash({ cause: e });
30
+ }
13
31
  if (typeof result?.then !== "function") {
14
32
  if ("issues" in result && result.issues) throw new ValidationError({ issues: result.issues });
15
33
  return result.value;
@@ -17,6 +35,8 @@ function validateSchema(schema, value) {
17
35
  return result.then((r) => {
18
36
  if ("issues" in r && r.issues) throw new ValidationError({ issues: r.issues });
19
37
  return r.value;
38
+ }, (e) => {
39
+ throw new SchemaValidatorCrash({ cause: e });
20
40
  });
21
41
  }
22
42
  function type(...args) {
@@ -30,4 +50,4 @@ function type(...args) {
30
50
  } };
31
51
  }
32
52
  //#endregion
33
- export { ValidationError, type, validateSchema };
53
+ export { SchemaValidatorCrash, ValidationError, type, validateSchema };
package/dist/core/sse.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SilgiError } from "./error.mjs";
2
+ import { AsyncIteratorClass } from "./iterator.mjs";
2
3
  //#region src/core/sse.ts
3
4
  /**
4
5
  * Server-Sent Events
@@ -60,6 +61,74 @@ function encodeEventMessage(msg) {
60
61
  return lines.join("\n") + "\n\n";
61
62
  }
62
63
  /**
64
+ * Incremental SSE decoder for chunked text input.
65
+ *
66
+ * Feed it text as it arrives from the network; it emits a full
67
+ * `EventMessage` through the `onEvent` callback once it has seen a
68
+ * blank-line terminator. The trailing partial event (if any) is held
69
+ * over until the next `feed()` call — or flushed explicitly on stream
70
+ * end via `flush()`.
71
+ */
72
+ var EventDecoder = class {
73
+ #partial = "";
74
+ #onEvent;
75
+ constructor(onEvent) {
76
+ this.#onEvent = onEvent;
77
+ }
78
+ feed(chunk) {
79
+ this.#partial += chunk;
80
+ const blocks = this.#partial.split("\n\n");
81
+ this.#partial = blocks.pop() ?? "";
82
+ for (const block of blocks) {
83
+ if (!block.trim()) continue;
84
+ const msg = this.#parseBlock(block);
85
+ if (msg) this.#onEvent(msg);
86
+ }
87
+ }
88
+ /** Parse any remaining partial block. Call once at end-of-stream. */
89
+ flush() {
90
+ if (this.#partial.trim()) {
91
+ const msg = this.#parseBlock(this.#partial);
92
+ if (msg) this.#onEvent(msg);
93
+ this.#partial = "";
94
+ }
95
+ }
96
+ #parseBlock(block) {
97
+ const msg = {};
98
+ let hasContent = false;
99
+ for (const line of block.split("\n")) {
100
+ if (line.startsWith(":")) {
101
+ msg.comment = (msg.comment ? msg.comment + "\n" : "") + line.slice(2);
102
+ hasContent = true;
103
+ continue;
104
+ }
105
+ const colon = line.indexOf(":");
106
+ if (colon === -1) continue;
107
+ const field = line.slice(0, colon);
108
+ const value = line.slice(colon + 1).trimStart();
109
+ switch (field) {
110
+ case "event":
111
+ msg.event = value;
112
+ hasContent = true;
113
+ break;
114
+ case "data":
115
+ msg.data = (msg.data ? msg.data + "\n" : "") + value;
116
+ hasContent = true;
117
+ break;
118
+ case "id":
119
+ msg.id = value;
120
+ hasContent = true;
121
+ break;
122
+ case "retry":
123
+ msg.retry = parseInt(value, 10);
124
+ hasContent = true;
125
+ break;
126
+ }
127
+ }
128
+ return hasContent ? msg : null;
129
+ }
130
+ };
131
+ /**
63
132
  * Build an SSE `ReadableStream` that consumes an async iterator.
64
133
  *
65
134
  * Each yielded value becomes a `message` event; the iterator's return
@@ -146,5 +215,102 @@ function iteratorToEventStream(iterator, options = {}) {
146
215
  }
147
216
  }).pipeThrough(new TextEncoderStream());
148
217
  }
218
+ /**
219
+ * Turn an SSE `ReadableStream` back into an async iterator.
220
+ *
221
+ * `message` events → yielded values (deserialized)
222
+ * `error` events → thrown exceptions
223
+ * `done` event → normal iterator completion
224
+ *
225
+ * The decoder runs on its own microtask loop, buffering decoded events
226
+ * into a queue that `next()` drains. The queue is needed because the
227
+ * network read loop and the consumer run at different cadences.
228
+ */
229
+ function eventStreamToIterator(stream, options = {}) {
230
+ const deserialize = options.deserialize ?? ((d) => JSON.parse(d));
231
+ const decodedStream = stream.pipeThrough(new TextDecoderStream());
232
+ const reader = decodedStream.getReader();
233
+ const events = [];
234
+ let wakeUp;
235
+ let done = false;
236
+ let error;
237
+ const decoder = new EventDecoder((msg) => {
238
+ events.push(msg);
239
+ wakeUp?.();
240
+ wakeUp = void 0;
241
+ });
242
+ /** Background reader — drains `stream` into `decoder` until end/err. */
243
+ const readLoop = async () => {
244
+ try {
245
+ while (true) {
246
+ const { done: readerDone, value } = await reader.read();
247
+ if (readerDone) {
248
+ decoder.flush();
249
+ done = true;
250
+ wakeUp?.();
251
+ return;
252
+ }
253
+ decoder.feed(value);
254
+ }
255
+ } catch (err) {
256
+ done = true;
257
+ error = err instanceof Error ? err : new Error(String(err));
258
+ wakeUp?.();
259
+ }
260
+ };
261
+ readLoop();
262
+ /** Wait until either a new event lands or the stream ends. */
263
+ const waitForEvent = () => new Promise((resolve) => {
264
+ wakeUp = resolve;
265
+ });
266
+ /** Translate one decoded `EventMessage` into an iterator step. */
267
+ const interpret = (msg) => {
268
+ switch (msg.event) {
269
+ case "message": {
270
+ const value = msg.data ? deserialize(msg.data) : void 0;
271
+ return {
272
+ done: false,
273
+ value: msg.id || msg.retry ? withEventMeta(value, {
274
+ id: msg.id,
275
+ retry: msg.retry
276
+ }) : value
277
+ };
278
+ }
279
+ case "error": {
280
+ const payload = msg.data ? JSON.parse(msg.data) : {};
281
+ throw new Error(payload.message ?? "Stream error");
282
+ }
283
+ case "done": return {
284
+ done: true,
285
+ value: void 0
286
+ };
287
+ default: return "skip";
288
+ }
289
+ };
290
+ return new AsyncIteratorClass(async () => {
291
+ while (true) {
292
+ if (events.length > 0) {
293
+ const step = interpret(events.shift());
294
+ if (step !== "skip") return step;
295
+ continue;
296
+ }
297
+ if (done) {
298
+ if (error) throw error;
299
+ return {
300
+ done: true,
301
+ value: void 0
302
+ };
303
+ }
304
+ await waitForEvent();
305
+ }
306
+ }, async () => {
307
+ try {
308
+ await decodedStream.cancel();
309
+ } catch {}
310
+ try {
311
+ reader.releaseLock();
312
+ } catch {}
313
+ });
314
+ }
149
315
  //#endregion
150
- export { encodeEventMessage, getEventMeta, iteratorToEventStream, withEventMeta };
316
+ export { encodeEventMessage, eventStreamToIterator, getEventMeta, iteratorToEventStream, withEventMeta };
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { AnySchema, InferSchemaInput, InferSchemaOutput, Schema, ValidationError, type, validateSchema } from "./core/schema.mjs";
1
+ import { AnySchema, InferSchemaInput, InferSchemaOutput, Schema, SchemaValidatorCrash, ValidationError, type, validateSchema } from "./core/schema.mjs";
2
2
  import { CronRegistry, ScheduledTaskInfo, TaskDef, TaskEvent, collectCronTasks, createCronRegistry, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs } from "./core/task.mjs";
3
3
  import { ErrorDef, ErrorDefItem, FailFn, GuardDef, GuardFn, InferClient, InferContextFromUse, InferGuardOutput, Meta, MiddlewareDef, ProcedureDef, ProcedureType, ResolveContext, RouterDef, WrapDef, WrapFn } from "./types.mjs";
4
4
  import { ConvertOptions, JSONSchema, SchemaConverter, SchemaRegistry, createSchemaRegistry, schemaToJsonSchema } from "./core/schema-converter.mjs";
@@ -18,4 +18,4 @@ import { mapInput } from "./map-input.mjs";
18
18
  import { compileProcedure, compileRouter, createContext } from "./compile.mjs";
19
19
  import { ProcedureSummary, collectProcedures, getProcedurePaths, isProcedureDef } from "./core/router-utils.mjs";
20
20
  import { LazyRouter, isLazy, lazy, resolveLazy } from "./lazy.mjs";
21
- export { type AnySchema, AsyncIteratorClass, type BaseContext, type CallableOptions, type ContextBridge, type ConvertOptions, type CronRegistry, type Driver, type ErrorDef, type ErrorDefItem, type EventMeta, type FailFn, type GuardDef, type GuardFn, type InferClient, type InferContextFromUse, type InferGuardOutput, type InferSchemaInput, type InferSchemaOutput, type JSONSchema, type LazyRouter, type LifecycleHooks, type Meta, type MiddlewareDef, type ProcedureBuilder, type ProcedureBuilderWithOutput, type ProcedureDef, type ProcedureSummary, type ProcedureType, type ResolveContext, type RouterDef, type ScalarOptions, type ScheduledTaskInfo, type Schema, type SchemaConverter, type SchemaRegistry, type ServeOptions, type SilgiConfig, SilgiError, type SilgiErrorCode, type SilgiErrorJSON, type SilgiErrorOptions, type SilgiInstance, type SilgiServer, type Storage, type StorageConfig, type StorageValue, type TaskDef, type TaskEvent, ValidationError, type WrapDef, type WrapFn, callable, collectCronTasks, collectProcedures, compileProcedure, compileRouter, createContext, createContextBridge, createCronRegistry, createSchemaRegistry, generateOpenAPI, getEventMeta, getProcedurePaths, getScheduledTasks, initStorage, isDefinedError, isLazy, isProcedureDef, isSilgiError, lazy, lifecycleWrap, mapAsyncIterator, mapInput, resetStorage, resolveLazy, runTask, scalarHTML, schemaToJsonSchema, setTaskAnalytics, silgi, startCronJobs, stopCronJobs, toSilgiError, type, useStorage, validateSchema, withEventMeta };
21
+ export { type AnySchema, AsyncIteratorClass, type BaseContext, type CallableOptions, type ContextBridge, type ConvertOptions, type CronRegistry, type Driver, type ErrorDef, type ErrorDefItem, type EventMeta, type FailFn, type GuardDef, type GuardFn, type InferClient, type InferContextFromUse, type InferGuardOutput, type InferSchemaInput, type InferSchemaOutput, type JSONSchema, type LazyRouter, type LifecycleHooks, type Meta, type MiddlewareDef, type ProcedureBuilder, type ProcedureBuilderWithOutput, type ProcedureDef, type ProcedureSummary, type ProcedureType, type ResolveContext, type RouterDef, type ScalarOptions, type ScheduledTaskInfo, type Schema, type SchemaConverter, type SchemaRegistry, SchemaValidatorCrash, type ServeOptions, type SilgiConfig, SilgiError, type SilgiErrorCode, type SilgiErrorJSON, type SilgiErrorOptions, type SilgiInstance, type SilgiServer, type Storage, type StorageConfig, type StorageValue, type TaskDef, type TaskEvent, ValidationError, type WrapDef, type WrapFn, callable, collectCronTasks, collectProcedures, compileProcedure, compileRouter, createContext, createContextBridge, createCronRegistry, createSchemaRegistry, generateOpenAPI, getEventMeta, getProcedurePaths, getScheduledTasks, initStorage, isDefinedError, isLazy, isProcedureDef, isSilgiError, lazy, lifecycleWrap, mapAsyncIterator, mapInput, resetStorage, resolveLazy, runTask, scalarHTML, schemaToJsonSchema, setTaskAnalytics, silgi, startCronJobs, stopCronJobs, toSilgiError, type, useStorage, validateSchema, withEventMeta };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { ValidationError, type, validateSchema } from "./core/schema.mjs";
1
+ import { SchemaValidatorCrash, ValidationError, type, validateSchema } from "./core/schema.mjs";
2
2
  import { collectCronTasks, createCronRegistry, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs } from "./core/task.mjs";
3
3
  import { SilgiError, isDefinedError, isSilgiError, toSilgiError } from "./core/error.mjs";
4
4
  import { collectProcedures, getProcedurePaths, isProcedureDef } from "./core/router-utils.mjs";
@@ -14,4 +14,4 @@ import { mapInput } from "./map-input.mjs";
14
14
  import { isLazy, lazy, resolveLazy } from "./lazy.mjs";
15
15
  import { initStorage, resetStorage, useStorage } from "./core/storage.mjs";
16
16
  import { generateOpenAPI, scalarHTML } from "./scalar.mjs";
17
- export { AsyncIteratorClass, SilgiError, ValidationError, callable, collectCronTasks, collectProcedures, compileProcedure, compileRouter, createContext, createContextBridge, createCronRegistry, createSchemaRegistry, generateOpenAPI, getEventMeta, getProcedurePaths, getScheduledTasks, initStorage, isDefinedError, isLazy, isProcedureDef, isSilgiError, lazy, lifecycleWrap, mapAsyncIterator, mapInput, resetStorage, resolveLazy, runTask, scalarHTML, schemaToJsonSchema, setTaskAnalytics, silgi, startCronJobs, stopCronJobs, toSilgiError, type, useStorage, validateSchema, withEventMeta };
17
+ export { AsyncIteratorClass, SchemaValidatorCrash, SilgiError, ValidationError, callable, collectCronTasks, collectProcedures, compileProcedure, compileRouter, createContext, createContextBridge, createCronRegistry, createSchemaRegistry, generateOpenAPI, getEventMeta, getProcedurePaths, getScheduledTasks, initStorage, isDefinedError, isLazy, isProcedureDef, isSilgiError, lazy, lifecycleWrap, mapAsyncIterator, mapInput, resetStorage, resolveLazy, runTask, scalarHTML, schemaToJsonSchema, setTaskAnalytics, silgi, startCronJobs, stopCronJobs, toSilgiError, type, useStorage, validateSchema, withEventMeta };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "silgi",
3
- "version": "0.53.1",
3
+ "version": "0.53.3",
4
4
  "private": false,
5
5
  "description": "The fastest end-to-end type-safe RPC framework for TypeScript — compiled pipelines, single package, every runtime",
6
6
  "keywords": [