silgi 0.53.0 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,19 +3,156 @@ import { createFetchHandler, wrapHandler } from "./handler.mjs";
3
3
  import { _createWSHooks } from "../ws.mjs";
4
4
  import { serve } from "srvx";
5
5
  //#region src/core/serve.ts
6
+ /**
7
+ * `serve()` orchestrator
8
+ * ------------------------
9
+ *
10
+ * Builds a Node/Bun/Deno HTTP server from a silgi router. The heavy
11
+ * lifting (the compiled Fetch handler, analytics/Scalar wrappers) is
12
+ * already done elsewhere; this module stitches them together with the
13
+ * runtime-specific WebSocket upgrade plumbing and hands off to `srvx`.
14
+ *
15
+ * The only per-runtime work that lives here is mounting WebSocket hooks
16
+ * for subscriptions:
17
+ *
18
+ * - **Bun** — inject crossws into `serve({ bun: { websocket } })`
19
+ * and intercept upgrade requests at the Fetch layer.
20
+ * - **Deno** — intercept upgrade requests at the Fetch layer and
21
+ * call the crossws Deno adapter directly.
22
+ * - **Node** — after srvx exposes the `http.Server`, attach crossws
23
+ * via `server.on('upgrade', …)`.
24
+ *
25
+ * Everything else is shared: URL resolution, graceful shutdown, hook
26
+ * firing, and the startup banner.
27
+ */
28
+ /**
29
+ * Detect the current JavaScript runtime from well-known globals.
30
+ *
31
+ * Written as a plain function rather than reading a module-global so
32
+ * the check re-evaluates when the module is imported into a different
33
+ * runtime (e.g. a test that spawns a Bun child process).
34
+ */
6
35
  function detectRuntime() {
7
36
  if (typeof globalThis.Bun !== "undefined") return "bun";
8
37
  if (typeof globalThis.Deno !== "undefined") return "deno";
9
38
  return "node";
10
39
  }
40
+ /**
41
+ * Walk the router tree looking for a subscription. We stop at the first
42
+ * hit because we only need to decide whether to wire up WS at all — we
43
+ * do not need an inventory.
44
+ *
45
+ * Mirrors the helper in `silgi.ts`; kept here so `core/serve.ts` has no
46
+ * runtime dependency on the top-level instance module.
47
+ */
11
48
  function routerHasSubscription(def) {
12
49
  if (!def || typeof def !== "object") return false;
13
50
  if (def.type === "subscription") return true;
14
- for (const v of Object.values(def)) if (routerHasSubscription(v)) return true;
51
+ for (const child of Object.values(def)) if (routerHasSubscription(child)) return true;
15
52
  return false;
16
53
  }
