@hulla/api-websocket 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ import { AdapterErrorInput, AdapterResponse } from "@hulla/api/adapters";
2
+ import { Awaitable, ServerAdapter, ServerContextInput, ServerExecutableFor } from "@hulla/api/server";
3
+ import { ClientTransport, ClientTransportRequest } from "@hulla/api/client";
4
+ import { Contract } from "@hulla/api";
5
+ //#region src/connection.d.ts
6
+ declare const WEBSOCKET_PROTOCOL: '@hulla/api-websocket/1';
7
+ type WebSocketTransportErrorCode = 'closed' | 'invalid-message' | 'remote-error';
8
+ declare class WebSocketTransportError extends Error {
9
+ readonly code: WebSocketTransportErrorCode;
10
+ constructor(code: WebSocketTransportErrorCode, message: string, options?: ErrorOptions);
11
+ }
12
+ type SocketEvent = {
13
+ readonly type: string;
14
+ readonly data?: unknown;
15
+ };
16
+ /** DOM WebSocket and ws both implement this EventTarget surface. The application owns the socket. */
17
+ type WebSocketLike = {
18
+ readonly readyState: number;
19
+ send(data: string): void;
20
+ addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: SocketEvent) => void): void;
21
+ removeEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: SocketEvent) => void): void;
22
+ };
23
+ //#endregion
24
+ //#region src/client.d.ts
25
+ type WebSocketTransport = ClientTransport & {
26
+ readonly ready: Promise<void>;
27
+ readonly closed: Promise<Error>;
28
+ /** Rejects active calls and releases listeners without closing the application's socket. */
29
+ readonly close: () => Promise<void>;
30
+ };
31
+ declare function webSocketTransport(socket: WebSocketLike): WebSocketTransport;
32
+ //#endregion
33
+ //#region src/server.d.ts
34
+ type WebSocketServerRequest = Omit<ClientTransportRequest, 'signal'> & {
35
+ readonly signal: AbortSignal;
36
+ };
37
+ type WebSocketAdapterContext = {
38
+ readonly socket: WebSocketLike;
39
+ readonly request: WebSocketServerRequest;
40
+ };
41
+ type WebSocketContextInput<ContractType extends Contract = Contract> = ServerContextInput<ContractType> & WebSocketAdapterContext;
42
+ type WebSocketServerErrorInput = Omit<AdapterErrorInput, 'request'> & WebSocketAdapterContext;
43
+ type WebSocketServerOptions = {
44
+ readonly onError?: (input: WebSocketServerErrorInput) => Awaitable<AdapterResponse | undefined | void>;
45
+ };
46
+ type WebSocketServer = {
47
+ readonly ready: Promise<void>;
48
+ readonly closed: Promise<Error>;
49
+ readonly close: () => Promise<void>;
50
+ };
51
+ type WebSocketAdapter = ServerAdapter<'websocket', WebSocketAdapterContext> & {
52
+ readonly mount: <const ContractType extends Contract, const Context extends object>(implementation: ServerExecutableFor<ContractType, Context, 'websocket', WebSocketAdapterContext>, options?: WebSocketServerOptions) => WebSocketServer;
53
+ };
54
+ /** Mount once per accepted socket; the application owns upgrades, authentication and socket lifetime. */
55
+ declare function webSocketAdapter(socket: WebSocketLike, defaults?: WebSocketServerOptions): WebSocketAdapter;
56
+ //#endregion
57
+ export { WEBSOCKET_PROTOCOL, type WebSocketAdapter, type WebSocketAdapterContext, type WebSocketContextInput, type WebSocketLike, type WebSocketServer, type WebSocketServerErrorInput, type WebSocketServerOptions, type WebSocketServerRequest, type WebSocketTransport, WebSocketTransportError, type WebSocketTransportErrorCode, webSocketAdapter, webSocketTransport };
package/dist/index.js ADDED
@@ -0,0 +1,605 @@
1
+ import { createAdapterHandler } from "@hulla/api/adapters";
2
+ import { assertAdapterContext, createServerAdapter } from "@hulla/api/server";
3
+
4
+ //#region src/wire.ts
5
+ function base64(bytes) {
6
+ let binary = "";
7
+ for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
8
+ return btoa(binary);
9
+ }
10
+ function bytes(value) {
11
+ if (typeof value !== "string") throw new TypeError("Invalid WebSocket binary value");
12
+ return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
13
+ }
14
+ async function encodeWire(value) {
15
+ const seen = /* @__PURE__ */ new WeakSet();
16
+ async function encode(value) {
17
+ if (value === void 0) return ["undefined"];
18
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
19
+ if (typeof value === "number" && Number.isFinite(value)) return value;
20
+ if (typeof value !== "object" || value === null) throw new TypeError("WebSocket values must be JSON-compatible, bytes or FormData");
21
+ if (seen.has(value)) throw new TypeError("WebSocket values must not contain cycles");
22
+ seen.add(value);
23
+ try {
24
+ if (value instanceof Uint8Array) return ["bytes", base64(value)];
25
+ if (value instanceof FormData) {
26
+ const entries = [];
27
+ for (const [name, field] of value) entries.push([name, typeof field === "string" ? field : [
28
+ "file",
29
+ base64(new Uint8Array(await field.arrayBuffer())),
30
+ field.name,
31
+ field.type,
32
+ field.lastModified
33
+ ]]);
34
+ return ["form-data", entries];
35
+ }
36
+ if (Array.isArray(value)) {
37
+ const entries = [];
38
+ for (const field of value) entries.push(await encode(field));
39
+ return ["array", entries];
40
+ }
41
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError("WebSocket objects must be plain records; encode native values with a contract codec");
42
+ const entries = [];
43
+ for (const [name, field] of Object.entries(value)) entries.push([name, await encode(field)]);
44
+ return ["object", entries];
45
+ } finally {
46
+ seen.delete(value);
47
+ }
48
+ }
49
+ return JSON.stringify(await encode(value));
50
+ }
51
+ function decodeWire(text) {
52
+ function decode(value) {
53
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
54
+ if (typeof value === "number" && Number.isFinite(value)) return value;
55
+ if (!Array.isArray(value)) throw new TypeError("Invalid WebSocket wire value");
56
+ if (value.length === 1 && value[0] === "undefined") return void 0;
57
+ if (value.length !== 2) throw new TypeError("Invalid WebSocket wire container");
58
+ if (value[0] === "bytes") return bytes(value[1]);
59
+ const entries = value[1];
60
+ if (!Array.isArray(entries)) throw new TypeError("Invalid WebSocket wire entries");
61
+ if (value[0] === "array") return entries.map(decode);
62
+ if (value[0] === "object" || value[0] === "form-data") {
63
+ const record = {};
64
+ const form = value[0] === "form-data" ? new FormData() : void 0;
65
+ for (const entry of entries) {
66
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") throw new TypeError("Invalid WebSocket wire entry");
67
+ const [name, field] = entry;
68
+ if (form === void 0) {
69
+ if (Object.hasOwn(record, name)) throw new TypeError("Duplicate WebSocket object key");
70
+ Object.defineProperty(record, name, {
71
+ value: decode(field),
72
+ enumerable: true,
73
+ writable: true,
74
+ configurable: true
75
+ });
76
+ } else if (typeof field === "string") form.append(name, field);
77
+ else {
78
+ if (!Array.isArray(field) || field.length !== 5 || field[0] !== "file" || typeof field[2] !== "string" || typeof field[3] !== "string" || typeof field[4] !== "number") throw new TypeError("Invalid WebSocket multipart file");
79
+ form.append(name, new File([bytes(field[1])], field[2], {
80
+ type: field[3],
81
+ lastModified: field[4]
82
+ }));
83
+ }
84
+ }
85
+ return form ?? record;
86
+ }
87
+ throw new TypeError("Unknown WebSocket wire container");
88
+ }
89
+ return decode(JSON.parse(text));
90
+ }
91
+
92
+ //#endregion
93
+ //#region src/connection.ts
94
+ const WEBSOCKET_PROTOCOL = "@hulla/api-websocket/1";
95
+ var WebSocketTransportError = class extends Error {
96
+ code;
97
+ constructor(code, message, options) {
98
+ super(message, options);
99
+ this.name = "WebSocketTransportError";
100
+ this.code = code;
101
+ }
102
+ };
103
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
104
+ /** Installs listeners immediately and serializes asynchronous frame decoding to preserve message order. */
105
+ function connection(socket, receive, ended) {
106
+ let resolveClosed;
107
+ const closed = new Promise((resolve) => {
108
+ resolveClosed = resolve;
109
+ });
110
+ let terminal;
111
+ const opened = socket.readyState === 1;
112
+ let resolveReady;
113
+ let rejectReady;
114
+ const ready = new Promise((resolve, reject) => {
115
+ resolveReady = resolve;
116
+ rejectReady = reject;
117
+ });
118
+ ready.catch(() => {});
119
+ const open = () => {
120
+ resolveReady();
121
+ };
122
+ const close = () => stop(new WebSocketTransportError("closed", "WebSocket connection closed"));
123
+ const error = () => stop(new WebSocketTransportError("closed", "WebSocket connection failed"));
124
+ let decoding = Promise.resolve();
125
+ const message = (event) => {
126
+ decoding = decoding.then(async () => {
127
+ if (terminal) return;
128
+ const data = event.data;
129
+ const text = typeof data === "string" ? data : data instanceof Blob ? await data.text() : data instanceof ArrayBuffer ? new TextDecoder("utf-8", { fatal: true }).decode(data) : ArrayBuffer.isView(data) ? new TextDecoder("utf-8", { fatal: true }).decode(data) : void 0;
130
+ if (text === void 0) throw new TypeError("Unsupported WebSocket frame data");
131
+ const frame = decodeWire(text);
132
+ if (!isRecord(frame) || frame["protocol"] !== "@hulla/api-websocket/1" || typeof frame["type"] !== "string" || frame["type"] !== "close" && typeof frame["id"] !== "string") throw new TypeError("Invalid WebSocket protocol envelope");
133
+ if (!terminal) receive(frame);
134
+ }).catch((cause) => stop(new WebSocketTransportError("invalid-message", "Invalid WebSocket protocol message", { cause })));
135
+ };
136
+ function stop(reason = new WebSocketTransportError("closed", "WebSocket transport closed")) {
137
+ if (terminal) return;
138
+ terminal = reason;
139
+ resolveClosed(reason);
140
+ rejectReady(reason);
141
+ socket.removeEventListener("open", open);
142
+ socket.removeEventListener("message", message);
143
+ socket.removeEventListener("close", close);
144
+ socket.removeEventListener("error", error);
145
+ ended(reason);
146
+ }
147
+ socket.addEventListener("open", open);
148
+ socket.addEventListener("message", message);
149
+ socket.addEventListener("close", close);
150
+ socket.addEventListener("error", error);
151
+ if (opened) resolveReady();
152
+ else if (socket.readyState !== 0) close();
153
+ let writing = Promise.resolve();
154
+ return {
155
+ ready,
156
+ closed,
157
+ stop,
158
+ send(frame, active = () => true) {
159
+ const operation = writing.then(async () => {
160
+ if (terminal) throw terminal;
161
+ await ready;
162
+ if (!active()) return;
163
+ const encoded = await encodeWire({
164
+ ...frame,
165
+ protocol: WEBSOCKET_PROTOCOL
166
+ });
167
+ if (terminal) throw terminal;
168
+ if (!active()) return;
169
+ if (socket.readyState !== 1) {
170
+ close();
171
+ throw terminal;
172
+ }
173
+ try {
174
+ socket.send(encoded);
175
+ } catch (cause) {
176
+ stop(new WebSocketTransportError("closed", "WebSocket send failed", { cause }));
177
+ throw terminal;
178
+ }
179
+ });
180
+ writing = operation.catch(() => {});
181
+ return operation;
182
+ }
183
+ };
184
+ }
185
+
186
+ //#endregion
187
+ //#region src/client.ts
188
+ var RemoteStream = class {
189
+ link;
190
+ id;
191
+ finish;
192
+ #done = false;
193
+ #failed = false;
194
+ #error;
195
+ #waiters = [];
196
+ constructor(link, id, finish) {
197
+ this.link = link;
198
+ this.id = id;
199
+ this.finish = finish;
200
+ }
201
+ [Symbol.asyncIterator]() {
202
+ return this;
203
+ }
204
+ next() {
205
+ if (this.#failed) return Promise.reject(this.#error);
206
+ if (this.#done) return Promise.resolve({
207
+ done: true,
208
+ value: void 0
209
+ });
210
+ const pending = new Promise((resolve, reject) => this.#waiters.push({
211
+ resolve,
212
+ reject
213
+ }));
214
+ this.link.send({
215
+ type: "pull",
216
+ id: this.id
217
+ }).catch((error) => this.fail(error));
218
+ return pending;
219
+ }
220
+ async return() {
221
+ if (!this.#done) {
222
+ this.end();
223
+ await this.link.send({
224
+ type: "cancel",
225
+ id: this.id
226
+ }).catch(() => {});
227
+ }
228
+ return {
229
+ done: true,
230
+ value: void 0
231
+ };
232
+ }
233
+ chunk(value) {
234
+ const waiter = this.#waiters.shift();
235
+ if (!(value instanceof Uint8Array) || !waiter) {
236
+ const error = new WebSocketTransportError("invalid-message", "Unsolicited or invalid WebSocket stream chunk");
237
+ waiter?.reject(error);
238
+ this.fail(error);
239
+ this.link.send({
240
+ type: "cancel",
241
+ id: this.id
242
+ }).catch(() => {});
243
+ return;
244
+ }
245
+ waiter.resolve({
246
+ done: false,
247
+ value
248
+ });
249
+ }
250
+ end() {
251
+ if (this.#done) return;
252
+ this.#done = true;
253
+ this.finish();
254
+ for (const waiter of this.#waiters.splice(0)) waiter.resolve({
255
+ done: true,
256
+ value: void 0
257
+ });
258
+ }
259
+ fail(error) {
260
+ if (this.#done) return;
261
+ this.#failed = true;
262
+ this.#error = error;
263
+ this.#done = true;
264
+ this.finish();
265
+ for (const waiter of this.#waiters.splice(0)) waiter.reject(error);
266
+ }
267
+ };
268
+ function headers(value) {
269
+ return isRecord(value) && Object.values(value).every((field) => typeof field === "string" || Array.isArray(field) && field.every((item) => typeof item === "string"));
270
+ }
271
+ const remoteError = (frame) => new WebSocketTransportError("remote-error", typeof frame["error"] === "string" ? frame["error"] : "Remote WebSocket operation failed");
272
+ function webSocketTransport(socket) {
273
+ const pending = /* @__PURE__ */ new Map();
274
+ const streams = /* @__PURE__ */ new Map();
275
+ let terminal;
276
+ let sequence = 0;
277
+ const prefix = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
278
+ const ended = (error) => {
279
+ terminal = error;
280
+ for (const request of pending.values()) {
281
+ request.cleanup();
282
+ request.reject(error);
283
+ }
284
+ pending.clear();
285
+ for (const stream of [...streams.values()]) stream.fail(error);
286
+ };
287
+ const link = connection(socket, (frame) => {
288
+ if (frame.type === "close") {
289
+ link.stop(new WebSocketTransportError("closed", "Remote WebSocket server closed"));
290
+ return;
291
+ }
292
+ const id = frame.id;
293
+ const stream = streams.get(id);
294
+ if (stream) {
295
+ if (frame.type === "chunk") stream.chunk(frame["chunk"]);
296
+ else if (frame.type === "end") stream.end();
297
+ else if (frame.type === "error") stream.fail(remoteError(frame));
298
+ else throw new TypeError("Unexpected WebSocket stream message");
299
+ return;
300
+ }
301
+ const request = pending.get(id);
302
+ if (!request) return;
303
+ if (frame.type === "error") {
304
+ pending.delete(id);
305
+ request.cleanup();
306
+ request.reject(remoteError(frame));
307
+ return;
308
+ }
309
+ const value = frame["response"];
310
+ if (frame.type !== "response" || !isRecord(value) || !Number.isInteger(value["status"]) || !headers(value["headers"]) || !isRecord(value["body"]) || ![
311
+ "json",
312
+ "text",
313
+ "bytes",
314
+ "form-data",
315
+ "empty",
316
+ "raw",
317
+ "stream"
318
+ ].includes(String(value["body"]["kind"]))) throw new TypeError("Invalid WebSocket response");
319
+ const body = value["body"];
320
+ pending.delete(id);
321
+ request.cleanup();
322
+ let remote;
323
+ if (body["kind"] === "stream") {
324
+ const abort = () => {
325
+ remote.fail(request.signal?.reason);
326
+ link.send({
327
+ type: "cancel",
328
+ id
329
+ }).catch(() => {});
330
+ };
331
+ remote = new RemoteStream(link, id, () => {
332
+ streams.delete(id);
333
+ request.signal?.removeEventListener("abort", abort);
334
+ });
335
+ streams.set(id, remote);
336
+ request.signal?.addEventListener("abort", abort, { once: true });
337
+ if (request.signal?.aborted) abort();
338
+ }
339
+ request.resolve({
340
+ status: value["status"],
341
+ headers: value["headers"],
342
+ native: frame,
343
+ dispose: async () => {
344
+ await remote?.return();
345
+ },
346
+ readBody: (kind) => {
347
+ if (body["kind"] !== kind) {
348
+ remote?.return();
349
+ throw new WebSocketTransportError("invalid-message", `Expected ${kind} WebSocket body, received ${String(body["kind"])}`);
350
+ }
351
+ return remote ?? body["value"];
352
+ }
353
+ });
354
+ }, ended);
355
+ const transport = (async (request) => {
356
+ if (terminal) throw terminal;
357
+ request.signal?.throwIfAborted();
358
+ await new Promise((resolve, reject) => {
359
+ const abort = () => {
360
+ request.signal?.removeEventListener("abort", abort);
361
+ reject(request.signal?.reason);
362
+ };
363
+ request.signal?.addEventListener("abort", abort, { once: true });
364
+ link.ready.then(resolve, reject).finally(() => request.signal?.removeEventListener("abort", abort));
365
+ if (request.signal?.aborted) abort();
366
+ });
367
+ if (terminal) throw terminal;
368
+ request.signal?.throwIfAborted();
369
+ const id = `${prefix}:${++sequence}`;
370
+ return new Promise((resolve, reject) => {
371
+ const abort = () => {
372
+ const active = pending.get(id);
373
+ if (!active) return;
374
+ pending.delete(id);
375
+ active.cleanup();
376
+ reject(request.signal?.reason);
377
+ link.send({
378
+ type: "cancel",
379
+ id
380
+ }).catch(() => {});
381
+ };
382
+ pending.set(id, {
383
+ resolve,
384
+ reject,
385
+ signal: request.signal,
386
+ cleanup: () => request.signal?.removeEventListener("abort", abort)
387
+ });
388
+ request.signal?.addEventListener("abort", abort, { once: true });
389
+ const { signal: _signal, ...input } = request;
390
+ link.send({
391
+ type: "request",
392
+ id,
393
+ request: input
394
+ }, () => pending.has(id)).catch((error) => {
395
+ const active = pending.get(id);
396
+ if (active) {
397
+ pending.delete(id);
398
+ active.cleanup();
399
+ reject(error);
400
+ }
401
+ });
402
+ });
403
+ });
404
+ Object.defineProperties(transport, {
405
+ ready: {
406
+ enumerable: true,
407
+ value: link.ready
408
+ },
409
+ closed: {
410
+ enumerable: true,
411
+ value: link.closed
412
+ },
413
+ close: {
414
+ enumerable: true,
415
+ value: async () => {
416
+ if (!terminal && socket.readyState === 1) await link.send({ type: "close" }).catch(() => {});
417
+ link.stop();
418
+ }
419
+ }
420
+ });
421
+ return transport;
422
+ }
423
+
424
+ //#endregion
425
+ //#region src/server.ts
426
+ function requestInput(value, signal) {
427
+ if (!isRecord(value) || typeof value["path"] !== "string" || typeof value["method"] !== "string" || !Array.isArray(value["key"]) || !value["key"].every((part) => typeof part === "string") || !isRecord(value["headers"]) || !Object.values(value["headers"]).every((header) => typeof header === "string")) throw new TypeError("Invalid WebSocket request");
428
+ if (value["body"] !== void 0 && (!isRecord(value["body"]) || ![
429
+ "json",
430
+ "text",
431
+ "bytes",
432
+ "form-data"
433
+ ].includes(String(value["body"]["kind"])) || typeof value["body"]["contentType"] !== "string")) throw new TypeError("Invalid WebSocket request body");
434
+ return {
435
+ ...value,
436
+ signal
437
+ };
438
+ }
439
+ function mount(socket, implementation, options) {
440
+ assertAdapterContext(implementation.context, "websocket");
441
+ const dispatch = createAdapterHandler(implementation, options.onError === void 0 ? {} : { onError: (input) => options.onError({
442
+ ...input,
443
+ request: input.request,
444
+ socket
445
+ }) });
446
+ const active = /* @__PURE__ */ new Map();
447
+ let closed = false;
448
+ const cleanup = /* @__PURE__ */ new Set();
449
+ const cancel = (id, state) => {
450
+ if (state.cancelled) return Promise.resolve();
451
+ state.cancelled = true;
452
+ state.controller.abort(/* @__PURE__ */ new Error("WebSocket call cancelled"));
453
+ active.delete(id);
454
+ const operation = Promise.resolve().then(async () => {
455
+ await state.iterator?.return?.();
456
+ });
457
+ cleanup.add(operation);
458
+ operation.finally(() => cleanup.delete(operation)).catch(() => {});
459
+ return operation;
460
+ };
461
+ const ended = () => {
462
+ closed = true;
463
+ for (const [id, state] of active) cancel(id, state).catch(() => {});
464
+ };
465
+ const fail = (id, error) => link.send({
466
+ type: "error",
467
+ id,
468
+ error: error instanceof Error ? error.message : String(error)
469
+ });
470
+ async function pump(id, state) {
471
+ if (state.pumping || !state.iterator) return;
472
+ state.pumping = true;
473
+ try {
474
+ while (!state.cancelled && state.pulls > 0) {
475
+ state.pulls--;
476
+ const chunk = await state.iterator.next();
477
+ if (state.cancelled) break;
478
+ if (chunk.done) {
479
+ active.delete(id);
480
+ await link.send({
481
+ type: "end",
482
+ id
483
+ });
484
+ return;
485
+ }
486
+ if (!(chunk.value instanceof Uint8Array)) throw new TypeError("WebSocket stream chunks must be Uint8Array");
487
+ await link.send({
488
+ type: "chunk",
489
+ id,
490
+ chunk: chunk.value
491
+ });
492
+ }
493
+ } catch (error) {
494
+ await cancel(id, state).catch(() => {});
495
+ if (!closed) await fail(id, error);
496
+ } finally {
497
+ state.pumping = false;
498
+ }
499
+ }
500
+ async function execute(frame) {
501
+ const id = frame.id;
502
+ if (active.has(id)) throw new TypeError("Duplicate WebSocket request ID");
503
+ const state = {
504
+ controller: new AbortController(),
505
+ pulls: 0,
506
+ pumping: false,
507
+ cancelled: false
508
+ };
509
+ active.set(id, state);
510
+ try {
511
+ const request = requestInput(frame["request"], state.controller.signal);
512
+ const result = await dispatch({
513
+ request,
514
+ signal: request.signal,
515
+ contextInput: {
516
+ socket,
517
+ request
518
+ },
519
+ hostContext: {
520
+ socket,
521
+ request
522
+ },
523
+ method: request.method,
524
+ pathname: request.path,
525
+ headers: request.headers,
526
+ ...request.query === void 0 ? {} : { query: request.query },
527
+ ...request.body === void 0 ? {} : { body: {
528
+ value: request.body.value,
529
+ contentType: request.body.contentType
530
+ } }
531
+ });
532
+ const wireResult = result.body.kind === "form-data" ? {
533
+ ...result,
534
+ headers: {
535
+ ...result.headers,
536
+ "content-type": "multipart/form-data"
537
+ }
538
+ } : result;
539
+ if (result.body.kind === "stream") state.iterator = result.body.value[Symbol.asyncIterator]();
540
+ if (state.cancelled) {
541
+ await state.iterator?.return?.();
542
+ return;
543
+ }
544
+ await link.send({
545
+ type: "response",
546
+ id,
547
+ response: result.body.kind === "stream" ? {
548
+ ...wireResult,
549
+ body: { kind: "stream" }
550
+ } : wireResult
551
+ });
552
+ if (result.body.kind !== "stream") active.delete(id);
553
+ } catch (error) {
554
+ if (!state.cancelled) {
555
+ await cancel(id, state).catch(() => {});
556
+ if (!closed) await fail(id, error);
557
+ }
558
+ }
559
+ }
560
+ const link = connection(socket, (frame) => {
561
+ if (frame.type === "close") {
562
+ link.stop();
563
+ return;
564
+ }
565
+ const id = frame.id;
566
+ if (frame.type === "request") {
567
+ if (active.has(id)) throw new TypeError("Duplicate WebSocket request ID");
568
+ execute(frame).catch((error) => link.stop(error instanceof Error ? error : new Error(String(error))));
569
+ return;
570
+ }
571
+ const state = active.get(id);
572
+ if (frame.type === "cancel") {
573
+ if (state) cancel(id, state).catch(() => {});
574
+ return;
575
+ }
576
+ if (frame.type === "pull") {
577
+ if (state?.iterator) {
578
+ state.pulls++;
579
+ pump(id, state).catch(() => {});
580
+ }
581
+ return;
582
+ }
583
+ throw new TypeError("Unexpected WebSocket client message");
584
+ }, ended);
585
+ return {
586
+ ready: link.ready,
587
+ closed: link.closed,
588
+ async close() {
589
+ if (!closed && socket.readyState === 1) await link.send({ type: "close" }).catch(() => {});
590
+ link.stop();
591
+ await Promise.allSettled([...cleanup]);
592
+ }
593
+ };
594
+ }
595
+ /** Mount once per accepted socket; the application owns upgrades, authentication and socket lifetime. */
596
+ function webSocketAdapter(socket, defaults = {}) {
597
+ const configured = { ...defaults };
598
+ return createServerAdapter("websocket", { mount: (implementation, options) => mount(socket, implementation, {
599
+ ...configured,
600
+ ...options
601
+ }) });
602
+ }
603
+
604
+ //#endregion
605
+ export { WEBSOCKET_PROTOCOL, WebSocketTransportError, webSocketAdapter, webSocketTransport };
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@hulla/api-websocket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0",
4
+ "description": "WebSocket request and response-stream transport for @hulla/api",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/hulladev/api.git",
9
+ "directory": "packages/websocket"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org/"
25
+ },
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "clean": "rm -rf dist coverage",
29
+ "test": "vitest run",
30
+ "test:coverage": "vitest run --coverage",
31
+ "test:watch": "vitest",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "devDependencies": {
35
+ "@hulla/api": "2.0.0",
36
+ "@types/node": "^26.2.0",
37
+ "@types/ws": "^8.18.1",
38
+ "ws": "^8.21.3",
39
+ "zod": "^4.4.3"
40
+ },
41
+ "peerDependencies": {
42
+ "@hulla/api": "^2.0.0"
43
+ }
44
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.