silgi 0.52.2 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core/sse.mjs CHANGED
@@ -1,36 +1,54 @@
1
1
  import { SilgiError } from "./error.mjs";
2
2
  //#region src/core/sse.ts
3
3
  /**
4
- * Server-Sent Events (SSE) encoding/decoding.
4
+ * Server-Sent Events
5
+ * -------------------
5
6
  *
6
- * Supports three event types:
7
- * - message: a yielded value
8
- * - error: an error event with data
9
- * - done: the return value (stream complete)
7
+ * silgi subscriptions are yielded to the client as an SSE event stream.
8
+ * This module holds the encoder, the streaming decoder (for client-side
9
+ * consumption), and the iterator stream bridges in both directions.
10
10
  *
11
- * Event metadata (id, retry) can be attached to object values
12
- * via withEventMeta() using a WeakMap side-channel.
11
+ * Wire vocabulary
12
+ * ---------------
13
+ *
14
+ * `event: message` → one yielded value
15
+ * `event: error` → resolver threw (sanitized for undefined errors)
16
+ * `event: done` → generator returned; `data` is the return value
17
+ * `: <comment>` → keepalive or boot marker; ignored by clients
18
+ *
19
+ * Event metadata (SSE `id` / `retry`) can be attached to any object
20
+ * value via `withEventMeta()` and round-trips through the decoder.
21
+ */
22
+ /**
23
+ * Metadata store for SSE `id` / `retry` fields.
24
+ *
25
+ * This `WeakMap` is module-scoped (i.e. shared across every subscription
26
+ * in the process). That is safe: entries are keyed by the *value object*
27
+ * the user passes in, and GC reclaims entries as soon as those objects
28
+ * become unreachable. A per-iterator store would add plumbing for
29
+ * nothing — two subscriptions yielding distinct objects never collide.
13
30
  */
14
- const _eventMeta = /* @__PURE__ */ new WeakMap();
31
+ const metaStore = /* @__PURE__ */ new WeakMap();
15
32
  /**
16
- * Attach SSE metadata (id, retry) to a value.
33
+ * Attach SSE `id` / `retry` metadata to a yielded value.
17
34
  *
18
- * Only works with object values (arrays, plain objects, etc.).
19
- * Primitives cannot carry metadata wrap them in an object first.
35
+ * Only object-shaped values can carry metadata; primitives cannot be
36
+ * keyed in the `WeakMap` and are returned unchanged. Wrap primitives
37
+ * in a one-field object when you need metadata on them.
20
38
  */
21
39
  function withEventMeta(value, meta) {
22
- if (typeof value === "object" && value !== null) _eventMeta.set(value, meta);
40
+ if (typeof value === "object" && value !== null) metaStore.set(value, meta);
23
41
  return value;
24
42
  }
25
- /**
26
- * Read SSE metadata from a value (if attached).
27
- */
43
+ /** Read SSE metadata previously attached via `withEventMeta`. */
28
44
  function getEventMeta(value) {
29
45
  if (typeof value !== "object" || value === null) return void 0;
30
- return _eventMeta.get(value);
46
+ return metaStore.get(value);
31
47
  }
32
48
  /**
33
- * Encode an EventMessage into SSE wire format.
49
+ * Serialize an `EventMessage` into SSE wire format (one event terminated
50
+ * by a blank line). Multi-line `data` and `comment` are split across
51
+ * multiple fields per the SSE spec so embedded newlines survive.
34
52
  */