54
+ /**
55
+ * Translate our `gracefulShutdown` option into the shape srvx expects.
56
+ *
57
+ * srvx uses `gracefulTimeout`, we expose `timeout` — the rename keeps
58
+ * the public API readable without leaking srvx vocabulary.
59
+ */
60
+ function resolveShutdown(option) {
61
+ if (option === void 0 || typeof option === "boolean") return option ?? true;
62
+ return {
63
+ gracefulTimeout: option.timeout,
64
+ forceTimeout: option.forceTimeout
65
+ };
66
+ }
67
+ /**
68
+ * Build the WS wiring for the current runtime.
69
+ *
70
+ * Everything stays lazy: when no subscriptions exist or WS is
71
+ * explicitly disabled, we return `{ fetch: httpHandler }` and never
72
+ * import the crossws adapters.
73
+ */
74
+ async function wireWebSocket(routerDef, httpHandler, enabled, wsOpts, runtime, bunServerRef) {
75
+ if (!enabled) return { fetch: httpHandler };
76
+ const hooksObj = _createWSHooks(routerDef, wsOpts);
77
+ if (runtime === "bun") {
78
+ const bunAdapter = (await import("crossws/adapters/bun")).default;
79
+ const adapter = bunAdapter({ hooks: hooksObj });
80
+ return {
81
+ bunWebsocket: adapter.websocket,
82
+ fetch: (async (req) => {
83
+ if (req.headers.get("upgrade") === "websocket" && bunServerRef.current) {
84
+ const res = await adapter.handleUpgrade(req, bunServerRef.current);
85
+ if (res) return res;
86
+ }
87
+ return httpHandler(req);
88
+ })
89
+ };
90
+ }
91
+ if (runtime === "deno") {
92
+ const denoAdapter = (await import("crossws/adapters/deno")).default;
93
+ const adapter = denoAdapter({ hooks: hooksObj });
94
+ return { fetch: (async (req) => {
95
+ if (req.headers.get("upgrade") === "websocket") return adapter.handleUpgrade(req, {});
96
+ return httpHandler(req);
97
+ }) };
98
+ }
99
+ return {
100
+ fetch: httpHandler,
101
+ attachNode: async (httpServer) => {
102
+ const { attachWebSocket } = await import("../ws.mjs");
103
+ await attachWebSocket(httpServer, routerDef, wsOpts);
104
+ }
105
+ };
106
+ }
107
+ /**
108
+ * Compute the final server URL from srvx's output.
109
+ *
110
+ * srvx usually populates `server.url` itself, but when the caller
111
+ * requests port `0` (pick-any-free) or when HTTP/2 is on, we have to
112
+ * piece it together from the runtime-specific socket info. Trailing
113
+ * slashes are stripped so `${url}/api/foo` always produces exactly one
114
+ * separator.
115
+ */
116
+ function resolveUrl(server, requestedPort, hostname, http2) {
117
+ let port = requestedPort;
118
+ if (server.node?.server) {
119
+ const addr = server.node.server.address();
120
+ if (addr && typeof addr === "object") port = addr.port;
121
+ } else if (server.bun?.server) port = server.bun.server.port ?? requestedPort;
122
+ const protocol = http2 ? "https" : "http";
123
+ const raw = server.url || `${protocol}://${hostname}:${port}`;
124
+ return {
125
+ url: raw.endsWith("/") ? raw.slice(0, -1) : raw,
126
+ port
127
+ };
128
+ }
129
+ /**
130
+ * Print the startup banner.
131
+ *
132
+ * Side-effect-y and intentionally not bypassable — a server starting
133
+ * silently is a surprising default; `silent: true` on srvx suppresses
134
+ * *its* banner, but silgi still wants to show where it bound.
135
+ */
136
+ function printBanner(url, hostname, port, runtime, options, wsEnabled) {
137
+ console.log(`\nSilgi server running at ${url}`);
138
+ if (options?.http2) console.log(` HTTP/2 enabled (with HTTP/1.1 fallback)`);
139
+ if (wsEnabled) console.log(` WebSocket RPC at ws://${hostname}:${port}/_ws (${runtime})`);
140
+ if (options?.scalar) console.log(` Scalar API Reference at ${url}/api/reference`);
141
+ if (options?.analytics) console.log(` Analytics dashboard at ${url}/api/analytics`);
142
+ console.log();
143
+ }
144
+ /**
145
+ * Build and start the HTTP (and optionally WebSocket) server.
146
+ *
147
+ * The function is intentionally long because the steps are strictly
148
+ * ordered: WS wiring has to happen before srvx starts (Bun needs its
149
+ * websocket handler at construction time); the `http.Server` only
150
+ * exists once srvx returns (Node attaches WS there); and the banner
151
+ * wants real bound port info. Splitting further would hide the
152
+ * ordering more than it would simplify anything.
153
+ */
17
154
  async function createServeHandler(routerDef, contextFactory, hooks, options, schemaRegistry, bridge) {
18
- const port = options?.port ?? 3e3;
155
+ const requestedPort = options?.port ?? 3e3;
19
156
  const hostname = options?.hostname ?? "127.0.0.1";
20
157
  const prefix = options?.basePath ? normalizePrefix(options.basePath) : void 0;
21
158
  const httpHandler = wrapHandler(createFetchHandler(routerDef, contextFactory, hooks, prefix, bridge), routerDef, options ? {
@@ -26,89 +163,42 @@ async function createServeHandler(routerDef, contextFactory, hooks, options, sch
26
163
  schemaRegistry,
27
164
  hooks
28
165
  }, prefix);
29
- const shutdownOpt = options?.gracefulShutdown ?? true;
30
- let gracefulShutdown;
31
- if (typeof shutdownOpt === "object") gracefulShutdown = {
32
- gracefulTimeout: shutdownOpt.timeout,
33
- forceTimeout: shutdownOpt.forceTimeout
34
- };
35
- else gracefulShutdown = shutdownOpt;
36
- const wsExplicitlyDisabled = options?.ws === false;
37
- const wsOpts = typeof options?.ws === "object" ? options.ws : void 0;
38
- const wsEnabled = !wsExplicitlyDisabled && routerHasSubscription(routerDef);
39
166
  const runtime = detectRuntime();
40
- let fetchHandler = httpHandler;
41
- let bunWebsocket;
167
+ const wsEnabled = !(options?.ws === false) && routerHasSubscription(routerDef);
168
+ const wsOpts = typeof options?.ws === "object" ? options.ws : void 0;
42
169
  const bunServerRef = { current: void 0 };
43
- let nodeAttach;
44
- if (wsEnabled) {
45
- const hooksObj = _createWSHooks(routerDef, wsOpts);
46
- if (runtime === "bun") {
47
- const bunAdapter = (await import("crossws/adapters/bun")).default;
48
- const adapter = bunAdapter({ hooks: hooksObj });
49
- bunWebsocket = adapter.websocket;
50
- fetchHandler = (async (req) => {
51
- if (req.headers.get("upgrade") === "websocket" && bunServerRef.current) {
52
- const res = await adapter.handleUpgrade(req, bunServerRef.current);
53
- if (res) return res;
54
- }
55
- return httpHandler(req);
56
- });
57
- } else if (runtime === "deno") {
58
- const denoAdapter = (await import("crossws/adapters/deno")).default;
59
- const adapter = denoAdapter({ hooks: hooksObj });
60
- fetchHandler = (async (req) => {
61
- if (req.headers.get("upgrade") === "websocket") return adapter.handleUpgrade(req, {});
62
- return httpHandler(req);
63
- });
64
- } else nodeAttach = async (httpServer) => {
65
- const { attachWebSocket } = await import("../ws.mjs");
66
- await attachWebSocket(httpServer, routerDef, wsOpts);
67
- };
68
- }
170
+ const wiring = await wireWebSocket(routerDef, httpHandler, wsEnabled, wsOpts, runtime, bunServerRef);
69
171
  const server = await serve({
70
- port,
172
+ port: requestedPort,
71
173
  hostname,
72
- fetch: fetchHandler,
73
- gracefulShutdown,
174
+ fetch: wiring.fetch,
175
+ gracefulShutdown: resolveShutdown(options?.gracefulShutdown),
74
176
  silent: true,
75
- ...options?.http2 && { tls: {
177
+ ...options?.http2 ? { tls: {
76
178
  cert: options.http2.cert,
77
179
  key: options.http2.key
78
- } },
79
- ...bunWebsocket ? { bun: { websocket: bunWebsocket } } : {}
180
+ } } : {},
181
+ ...wiring.bunWebsocket ? { bun: { websocket: wiring.bunWebsocket } } : {}
80
182
  });
81
183
  await server.ready();
82
184
  if (runtime === "bun" && server.bun?.server) bunServerRef.current = server.bun.server;
83
- if (nodeAttach && server.node?.server) await nodeAttach(server.node.server);
84
- let resolvedPort = port;
85
- if (server.node?.server) {
86
- const addr = server.node.server.address();
87
- if (addr && typeof addr === "object") resolvedPort = addr.port;
88
- } else if (server.bun?.server) resolvedPort = server.bun.server.port ?? port;
89
- const protocol = options?.http2 ? "https" : "http";
90
- const rawUrl = server.url || `${protocol}://${hostname}:${resolvedPort}`;
91
- const url = rawUrl.endsWith("/") ? rawUrl.slice(0, -1) : rawUrl;
92
- console.log(`\nSilgi server running at ${url}`);
93
- if (options?.http2) console.log(` HTTP/2 enabled (with HTTP/1.1 fallback)`);
94
- if (wsEnabled) console.log(` WebSocket RPC at ws://${hostname}:${resolvedPort}/_ws (${runtime})`);
95
- if (options?.scalar) console.log(` Scalar API Reference at ${url}/api/reference`);
96
- if (options?.analytics) console.log(` Analytics dashboard at ${url}/api/analytics`);
97
- console.log();
185
+ if (wiring.attachNode && server.node?.server) await wiring.attachNode(server.node.server);
186
+ const { url, port } = resolveUrl(server, requestedPort, hostname, Boolean(options?.http2));
187
+ printBanner(url, hostname, port, runtime, options, wsEnabled);
98
188
  await hooks.callHook("serve:start", {
99
189
  url,
100
- port: resolvedPort,
190
+ port,
101
191
  hostname
102
192
  });
103
193
  return {
104
194
  url,
105
- port: resolvedPort,
195
+ port,
106
196
  hostname,
107
197
  async close(forceCloseConnections = false) {
108
198
  await server.close(forceCloseConnections);
109
199
  await hooks.callHook("serve:stop", {
110
200
  url,
111
- port: resolvedPort,
201
+ port,
112
202
  hostname
113
203
  });
114
204
  }
@@ -4,15 +4,14 @@ interface EventMeta {
4
4
  retry?: number;
5
5
  }
6
6
  /**
7
- * Attach SSE metadata (id, retry) to a value.
7
+ * Attach SSE `id` / `retry` metadata to a yielded value.
8
8
  *
9
- * Only works with object values (arrays, plain objects, etc.).
10
- * Primitives cannot carry metadata wrap them in an object first.
9
+ * Only object-shaped values can carry metadata; primitives cannot be
10
+ * keyed in the `WeakMap` and are returned unchanged. Wrap primitives
11
+ * in a one-field object when you need metadata on them.
11
12
  */
12
13
  declare function withEventMeta<T>(value: T, meta: EventMeta): T;
13
- /**
14
- * Read SSE metadata from a value (if attached).
15
- */
14
+ /** Read SSE metadata previously attached via `withEventMeta`. */
16
15
  declare function getEventMeta(value: unknown): EventMeta | undefined;
17
16
  //#endregion
18
17
  export { EventMeta, getEventMeta, withEventMeta };
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,6 +42,11 @@ 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>;
@@ -61,9 +69,12 @@ interface CronRegistry {
61
69
  list: () => ScheduledTaskInfo[];
62
70
  }
63
71
  /**
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.
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.
67
78
  */
68
79
  declare function createCronRegistry(): CronRegistry;
69
80
  /** @deprecated Use {@link createCronRegistry} — each silgi instance owns its own. */