silgi 0.53.0 → 0.53.2

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/ws.mjs CHANGED
@@ -1,64 +1,132 @@
1
1
  import { SilgiError, toSilgiError } from "./core/error.mjs";
2
- import { compileRouter, createContext, releaseContext } from "./compile.mjs";
2
+ import { compileRouter } from "./compile.mjs";
3
3
  import { stringifyJSON } from "./core/utils.mjs";
4
4
  import { decode, encode } from "./codec/msgpack.mjs";
5
5
  //#region src/ws.ts
6
6
  /**
7
- * WebSocket RPC adapter — powered by crossws.
7
+ * WebSocket RPC adapter
8
+ * -----------------------
8
9
  *
9
- * Bidirectional type-safe RPC over WebSocket.
10
- * Supports subscriptions (server client streaming) natively.
10
+ * Exposes silgi procedures over a WebSocket connection using the
11
+ * `crossws` runtime-agnostic adapter underneath. Every procedure
12
+ * registered via `router()` is reachable — no opt-in flag required.
11
13
  *
12
- * Protocol:
13
- * Client → Server: { id: string, path: string, input?: unknown }
14
- * Server → Client: { id: string, result?: unknown, error?: unknown }
15
- * ServerClient (stream): { id: string, data: unknown, done?: boolean }
14
+ * Wire protocol
15
+ * -------------
16
+ *
17
+ * ClientServer: { id, path, input? }
18
+ * Server → Client: { id, result?, error? } (single value)
19
+ * Server → Client: { id, data, done? } (streaming chunk)
20
+ *
21
+ * Requests are correlated by `id`. A subscription (any resolver that returns
22
+ * an async iterable) streams back one `{ id, data }` frame per yielded
23
+ * value, followed by a terminal `{ id, data: null, done: true }`. Clients
24
+ * close a subscription by closing the socket; the peer disconnect aborts
25
+ * every in-flight resolver for that peer.
26
+ *
27
+ * Two encodings are supported: UTF-8 JSON (default) and binary MessagePack.
28
+ * The choice is per-adapter, not per-message.
16
29
  */
17
30
  /**
18
- * Internal build crossws-compatible hooks for Silgi RPC over WebSocket.
31
+ * Build the crossws hook set that implements silgi's WebSocket RPC.
19
32
  *
20
- * Used by `attachWebSocket()`, `serve({ ws: true })`, and `handler()` auto-WS.
21
- * Not part of the public API; callers should use one of those higher-level entry points.
33
+ * @internal
34
+ *
35
+ * This is not part of the public API — `silgi({...}).handler()`,
36
+ * `serve({ ws: true })`, and `attachWebSocket()` are the three supported
37
+ * entry points. They all go through this builder so protocol behavior
38
+ * stays identical everywhere.
22
39
  */