35
53
  function encodeEventMessage(msg) {
36
54
  const lines = [];
@@ -42,15 +60,60 @@ function encodeEventMessage(msg) {
42
60
  return lines.join("\n") + "\n\n";
43
61
  }
44
62
  /**
45
- * Convert an async iterator to an SSE ReadableStream.
46
- * Each yielded value becomes a "message" event.
47
- * Errors become "error" events. Return value becomes "done".
63
+ * Build an SSE `ReadableStream` that consumes an async iterator.
64
+ *
65
+ * Each yielded value becomes a `message` event; the iterator's return
66
+ * value becomes the `done` event; a thrown error becomes an `error`
67
+ * event (and only the message is exposed when the error is not a
68
+ * `SilgiError` flagged `defined` — undefined errors must not leak
69
+ * internals).
70
+ *
71
+ * A comment-only `keepalive` event is emitted every `keepAliveMs` so
72
+ * intermediaries (proxies, load balancers) do not close the connection
73
+ * while the resolver is quiet.
48
74
  */
49
75
  function iteratorToEventStream(iterator, options = {}) {
50
76
  const serialize = options.serialize ?? JSON.stringify;
51
77
  const keepAliveMs = options.keepAliveMs ?? 3e4;
52
78
  let keepAliveTimer;
53
79
  let cancelled = false;
80
+ /** Build the wire form of one yielded value, carrying any attached meta. */
81
+ const encodeValue = (value) => {
82
+ const meta = getEventMeta(value);
83
+ return encodeEventMessage({
84
+ event: "message",
85
+ data: serialize(value),
86
+ id: meta?.id,
87
+ retry: meta?.retry
88
+ });
89
+ };
90
+ /** Build the wire form of the terminal `done` event, if a return value was yielded. */
91
+ const encodeDone = (value) => {
92
+ return encodeEventMessage({
93
+ event: "done",
94
+ data: value !== void 0 ? serialize(value) : void 0
95
+ });
96
+ };
97
+ /**
98
+ * Build the wire form of an `error` event.
99
+ *
100
+ * Only `SilgiError` with `defined === true` surfaces its `code` and
101
+ * `message` to the wire — the author opted into publishing those by
102
+ * declaring the error. Everything else collapses to a generic 500
103
+ * shape so we do not leak stack traces or internal codes.
104
+ */
105
+ const encodeError = (err) => {
106
+ return encodeEventMessage({
107
+ event: "error",
108
+ data: err instanceof SilgiError && err.defined ? JSON.stringify({
109
+ message: err.message,
110
+ code: err.code
111
+ }) : JSON.stringify({
112
+ message: "Internal server error",
113
+ code: "INTERNAL_SERVER_ERROR"
114
+ })
115
+ });
116
+ };
54
117
  return new ReadableStream({
55
118
  start(controller) {
56
119
  if (options.initialComment !== void 0) controller.enqueue(encodeEventMessage({ comment: options.initialComment }));
@@ -64,38 +127,15 @@ function iteratorToEventStream(iterator, options = {}) {
64
127
  if (cancelled) return;
65
128
  if (result.done) {
66
129
  clearInterval(keepAliveTimer);
67
- const data = result.value !== void 0 ? serialize(result.value) : void 0;
68
- controller.enqueue(encodeEventMessage({
69
- event: "done",
70
- data
71
- }));
130
+ controller.enqueue(encodeDone(result.value));
72
131
  controller.close();
73
132
  return;
74
133
  }
75
- const meta = getEventMeta(result.value);
76
- const msg = {
77
- event: "message",
78
- data: serialize(result.value),
79
- id: meta?.id,
80
- retry: meta?.retry
81
- };
82
- controller.enqueue(encodeEventMessage(msg));
134
+ controller.enqueue(encodeValue(result.value));
83
135
  } catch (error) {
84
136
  clearInterval(keepAliveTimer);
85
137
  if (cancelled) return;
86
- let errorData;
87
- if (error instanceof SilgiError && error.defined) errorData = JSON.stringify({
88
- message: error.message,
89
- code: error.code
90
- });
91
- else errorData = JSON.stringify({
92
- message: "Internal server error",
93
- code: "INTERNAL_SERVER_ERROR"
94
- });
95
- controller.enqueue(encodeEventMessage({
96
- event: "error",
97
- data: errorData
98
- }));
138
+ controller.enqueue(encodeError(error));
99
139
  controller.close();
100
140
  }
101
141
  },
@@ -23,7 +23,10 @@ interface TaskDef<TInput = unknown, TOutput = unknown> {
23
23
  } | null;
24
24
  readonly meta: null;
25
25
  readonly _contextFactory: (() => unknown | Promise<unknown>) | null;
26
- /** Dispatch the task. Pass ctx from a procedure to auto-record a trace span. */
26
+ /**
27
+ * Dispatch the task. Pass the *parent* request's `ctx` as the second
28
+ * argument to fold the dispatch into that request's trace span.
29
+ */
27
30
  dispatch: undefined extends TInput ? (input?: TInput, ctx?: Record<string, unknown>) => Promise<TOutput> : (input: TInput, ctx?: Record<string, unknown>) => Promise<TOutput>;
28
31
  }
29
32
  type TaskCompleteCallback = (entry: {
@@ -39,14 +42,15 @@ type TaskCompleteCallback = (entry: {
39
42
  }) => void;
40
43
  declare function setTaskAnalytics(cb: TaskCompleteCallback | null): void;
41
44
  declare function runTask<TInput, TOutput>(task: TaskDef<TInput, TOutput>, ...args: undefined extends TInput ? [input?: TInput] : [input: TInput]): Promise<TOutput>;
45
+ /**
46
+ * Walk a router tree and collect every task that has a `cron` field set.
47
+ * Nested namespaces are recursed into; we stop at any node already
48
+ * tagged as a task so a task's internal structure is never inspected.
49
+ */
42
50
  declare function collectCronTasks(def: Record<string, unknown>): Array<{
43
51
  cron: string;
44
52
  task: TaskDef<any, any>;
45
53
  }>;
46
- declare function startCronJobs(cronTasks: Array<{
47
- cron: string;
48
- task: TaskDef<any, any>;
49
- }>): Promise<void>;
50
54
  interface ScheduledTaskInfo {
51
55
  name: string;
52
56
  cron: string;
@@ -56,7 +60,31 @@ interface ScheduledTaskInfo {
56
60
  runs: number;
57
61
  errors: number;
58
62
  }
59
- declare function getScheduledTasks(): ScheduledTaskInfo[];
60
- declare function stopCronJobs(): void;
63
+ interface CronRegistry {
64
+ start: (cronTasks: Array<{
65
+ cron: string;
66
+ task: TaskDef<any, any>;
67
+ }>) => Promise<void>;
68
+ stop: () => void;
69
+ list: () => ScheduledTaskInfo[];
70
+ }
71
+ /**
72
+ * Create an isolated cron registry.
73
+ *
74
+ * Each silgi instance owns one, so `server.close()` on instance A
75
+ * never stops instance B's jobs and `list()` never returns jobs from
76
+ * another instance. The module-default registry below keeps the
77
+ * legacy top-level exports working.
78
+ */
79
+ declare function createCronRegistry(): CronRegistry;
80
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
81
+ declare const startCronJobs: (cronTasks: Array<{
82
+ cron: string;
83
+ task: TaskDef<any, any>;
84
+ }>) => Promise<void>;
85
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
86
+ declare const stopCronJobs: () => void;
87
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
88
+ declare const getScheduledTasks: () => ScheduledTaskInfo[];
61
89
  //#endregion
62
- export { ScheduledTaskInfo, TaskDef, TaskEvent, collectCronTasks, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs };
90
+ export { CronRegistry, ScheduledTaskInfo, TaskDef, TaskEvent, collectCronTasks, createCronRegistry, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs };
@@ -1,19 +1,104 @@
1
1
  import { validateSchema } from "./schema.mjs";
2
2
  //#region src/core/task.ts
3
3
  /**
4
- * Task API — type-safe background tasks built on the procedure builder.
4
+ * Background tasks
5
+ * ------------------
6
+ *
7
+ * A *task* is a procedure with two extra capabilities:
8
+ *
9
+ * 1. Programmatic `dispatch(input)` — call it directly, outside the
10
+ * HTTP pipeline. Used for async work (send-email, rebuild-index)
11
+ * and as the callback target for cron schedules.
12
+ * 2. Optional `cron` spec — when set, the task is auto-registered
13
+ * with a `croner` job at `serve()` time.
14
+ *
15
+ * Tasks are built via the procedure builder:
5
16
  *
6
- * Tasks are procedures with dispatch + cron capabilities:
7
17
  * s.$use(auth).$input(schema).$task({ name: 'send-email', resolve })
18
+ *
19
+ * Dispatch runs through the same root-wrap onion as the HTTP pipeline
20
+ * so tenant scoping, trace propagation, and similar cross-cutting
21
+ * concerns apply uniformly. When no root wraps are configured the
22
+ * dispatch path reduces to a direct `await resolveFn(…)`.
8
23
  */
9
- let _onTaskComplete = null;
24
+ /**
25
+ * Module-global sink for task completion events. This is shared across
26
+ * every silgi instance in the process — analytics is currently wired
27
+ * through the process-default cron registry for the same reason.
28
+ * Per-instance analytics is a future refactor; `setTaskAnalytics(null)`
29
+ * detaches the sink.
30
+ */
31
+ let onTaskComplete = null;
10
32
  function setTaskAnalytics(cb) {
11
- _onTaskComplete = cb;
33
+ onTaskComplete = cb;
12
34
  }
13
- function createTaskFromProcedure(config, resolveFn, inputSchema, use, contextFactory) {
35
+ /** Round a millisecond duration to two decimal places — matches dashboard display. */
36
+ const round2 = (ms) => Math.round(ms * 100) / 100;
37
+ /**
38
+ * Wrap `run` in the root-wrap onion. Root wraps are outermost-first, so
39
+ * we fold from the end of the list: the last wrap wraps `run`, the one
40
+ * before wraps that, and so on. When there are no wraps we just return
41
+ * `run` unchanged — zero onion overhead.
42
+ *
43
+ * Same shape as `composeWraps` in `compile.ts`; kept here privately to
44
+ * avoid a core → compile import cycle.
45
+ */
46
+ function applyRootWraps(ctx, wraps, run) {
47
+ let chain = run;
48
+ for (let i = wraps.length - 1; i >= 0; i--) {
49
+ const wrap = wraps[i];
50
+ const next = chain;
51
+ chain = () => Promise.resolve(wrap.fn(ctx, next));
52
+ }
53
+ return chain();
54
+ }
55
+ /**
56
+ * Lazy-loaded `RequestTrace` constructor. The analytics module is
57
+ * optional — missing it must not break task dispatch — so we attempt
58
+ * the import once and cache the outcome. `null` means "we've tried;
59
+ * analytics is not available in this build".
60
+ */
61
+ let requestTraceCtor;
62
+ async function getRequestTrace() {
63
+ if (requestTraceCtor !== void 0) return requestTraceCtor;
64
+ try {
65
+ requestTraceCtor = (await import("../plugins/analytics.mjs")).RequestTrace;
66
+ } catch {
67
+ requestTraceCtor = null;
68
+ }
69
+ return requestTraceCtor;
70
+ }
71
+ /** Record this dispatch as a span on the parent request's trace, if one exists. */
72
+ function recordParentSpan(parentTrace, name, spanStart, err) {
73
+ if (!parentTrace) return;
74
+ const span = {
75
+ name: `task:${name}`,
76
+ kind: "queue",
77
+ durationMs: round2(performance.now() - spanStart),
78
+ startOffsetMs: round2(spanStart - parentTrace.t0),
79
+ detail: `dispatch ${name}`
80
+ };
81
+ if (err !== void 0) span.error = err instanceof Error ? err.message : String(err);
82
+ parentTrace.spans.push(span);
83
+ }
84
+ /**
85
+ * Build a `TaskDef` from the procedure builder's `.$task()` configuration.
86
+ *
87
+ * @param rootWrapsGetter A *live* getter so a task constructed before
88
+ * `s.router()` stamps wraps still picks them up at dispatch time. The
89
+ * silgi instance threads a closure over its own rootWraps reference.
90
+ * Pass `null` when no wraps are configured — dispatch then skips the
91
+ * onion entirely.
92
+ */
93
+ function createTaskFromProcedure(config, resolveFn, inputSchema, use, contextFactory, rootWrapsGetter = null) {
14
94
  const { name, cron = null, description } = config;
15
95
  if (!name) throw new TypeError("Task name is required");
16
- const taskResolve = async (opts) => {
96
+ /**
97
+ * Resolver called by the *HTTP* pipeline when the task is reachable
98
+ * through the router tree. `ctx` already carries everything the
99
+ * pipeline set up (base context, guards, trace), so we just forward.
100
+ */
101
+ const pipelineResolve = async (opts) => {
17
102
  return resolveFn({
18
103
  input: opts.input,
19
104
  ctx: opts.ctx,
@@ -21,61 +106,52 @@ function createTaskFromProcedure(config, resolveFn, inputSchema, use, contextFac
21
106
  scheduledTime: void 0
22
107
  });
23
108
  };
109
+ /**
110
+ * Programmatic dispatch — the one users call from another procedure
111
+ * or a cron callback. Validates input, builds its own context, runs
112
+ * the root-wrap onion around the resolver, records analytics.
113
+ */
24
114
  const dispatch = async (rawInput, parentCtx) => {
25
115
  const input = inputSchema ? await validateSchema(inputSchema, rawInput) : rawInput;
26
116
  const ctx = contextFactory ? await contextFactory() : {};
27
117
  const parentTrace = parentCtx?.trace;
28
118
  const spanStart = parentTrace ? performance.now() : 0;
29
- let reqTrace = null;
30
- try {
31
- const { RequestTrace } = await import("../plugins/analytics.mjs");
32
- reqTrace = new RequestTrace();
33
- ctx.trace = reqTrace;
34
- } catch {}
119
+ const RequestTrace = await getRequestTrace();
120
+ const selfTrace = RequestTrace ? new RequestTrace() : null;
121
+ if (selfTrace) ctx.trace = selfTrace;
122
+ const runResolver = () => Promise.resolve(resolveFn({
123
+ input,
124
+ ctx,
125
+ name,
126
+ scheduledTime: void 0
127
+ }));
128
+ const wraps = rootWrapsGetter?.() ?? null;
35
129
  const t0 = performance.now();
36
130
  try {
37
- const output = await resolveFn({
38
- input,
39
- ctx,
40
- name,
41
- scheduledTime: void 0
42
- });
43
- if (parentTrace) parentTrace.spans.push({
44
- name: `task:${name}`,
45
- kind: "queue",
46
- durationMs: Math.round((performance.now() - spanStart) * 100) / 100,
47
- startOffsetMs: Math.round((spanStart - parentTrace.t0) * 100) / 100,
48
- detail: `dispatch ${name}`
49
- });
50
- if (_onTaskComplete) _onTaskComplete({
131
+ const output = wraps && wraps.length > 0 ? await applyRootWraps(ctx, wraps, runResolver) : await runResolver();
132
+ recordParentSpan(parentTrace, name, spanStart);
133
+ onTaskComplete?.({
51
134
  taskName: name,
52
135
  trigger: "dispatch",
53
136
  timestamp: Date.now(),
54
- durationMs: Math.round((performance.now() - t0) * 100) / 100,
137
+ durationMs: round2(performance.now() - t0),
55
138
  status: "success",
56
139
  input,
57
140
  output,
58
- spans: reqTrace?.spans
141
+ spans: selfTrace?.spans
59
142
  });
60
143
  return output;
61
144
  } catch (err) {
62
- if (parentTrace) parentTrace.spans.push({
63
- name: `task:${name}`,
64
- kind: "queue",
65
- durationMs: Math.round((performance.now() - spanStart) * 100) / 100,
66
- startOffsetMs: Math.round((spanStart - parentTrace.t0) * 100) / 100,
67
- detail: `dispatch ${name}`,
68
- error: err instanceof Error ? err.message : String(err)
69
- });
70
- if (_onTaskComplete) _onTaskComplete({
145
+ recordParentSpan(parentTrace, name, spanStart, err);
146
+ onTaskComplete?.({
71
147
  taskName: name,
72
148
  trigger: "dispatch",
73
149
  timestamp: Date.now(),
74
- durationMs: Math.round((performance.now() - t0) * 100) / 100,
150
+ durationMs: round2(performance.now() - t0),
75
151
  status: "error",
76
152
  error: err instanceof Error ? err.message : String(err),
77
153
  input,
78
- spans: reqTrace?.spans
154
+ spans: selfTrace?.spans
79
155
  });
80
156
  throw err;
81
157
  }
@@ -88,7 +164,7 @@ function createTaskFromProcedure(config, resolveFn, inputSchema, use, contextFac
88
164
  output: null,
89
165
  errors: null,
90
166
  use,
91
- resolve: taskResolve,
167
+ resolve: pipelineResolve,
92
168
  route: description ? {
93
169
  summary: description,
94
170
  tags: ["Tasks"]
@@ -98,68 +174,112 @@ function createTaskFromProcedure(config, resolveFn, inputSchema, use, contextFac
98
174
  dispatch
99
175
  };
100
176
  }
101
- const _running = /* @__PURE__ */ new Map();
177
+ /**
178
+ * In-flight dispatches keyed by task object.
179
+ *
180
+ * `runTask` coalesces concurrent calls so two callers asking for the
181
+ * same task at once share a single execution. Useful for idempotent
182
+ * background jobs that can be kicked off from multiple places.
183
+ */
184
+ const running = /* @__PURE__ */ new Map();
102
185
  async function runTask(task, ...args) {
103
- const existing = _running.get(task);
186
+ const existing = running.get(task);
104
187
  if (existing) return existing;
105
188
  const promise = task.dispatch(args[0]);
106
- _running.set(task, promise);
189
+ running.set(task, promise);
107
190
  try {
108
191
  return await promise;
109
192
  } finally {
110
- _running.delete(task);
193
+ running.delete(task);
111
194
  }
112
195
  }
196
+ /**
197
+ * Walk a router tree and collect every task that has a `cron` field set.
198
+ * Nested namespaces are recursed into; we stop at any node already
199
+ * tagged as a task so a task's internal structure is never inspected.
200
+ */
113
201
  function collectCronTasks(def) {
114
202
  const result = [];
115
- for (const value of Object.values(def)) if (value && typeof value === "object") {
116
- if ("_tag" in value && value._tag === "task" && value.cron) result.push({
117
- cron: value.cron,
118
- task: value
119
- });
120
- else if (!("_tag" in value)) result.push(...collectCronTasks(value));
121
- }
122
- return result;
123
- }
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);
203
+ for (const value of Object.values(def)) {
204
+ if (!value || typeof value !== "object") continue;
205
+ if ("_tag" in value && value._tag === "task") {
206
+ const task = value;
207
+ if (task.cron) result.push({
208
+ cron: task.cron,
209
+ task
144
210
  });
145
- });
146
- _cronEntries.push(entry);
211
+ } else if (!("_tag" in value)) result.push(...collectCronTasks(value));
147
212
  }
213
+ return result;
148
214
  }
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 = [];
215
+ /**
216
+ * Create an isolated cron registry.
217
+ *
218
+ * Each silgi instance owns one, so `server.close()` on instance A
219
+ * never stops instance B's jobs and `list()` never returns jobs from
220
+ * another instance. The module-default registry below keeps the
221
+ * legacy top-level exports working.
222
+ */
223
+ function createCronRegistry() {
224
+ const entries = [];
225
+ return {
226
+ async start(cronTasks) {
227
+ if (cronTasks.length === 0) return;
228
+ const { Cron } = await import("croner");
229
+ for (const { cron, task } of cronTasks) {
230
+ const entry = {
231
+ name: task.route?.summary || cron,
232
+ cron,
233
+ description: task.route?.summary,
234
+ job: null,
235
+ lastRun: null,
236
+ runs: 0,
237
+ errors: 0
238
+ };
239
+ entry.job = new Cron(cron, async () => {
240
+ entry.lastRun = Date.now();
241
+ entry.runs++;
242
+ task.dispatch(void 0).catch((err) => {
243
+ entry.errors++;
244
+ console.error(`[silgi] Cron task failed:`, err instanceof Error ? err.message : err);
245
+ });
246
+ });
247
+ entries.push(entry);
248
+ }
249
+ },
250
+ stop() {
251
+ for (const entry of entries) entry.job.stop();
252
+ entries.length = 0;
253
+ },
254
+ list() {
255
+ return entries.map((entry) => ({
256
+ name: entry.name,
257
+ cron: entry.cron,
258
+ description: entry.description,
259
+ nextRun: entry.job.nextRun()?.getTime() ?? null,
260
+ lastRun: entry.lastRun,
261
+ runs: entry.runs,
262
+ errors: entry.errors
263
+ }));
264
+ }
265
+ };
163
266
  }
267
+ /**
268
+ * Process-default cron registry.
269
+ *
270
+ * @deprecated
271
+ * Shared state. Prefer {@link createCronRegistry} and give each silgi
272
+ * instance its own. The module-default registry is retained so
273
+ * existing imports of `startCronJobs` / `stopCronJobs` /
274
+ * `getScheduledTasks` keep working; a future major will remove these
275
+ * top-level re-exports.
276
+ */
277
+ const defaultRegistry = createCronRegistry();
278
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
279
+ const startCronJobs = defaultRegistry.start;
280
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
281
+ const stopCronJobs = defaultRegistry.stop;
282
+ /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */
283
+ const getScheduledTasks = defaultRegistry.list;
164
284
  //#endregion
165
- export { collectCronTasks, createTaskFromProcedure, getScheduledTasks, runTask, setTaskAnalytics, startCronJobs, stopCronJobs };
285
+ 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 };