@geekmidas/constructs 10.0.0-alpha.126 → 10.0.0-alpha.127

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.
@@ -0,0 +1,769 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_shutdown = require("./shutdown-BMJmAhSt.cjs");
3
+ const require_construct_interface = require("./construct-interface.cjs");
4
+ const require_surfaceEnv = require("./surfaceEnv-CNimK76p.cjs");
5
+ let _geekmidas_manifest = require("@geekmidas/manifest");
6
+ let _geekmidas_logger_console = require("@geekmidas/logger/console");
7
+ let node_crypto = require("node:crypto");
8
+ let _geekmidas_services = require("@geekmidas/services");
9
+ let _geekmidas_errors = require("@geekmidas/errors");
10
+ /** A connection string names no transport this package knows. */
11
+ var UnsupportedWebSocketTransport = class extends Error {
12
+ connectionString;
13
+ constructor(connectionString) {
14
+ super(`'${redact(connectionString)}' is not a WebSocket publisher connection string. Use redis://, rediss:// or memory:// — gkm composes it as <ID>_PUBLISHER_CONNECTION_STRING.`);
15
+ this.connectionString = connectionString;
16
+ this.name = "UnsupportedWebSocketTransport";
17
+ }
18
+ };
19
+ /** The scheme alone, so an error never prints a password. */
20
+ function redact(connectionString) {
21
+ const scheme = connectionString.split("://")[0];
22
+ return scheme && scheme !== connectionString ? `${scheme}://…` : connectionString.slice(0, 12);
23
+ }
24
+ const key = Symbol.for("@geekmidas/constructs/websocket/memory");
25
+ const registry = globalThis;
26
+ registry[key] ??= /* @__PURE__ */ new Map();
27
+ const channels = registry[key];
28
+ /**
29
+ * In one process: the server and whoever sends meet in a map, keyed by the
30
+ * channel the connection string names.
31
+ */
32
+ var MemoryTransport = class {
33
+ channel;
34
+ subscribed = /* @__PURE__ */ new Set();
35
+ constructor(channel) {
36
+ this.channel = channel;
37
+ }
38
+ async publish(instance, delivery) {
39
+ const instances = channels.get(this.channel);
40
+ if (!instances) return;
41
+ if (instance === "*") {
42
+ for (const receive of instances.values()) receive(delivery);
43
+ return;
44
+ }
45
+ instances.get(instance)?.(delivery);
46
+ }
47
+ async subscribe(instance, receive) {
48
+ let instances = channels.get(this.channel);
49
+ if (!instances) {
50
+ instances = /* @__PURE__ */ new Map();
51
+ channels.set(this.channel, instances);
52
+ }
53
+ instances.set(instance, receive);
54
+ this.subscribed.add(instance);
55
+ }
56
+ async close() {
57
+ const instances = channels.get(this.channel);
58
+ for (const instance of this.subscribed) instances?.delete(instance);
59
+ this.subscribed.clear();
60
+ }
61
+ };
62
+ /**
63
+ * Over Redis pub/sub: one channel per instance and one for broadcasts, under
64
+ * a prefix that keeps two surfaces — or two projects on one Redis — apart.
65
+ * Channels are not scoped by database index, so the prefix is what does it.
66
+ *
67
+ * Publishing and subscribing take separate connections, because a connection
68
+ * that has subscribed can do nothing else.
69
+ */
70
+ var RedisTransport = class {
71
+ client;
72
+ prefix;
73
+ subscriber;
74
+ closed;
75
+ constructor(client, prefix) {
76
+ this.client = client;
77
+ this.prefix = prefix;
78
+ }
79
+ channel(instance) {
80
+ return `${this.prefix}:${instance}`;
81
+ }
82
+ async publish(instance, delivery) {
83
+ await this.client.publish(this.channel(instance), JSON.stringify(delivery));
84
+ }
85
+ async subscribe(instance, receive) {
86
+ const subscriber = this.client.duplicate();
87
+ this.subscriber = subscriber;
88
+ const own = this.channel(instance);
89
+ const every = this.channel("*");
90
+ subscriber.on("message", (channel, message) => {
91
+ if (channel !== own && channel !== every) return;
92
+ receive(JSON.parse(message));
93
+ });
94
+ await subscriber.subscribe(own, every);
95
+ }
96
+ /** Idempotent: the server and a shutdown hook may both close it. */
97
+ close() {
98
+ this.closed ??= Promise.all([this.subscriber?.quit(), this.client.quit()]).then(() => void 0);
99
+ return this.closed;
100
+ }
101
+ };
102
+ /**
103
+ * The transport a connection string names.
104
+ *
105
+ * `redis://` and `rediss://` take an optional `channel` query parameter, the
106
+ * prefix every channel is named under; `memory://<channel>` is in-process.
107
+ * Absent, the channel is `fallback` — the surface's own.
108
+ */
109
+ async function transportFromConnectionString(connectionString, fallback) {
110
+ if (connectionString.startsWith("memory://")) return new MemoryTransport(connectionString.slice(9) || fallback);
111
+ const url = URL.canParse(connectionString) ? new URL(connectionString) : void 0;
112
+ if (!url) throw new UnsupportedWebSocketTransport(connectionString);
113
+ if (url.protocol === "redis:" || url.protocol === "rediss:") {
114
+ const channel = url.searchParams.get("channel") || fallback;
115
+ url.searchParams.delete("channel");
116
+ const { Redis } = await import("ioredis");
117
+ return new RedisTransport(new Redis(url.toString(), {
118
+ maxRetriesPerRequest: null,
119
+ lazyConnect: false
120
+ }), channel);
121
+ }
122
+ throw new UnsupportedWebSocketTransport(connectionString);
123
+ }
124
+ //#endregion
125
+ //#region src/websocket/publisher.ts
126
+ /** The instance a connection id was issued by: everything before its dot. */
127
+ function instanceOf(connectionId) {
128
+ const dot = connectionId.indexOf(".");
129
+ return dot > 0 ? connectionId.slice(0, dot) : void 0;
130
+ }
131
+ /** A message sent on an action the surface does not declare. */
132
+ var UnknownWebSocketMessage = class extends Error {
133
+ surface;
134
+ action;
135
+ declared;
136
+ constructor(surface, action, declared) {
137
+ super(`${surface} sends no '${action}' message. It declares ${declared.map((a) => `'${a}'`).join(", ")} — send one of them, or add '${action}' to its messages.`);
138
+ this.surface = surface;
139
+ this.action = action;
140
+ this.declared = declared;
141
+ this.name = "UnknownWebSocketMessage";
142
+ }
143
+ };
144
+ /** A message whose data its action's schema refused. */
145
+ var InvalidWebSocketMessage = class extends Error {
146
+ surface;
147
+ action;
148
+ issues;
149
+ constructor(surface, action, issues) {
150
+ super(`${surface}'s '${action}' message was refused by its schema: ${issues.map((issue) => issue.message).join("; ")}. Nothing was sent.`);
151
+ this.surface = surface;
152
+ this.action = action;
153
+ this.issues = issues;
154
+ this.name = "InvalidWebSocketMessage";
155
+ }
156
+ };
157
+ /**
158
+ * How anything reaches a socket — `services.realtime` in an endpoint, and
159
+ * `sockets` in a route of the surface itself.
160
+ *
161
+ * A message is `{ action, data }`, the shape a client's frames arrive in.
162
+ * Where the surface declares its messages, `action` is one of them and
163
+ * `data` is typed and checked by its schema before anything is sent; where it
164
+ * declares none, any action and any JSON value go. A message is serialised
165
+ * once, whatever the number of sockets it goes to. An id whose process has
166
+ * gone is dropped silently, as API Gateway's `postToConnection` to a closed
167
+ * connection is a no-op the sender rarely wants to handle.
168
+ */
169
+ var WebSocketPublisher = class {
170
+ transport;
171
+ local;
172
+ declared;
173
+ constructor(transport, local, declared) {
174
+ this.transport = transport;
175
+ this.local = local;
176
+ this.declared = declared;
177
+ }
178
+ /** Send `action`'s message to each connection. */
179
+ async send(ids, action, data) {
180
+ const payload = await this.payload(action, data);
181
+ await this.each(ids, (group) => ({
182
+ kind: "send",
183
+ ids: group,
184
+ payload
185
+ }));
186
+ }
187
+ /** Send `action`'s message to every open connection, on every process. */
188
+ async broadcast(action, data, options = {}) {
189
+ const payload = await this.payload(action, data);
190
+ await this.transport.publish("*", {
191
+ kind: "broadcast",
192
+ payload,
193
+ ...options.except?.length ? { except: options.except } : {}
194
+ });
195
+ }
196
+ /** Close each connection, with a close code and reason its client sees. */
197
+ async close(ids, code = 1e3, reason = "") {
198
+ await this.each(ids, (group) => ({
199
+ kind: "close",
200
+ ids: group,
201
+ code,
202
+ reason
203
+ }));
204
+ }
205
+ /**
206
+ * The frame, checked by the action's schema where the surface declares
207
+ * its messages.
208
+ *
209
+ * @throws {UnknownWebSocketMessage}, {InvalidWebSocketMessage}
210
+ */
211
+ async payload(action, data) {
212
+ if (!this.declared) return JSON.stringify({
213
+ action,
214
+ data
215
+ });
216
+ const { surface, messages } = this.declared;
217
+ const schema = messages[action];
218
+ if (!schema) throw new UnknownWebSocketMessage(surface, action, Object.keys(messages));
219
+ const result = await schema["~standard"].validate(data);
220
+ if (result.issues) throw new InvalidWebSocketMessage(surface, action, result.issues);
221
+ return JSON.stringify({
222
+ action,
223
+ data: result.value
224
+ });
225
+ }
226
+ /** One delivery per process that holds any of `ids`. */
227
+ async each(ids, delivery) {
228
+ const byInstance = /* @__PURE__ */ new Map();
229
+ for (const id of typeof ids === "string" ? [ids] : ids) {
230
+ const instance = instanceOf(id);
231
+ if (!instance) continue;
232
+ const group = byInstance.get(instance) ?? [];
233
+ group.push(id);
234
+ byInstance.set(instance, group);
235
+ }
236
+ await Promise.all([...byInstance].map(([instance, group]) => this.local?.instance === instance ? this.local.deliver(delivery(group)) : this.transport.publish(instance, delivery(group))));
237
+ }
238
+ };
239
+ //#endregion
240
+ //#region src/websocket/route.ts
241
+ /**
242
+ * A handler bound to a WebSocket surface — what `realtime.route('chat.send')
243
+ * …handle()` returns, and what a file exports.
244
+ *
245
+ * Carries `surface` and `constructs` the way an endpoint does, so discovery
246
+ * records what its process reaches without knowing what a route is.
247
+ */
248
+ var WebSocketRoute = class {
249
+ kind;
250
+ action;
251
+ surface;
252
+ services;
253
+ constructs;
254
+ handler;
255
+ schema;
256
+ __IS_WEBSOCKET_ROUTE__ = true;
257
+ static isRoute(value) {
258
+ return Boolean(value && value.__IS_WEBSOCKET_ROUTE__ === true);
259
+ }
260
+ constructor(kind, action, surface, services, constructs, handler, schema) {
261
+ this.kind = kind;
262
+ this.action = action;
263
+ this.surface = surface;
264
+ this.services = services;
265
+ this.constructs = constructs;
266
+ this.handler = handler;
267
+ this.schema = schema;
268
+ }
269
+ };
270
+ /** The builder `connect()`, `disconnect()` and `route()` start. */
271
+ var WebSocketRouteBuilder = class WebSocketRouteBuilder {
272
+ kind;
273
+ action;
274
+ surface;
275
+ _services;
276
+ _constructs;
277
+ schema;
278
+ constructor(kind, action, surface, _services = [], _constructs = [], schema) {
279
+ this.kind = kind;
280
+ this.action = action;
281
+ this.surface = surface;
282
+ this._services = _services;
283
+ this._constructs = _constructs;
284
+ this.schema = schema;
285
+ }
286
+ /** The schema a frame's `data` must satisfy before the handler runs. */
287
+ body(schema) {
288
+ return new WebSocketRouteBuilder(this.kind, this.action, this.surface, this._services, this._constructs, schema);
289
+ }
290
+ /** What the handler reaches, as `services.<id>` — per route, as an endpoint's. */
291
+ dependsOn(constructs) {
292
+ return new WebSocketRouteBuilder(this.kind, this.action, this.surface, [...this._services, ...require_construct_interface.servicesOf(constructs)], require_construct_interface.idsOf(constructs, this._constructs), this.schema);
293
+ }
294
+ /**
295
+ * The handler. A message handler's return value, when there is one, is
296
+ * sent back to the connection as the reply to that frame; a connect
297
+ * handler's is kept as the connection's `data`.
298
+ */
299
+ handle(handler) {
300
+ return new WebSocketRoute(this.kind, this.action, this.surface, this._services, this._constructs, handler, this.schema);
301
+ }
302
+ };
303
+ //#endregion
304
+ //#region src/websocket/server.ts
305
+ /**
306
+ * The process that holds a WebSocket surface's sockets.
307
+ *
308
+ * Node's own `http` server and `ws`, nothing between them: a frame is parsed,
309
+ * validated and handed to its route, and that is the whole hot path. The HTTP
310
+ * server is whatever the entry started — the generated one serves `/health`
311
+ * on the same port — and this attaches to its `upgrade` event.
312
+ */
313
+ /** Close codes a client sees. */
314
+ const CLOSE = {
315
+ normal: 1e3,
316
+ /** The process is stopping; reconnect and land on another. */
317
+ serviceRestart: 1012,
318
+ /** It stopped reading, and what was queued for it passed the limit. */
319
+ tooSlow: 1013
320
+ };
321
+ /** Two routes answer one action. */
322
+ var DuplicateWebSocketRoute = class extends Error {
323
+ surface;
324
+ action;
325
+ constructor(surface, action) {
326
+ super(`Two routes on ${surface} answer '${action}'. Each action has one handler — rename one of them.`);
327
+ this.surface = surface;
328
+ this.action = action;
329
+ this.name = "DuplicateWebSocketRoute";
330
+ }
331
+ };
332
+ var WebSocketSurfaceServer = class WebSocketSurfaceServer {
333
+ wss;
334
+ options;
335
+ /** This process, as connection ids name it. */
336
+ instance = (0, node_crypto.randomBytes)(6).toString("hex");
337
+ /** `sockets` in a route: local sends skip the transport. */
338
+ publisher;
339
+ held = /* @__PURE__ */ new Map();
340
+ routes = /* @__PURE__ */ new Map();
341
+ lifecycle = {};
342
+ heartbeat;
343
+ next = 0;
344
+ closing = false;
345
+ constructor(wss, options) {
346
+ this.wss = wss;
347
+ this.options = options;
348
+ for (const route of options.routes) {
349
+ if (route.surface.id !== options.surface) continue;
350
+ if (route.kind === "message") {
351
+ if (this.routes.has(route.action)) throw new DuplicateWebSocketRoute(options.surface, route.action);
352
+ this.routes.set(route.action, route);
353
+ } else {
354
+ if (this.lifecycle[route.kind]) throw new DuplicateWebSocketRoute(options.surface, route.action);
355
+ this.lifecycle[route.kind] = route;
356
+ }
357
+ }
358
+ this.publisher = new WebSocketPublisher(options.transport, {
359
+ instance: this.instance,
360
+ deliver: (delivery) => this.deliver(delivery)
361
+ }, options.messages ? {
362
+ surface: options.surface,
363
+ messages: options.messages
364
+ } : void 0);
365
+ this.heartbeat = setInterval(() => this.beat(), options.heartbeatMs ?? 3e4);
366
+ this.heartbeat.unref();
367
+ }
368
+ static async start(options) {
369
+ const { WebSocketServer } = await import("ws");
370
+ const wss = new WebSocketServer({
371
+ noServer: true,
372
+ maxPayload: options.maxPayload ?? 1048576,
373
+ perMessageDeflate: false,
374
+ clientTracking: false
375
+ });
376
+ const server = new WebSocketSurfaceServer(wss, options);
377
+ await options.transport.subscribe(server.instance, (delivery) => server.deliver(delivery));
378
+ return server;
379
+ }
380
+ /** How many sockets this process holds. */
381
+ get size() {
382
+ return this.held.size;
383
+ }
384
+ /** Take over `server`'s upgrades. */
385
+ attach(server) {
386
+ server.on("upgrade", (request, socket, head) => {
387
+ this.upgrade(request, socket, head);
388
+ });
389
+ }
390
+ /** Upgrade one request, or refuse it with a status line. */
391
+ async upgrade(request, socket, head) {
392
+ if (this.closing) return refuse(socket, 503, "Service Unavailable");
393
+ const origin = request.headers.origin;
394
+ if (origin && !this.options.trustedOrigins.includes(origin)) return refuse(socket, 403, "Forbidden");
395
+ const connectionId = `${this.instance}.${(this.next++).toString(36)}`;
396
+ const headers = headersOf(request);
397
+ let data = {};
398
+ const connect = this.lifecycle.connect;
399
+ if (connect) try {
400
+ const session = this.options.authenticator ? await this.options.authenticator.verify(headers, this.options.envParser) : null;
401
+ const returned = await this.run(connect, connectionId, (base) => ({
402
+ ...base,
403
+ connectionId,
404
+ headers,
405
+ query: new URL(request.url ?? "/", "http://localhost").searchParams,
406
+ session
407
+ }));
408
+ if (returned && typeof returned === "object") data = returned;
409
+ } catch (error) {
410
+ if (error instanceof _geekmidas_errors.HttpError) return refuse(socket, error.statusCode, error.statusMessage);
411
+ this.options.logger.error({
412
+ err: error,
413
+ connectionId
414
+ }, "WebSocket connect handler failed");
415
+ return refuse(socket, 500, "Internal Server Error");
416
+ }
417
+ if (socket.destroyed) return;
418
+ this.wss.handleUpgrade(request, socket, head, (ws) => this.open(ws, {
419
+ id: connectionId,
420
+ data
421
+ }));
422
+ }
423
+ open(socket, connection) {
424
+ const held = {
425
+ socket,
426
+ connection,
427
+ alive: true
428
+ };
429
+ this.held.set(connection.id, held);
430
+ socket.on("pong", () => {
431
+ held.alive = true;
432
+ });
433
+ socket.on("message", (raw, isBinary) => {
434
+ held.alive = true;
435
+ if (isBinary) return this.error(held, void 0, "Frames are JSON text.");
436
+ this.dispatch(held, raw.toString());
437
+ });
438
+ socket.on("error", () => socket.terminate());
439
+ socket.on("close", (code, reason) => {
440
+ this.held.delete(connection.id);
441
+ const disconnect = this.lifecycle.disconnect;
442
+ if (!disconnect) return;
443
+ this.run(disconnect, connection.id, (base) => ({
444
+ ...base,
445
+ connection,
446
+ code,
447
+ reason: reason.toString()
448
+ })).catch((error) => this.options.logger.error({
449
+ err: error,
450
+ connectionId: connection.id
451
+ }, "WebSocket disconnect handler failed"));
452
+ });
453
+ }
454
+ /** Parse, validate and run one frame. Errors go back as frames, not closes. */
455
+ async dispatch(held, text) {
456
+ let frame;
457
+ try {
458
+ frame = JSON.parse(text);
459
+ } catch {
460
+ return this.error(held, void 0, "Frames are JSON.");
461
+ }
462
+ if (!frame || typeof frame.action !== "string") return this.error(held, frame?.id, "A frame names its `action`.");
463
+ const route = this.routes.get(frame.action) ?? this.routes.get("$default");
464
+ if (!route) return this.error(held, frame.id, `No route answers '${frame.action}'.`);
465
+ let body = frame.data;
466
+ if (route.schema) {
467
+ const result = await route.schema["~standard"].validate(frame.data);
468
+ if (result.issues) return this.error(held, frame.id, "The frame was invalid.", result.issues);
469
+ body = result.value;
470
+ }
471
+ try {
472
+ const returned = await this.run(route, held.connection.id, (base) => ({
473
+ ...base,
474
+ connection: held.connection,
475
+ body,
476
+ reply: (message) => this.write(held, JSON.stringify(message))
477
+ }));
478
+ if (returned !== void 0) this.write(held, JSON.stringify({
479
+ action: frame.action,
480
+ ...frame.id !== void 0 ? { id: frame.id } : {},
481
+ data: returned
482
+ }));
483
+ } catch (error) {
484
+ if (error instanceof _geekmidas_errors.HttpError) return this.error(held, frame.id, error.message);
485
+ this.options.logger.error({
486
+ err: error,
487
+ connectionId: held.connection.id,
488
+ action: frame.action
489
+ }, "WebSocket route failed");
490
+ this.error(held, frame.id, "The route failed.");
491
+ }
492
+ }
493
+ /** Run a handler with its services, inside a request context of its own. */
494
+ async run(route, connectionId, context) {
495
+ const logger = this.options.logger.child({
496
+ connectionId,
497
+ action: route.action
498
+ });
499
+ return (0, _geekmidas_services.runWithRequestContext)({
500
+ logger,
501
+ requestId: connectionId,
502
+ startTime: Date.now(),
503
+ operation: `ws ${route.action}`
504
+ }, async () => {
505
+ const services = route.services.length ? await _geekmidas_services.ServiceDiscovery.getInstance(this.options.envParser).register([...route.services]) : {};
506
+ return route.handler(context({
507
+ services,
508
+ logger,
509
+ sockets: this.publisher
510
+ }));
511
+ });
512
+ }
513
+ error(held, id, message, issues) {
514
+ this.write(held, JSON.stringify({
515
+ action: "$error",
516
+ ...id !== void 0 ? { id } : {},
517
+ data: {
518
+ message,
519
+ ...issues ? { issues } : {}
520
+ }
521
+ }));
522
+ }
523
+ /** Write, unless the socket has stopped reading and its queue is full. */
524
+ write(held, payload) {
525
+ const { socket } = held;
526
+ if (socket.readyState !== socket.OPEN) return;
527
+ if (socket.bufferedAmount > (this.options.maxBufferedBytes ?? 4194304)) {
528
+ socket.close(CLOSE.tooSlow, "Too slow");
529
+ return;
530
+ }
531
+ socket.send(payload);
532
+ }
533
+ /** What the transport, or a local publisher, asked of this process. */
534
+ deliver(delivery) {
535
+ switch (delivery.kind) {
536
+ case "send":
537
+ for (const id of delivery.ids) {
538
+ const held = this.held.get(id);
539
+ if (held) this.write(held, delivery.payload);
540
+ }
541
+ return;
542
+ case "broadcast": {
543
+ const except = delivery.except ? new Set(delivery.except) : void 0;
544
+ for (const [id, held] of this.held) if (!except?.has(id)) this.write(held, delivery.payload);
545
+ return;
546
+ }
547
+ case "close":
548
+ for (const id of delivery.ids) this.held.get(id)?.socket.close(delivery.code, delivery.reason);
549
+ return;
550
+ }
551
+ }
552
+ /** Drop every socket that missed the last ping, and ping the rest. */
553
+ beat() {
554
+ for (const [id, held] of this.held) {
555
+ if (!held.alive) {
556
+ held.socket.terminate();
557
+ this.held.delete(id);
558
+ continue;
559
+ }
560
+ held.alive = false;
561
+ held.socket.ping();
562
+ }
563
+ }
564
+ /**
565
+ * Stop: refuse new upgrades, close every socket with `code` — 1012 tells
566
+ * a client to reconnect, and it lands on a process that is still up — and
567
+ * leave the transport.
568
+ */
569
+ async close(code = CLOSE.serviceRestart, reason = "Restarting") {
570
+ if (this.closing) return;
571
+ this.closing = true;
572
+ clearInterval(this.heartbeat);
573
+ const closed = [...this.held.values()].map(({ socket }) => new Promise((resolve) => {
574
+ if (socket.readyState === socket.CLOSED) return resolve();
575
+ socket.once("close", () => resolve());
576
+ socket.close(code, reason);
577
+ setTimeout(() => {
578
+ socket.terminate();
579
+ resolve();
580
+ }, 2e3).unref();
581
+ }));
582
+ await Promise.all(closed);
583
+ await this.options.transport.close();
584
+ this.wss.close();
585
+ }
586
+ };
587
+ function headersOf(request) {
588
+ const headers = new Headers();
589
+ for (const [name, value] of Object.entries(request.headers)) {
590
+ if (value === void 0) continue;
591
+ for (const one of Array.isArray(value) ? value : [value]) headers.append(name, one);
592
+ }
593
+ return headers;
594
+ }
595
+ function refuse(socket, status, message) {
596
+ if (!socket.destroyed) socket.end(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
597
+ }
598
+ //#endregion
599
+ //#region src/websocket-api.ts
600
+ /** A replica count that is not a whole number of at least one. */
601
+ var InvalidReplicaCount = class extends Error {
602
+ id;
603
+ replicas;
604
+ constructor(id, replicas) {
605
+ super(`${id} asks for ${replicas} replicas. A surface runs on at least one process — give a whole number of 1 or more.`);
606
+ this.id = id;
607
+ this.replicas = replicas;
608
+ this.name = "InvalidReplicaCount";
609
+ }
610
+ };
611
+ var WebSocketApi = class WebSocketApi {
612
+ config;
613
+ dependencies;
614
+ authenticator;
615
+ id;
616
+ keys;
617
+ logger;
618
+ envParser;
619
+ telemetry;
620
+ /**
621
+ * The surface as a dependency: `services.<id>` is its publisher, so an
622
+ * endpoint sends to a socket the way a route beside it does.
623
+ */
624
+ service;
625
+ constructor(id, config, dependencies = [], authenticator) {
626
+ this.config = config;
627
+ this.dependencies = dependencies;
628
+ this.authenticator = authenticator;
629
+ const canonical = (0, _geekmidas_manifest.canonicalId)(id);
630
+ this.id = canonical;
631
+ const replicas = config.replicas ?? 1;
632
+ if (!Number.isInteger(replicas) || replicas < 1) throw new InvalidReplicaCount(canonical, replicas);
633
+ this.keys = {
634
+ url: (0, _geekmidas_manifest.provideKey)(canonical, "url"),
635
+ trustedOrigins: (0, _geekmidas_manifest.provideKey)(canonical, "trustedOrigins"),
636
+ publisherConnectionString: (0, _geekmidas_manifest.provideKey)(canonical, "publisherConnectionString")
637
+ };
638
+ this.logger = config.logger ?? _geekmidas_logger_console.DEFAULT_LOGGER;
639
+ this.telemetry = config.telemetry;
640
+ this.envParser = require_surfaceEnv.envParserFor(config.envParser ? {
641
+ id: canonical,
642
+ envParser: config.envParser
643
+ } : void 0);
644
+ const envVar = this.keys.publisherConnectionString;
645
+ const channel = channelOf(canonical);
646
+ const declared = this.declared;
647
+ this.service = {
648
+ serviceName: (0, _geekmidas_manifest.serviceKey)(canonical),
649
+ async register({ envParser }) {
650
+ const { connectionString } = envParser.create((get) => ({ connectionString: get(envVar).string() })).parse();
651
+ const transport = await transportFromConnectionString(connectionString, channel);
652
+ require_shutdown.onShutdown(() => transport.close());
653
+ return new WebSocketPublisher(transport, void 0, declared);
654
+ }
655
+ };
656
+ }
657
+ /** The handler a connection's upgrade runs: refuse it, or say who it is. */
658
+ connect() {
659
+ return new WebSocketRouteBuilder("connect", "$connect", { id: this.id });
660
+ }
661
+ /** The handler a closed connection runs. */
662
+ disconnect() {
663
+ return new WebSocketRouteBuilder("disconnect", "$disconnect", { id: this.id });
664
+ }
665
+ /**
666
+ * The handler for frames whose `action` is `action`. `$default` answers
667
+ * any action nothing else does.
668
+ */
669
+ route(action) {
670
+ return new WebSocketRouteBuilder("message", action, { id: this.id });
671
+ }
672
+ /** What authenticates a connection — its session reaches `connect()`. */
673
+ auth(construct) {
674
+ return new WebSocketApi(this.id, this.config, [...this.dependencies, require_construct_interface.edgeTo(construct)], construct);
675
+ }
676
+ /** Other surfaces this one calls. See `RestApi.calls`. */
677
+ calls(constructs) {
678
+ return new WebSocketApi(this.id, this.config, [...this.dependencies, ...constructs.map(require_construct_interface.edgeTo)], this.authenticator);
679
+ }
680
+ /**
681
+ * Serve it: the routes exported by `modules` that were built from this
682
+ * surface, on whatever HTTP server `attach` is handed.
683
+ *
684
+ * `app` answers plain HTTP — the generated entry puts `/health` in front
685
+ * of it — and `attach` takes over the server's upgrades. The sockets are
686
+ * closed with 1012 when the server shuts down.
687
+ */
688
+ async server(options) {
689
+ const { Hono } = await import("hono");
690
+ const routes = (options.modules ?? []).flatMap((module) => Object.values(module).filter(WebSocketRoute.isRoute));
691
+ const { connectionString, origins } = options.envParser.create((get) => ({
692
+ connectionString: get(this.keys.publisherConnectionString).string().default(`memory://${channelOf(this.id)}`),
693
+ origins: get(this.keys.trustedOrigins).string().default("").transform((value) => value.split(",").map((origin) => origin.trim()).filter(Boolean))
694
+ })).parse();
695
+ const server = await WebSocketSurfaceServer.start({
696
+ surface: this.id,
697
+ routes,
698
+ transport: await transportFromConnectionString(connectionString, channelOf(this.id)),
699
+ envParser: options.envParser,
700
+ logger: this.logger,
701
+ trustedOrigins: origins,
702
+ ...this.declared ? { messages: this.declared.messages } : {},
703
+ ...this.authenticator ? { authenticator: this.authenticator } : {},
704
+ ...this.config.heartbeat ? { heartbeatMs: this.config.heartbeat * 1e3 } : {},
705
+ ...this.config.maxPayload ? { maxPayload: this.config.maxPayload } : {}
706
+ });
707
+ require_shutdown.onShutdown(() => server.close());
708
+ const app = new Hono();
709
+ app.get("/health", (c) => c.json({
710
+ status: "ok",
711
+ connections: server.size
712
+ }));
713
+ app.get("/", (c) => c.json({
714
+ surface: this.id,
715
+ connections: server.size
716
+ }, 426, { Upgrade: "websocket" }));
717
+ return {
718
+ app,
719
+ attach: (http) => server.attach(http),
720
+ release: () => server.close(),
721
+ server
722
+ };
723
+ }
724
+ /** The declared messages, with the surface they are checked for. */
725
+ get declared() {
726
+ return this.config.messages ? {
727
+ surface: this.id,
728
+ messages: this.config.messages
729
+ } : void 0;
730
+ }
731
+ declare() {
732
+ return [{
733
+ kind: "websocket-api",
734
+ id: this.id,
735
+ path: this.config.path,
736
+ ...this.config.subdomain ? { subdomain: this.config.subdomain } : {},
737
+ ...this.authenticator ? { auth: this.authenticator.id } : {},
738
+ ...this.dependencies.length ? { calls: this.dependencies } : {},
739
+ ...this.config.telemetry ? { telemetry: this.config.telemetry.id } : {},
740
+ ...this.config.replicas && this.config.replicas > 1 ? { replicas: this.config.replicas } : {},
741
+ provides: [
742
+ this.keys.url,
743
+ this.keys.trustedOrigins,
744
+ this.keys.publisherConnectionString
745
+ ]
746
+ }];
747
+ }
748
+ };
749
+ /** The prefix a surface's channels are named under. */
750
+ function channelOf(id) {
751
+ return `gkm:ws:${id}`;
752
+ }
753
+ //#endregion
754
+ exports.CLOSE = CLOSE;
755
+ exports.DuplicateWebSocketRoute = DuplicateWebSocketRoute;
756
+ exports.InvalidReplicaCount = InvalidReplicaCount;
757
+ exports.InvalidWebSocketMessage = InvalidWebSocketMessage;
758
+ exports.MemoryTransport = MemoryTransport;
759
+ exports.RedisTransport = RedisTransport;
760
+ exports.UnknownWebSocketMessage = UnknownWebSocketMessage;
761
+ exports.UnsupportedWebSocketTransport = UnsupportedWebSocketTransport;
762
+ exports.WebSocketApi = WebSocketApi;
763
+ exports.WebSocketPublisher = WebSocketPublisher;
764
+ exports.WebSocketRoute = WebSocketRoute;
765
+ exports.WebSocketRouteBuilder = WebSocketRouteBuilder;
766
+ exports.WebSocketSurfaceServer = WebSocketSurfaceServer;
767
+ exports.transportFromConnectionString = transportFromConnectionString;
768
+
769
+ //# sourceMappingURL=websocket-api.cjs.map