silgi 0.52.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,7 @@ function createHandler(router, options = {}) {
26
26
  const isV2 = event.version === "2.0";
27
27
  const method = isV2 ? event.requestContext?.http?.method ?? "GET" : event.httpMethod ?? "GET";
28
28
  let pathname = isV2 ? event.rawPath ?? "/" : event.path ?? "/";
29
- if (prefix && pathname.startsWith(prefix)) pathname = pathname.slice(prefix.length);
29
+ if (prefix && (pathname === prefix || pathname.startsWith(prefix + "/"))) pathname = pathname.slice(prefix.length);
30
30
  if (pathname.startsWith("/")) pathname = pathname.slice(1);
31
31
  const match = flatRouter(method, "/" + pathname);
32
32
  if (!match) return {
@@ -36,7 +36,6 @@ function ioredisTransport(pub, sub) {
36
36
  }
37
37
  };
38
38
  }
39
- let globalSeq = 0;
40
39
  /**
41
40
  * Create a Redis broker driver from a RedisTransport.
42
41
  *
@@ -49,7 +48,7 @@ let globalSeq = 0;
49
48
  * Wire format: `"inbox-channel\npayload"` (newline separator, no double-serialization)
50
49
  */
51
50
  function redisBroker(transport, options = {}) {
52
- const inboxPrefix = options.inbox ?? `silgi:inbox:${Date.now().toString(36)}:${(++globalSeq).toString(36)}`;
51
+ const inboxPrefix = options.inbox ?? `silgi:inbox:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 10)}`;
53
52
  let requestSeq = 0;
54
53
  return {
55
54
  async subscribe(subject, handler) {
package/dist/builder.mjs CHANGED
@@ -22,6 +22,7 @@ var ProcBuilder = class {
22
22
  route = null;
23
23
  meta = null;
24
24
  _contextFactory = null;
25
+ _rootWrapsGetter = null;
25
26
  constructor(type) {
26
27
  this.type = type;
27
28
  }
@@ -58,12 +59,13 @@ var ProcBuilder = class {
58
59
  return this;
59
60
  }
60
61
  $task(config) {
61
- return createTaskFromProcedure(config, config.resolve, this.input, this.use, this._contextFactory);
62
+ return createTaskFromProcedure(config, config.resolve, this.input, this.use, this._contextFactory, this._rootWrapsGetter);
62
63
  }
63
64
  };
64
- function createProcedureBuilder(type, contextFactory) {
65
+ function createProcedureBuilder(type, contextFactory, rootWrapsGetter) {
65
66
  const b = new ProcBuilder(type);
66
67
  if (contextFactory) b._contextFactory = contextFactory;
68
+ if (rootWrapsGetter) b._rootWrapsGetter = rootWrapsGetter;
67
69
  return b;
68
70
  }
69
71
  //#endregion
package/dist/caller.mjs CHANGED
@@ -23,6 +23,13 @@ import { applyContext } from "./core/dispatch.mjs";
23
23
  * ```
24
24
  */
25
25
  /**
26
+ * Never-aborting signal used when `timeout: null` is opted into and the
27
+ * caller passes no per-call signal. Allocated once at module load; compiled
28
+ * handlers can still `.addEventListener('abort', …)` safely — the listener
29
+ * simply never fires.
30
+ */
31
+ const NEVER = new AbortController().signal;
32
+ /**
26
33
  * Create a direct caller for a router — no HTTP, no serialization.
27
34
  *
28
35
  * Returns a proxy that mirrors the router's nested structure.
@@ -73,7 +80,7 @@ function createCaller(routerDef, contextFactory, options) {
73
80
  if (!match) throw new Error(`Procedure not found: ${path}`);
74
81
  const ctx = await resolveContext(callOptions?.context);
75
82
  if (match.params) ctx.params = match.params;
76
- const signal = callOptions?.signal ?? (defaultTimeout !== null ? AbortSignal.timeout(defaultTimeout) : void 0);
83
+ const signal = callOptions?.signal ?? (defaultTimeout !== null ? AbortSignal.timeout(defaultTimeout) : NEVER);
77
84
  try {
78
85
  const result = match.data.handler(ctx, input, signal);
79
86
  return result instanceof Promise ? await result : result;
@@ -65,11 +65,16 @@ function withRetry(link, options = {}) {
65
65
  path
66
66
  });
67
67
  await new Promise((resolve, reject) => {
68
- const timer = setTimeout(resolve, delay);
69
- callOptions.signal?.addEventListener("abort", () => {
68
+ const signal = callOptions.signal;
69
+ const onAbort = () => {
70
70
  clearTimeout(timer);
71
- reject(callOptions.signal.reason);
72
- }, { once: true });
71
+ reject(signal.reason);
72
+ };
73
+ const timer = setTimeout(() => {
74
+ signal?.removeEventListener("abort", onAbort);
75
+ resolve();
76
+ }, delay);
77
+ signal?.addEventListener("abort", onAbort, { once: true });
73
78
  });
74
79
  }
75
80
  throw new Error("Retry exhausted");
@@ -1,4 +1,4 @@
1
- import { ProcedureDef } from "./types.mjs";
1
+ import { ProcedureDef, WrapDef } from "./types.mjs";
2
2
 
3
3
  //#region src/compile.d.ts
4
4
  /**
@@ -15,7 +15,7 @@ type CompiledHandler = (ctx: Record<string, unknown>, rawInput: unknown, signal:
15
15
  * - Pre-computed fail function (singleton per procedure)
16
16
  * - Sync fast path when all guards are sync
17
17
  */
18
- declare function compileProcedure(procedure: ProcedureDef): CompiledHandler;
18
+ declare function compileProcedure(procedure: ProcedureDef, rootWraps?: readonly WrapDef[] | null): CompiledHandler;
19
19
  interface CompiledRoute {
20
20
  handler: CompiledHandler;
21
21
  /** Pre-computed Cache-Control header value, or undefined if no caching */
package/dist/compile.mjs CHANGED
@@ -1,7 +1,7 @@
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
  /**
@@ -207,10 +207,11 @@ function _validateAndResolve(inputSchema, outputSchema, resolveFn, rawInput, ctx
207
207
  * - Pre-computed fail function (singleton per procedure)
208
208
  * - Sync fast path when all guards are sync
209
209
  */
210
- function compileProcedure(procedure) {
210
+ function compileProcedure(procedure, rootWraps) {
211
211
  const middlewares = procedure.use ?? [];
212
212
  const guards = [];
213
213
  const wraps = [];
214
+ if (rootWraps && rootWraps.length > 0) for (let i = 0; i < rootWraps.length; i++) wraps.push(rootWraps[i]);
214
215
  for (const mw of middlewares) if (mw.kind === "guard") guards.push(mw);
215
216
  else wraps.push(mw);
216
217
  const inputSchema = procedure.input;
@@ -290,6 +291,7 @@ function compileProcedure(procedure) {
290
291
  */
291
292
  function compileRouter(def) {
292
293
  const router = createRouter();
294
+ const rootWraps = def[ROOT_WRAPS];
293
295
  function walk(node, path) {
294
296
  if (isProcedureDef(node)) {
295
297
  const proc = node;
@@ -299,7 +301,7 @@ function compileRouter(def) {
299
301
  let cacheControl;
300
302
  if (route?.cache != null) cacheControl = typeof route.cache === "number" ? `public, max-age=${route.cache}` : route.cache;
301
303
  const compiled = {
302
- handler: compileProcedure(proc),
304
+ handler: compileProcedure(proc, rootWraps),
303
305
  cacheControl,
304
306
  passthrough: routePath.includes("**") || void 0,
305
307
  method
@@ -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 };
@@ -77,23 +77,31 @@ function makeResponse(output, route, format, ctx) {
77
77
  */
78
78
  function wrapHandler(handler, router, options, prefix) {
79
79
  if (!options?.scalar && !options?.analytics) return handler;
80
- let wrapped;
80
+ let wrapped = handler;
81
+ let initDone = false;
81
82
  let initPromise;
82
83
  async function init() {
83
- let h = handler;
84
- if (options.scalar) {
85
- const { wrapWithScalar } = await import("../scalar.mjs");
86
- const scalarOpts = typeof options.scalar === "object" ? options.scalar : {};
87
- h = wrapWithScalar(h, router, scalarOpts, prefix, options.schemaRegistry);
88
- }
89
- if (options.analytics) {
90
- const { wrapWithAnalytics } = await import("../plugins/analytics.mjs");
91
- h = wrapWithAnalytics(h, router, options.analytics, options.schemaRegistry, options.hooks);
84
+ try {
85
+ let h = handler;
86
+ if (options.scalar) {
87
+ const { wrapWithScalar } = await import("../scalar.mjs");
88
+ const scalarOpts = typeof options.scalar === "object" ? options.scalar : {};
89
+ h = wrapWithScalar(h, router, scalarOpts, prefix, options.schemaRegistry);
90
+ }
91
+ if (options.analytics) {
92
+ const { wrapWithAnalytics } = await import("../plugins/analytics.mjs");
93
+ h = wrapWithAnalytics(h, router, options.analytics, options.schemaRegistry, options.hooks);
94
+ }
95
+ wrapped = h;
96
+ } catch (err) {
97
+ console.error("[silgi] Failed to initialise scalar/analytics wrapper:", err);
98
+ wrapped = handler;
99
+ } finally {
100
+ initDone = true;
92
101
  }
93
- wrapped = h;
94
102
  }
95
103
  return (request) => {
96
- if (wrapped) return wrapped(request);
104
+ if (initDone) return wrapped(request);
97
105
  initPromise ??= init();
98
106
  return initPromise.then(() => wrapped(request));
99
107
  };
@@ -111,25 +119,32 @@ function createFetchHandler(routerDef, contextFactory, hooks, prefix, bridge) {
111
119
  status: 404,
112
120
  message: "Procedure not found"
113
121
  });
122
+ function reportHookError(name, err) {
123
+ console.error(`[silgi] hook "${name}" threw:`, err);
124
+ }
114
125
  function callHook(name, event) {
115
126
  if (!hooks) return;
116
127
  try {
117
128
  const result = hooks.callHook(name, event);
118
- if (result instanceof Promise) result.catch(() => {});
119
- } catch {}
129
+ if (result instanceof Promise) result.catch((err) => reportHookError(name, err));
130
+ } catch (err) {
131
+ reportHookError(name, err);
132
+ }
120
133
  }
121
134
  function awaitHook(name, event) {
122
135
  if (!hooks) return;
123
136
  try {
124
137
  const result = hooks.callHook(name, event);
125
- if (result instanceof Promise) return result.catch(() => {});
126
- } catch {}
138
+ if (result instanceof Promise) return result.catch((err) => reportHookError(name, err));
139
+ } catch (err) {
140
+ reportHookError(name, err);
141
+ }
127
142
  }
128
143
  return async function handleRequest(request) {
129
144
  const url = request.url;
130
145
  let fullPath = parseUrlPath(url);
131
146
  if (prefix) {
132
- if (!fullPath.startsWith(prefix)) return new Response(notFoundBody, {
147
+ if (fullPath !== prefix && !fullPath.startsWith(prefix + "/")) return new Response(notFoundBody, {
133
148
  status: 404,
134
149
  headers: jsonHeaders
135
150
  });
@@ -5,19 +5,36 @@ import { SilgiError } from "./error.mjs";
5
5
  */
6
6
  let _msgpack;
7
7
  let _devalue;
8
- const isBun = typeof globalThis.Bun !== "undefined";
9
8
  /** Max allowed size for GET ?data= parameter (bytes). Prevents JSON bomb via URL. */
10
9
  const MAX_QUERY_DATA_LENGTH = 8192;
10
+ /**
11
+ * Find the value of the `data=` query parameter by key, not by substring.
12
+ *
13
+ * The previous `indexOf('data=')` implementation matched `userdata=...`,
14
+ * `xdata=...`, or any other key ending in `data` — silently returning the
15
+ * wrong value. This scans for `data=` at a parameter boundary (either the
16
+ * first param or after `&`).
17
+ */
18
+ function findDataParam(searchStr) {
19
+ let i = 0;
20
+ while (i < searchStr.length) {
21
+ if (searchStr.startsWith("data=", i)) {
22
+ const valueStart = i + 5;
23
+ const valueEnd = searchStr.indexOf("&", valueStart);
24
+ return valueEnd === -1 ? searchStr.slice(valueStart) : searchStr.slice(valueStart, valueEnd);
25
+ }
26
+ const nextAmp = searchStr.indexOf("&", i);
27
+ if (nextAmp === -1) return null;
28
+ i = nextAmp + 1;
29
+ }
30
+ return null;
31
+ }
11
32
  /** Parse request input from body or query string */
12
33
  async function parseInput(request, url, qMark) {
13
34
  if (request.method === "GET" || !request.body) {
14
35
  if (qMark !== -1) {
15
- const searchStr = url.slice(qMark + 1);
16
- const dataIdx = searchStr.indexOf("data=");
17
- if (dataIdx !== -1) {
18
- const valueStart = dataIdx + 5;
19
- const valueEnd = searchStr.indexOf("&", valueStart);
20
- const encoded = valueEnd === -1 ? searchStr.slice(valueStart) : searchStr.slice(valueStart, valueEnd);
36
+ const encoded = findDataParam(url.slice(qMark + 1));
37
+ if (encoded !== null) {
21
38
  if (encoded.length > MAX_QUERY_DATA_LENGTH) throw new SilgiError("BAD_REQUEST", { message: "Query data parameter too large" });
22
39
  return JSON.parse(decodeURIComponent(encoded));
23
40
  }
@@ -37,13 +54,13 @@ async function parseInput(request, url, qMark) {
37
54
  return text ? _devalue.decode(text) : void 0;
38
55
  }
39
56
  }
40
- if (isBun) try {
41
- return await request.json();
57
+ const text = await request.text();
58
+ if (!text) return void 0;
59
+ try {
60
+ return JSON.parse(text);
42
61
  } catch {
43
- return;
62
+ throw new SilgiError("BAD_REQUEST", { message: "Malformed JSON body" });
44
63
  }
45
- const text = await request.text();
46
- return text ? JSON.parse(text) : void 0;
47
64
  }
48
65
  //#endregion
49
66
  export { parseInput };
@@ -43,10 +43,6 @@ declare function collectCronTasks(def: Record<string, unknown>): Array<{
43
43
  cron: string;
44
44
  task: TaskDef<any, any>;
45
45
  }>;
46
- declare function startCronJobs(cronTasks: Array<{
47
- cron: string;
48
- task: TaskDef<any, any>;
49
- }>): Promise<void>;
50
46
  interface ScheduledTaskInfo {
51
47
  name: string;
52
48
  cron: string;
@@ -56,7 +52,28 @@ interface ScheduledTaskInfo {
56
52
  runs: number;
57
53
  errors: number;
58
54
  }
59
- declare function getScheduledTasks(): ScheduledTaskInfo[];
60
- declare function stopCronJobs(): void;
55
+ interface CronRegistry {
56
+ start: (cronTasks: Array<{
57
+ cron: string;
58
+ task: TaskDef<any, any>;
59
+ }>) => Promise<void>;
60
+ stop: () => void;
61
+ list: () => ScheduledTaskInfo[];
62
+ }
63
+ /**
64
+ * Create an isolated cron registry. Each silgi instance owns one, so
65
+ * `server.close()` on instance A never stops instance B's jobs and
66
+ * `list()` never returns jobs from another instance.
67
+ */
68
+ declare function createCronRegistry(): CronRegistry;
69
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
70
+ declare const startCronJobs: (cronTasks: Array<{
71
+ cron: string;
72
+ task: TaskDef<any, any>;
73
+ }>) => Promise<void>;
74
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
75
+ declare const stopCronJobs: () => void;
76
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
77
+ declare const getScheduledTasks: () => ScheduledTaskInfo[];
61
78
  //#endregion
62
- export { ScheduledTaskInfo, TaskDef, TaskEvent, collectCronTasks, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs };
79
+ export { CronRegistry, ScheduledTaskInfo, TaskDef, TaskEvent, collectCronTasks, createCronRegistry, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs };
@@ -10,7 +10,7 @@ let _onTaskComplete = null;
10
10
  function setTaskAnalytics(cb) {
11
11
  _onTaskComplete = cb;
12
12
  }
13
- function createTaskFromProcedure(config, resolveFn, inputSchema, use, contextFactory) {
13
+ function createTaskFromProcedure(config, resolveFn, inputSchema, use, contextFactory, rootWrapsGetter = null) {
14
14
  const { name, cron = null, description } = config;
15
15
  if (!name) throw new TypeError("Task name is required");
16
16
  const taskResolve = async (opts) => {
@@ -32,14 +32,25 @@ function createTaskFromProcedure(config, resolveFn, inputSchema, use, contextFac
32
32
  reqTrace = new RequestTrace();
33
33
  ctx.trace = reqTrace;
34
34
  } catch {}
35
+ const rootWraps = rootWrapsGetter ? rootWrapsGetter() : null;
36
+ const runResolver = () => resolveFn({
37
+ input,
38
+ ctx,
39
+ name,
40
+ scheduledTime: void 0
41
+ });
35
42
  const t0 = performance.now();
36
43
  try {
37
- const output = await resolveFn({
38
- input,
39
- ctx,
40
- name,
41
- scheduledTime: void 0
42
- });
44
+ let output;
45
+ if (rootWraps && rootWraps.length > 0) {
46
+ let execute = () => Promise.resolve(runResolver());
47
+ for (let i = rootWraps.length - 1; i >= 0; i--) {
48
+ const wrapFn = rootWraps[i].fn;
49
+ const next = execute;
50
+ execute = () => wrapFn(ctx, next);
51
+ }
52
+ output = await execute();
53
+ } else output = await runResolver();
43
54
  if (parentTrace) parentTrace.spans.push({
44
55
  name: `task:${name}`,
45
56
  kind: "queue",
@@ -121,45 +132,70 @@ function collectCronTasks(def) {
121
132
  }
122
133
  return result;
123
134
  }
124
- let _cronEntries = [];
125
- async function startCronJobs(cronTasks) {
126
- if (cronTasks.length === 0) return;
127
- const { Cron } = await import("croner");
128
- for (const { cron, task } of cronTasks) {
129
- const entry = {
130
- name: task.route?.summary || cron,
131
- cron,
132
- description: task.route?.summary,
133
- job: null,
134
- lastRun: null,
135
- runs: 0,
136
- errors: 0
137
- };
138
- entry.job = new Cron(cron, async () => {
139
- entry.lastRun = Date.now();
140
- entry.runs++;
141
- task.dispatch(void 0).catch((err) => {
142
- entry.errors++;
143
- console.error(`[silgi] Cron task failed:`, err instanceof Error ? err.message : err);
144
- });
145
- });
146
- _cronEntries.push(entry);
147
- }
148
- }
149
- function getScheduledTasks() {
150
- return _cronEntries.map((e) => ({
151
- name: e.name,
152
- cron: e.cron,
153
- description: e.description,
154
- nextRun: e.job.nextRun()?.getTime() ?? null,
155
- lastRun: e.lastRun,
156
- runs: e.runs,
157
- errors: e.errors
158
- }));
159
- }
160
- function stopCronJobs() {
161
- for (const e of _cronEntries) e.job.stop();
162
- _cronEntries = [];
135
+ /**
136
+ * Create an isolated cron registry. Each silgi instance owns one, so
137
+ * `server.close()` on instance A never stops instance B's jobs and
138
+ * `list()` never returns jobs from another instance.
139
+ */
140
+ function createCronRegistry() {
141
+ const entries = [];
142
+ return {
143
+ async start(cronTasks) {
144
+ if (cronTasks.length === 0) return;
145
+ const { Cron } = await import("croner");
146
+ for (const { cron, task } of cronTasks) {
147
+ const entry = {
148
+ name: task.route?.summary || cron,
149
+ cron,
150
+ description: task.route?.summary,
151
+ job: null,
152
+ lastRun: null,
153
+ runs: 0,
154
+ errors: 0
155
+ };
156
+ entry.job = new Cron(cron, async () => {
157
+ entry.lastRun = Date.now();
158
+ entry.runs++;
159
+ task.dispatch(void 0).catch((err) => {
160
+ entry.errors++;
161
+ console.error(`[silgi] Cron task failed:`, err instanceof Error ? err.message : err);
162
+ });
163
+ });
164
+ entries.push(entry);
165
+ }
166
+ },
167
+ stop() {
168
+ for (const e of entries) e.job.stop();
169
+ entries.length = 0;
170
+ },
171
+ list() {
172
+ return entries.map((e) => ({
173
+ name: e.name,
174
+ cron: e.cron,
175
+ description: e.description,
176
+ nextRun: e.job.nextRun()?.getTime() ?? null,
177
+ lastRun: e.lastRun,
178
+ runs: e.runs,
179
+ errors: e.errors
180
+ }));
181
+ }
182
+ };
163
183
  }
184
+ /**
185
+ * Process-default cron registry. Shared state — use {@link createCronRegistry}
186
+ * when a silgi instance should own its own jobs.
187
+ *
188
+ * @deprecated Prefer per-instance registries. The module-default is
189
+ * retained so existing imports of `startCronJobs` / `stopCronJobs` /
190
+ * `getScheduledTasks` keep working; a future major will remove these
191
+ * top-level re-exports.
192
+ */
193
+ const _defaultRegistry = createCronRegistry();
194
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
195
+ const startCronJobs = _defaultRegistry.start;
196
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
197
+ const stopCronJobs = _defaultRegistry.stop;
198
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
199
+ const getScheduledTasks = _defaultRegistry.list;
164
200
  //#endregion
165
- export { collectCronTasks, createTaskFromProcedure, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs };
201
+ export { collectCronTasks, createCronRegistry, createTaskFromProcedure, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs };
package/dist/core/url.mjs CHANGED
@@ -10,9 +10,27 @@
10
10
  * Returns the path portion without query string.
11
11
  *
12
12
  * Uses manual indexOf — no `new URL()` overhead.
13
+ *
14
+ * @remarks
15
+ * Handles both absolute URLs (`http://host/path?q`) and bare paths
16
+ * (`/path?q`). The latter shape is produced by adapters that strip the
17
+ * origin before calling the handler, and by test harnesses constructing
18
+ * synthetic requests. Without the bare-path branch, a missing `//`
19
+ * caused `indexOf('//') + 2 = 1` and `indexOf('/', 1)` returned a bogus
20
+ * offset that silently produced the wrong path.
13
21
  */
14
22
  function parseUrlPath(url) {
15
- const pathStart = url.indexOf("/", url.indexOf("//") + 2);
23
+ if (url.length > 0 && url.charCodeAt(0) === 47) {
24
+ const qMark = url.indexOf("?");
25
+ return qMark === -1 ? url : url.slice(0, qMark);
26
+ }
27
+ const schemeEnd = url.indexOf("//");
28
+ if (schemeEnd === -1) {
29
+ const qMark = url.indexOf("?");
30
+ return qMark === -1 ? url : url.slice(0, qMark);
31
+ }
32
+ const pathStart = url.indexOf("/", schemeEnd + 2);
33
+ if (pathStart === -1) return "/";
16
34
  const qMark = url.indexOf("?", pathStart);
17
35
  return qMark === -1 ? url.slice(pathStart) : url.slice(pathStart, qMark);
18
36
  }
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AnySchema, InferSchemaInput, InferSchemaOutput, Schema, ValidationError, type, validateSchema } from "./core/schema.mjs";
2
- import { ScheduledTaskInfo, TaskDef, TaskEvent, collectCronTasks, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs } from "./core/task.mjs";
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";
5
5
  import { ScalarOptions, generateOpenAPI, scalarHTML } from "./scalar.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 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, 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, 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,5 +1,5 @@
1
1
  import { ValidationError, type, validateSchema } from "./core/schema.mjs";
2
- import { collectCronTasks, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs } from "./core/task.mjs";
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";
5
5
  import { compileProcedure, compileRouter, createContext } from "./compile.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, 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, 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 };
@@ -48,6 +48,14 @@ function runWithCtx(ctx, fn) {
48
48
  * Keyed on `Request` identity. GC-friendly: entry auto-released when the
49
49
  * `Request` is collected.
50
50
  *
51
+ * @remarks
52
+ * This is a documented exception to ARCHITECTURE §3 ("no WeakMap keyed on
53
+ * Request identity"). Kept only because `setRequestContext` is a public
54
+ * API consumed by user code outside silgi's control — removing it is a
55
+ * breaking change reserved for the next major. New integrations must
56
+ * use `silgi.runInContext(ctx, fn)` or the per-plugin closure pattern
57
+ * (see `requestMetas` inside `tracing()` below).
58
+ *
51
59
  * @internal
52
60
  */
53
61
  const _requestContextMap = /* @__PURE__ */ new WeakMap();
@@ -186,7 +194,6 @@ function extractUserData(returned) {
186
194
  }
187
195
  return result;
188
196
  }
189
- const requestMetas = /* @__PURE__ */ new WeakMap();
190
197
  /**
191
198
  * Creates a Better Auth plugin that auto-traces all auth operations
192
199
  * into silgi analytics spans.
@@ -197,6 +204,8 @@ const requestMetas = /* @__PURE__ */ new WeakMap();
197
204
  function tracing(config) {
198
205
  const captureInput = config?.captureInput ?? true;
199
206
  const captureOutput = config?.captureOutput ?? true;
207
+ const wrapMiddleware = config?.createAuthMiddleware ?? ((fn) => fn);
208
+ const requestMetas = /* @__PURE__ */ new WeakMap();
200
209
  return {
201
210
  id: "silgi-tracing",
202
211
  onRequest: async (request, _ctx) => {
@@ -215,7 +224,7 @@ function tracing(config) {
215
224
  },
216
225
  hooks: { after: [{
217
226
  matcher: () => true,
218
- handler: (config?.createAuthMiddleware ?? ((fn) => fn))(async (ctx) => {
227
+ handler: wrapMiddleware(async (ctx) => {
219
228
  try {
220
229
  const request = ctx.request;
221
230
  if (!request) return;
package/dist/lazy.mjs CHANGED
@@ -62,6 +62,9 @@ async function resolveLazy(value) {
62
62
  resolved.set(value, mod.default);
63
63
  loading.delete(value);
64
64
  return mod.default;
65
+ }, (err) => {
66
+ loading.delete(value);
67
+ throw err;
65
68
  });
66
69
  loading.set(value, pending);
67
70
  }
@@ -4,13 +4,15 @@ import { WrapDef } from "./types.mjs";
4
4
  /**
5
5
  * Create a wrap that transforms the procedure input before execution.
6
6
  *
7
- * The mapper function receives the raw input and returns the transformed input.
8
- * The mapped input is set on the context as `__mappedInput` and picked up
9
- * by the pipeline.
7
+ * @remarks
8
+ * The mapper receives the raw input and returns the transformed input.
9
+ * Internally this wrap replaces the value in the pipeline's `RAW_INPUT`
10
+ * symbol slot on `ctx`; the resolver reads the same slot to get the
11
+ * rewritten input. Users never interact with the slot directly — it's
12
+ * framework-internal (see `src/core/ctx-symbols.ts`).
10
13
  *
11
- * Note: Since Silgi's pipeline receives input as a separate argument
12
- * (not on ctx), mapInput works as a wrap that intercepts and transforms
13
- * before calling next().
14
+ * Must run inside the wrap onion (not as a guard) so it can observe and
15
+ * mutate the already-parsed input after schema validation.
14
16
  */
15
17
  declare function mapInput<TIn = unknown, TOut = unknown>(mapper: (input: TIn) => TOut | Promise<TOut>): WrapDef;
16
18
  //#endregion
@@ -25,13 +25,15 @@ import { RAW_INPUT } from "./core/ctx-symbols.mjs";
25
25
  /**
26
26
  * Create a wrap that transforms the procedure input before execution.
27
27
  *
28
- * The mapper function receives the raw input and returns the transformed input.
29
- * The mapped input is set on the context as `__mappedInput` and picked up
30
- * by the pipeline.
28
+ * @remarks
29
+ * The mapper receives the raw input and returns the transformed input.
30
+ * Internally this wrap replaces the value in the pipeline's `RAW_INPUT`
31
+ * symbol slot on `ctx`; the resolver reads the same slot to get the
32
+ * rewritten input. Users never interact with the slot directly — it's
33
+ * framework-internal (see `src/core/ctx-symbols.ts`).
31
34
  *
32
- * Note: Since Silgi's pipeline receives input as a separate argument
33
- * (not on ctx), mapInput works as a wrap that intercepts and transforms
34
- * before calling next().
35
+ * Must run inside the wrap onion (not as a guard) so it can observe and
36
+ * mutate the already-parsed input after schema validation.
35
37
  */
36
38
  function mapInput(mapper) {
37
39
  return {
@@ -107,6 +107,22 @@ function parseAnalyticsDetailPath(pathname, prefix) {
107
107
  function jsonResponse(data, headers) {
108
108
  return new Response(JSON.stringify(data), { headers });
109
109
  }
110
+ /**
111
+ * Parse a request body as JSON without throwing. Returns `null` on any
112
+ * failure (empty body, malformed JSON, non-JSON content). Used by the
113
+ * analytics hidden-paths endpoint so a bad request produces a 400 instead
114
+ * of an uncaught exception that surfaces as a 500 to the client.
115
+ */
116
+ async function parseJsonBody(request) {
117
+ try {
118
+ const text = await request.text();
119
+ if (!text) return null;
120
+ const parsed = JSON.parse(text);
121
+ return parsed && typeof parsed === "object" ? parsed : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
110
126
  /** Serve analytics dashboard and API routes. */
111
127
  async function serveAnalyticsRoute(pathname, request, collector, dashboardHtml) {
112
128
  const jsonCacheHeaders = {
@@ -122,24 +138,20 @@ async function serveAnalyticsRoute(pathname, request, collector, dashboardHtml)
122
138
  if (pathname === "api/analytics/stats") return jsonResponse(collector.toJSON(), jsonCacheHeaders);
123
139
  if (pathname === "api/analytics/hidden") {
124
140
  if (request.method === "GET") return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
125
- if (request.method === "POST") {
126
- const body = await request.json();
127
- if (typeof body.path !== "string") return new Response("{\"error\":\"path required\"}", {
128
- status: 400,
129
- headers: jsonCacheHeaders
130
- });
131
- collector.addHiddenPath(body.path);
132
- return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
133
- }
134
- if (request.method === "DELETE") {
135
- const body = await request.json();
136
- if (typeof body.path !== "string") return new Response("{\"error\":\"path required\"}", {
141
+ if (request.method === "POST" || request.method === "DELETE") {
142
+ const body = await parseJsonBody(request);
143
+ if (!body || typeof body.path !== "string") return new Response("{\"error\":\"path required\"}", {
137
144
  status: 400,
138
145
  headers: jsonCacheHeaders
139
146
  });
140
- collector.removeHiddenPath(body.path);
147
+ if (request.method === "POST") collector.addHiddenPath(body.path);
148
+ else collector.removeHiddenPath(body.path);
141
149
  return jsonResponse(collector.getHiddenPaths(), jsonCacheHeaders);
142
150
  }
151
+ return new Response("{\"error\":\"method not allowed\"}", {
152
+ status: 405,
153
+ headers: jsonCacheHeaders
154
+ });
143
155
  }
144
156
  if (pathname === "api/analytics/errors") return jsonResponse(queryErrors((await collector.getErrors()).filter((e) => !collector.isHidden(e.procedure)), parseQueryParams(url.searchParams)), jsonCacheHeaders);
145
157
  if (pathname === "api/analytics/requests") return jsonResponse(queryRequests((await collector.getRequests()).filter((r) => !collector.isHidden(r.path)), parseQueryParams(url.searchParams)), jsonCacheHeaders);
@@ -48,11 +48,8 @@ function ensureStorageConnected() {
48
48
  setStorage({
49
49
  get: (key) => storage.getItem(key),
50
50
  set: (key, value, opts) => {
51
- if (value === null || value === void 0) {
52
- storage.removeItem(key);
53
- return;
54
- }
55
- storage.setItem(key, value, opts?.ttl ? { ttl: opts.ttl } : void 0);
51
+ if (value === null || value === void 0) return storage.removeItem(key);
52
+ return storage.setItem(key, value, opts?.ttl ? { ttl: opts.ttl } : void 0);
56
53
  }
57
54
  });
58
55
  } catch {}
@@ -200,11 +197,8 @@ function createUnstorageAdapter(storage) {
200
197
  return {
201
198
  get: (key) => storage.getItem(key),
202
199
  set: (key, value, opts) => {
203
- if (value === null || value === void 0) {
204
- storage.removeItem(key);
205
- return;
206
- }
207
- storage.setItem(key, value, opts);
200
+ if (value === null || value === void 0) return storage.removeItem(key);
201
+ return storage.setItem(key, value, opts);
208
202
  }
209
203
  };
210
204
  }
@@ -13,8 +13,9 @@ import { WrapDef } from "../types.mjs";
13
13
  * - "" → undefined (empty strings become undefined)
14
14
  * - Everything else → kept as-is
15
15
  *
16
- * Implemented as a wrap (not a guard) so it runs after __rawInput is populated
17
- * by the pipeline compiler.
16
+ * Implemented as a wrap so it runs after the pipeline has populated the
17
+ * `RAW_INPUT` slot on ctx — see the caveat in the top-level docstring
18
+ * about ordering vs. input schema validation.
18
19
  */
19
20
  declare const coerceGuard: WrapDef<Record<string, unknown>>;
20
21
  declare function coerceValue(value: unknown): unknown;
@@ -7,17 +7,33 @@ import { RAW_INPUT } from "../core/ctx-symbols.mjs";
7
7
  * query parameters. This wrap coerces common types automatically:
8
8
  * "123" → 123, "true" → true, "null" → null, etc.
9
9
  *
10
+ * @remarks
11
+ * **Ordering caveat.** The pipeline validates `$input` schemas *before*
12
+ * running the wrap onion. That means a plain `z.number()` rejects "123"
13
+ * before this wrap can see it. You have three options:
14
+ *
15
+ * 1. **Use Zod's own coercion** — `z.coerce.number()`, `z.coerce.boolean()`.
16
+ * Zero extra wrap, works for simple cases.
17
+ * 2. **Skip the input schema and validate inside the resolver** — pairs
18
+ * naturally with `coerceGuard` because the wrap always runs first.
19
+ * 3. **Use a string-accepting schema and coerce with `.transform()`** —
20
+ * e.g. `z.object({ id: z.string().transform(Number) })`. Again no
21
+ * wrap needed.
22
+ *
23
+ * `coerceGuard` itself is useful when you have NO input schema but still
24
+ * want `"42"` / `"true"` / `"null"` normalised before your resolver runs.
25
+ *
10
26
  * @example
11
27
  * ```ts
12
- * import { coerceGuard } from "silgi/plugins"
13
- *
28
+ * // Works: no schema wrap can reshape freely.
14
29
  * const getUser = k
15
30
  * .$use(coerceGuard)
16
- * .$input(z.object({ id: z.number(), active: z.boolean().optional() }))
17
- * .$resolve(({ input }) => db.users.find(input.id))
31
+ * .$resolve(({ input }) => db.users.find((input as any).id))
18
32
  *
19
- * // GET /users/get?data={"id":"42","active":"true"}
20
- * // input is coerced to { id: 42, active: true }
33
+ * // Works: schema uses z.coerce.
34
+ * const getUser2 = k
35
+ * .$input(z.object({ id: z.coerce.number() }))
36
+ * .$resolve(({ input }) => db.users.find(input.id))
21
37
  * ```
22
38
  */
23
39
  /**
@@ -32,8 +48,9 @@ import { RAW_INPUT } from "../core/ctx-symbols.mjs";
32
48
  * - "" → undefined (empty strings become undefined)
33
49
  * - Everything else → kept as-is
34
50
  *
35
- * Implemented as a wrap (not a guard) so it runs after __rawInput is populated
36
- * by the pipeline compiler.
51
+ * Implemented as a wrap so it runs after the pipeline has populated the
52
+ * `RAW_INPUT` slot on ctx — see the caveat in the top-level docstring
53
+ * about ordering vs. input schema validation.
37
54
  */
38
55
  const coerceGuard = {
39
56
  kind: "wrap",
package/dist/silgi.d.mts CHANGED
@@ -85,6 +85,41 @@ interface SilgiConfig<TCtx extends Record<string, unknown>> {
85
85
  * ```
86
86
  */
87
87
  schemaConverters?: SchemaConverter[];
88
+ /**
89
+ * Root-level wrap middleware applied to every procedure in the router.
90
+ *
91
+ * @remarks
92
+ * Each entry must be created via `instance.wrap(fn)`. Root wraps run
93
+ * as the outermost layer of the onion: root wraps → route-level
94
+ * `.$use()` guards/wraps → resolver. Use this for concerns that must
95
+ * apply to every route (tenant scoping, `AsyncLocalStorage` setup,
96
+ * trace propagation), where missing one route would be a bug.
97
+ *
98
+ * Root wraps cannot mutate the context type — use a route-level
99
+ * `$use(guard)` for that. The ambient context passed to `next()` is
100
+ * `TBaseCtx`, identical to the one seen by route-level wraps.
101
+ *
102
+ * Applies to every procedure reachable through `handler()`,
103
+ * `createCaller()`, and HTTP/cron task invocation. Task `dispatch()`
104
+ * (programmatic, bypasses the pipeline) is not wrapped.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * const tenantScopeWrap: WrapDef = {
109
+ * kind: 'wrap',
110
+ * fn: (ctx, next) => tenantScope.run({ orgId: ctx.user.orgId }, next),
111
+ * }
112
+ *
113
+ * const s = silgi({
114
+ * context: (req) => ({ db, user: readUser(req) }),
115
+ * wraps: [tenantScopeWrap],
116
+ * })
117
+ * ```
118
+ *
119
+ * For convenience you can also create wraps with the standalone
120
+ * helper or from another silgi instance's `wrap()` method.
121
+ */
122
+ wraps?: WrapDef<TCtx>[];
88
123
  /**
89
124
  * Storage configuration — mount drivers by path prefix.
90
125
  *
package/dist/silgi.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createTaskFromProcedure } from "./core/task.mjs";
2
2
  import { createProcedureBuilder } from "./builder.mjs";
3
3
  import { assignPaths, routerCache } from "./core/router-utils.mjs";
4
+ import { ROOT_WRAPS } from "./core/ctx-symbols.mjs";
4
5
  import { compileRouter } from "./compile.mjs";
5
6
  import { createCaller } from "./caller.mjs";
6
7
  import { createContextBridge } from "./core/context-bridge.mjs";
@@ -45,6 +46,26 @@ function createProcedure(type, ...args) {
45
46
  throw new TypeError(`Invalid arguments for ${type}()`);
46
47
  }
47
48
  /**
49
+ * Stamp root wraps onto a router def as a non-enumerable Symbol-keyed
50
+ * property. Idempotent — if the def is already branded with the same
51
+ * reference, this is a no-op. Multiple silgi instances registering the
52
+ * same def throws, because a single compiled router cannot serve two
53
+ * context shapes.
54
+ *
55
+ * @internal
56
+ */
57
+ function stampRootWraps(def, wraps) {
58
+ const existing = def[ROOT_WRAPS];
59
+ if (existing === wraps) return;
60
+ if (existing) throw new TypeError("silgi: this router def is already registered with a different silgi instance — build a fresh router object.");
61
+ Object.defineProperty(def, ROOT_WRAPS, {
62
+ value: wraps,
63
+ enumerable: false,
64
+ writable: false,
65
+ configurable: false
66
+ });
67
+ }
68
+ /**
48
69
  * Create a Silgi RPC instance with typed context.
49
70
  *
50
71
  * @remarks
@@ -88,6 +109,12 @@ function silgi(config) {
88
109
  m.initStorage(config.storage);
89
110
  }) : Promise.resolve();
90
111
  const ctxFactory = () => contextFactory(new Request("http://localhost"));
112
+ let rootWraps = null;
113
+ if (config.wraps && config.wraps.length > 0) {
114
+ for (const w of config.wraps) if (!w || w.kind !== "wrap") throw new TypeError("silgi({ wraps }) only accepts wrap middleware — use route-level .$use() for guards.");
115
+ rootWraps = Object.freeze([...config.wraps]);
116
+ }
117
+ const rootWrapsGetter = rootWraps ? () => rootWraps : null;
91
118
  return {
92
119
  hook: hooks.hook.bind(hooks),
93
120
  removeHook: hooks.removeHook.bind(hooks),
@@ -113,22 +140,26 @@ function silgi(config) {
113
140
  fn
114
141
  }),
115
142
  $resolve: ((fn) => createProcedure("query", fn)),
116
- $input: ((schema) => createProcedureBuilder("query", ctxFactory).$input(schema)),
143
+ $input: ((schema) => createProcedureBuilder("query", ctxFactory, rootWrapsGetter).$input(schema)),
117
144
  $use: ((...middleware) => {
118
- const b = createProcedureBuilder("query", ctxFactory);
145
+ const b = createProcedureBuilder("query", ctxFactory, rootWrapsGetter);
119
146
  for (const m of middleware) b.$use(m);
120
147
  return b;
121
148
  }),
122
- $output: ((schema) => createProcedureBuilder("query", ctxFactory).$output(schema)),
123
- $errors: ((errors) => createProcedureBuilder("query", ctxFactory).$errors(errors)),
124
- $route: ((route) => createProcedureBuilder("query", ctxFactory).$route(route)),
125
- $meta: ((meta) => createProcedureBuilder("query", ctxFactory).$meta(meta)),
149
+ $output: ((schema) => createProcedureBuilder("query", ctxFactory, rootWrapsGetter).$output(schema)),
150
+ $errors: ((errors) => createProcedureBuilder("query", ctxFactory, rootWrapsGetter).$errors(errors)),
151
+ $route: ((route) => createProcedureBuilder("query", ctxFactory, rootWrapsGetter).$route(route)),
152
+ $meta: ((meta) => createProcedureBuilder("query", ctxFactory, rootWrapsGetter).$meta(meta)),
126
153
  subscription: ((...args) => createProcedure("subscription", ...args)),
127
154
  $task: ((config) => {
128
- return createTaskFromProcedure(config, config.resolve, null, null, ctxFactory);
155
+ return createTaskFromProcedure(config, config.resolve, null, null, ctxFactory, rootWrapsGetter);
129
156
  }),
130
157
  router: (def) => {
131
158
  const assigned = assignPaths(def);
159
+ if (rootWraps) {
160
+ stampRootWraps(def, rootWraps);
161
+ stampRootWraps(assigned, rootWraps);
162
+ }
132
163
  const flat = compileRouter(assigned);
133
164
  routerCache.set(def, flat);
134
165
  routerCache.set(assigned, flat);
package/dist/ws.mjs CHANGED
@@ -14,8 +14,6 @@ import { decode, encode } from "./codec/msgpack.mjs";
14
14
  * Server → Client: { id: string, result?: unknown, error?: unknown }
15
15
  * Server → Client (stream): { id: string, data: unknown, done?: boolean }
16
16
  */
17
- const peerAbortControllers = /* @__PURE__ */ new WeakMap();
18
- const peerKeepaliveTimers = /* @__PURE__ */ new WeakMap();
19
17
  /**
20
18
  * Internal — build crossws-compatible hooks for Silgi RPC over WebSocket.
21
19
  *
@@ -28,6 +26,8 @@ function _createWSHooks(routerDef, options = {}) {
28
26
  const useMsgpack = options.protocol === "messagepack" || options.protocol == null && (options.binary ?? false);
29
27
  const contextFactory = options.context;
30
28
  const keepaliveMs = options.keepalive === false ? 0 : options.keepalive ?? 3e4;
29
+ const peerAbortControllers = /* @__PURE__ */ new WeakMap();
30
+ const peerKeepaliveTimers = /* @__PURE__ */ new WeakMap();
31
31
  function send(peer, data) {
32
32
  const compress = !!options.compress;
33
33
  if (useMsgpack) peer.send(encode(data), { compress });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "silgi",
3
- "version": "0.52.2",
3
+ "version": "0.53.0",
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": [
@@ -41,9 +41,7 @@
41
41
  "lib"
42
42
  ],
43
43
  "type": "module",
44
- "sideEffects": [
45
- "./dist/integrations/zod/index.mjs"
46
- ],
44
+ "sideEffects": false,
47
45
  "exports": {
48
46
  ".": {
49
47
  "import": "./dist/index.mjs",