23
- /** @internal — exported only for use by silgi.ts handler() and attachWebSocket(). Not part of the public API. */
24
40
  function _createWSHooks(routerDef, options = {}) {
25
- const flat = compileRouter(routerDef);
41
+ const compiled = compileRouter(routerDef);
26
42
  const useMsgpack = options.protocol === "messagepack" || options.protocol == null && (options.binary ?? false);
27
43
  const contextFactory = options.context;
28
44
  const keepaliveMs = options.keepalive === false ? 0 : options.keepalive ?? 3e4;
29
45
  const peerAbortControllers = /* @__PURE__ */ new WeakMap();
30
46
  const peerKeepaliveTimers = /* @__PURE__ */ new WeakMap();
31
- function send(peer, data) {
47
+ /** Send a single frame, applying the peer's chosen encoding and compression. */
48
+ const send = (peer, data) => {
32
49
  const compress = !!options.compress;
33
50
  if (useMsgpack) peer.send(encode(data), { compress });
34
51
  else peer.send(stringifyJSON(data), { compress });
35
- }
36
- function parseMessage(message) {
52
+ };
53
+ /** Decode an incoming frame into an `RPCRequest`. Throws on parse error. */
54
+ const parseMessage = (message) => {
37
55
  if (useMsgpack) return decode(message.uint8Array());
38
56
  return message.json();
39
- }
57
+ };
58
+ /**
59
+ * Build the per-request context.
60
+ *
61
+ * Isolated here so the message handler stays readable; the caller
62
+ * handles send-back-error on failure.
63
+ */
64
+ const buildContext = async (peer) => {
65
+ const ctx = Object.create(null);
66
+ if (contextFactory) {
67
+ const base = await contextFactory(peer);
68
+ for (const key of Object.keys(base)) ctx[key] = base[key];
69
+ }
70
+ return ctx;
71
+ };
72
+ /**
73
+ * Stream a subscription result back to the peer. Returns when the
74
+ * iterator is exhausted, the peer disconnects, or the resolver throws.
75
+ *
76
+ * `iter.return?.()` is called in `finally` so the resolver's cleanup
77
+ * (database cursors, external watchers, etc.) runs even on disconnect.
78
+ */
79
+ const streamSubscription = async (peer, id, iter, signal) => {
80
+ try {
81
+ for await (const data of iter) {
82
+ if (signal.aborted) break;
83
+ send(peer, {
84
+ id,
85
+ data
86
+ });
87
+ }
88
+ if (!signal.aborted) send(peer, {
89
+ id,
90
+ data: null,
91
+ done: true
92
+ });
93
+ } catch (err) {
94
+ if (!signal.aborted) send(peer, {
95
+ id,
96
+ error: toClientError(err)
97
+ });
98
+ } finally {
99
+ await iter.return?.();
100
+ }
101
+ };
102
+ /**
103
+ * Install a keepalive ping loop on a peer, if the runtime gives us
104
+ * access to the underlying `ws` instance. Silently no-ops when it
105
+ * does not — some adapters (e.g. Bun) do not expose that handle.
106
+ */
107
+ const installKeepalive = (peer) => {
108
+ if (keepaliveMs <= 0) return;
109
+ const ws = peer._internal?.ws;
110
+ if (!ws || typeof ws.ping !== "function" || typeof ws.on !== "function" || typeof ws.terminate !== "function") return;
111
+ let alive = true;
112
+ ws.on("pong", () => {
113
+ alive = true;
114
+ });
115
+ const timer = setInterval(() => {
116
+ if (!alive) {
117
+ clearInterval(timer);
118
+ ws.terminate();
119
+ return;
120
+ }
121
+ alive = false;
122
+ ws.ping();
123
+ }, keepaliveMs);
124
+ peerKeepaliveTimers.set(peer, timer);
125
+ };
40
126
  return {
41
127
  open(peer) {
42
128
  peerAbortControllers.set(peer, /* @__PURE__ */ new Set());
43
- if (keepaliveMs > 0) {
44
- const ws = peer._internal?.ws;
45
- if (ws && typeof ws.ping === "function") {
46
- let alive = true;
47
- ws.on("pong", () => {
48
- alive = true;
49
- });
50
- const timer = setInterval(() => {
51
- if (!alive) {
52
- clearInterval(timer);
53
- ws.terminate();
54
- return;
55
- }
56
- alive = false;
57
- ws.ping();
58
- }, keepaliveMs);
59
- peerKeepaliveTimers.set(peer, timer);
60
- }
61
- }
129
+ installKeepalive(peer);
62
130
  },
63
131
  async message(peer, message) {
64
132
  let req;
@@ -76,7 +144,7 @@ function _createWSHooks(routerDef, options = {}) {
76
144
  return;
77
145
  }
78
146
  const { id, path, input } = req;
79
- const route = flat("POST", "/" + path)?.data;
147
+ const route = compiled("POST", "/" + path)?.data;
80
148
  if (!route) {
81
149
  send(peer, {
82
150
  id,
@@ -88,66 +156,35 @@ function _createWSHooks(routerDef, options = {}) {
88
156
  });
89
157
  return;
90
158
  }
91
- const ctx = createContext();
92
- if (contextFactory) try {
93
- const baseResult = contextFactory(peer);
94
- const base = baseResult instanceof Promise ? await baseResult : baseResult;
95
- const keys = Object.keys(base);
96
- for (let i = 0; i < keys.length; i++) ctx[keys[i]] = base[keys[i]];
159
+ let ctx;
160
+ try {
161
+ ctx = await buildContext(peer);
97
162
  } catch (err) {
98
- releaseContext(ctx);
99
163
  send(peer, {
100
164
  id,
101
- error: (err instanceof SilgiError ? err : toSilgiError(err)).toJSON()
165
+ error: toClientError(err)
102
166
  });
103
167
  return;
104
168
  }
105
169
  const ac = new AbortController();
106
- const controllers = peerAbortControllers.get(peer);
107
- controllers?.add(ac);
170
+ peerAbortControllers.get(peer)?.add(ac);
108
171
  try {
109
- const result = route.handler(ctx, input ?? {}, ac.signal);
110
- const output = result instanceof Promise ? await result : result;
111
- if (output && typeof output === "object" && Symbol.asyncIterator in output) {
112
- const iter = output;
113
- try {
114
- for await (const data of iter) {
115
- if (ac.signal.aborted) break;
116
- send(peer, {
117
- id,
118
- data
119
- });
120
- }
121
- if (!ac.signal.aborted) send(peer, {
122
- id,
123
- data: null,
124
- done: true
125
- });
126
- } catch (err) {
127
- if (!ac.signal.aborted) send(peer, {
128
- id,
129
- error: (err instanceof SilgiError ? err : toSilgiError(err)).toJSON()
130
- });
131
- } finally {
132
- await iter.return?.();
133
- }
134
- return;
135
- }
136
- send(peer, {
172
+ const output = await route.handler(ctx, input ?? {}, ac.signal);
173
+ if (output && typeof output === "object" && Symbol.asyncIterator in output) await streamSubscription(peer, id, output, ac.signal);
174
+ else send(peer, {
137
175
  id,
138
176
  result: output
139
177
  });
140
178
  } catch (err) {
141
179
  send(peer, {
142
180
  id,
143
- error: (err instanceof SilgiError ? err : toSilgiError(err)).toJSON()
181
+ error: toClientError(err)
144
182
  });
145
183
  } finally {
146
- controllers?.delete(ac);
147
- releaseContext(ctx);
184
+ peerAbortControllers.get(peer)?.delete(ac);
148
185
  }
149
186
  },
150
- close(peer, _details) {
187
+ close(peer) {
151
188
  const timer = peerKeepaliveTimers.get(peer);
152
189
  if (timer) {
153
190
  clearInterval(timer);
@@ -165,18 +202,20 @@ function _createWSHooks(routerDef, options = {}) {
165
202
  }
166
203
  };
167
204
  }
205
+ /** Normalize any thrown value into the `{ code, status, message, ... }` shape clients expect. */
206
+ function toClientError(err) {
207
+ return (err instanceof SilgiError ? err : toSilgiError(err)).toJSON();
208
+ }
168
209
  /**
169
- * Attach WebSocket RPC handler to an existing Node.js HTTP server.
210
+ * Attach silgi's WebSocket RPC to an existing Node.js `http.Server`.
170
211
  *
171
212
  * @example
172
- * ```ts
173
- * import { createServer } from "node:http";
174
- * import { attachWebSocket } from "silgi/ws";
213
+ * import { createServer } from 'node:http'
214
+ * import { attachWebSocket } from 'silgi/ws'
175
215
  *
176
- * const server = createServer(httpHandler);
177
- * attachWebSocket(server, appRouter);
178
- * server.listen(3000);
179
- * ```
216
+ * const server = createServer(httpHandler)
217
+ * await attachWebSocket(server, appRouter)
218
+ * server.listen(3000)
180
219
  */
181
220
  async function attachWebSocket(server, routerDef, options = {}) {
182
221
  const nodeAdapter = (await import("crossws/adapters/node")).default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "silgi",
3
- "version": "0.53.0",
3
+ "version": "0.53.2",
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": [