transport-io 0.0.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +28 -0
  3. package/dist/adapter.d.ts +69 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +51 -0
  6. package/dist/adapter.js.map +1 -0
  7. package/dist/client.d.ts +60 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +162 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/codec.d.ts +5 -0
  12. package/dist/codec.d.ts.map +1 -0
  13. package/dist/codec.js +47 -0
  14. package/dist/codec.js.map +1 -0
  15. package/dist/contract.d.ts +79 -0
  16. package/dist/contract.d.ts.map +1 -0
  17. package/dist/contract.js +54 -0
  18. package/dist/contract.js.map +1 -0
  19. package/dist/datagram.d.ts +29 -0
  20. package/dist/datagram.d.ts.map +1 -0
  21. package/dist/datagram.js +92 -0
  22. package/dist/datagram.js.map +1 -0
  23. package/dist/errors.d.ts +8 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +11 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/framer.d.ts +21 -0
  28. package/dist/framer.d.ts.map +1 -0
  29. package/dist/framer.js +110 -0
  30. package/dist/framer.js.map +1 -0
  31. package/dist/handshake.d.ts +34 -0
  32. package/dist/handshake.d.ts.map +1 -0
  33. package/dist/handshake.js +79 -0
  34. package/dist/handshake.js.map +1 -0
  35. package/dist/hub.d.ts +43 -0
  36. package/dist/hub.d.ts.map +1 -0
  37. package/dist/hub.js +149 -0
  38. package/dist/hub.js.map +1 -0
  39. package/dist/index.d.ts +19 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +18 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/origin.d.ts +18 -0
  44. package/dist/origin.d.ts.map +1 -0
  45. package/dist/origin.js +72 -0
  46. package/dist/origin.js.map +1 -0
  47. package/dist/protocol.d.ts +79 -0
  48. package/dist/protocol.d.ts.map +1 -0
  49. package/dist/protocol.js +76 -0
  50. package/dist/protocol.js.map +1 -0
  51. package/dist/queue.d.ts +45 -0
  52. package/dist/queue.d.ts.map +1 -0
  53. package/dist/queue.js +94 -0
  54. package/dist/queue.js.map +1 -0
  55. package/dist/server.d.ts +45 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +127 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/session.d.ts +94 -0
  60. package/dist/session.d.ts.map +1 -0
  61. package/dist/session.js +645 -0
  62. package/dist/session.js.map +1 -0
  63. package/dist/testing/hostile-adapter.d.ts +44 -0
  64. package/dist/testing/hostile-adapter.d.ts.map +1 -0
  65. package/dist/testing/hostile-adapter.js +116 -0
  66. package/dist/testing/hostile-adapter.js.map +1 -0
  67. package/dist/testing/index.d.ts +8 -0
  68. package/dist/testing/index.d.ts.map +1 -0
  69. package/dist/testing/index.js +8 -0
  70. package/dist/testing/index.js.map +1 -0
  71. package/dist/transport/browser.d.ts +8 -0
  72. package/dist/transport/browser.d.ts.map +1 -0
  73. package/dist/transport/browser.js +89 -0
  74. package/dist/transport/browser.js.map +1 -0
  75. package/dist/transport/fails.node.d.ts +33 -0
  76. package/dist/transport/fails.node.d.ts.map +1 -0
  77. package/dist/transport/fails.node.js +184 -0
  78. package/dist/transport/fails.node.js.map +1 -0
  79. package/dist/transport/loopback.d.ts +11 -0
  80. package/dist/transport/loopback.d.ts.map +1 -0
  81. package/dist/transport/loopback.js +98 -0
  82. package/dist/transport/loopback.js.map +1 -0
  83. package/dist/transport/moq.node.d.ts +19 -0
  84. package/dist/transport/moq.node.d.ts.map +1 -0
  85. package/dist/transport/moq.node.js +211 -0
  86. package/dist/transport/moq.node.js.map +1 -0
  87. package/dist/transport/parity-suite.d.ts +40 -0
  88. package/dist/transport/parity-suite.d.ts.map +1 -0
  89. package/dist/transport/parity-suite.js +133 -0
  90. package/dist/transport/parity-suite.js.map +1 -0
  91. package/dist/transport/types.d.ts +36 -0
  92. package/dist/transport/types.d.ts.map +1 -0
  93. package/dist/transport/types.js +2 -0
  94. package/dist/transport/types.js.map +1 -0
  95. package/dist/transport/unreliable.d.ts +37 -0
  96. package/dist/transport/unreliable.d.ts.map +1 -0
  97. package/dist/transport/unreliable.js +59 -0
  98. package/dist/transport/unreliable.js.map +1 -0
  99. package/package.json +55 -0
@@ -0,0 +1,29 @@
1
+ export interface Datagram {
2
+ readonly eventId: number;
3
+ readonly origin: number;
4
+ readonly sequence: number;
5
+ readonly payload: Uint8Array;
6
+ }
7
+ /**
8
+ * The usable size is a property of the path, not a constant, so it is queried at send
9
+ * time. The floor is used only when the transport reports nothing usable.
10
+ */
11
+ export declare function maxDatagramPayload(reportedMax: number): number;
12
+ export declare function encodeDatagram(dg: Datagram, reportedMax: number): Uint8Array;
13
+ export declare function decodeDatagram(bytes: Uint8Array): Datagram;
14
+ /**
15
+ * Last-write-wins keyed on (origin, event). PROTOCOL.md §7.3.
16
+ *
17
+ * Wrap is treated as circular over the 32-bit space: a difference greater than 2^31 reads
18
+ * as wrap rather than regression.
19
+ */
20
+ export declare class SequenceGate {
21
+ #private;
22
+ get staleReceived(): number;
23
+ /** Returns true when the datagram is fresh and should be delivered. */
24
+ accept(origin: number, eventId: number, sequence: number, now: number): boolean;
25
+ /** A receiver discards (origin, event) state after this idle, so origins can be reused. */
26
+ sweep(now: number, retentionMs: number): void;
27
+ get tracked(): number;
28
+ }
29
+ //# sourceMappingURL=datagram.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"datagram.d.ts","sourceRoot":"","sources":["../src/datagram.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;CAC7B;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAG9D;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,GAAG,UAAU,CAkC5E;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,UAAU,GAAG,QAAQ,CA+B1D;AAED;;;;;GAKG;AACH,qBAAa,YAAY;;IAIvB,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED,uEAAuE;IACvE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAa9E;IAED,2FAA2F;IAC3F,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAI5C;IAED,IAAI,OAAO,IAAI,MAAM,CAEpB;CACF"}
@@ -0,0 +1,92 @@
1
+ /** Datagram header. PROTOCOL.md §7.1 — codec, event id, origin, sequence, then payload. */
2
+ import { TransportError } from './errors.js';
3
+ import { Codec, DATAGRAM_CONSERVATIVE_FLOOR, DATAGRAM_HEADER_BYTES, EVENT_ID_NOT_APPLICABLE, } from './protocol.js';
4
+ /**
5
+ * The usable size is a property of the path, not a constant, so it is queried at send
6
+ * time. The floor is used only when the transport reports nothing usable.
7
+ */
8
+ export function maxDatagramPayload(reportedMax) {
9
+ const effective = reportedMax > 0 ? reportedMax : DATAGRAM_CONSERVATIVE_FLOOR;
10
+ return Math.max(0, effective - DATAGRAM_HEADER_BYTES);
11
+ }
12
+ export function encodeDatagram(dg, reportedMax) {
13
+ if (dg.payload.byteLength === 0) {
14
+ throw new TransportError('WT_PROTOCOL_ERROR', 'a datagram payload of zero bytes is not representable', 'Send at least one byte. A zero-length write freezes some transports.');
15
+ }
16
+ if (dg.eventId === EVENT_ID_NOT_APPLICABLE) {
17
+ throw new TransportError('WT_PROTOCOL_ERROR', 'event id 0 is not valid on the datagram lane', 'Datagrams always carry a contract event. Check the event table.');
18
+ }
19
+ const limit = maxDatagramPayload(reportedMax);
20
+ if (dg.payload.byteLength > limit) {
21
+ // The transport accepts an oversized datagram, discards it, and reports success, so
22
+ // this check is the only thing standing between a user and a silent drop.
23
+ throw new TransportError('WT_DATAGRAM_TOO_LARGE', `payload is ${dg.payload.byteLength} bytes and the path allows ${limit}`, 'Shorten the payload, or declare this event on the stream lane where size is not capped this tightly.');
24
+ }
25
+ const out = new Uint8Array(DATAGRAM_HEADER_BYTES + dg.payload.byteLength);
26
+ const view = new DataView(out.buffer);
27
+ view.setUint8(0, Codec.JSON);
28
+ view.setUint32(1, dg.eventId, false);
29
+ view.setUint32(5, dg.origin, false);
30
+ view.setUint32(9, dg.sequence, false);
31
+ out.set(dg.payload, DATAGRAM_HEADER_BYTES);
32
+ return out;
33
+ }
34
+ export function decodeDatagram(bytes) {
35
+ if (bytes.byteLength <= DATAGRAM_HEADER_BYTES) {
36
+ throw new TransportError('WT_PROTOCOL_ERROR', `datagram is ${bytes.byteLength} bytes, at or below the ${DATAGRAM_HEADER_BYTES}-byte header`, 'A datagram must carry at least one payload byte. See PROTOCOL.md §7.2.');
37
+ }
38
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
39
+ const codec = view.getUint8(0);
40
+ if (codec !== Codec.JSON) {
41
+ throw new TransportError('WT_UNSUPPORTED_CODEC', `codec 0x${codec.toString(16).padStart(2, '0')} is not supported`, 'This version speaks JSON only. Send codec 0x01.');
42
+ }
43
+ const eventId = view.getUint32(1, false);
44
+ if (eventId === EVENT_ID_NOT_APPLICABLE) {
45
+ throw new TransportError('WT_PROTOCOL_ERROR', 'event id 0 is not valid on the datagram lane', 'Check the sender against PROTOCOL.md §7.2.');
46
+ }
47
+ return {
48
+ eventId,
49
+ origin: view.getUint32(5, false),
50
+ sequence: view.getUint32(9, false),
51
+ payload: bytes.slice(DATAGRAM_HEADER_BYTES),
52
+ };
53
+ }
54
+ /**
55
+ * Last-write-wins keyed on (origin, event). PROTOCOL.md §7.3.
56
+ *
57
+ * Wrap is treated as circular over the 32-bit space: a difference greater than 2^31 reads
58
+ * as wrap rather than regression.
59
+ */
60
+ export class SequenceGate {
61
+ #seen = new Map();
62
+ #staleReceived = 0;
63
+ get staleReceived() {
64
+ return this.#staleReceived;
65
+ }
66
+ /** Returns true when the datagram is fresh and should be delivered. */
67
+ accept(origin, eventId, sequence, now) {
68
+ const key = `${origin}:${eventId}`;
69
+ const prev = this.#seen.get(key);
70
+ if (prev !== undefined) {
71
+ const delta = (sequence - prev.seq) >>> 0;
72
+ const isNewer = delta !== 0 && delta < 0x80000000;
73
+ if (!isNewer) {
74
+ this.#staleReceived++;
75
+ return false;
76
+ }
77
+ }
78
+ this.#seen.set(key, { seq: sequence, at: now });
79
+ return true;
80
+ }
81
+ /** A receiver discards (origin, event) state after this idle, so origins can be reused. */
82
+ sweep(now, retentionMs) {
83
+ for (const [key, v] of this.#seen) {
84
+ if (now - v.at >= retentionMs)
85
+ this.#seen.delete(key);
86
+ }
87
+ }
88
+ get tracked() {
89
+ return this.#seen.size;
90
+ }
91
+ }
92
+ //# sourceMappingURL=datagram.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"datagram.js","sourceRoot":"","sources":["../src/datagram.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /** Every error carries a stable code and a sentence saying what to do about it. */
2
+ export type TransportErrorCode = 'WT_NO_SUPPORT' | 'WT_DATAGRAM_TOO_LARGE' | 'WT_ROOM_NOT_JOINED' | 'WT_SESSION_CLOSED' | 'WT_ABORTED' | 'WT_HANDLER_ERROR' | 'WT_PROTOCOL_ERROR' | 'WT_UNSUPPORTED_CODEC' | 'WT_PAYLOAD_TOO_LARGE' | 'WT_HANDSHAKE_INCOMPLETE' | 'WT_UNKNOWN_EVENT' | 'WT_VALIDATION_FAILED' | 'WT_PROTOCOL_VERSION_MISMATCH' | 'WT_CONTRACT_MISMATCH' | 'WT_HANDSHAKE_TIMEOUT' | 'WT_PEER_TOO_SLOW' | 'WT_TOO_MANY_STREAMS' | 'WT_RELIABILITY_REFUSED';
3
+ export declare class TransportError extends Error {
4
+ readonly code: TransportErrorCode;
5
+ readonly remedy: string;
6
+ constructor(code: TransportErrorCode, message: string, remedy: string);
7
+ }
8
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,MAAM,MAAM,kBAAkB,GAC1B,eAAe,GACf,uBAAuB,GACvB,oBAAoB,GACpB,mBAAmB,GACnB,YAAY,GACZ,kBAAkB,GAClB,mBAAmB,GACnB,sBAAsB,GACtB,sBAAsB,GACtB,yBAAyB,GACzB,kBAAkB,GAClB,sBAAsB,GACtB,8BAA8B,GAC9B,sBAAsB,GACtB,sBAAsB,GACtB,kBAAkB,GAClB,qBAAqB,GACrB,wBAAwB,CAAA;AAE5B,qBAAa,cAAe,SAAQ,KAAK;IACvC,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAA;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IAEvB,YAAY,IAAI,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAKpE;CACF"}
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ export class TransportError extends Error {
2
+ code;
3
+ remedy;
4
+ constructor(code, message, remedy) {
5
+ super(`${code}: ${message} — ${remedy}`);
6
+ this.name = 'TransportError';
7
+ this.code = code;
8
+ this.remedy = remedy;
9
+ }
10
+ }
11
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAqBA,MAAM,OAAO,cAAe,SAAQ,KAAK;IAC9B,IAAI,CAAoB;IACxB,MAAM,CAAQ;IAEvB,YAAY,IAAwB,EAAE,OAAe,EAAE,MAAc;QACnE,KAAK,CAAC,GAAG,IAAI,KAAK,OAAO,MAAM,MAAM,EAAE,CAAC,CAAA;QACxC,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAA;QAC5B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAA;QAChB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAA;IACtB,CAAC;CACF"}
@@ -0,0 +1,21 @@
1
+ import { FrameType } from './protocol.ts';
2
+ export interface Frame {
3
+ readonly type: FrameType;
4
+ readonly codec: number;
5
+ readonly eventId: number;
6
+ readonly payload: Uint8Array;
7
+ }
8
+ /** §5.1 — the cap is per frame type: a call is the documented home for a large payload. */
9
+ export declare function maxPayloadFor(type: FrameType): number;
10
+ export declare function encodeFrame(frame: Frame): Uint8Array;
11
+ /**
12
+ * Incremental decoder. Feed it whatever the transport hands you — a fragment, several
13
+ * frames, or a frame split across many reads — and it yields whole frames only.
14
+ */
15
+ export declare class FrameDecoder {
16
+ #private;
17
+ /** Bytes held pending a complete frame. Exposed so tests can assert nothing leaks. */
18
+ get buffered(): number;
19
+ push(chunk: Uint8Array): Frame[];
20
+ }
21
+ //# sourceMappingURL=framer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"framer.d.ts","sourceRoot":"","sources":["../src/framer.ts"],"names":[],"mappings":"AAWA,OAAO,EAEL,SAAS,EAOV,MAAM,eAAe,CAAA;AAEtB,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAA;CAC7B;AAED,2FAA2F;AAC3F,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAIrD;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,UAAU,CA2BpD;AAED;;;GAGG;AACH,qBAAa,YAAY;;IAGvB,sFAAsF;IACtF,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,KAAK,EAAE,CAmF/B;CACF"}
package/dist/framer.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Stream framing, implemented to PROTOCOL.md §5.
3
+ *
4
+ * QUIC streams are byte streams and do not preserve write boundaries. Measured on the
5
+ * reference transport, 51 writes arrived as 217 reads, and the large write fragmented
6
+ * while the small ones happened to survive — which is the worst case, because naive
7
+ * boundary-trusting code passes in development and fails under load. The length prefix
8
+ * is the only thing that recovers frame boundaries, and nobody using this library should
9
+ * ever have to think about it.
10
+ */
11
+ import { TransportError } from './errors.js';
12
+ import { Codec, FrameType, isFrameType, LENGTH_PREFIX_BYTES, MAX_CALL_PAYLOAD_BYTES, MAX_EMIT_PAYLOAD_BYTES, MIN_LENGTH, STREAM_HEADER_BYTES, } from './protocol.js';
13
+ /** §5.1 — the cap is per frame type: a call is the documented home for a large payload. */
14
+ export function maxPayloadFor(type) {
15
+ return type === FrameType.CALL_REQUEST || type === FrameType.CALL_RESPONSE
16
+ ? MAX_CALL_PAYLOAD_BYTES
17
+ : MAX_EMIT_PAYLOAD_BYTES;
18
+ }
19
+ export function encodeFrame(frame) {
20
+ const payloadLength = frame.payload.byteLength;
21
+ if (payloadLength === 0) {
22
+ throw new TransportError('WT_PROTOCOL_ERROR', 'a frame payload of zero bytes is not representable', 'Stream close terminates a response, so no zero-length sentinel is needed. Send at least one byte.');
23
+ }
24
+ const cap = maxPayloadFor(frame.type);
25
+ if (payloadLength > cap) {
26
+ throw new TransportError('WT_PAYLOAD_TOO_LARGE', `payload is ${payloadLength} bytes and the cap for this frame type is ${cap}`, 'Use a call rather than an emit, or split the payload.');
27
+ }
28
+ const out = new Uint8Array(LENGTH_PREFIX_BYTES + STREAM_HEADER_BYTES + payloadLength);
29
+ const view = new DataView(out.buffer);
30
+ view.setUint32(0, STREAM_HEADER_BYTES + payloadLength, false);
31
+ view.setUint8(4, frame.type);
32
+ view.setUint8(5, frame.codec);
33
+ view.setUint16(6, 0, false); // Reserved, MUST be zero
34
+ view.setUint32(8, frame.eventId, false);
35
+ out.set(frame.payload, LENGTH_PREFIX_BYTES + STREAM_HEADER_BYTES);
36
+ return out;
37
+ }
38
+ /**
39
+ * Incremental decoder. Feed it whatever the transport hands you — a fragment, several
40
+ * frames, or a frame split across many reads — and it yields whole frames only.
41
+ */
42
+ export class FrameDecoder {
43
+ #buf = new Uint8Array(0);
44
+ /** Bytes held pending a complete frame. Exposed so tests can assert nothing leaks. */
45
+ get buffered() {
46
+ return this.#buf.byteLength;
47
+ }
48
+ push(chunk) {
49
+ if (chunk.byteLength > 0) {
50
+ const merged = new Uint8Array(this.#buf.byteLength + chunk.byteLength);
51
+ merged.set(this.#buf, 0);
52
+ merged.set(chunk, this.#buf.byteLength);
53
+ this.#buf = merged;
54
+ }
55
+ const frames = [];
56
+ for (;;) {
57
+ if (this.#buf.byteLength < LENGTH_PREFIX_BYTES)
58
+ break;
59
+ const view = new DataView(this.#buf.buffer, this.#buf.byteOffset, this.#buf.byteLength);
60
+ const length = view.getUint32(0, false);
61
+ if (length < MIN_LENGTH) {
62
+ throw new TransportError('WT_PROTOCOL_ERROR', `frame length ${length} is below the minimum of ${MIN_LENGTH}`, 'A length of 0 or a header-only frame is a protocol error. Check the sender against PROTOCOL.md §5.1.');
63
+ }
64
+ const payloadLength = length - STREAM_HEADER_BYTES;
65
+ // §5.3 gives calls 16 MiB and everything else 1 MiB. This enforced the call cap for
66
+ // every frame type, so a peer could declare 16 MiB on the emit lane — sixteen times
67
+ // its documented cap — and the decoder would buffer toward it.
68
+ //
69
+ // The type byte is at offset 4, so it is readable as soon as five bytes are, which is
70
+ // before any payload has to be held. Until then the universal cap applies, which
71
+ // bounds the wait rather than assuming the generous case.
72
+ const type_ = this.#buf.byteLength > 4 ? view.getUint8(4) : undefined;
73
+ const cap = type_ === undefined ||
74
+ type_ === FrameType.CALL_REQUEST ||
75
+ type_ === FrameType.CALL_RESPONSE ||
76
+ type_ === FrameType.CALL_ERROR
77
+ ? MAX_CALL_PAYLOAD_BYTES
78
+ : MAX_EMIT_PAYLOAD_BYTES;
79
+ if (payloadLength > cap) {
80
+ throw new TransportError('WT_PAYLOAD_TOO_LARGE', `frame declares a ${payloadLength}-byte payload, above the ${cap}-byte cap for its type`, 'Split the payload, or use a call. See PROTOCOL.md §5.3.');
81
+ }
82
+ const total = LENGTH_PREFIX_BYTES + length;
83
+ if (this.#buf.byteLength < total)
84
+ break; // partial frame: wait for more bytes
85
+ const type = view.getUint8(4);
86
+ if (!isFrameType(type)) {
87
+ throw new TransportError('WT_PROTOCOL_ERROR', `frame type 0x${type.toString(16).padStart(2, '0')} is reserved or unknown`, 'Check the sender against the frame type table in PROTOCOL.md §5.2.');
88
+ }
89
+ const codec = view.getUint8(5);
90
+ if (codec !== Codec.JSON) {
91
+ throw new TransportError('WT_UNSUPPORTED_CODEC', `codec 0x${codec.toString(16).padStart(2, '0')} is not supported`, 'This version speaks JSON only. Send codec 0x01.');
92
+ }
93
+ if (view.getUint16(6, false) !== 0) {
94
+ throw new TransportError('WT_PROTOCOL_ERROR', 'the reserved header field is not zero', 'Zero bytes 6 and 7 of the frame header. See PROTOCOL.md §5.1.');
95
+ }
96
+ const eventId = view.getUint32(8, false);
97
+ const payloadStart = LENGTH_PREFIX_BYTES + STREAM_HEADER_BYTES;
98
+ // Copy rather than subarray: the caller keeps this past the next push().
99
+ frames.push({
100
+ type,
101
+ codec,
102
+ eventId,
103
+ payload: this.#buf.slice(payloadStart, total),
104
+ });
105
+ this.#buf = this.#buf.slice(total);
106
+ }
107
+ return frames;
108
+ }
109
+ }
110
+ //# sourceMappingURL=framer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"framer.js","sourceRoot":"","sources":["../src/framer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Handshake. PROTOCOL.md §4 — frame 0 of the emit stream.
3
+ *
4
+ * Being frame 0 of a stream that already exists is what removes the early-traffic race:
5
+ * in-order delivery within a stream means nothing can arrive before it. There is no
6
+ * separate handshake stream and no rule guarding one.
7
+ */
8
+ import type { EventTable, Lane } from './contract.ts';
9
+ export type WireEvent = readonly [name: string, id: number, lane: Lane];
10
+ export interface HandshakePayload {
11
+ readonly v: number;
12
+ readonly feat: readonly string[];
13
+ readonly events: readonly WireEvent[];
14
+ }
15
+ /** Reserved and unimplemented in this version. PROTOCOL.md §4.2. */
16
+ export declare const RESERVED_FEATURES: readonly string[];
17
+ export declare function buildHandshake(table: EventTable, feat?: readonly string[]): HandshakePayload;
18
+ export interface Negotiated {
19
+ readonly feat: readonly string[];
20
+ /** Events the peer knows that we do not. Sending one yields WT_UNKNOWN_EVENT, not a fault. */
21
+ readonly peerOnly: readonly string[];
22
+ /** Events we know that the peer does not. */
23
+ readonly localOnly: readonly string[];
24
+ }
25
+ export declare function parseHandshake(value: unknown): HandshakePayload;
26
+ /**
27
+ * PROTOCOL.md §4.3 and §4.4. The event table is validated first and conflicts are fatal;
28
+ * `feat` is negotiated second and is never fatal.
29
+ *
30
+ * Comparison is per event. A whole-contract equality check would refuse a session over an
31
+ * added event, which turns every additive change into a fleet-wide cutover.
32
+ */
33
+ export declare function negotiate(local: HandshakePayload, peer: HandshakePayload): Negotiated;
34
+ //# sourceMappingURL=handshake.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handshake.d.ts","sourceRoot":"","sources":["../src/handshake.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,eAAe,CAAA;AAIrD,MAAM,MAAM,SAAS,GAAG,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,CAAA;AAEvE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC,QAAQ,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,CAAA;CACtC;AAED,oEAAoE;AACpE,eAAO,MAAM,iBAAiB,EAAE,SAAS,MAAM,EAI9C,CAAA;AAED,wBAAgB,cAAc,CAC5B,KAAK,EAAE,UAAU,EACjB,IAAI,GAAE,SAAS,MAAM,EAAO,GAC3B,gBAAgB,CAElB;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAA;IAChC,8FAA8F;IAC9F,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,6CAA6C;IAC7C,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;CACtC;AAYD,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,CAkB/D;AAED;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,gBAAgB,GAAG,UAAU,CA+CrF"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Handshake. PROTOCOL.md §4 — frame 0 of the emit stream.
3
+ *
4
+ * Being frame 0 of a stream that already exists is what removes the early-traffic race:
5
+ * in-order delivery within a stream means nothing can arrive before it. There is no
6
+ * separate handshake stream and no rule guarding one.
7
+ */
8
+ import { TransportError } from './errors.js';
9
+ import { PROTOCOL_VERSION } from './protocol.js';
10
+ /** Reserved and unimplemented in this version. PROTOCOL.md §4.2. */
11
+ export const RESERVED_FEATURES = [
12
+ 'emit-per-room',
13
+ 'codec-msgpack',
14
+ 'session-resume',
15
+ ];
16
+ export function buildHandshake(table, feat = []) {
17
+ return { v: PROTOCOL_VERSION, feat, events: table.wire() };
18
+ }
19
+ function isWireEvent(v) {
20
+ return (Array.isArray(v) &&
21
+ v.length === 3 &&
22
+ typeof v[0] === 'string' &&
23
+ typeof v[1] === 'number' &&
24
+ (v[2] === 'stream' || v[2] === 'datagram'));
25
+ }
26
+ export function parseHandshake(value) {
27
+ const bad = (why) => {
28
+ throw new TransportError('WT_PROTOCOL_ERROR', `malformed handshake: ${why}`, 'Check the sender against PROTOCOL.md §4.1.');
29
+ };
30
+ if (typeof value !== 'object' || value === null)
31
+ return bad('not an object');
32
+ const { v, feat, events } = value;
33
+ if (typeof v !== 'number')
34
+ return bad('`v` is not a number');
35
+ if (!Array.isArray(feat) || feat.some((f) => typeof f !== 'string')) {
36
+ return bad('`feat` is not an array of strings');
37
+ }
38
+ if (!Array.isArray(events) || !events.every(isWireEvent)) {
39
+ return bad('`events` is not an array of [name, id, lane] triples');
40
+ }
41
+ return { v, feat: feat, events: events };
42
+ }
43
+ /**
44
+ * PROTOCOL.md §4.3 and §4.4. The event table is validated first and conflicts are fatal;
45
+ * `feat` is negotiated second and is never fatal.
46
+ *
47
+ * Comparison is per event. A whole-contract equality check would refuse a session over an
48
+ * added event, which turns every additive change into a fleet-wide cutover.
49
+ */
50
+ export function negotiate(local, peer) {
51
+ if (peer.v !== local.v) {
52
+ throw new TransportError('WT_PROTOCOL_VERSION_MISMATCH', `peer speaks protocol v${peer.v} and this is v${local.v}`, 'Protocol v0 is unstable and requires an exact match. Deploy both sides together.');
53
+ }
54
+ const localByName = new Map(local.events.map((e) => [e[0], e]));
55
+ const localById = new Map(local.events.map((e) => [e[1], e]));
56
+ const peerByName = new Map(peer.events.map((e) => [e[0], e]));
57
+ for (const [name, id, lane] of peer.events) {
58
+ const mine = localByName.get(name);
59
+ if (mine !== undefined) {
60
+ if (mine[2] !== lane) {
61
+ throw new TransportError('WT_CONTRACT_MISMATCH', `event '${name}' is '${mine[2]}' here and '${lane}' at the peer`, 'The two sides disagree about a delivery guarantee. Deploy the same contract on both.');
62
+ }
63
+ if (mine[1] !== id) {
64
+ throw new TransportError('WT_CONTRACT_MISMATCH', `event '${name}' is id ${mine[1]} here and ${id} at the peer`, 'One side has an explicit `id` override the other lacks. Align the contract.');
65
+ }
66
+ continue;
67
+ }
68
+ const collides = localById.get(id);
69
+ if (collides !== undefined) {
70
+ throw new TransportError('WT_CONTRACT_MISMATCH', `peer event '${name}' has id ${id}, which is '${collides[0]}' here`, 'Two different events share an id across the two sides. Align the contract.');
71
+ }
72
+ }
73
+ return {
74
+ feat: local.feat.filter((f) => peer.feat.includes(f)),
75
+ peerOnly: peer.events.filter((e) => !localByName.has(e[0])).map((e) => e[0]),
76
+ localOnly: local.events.filter((e) => !peerByName.has(e[0])).map((e) => e[0]),
77
+ };
78
+ }
79
+ //# sourceMappingURL=handshake.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handshake.js","sourceRoot":"","sources":["../src/handshake.ts"],"names":[],"mappings":"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"}
package/dist/hub.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Rooms and fan-out. Server-side.
3
+ *
4
+ * A frame is encoded ONCE and the same bytes go to every local member and onto the bus.
5
+ * That is what makes origin-scoped datagram sequencing correct: a per-recipient re-encode
6
+ * would be needed if the sequence belonged to the receiving session, and the adapter
7
+ * boundary forbids it anyway.
8
+ *
9
+ * Local peers are delivered directly rather than via a bus round-trip: lower latency and
10
+ * no dependency on the adapter echoing. The documented consequence is that local peers
11
+ * observe a message slightly before remote ones.
12
+ */
13
+ import type { Adapter, PeerId } from './adapter.ts';
14
+ import type { EventTable } from './contract.ts';
15
+ import type { Session } from './session.ts';
16
+ export interface BroadcastArgs {
17
+ readonly origin: number;
18
+ readonly except?: readonly PeerId[];
19
+ }
20
+ export declare class Hub {
21
+ #private;
22
+ constructor(adapter: Adapter, table: EventTable);
23
+ join(room: string, id: PeerId, session: Session): Promise<void>;
24
+ leave(room: string, id: PeerId): Promise<void>;
25
+ /**
26
+ * Teardown runs to completion whatever the bus does.
27
+ *
28
+ * `broadcast` already wrapped its adapter call; this did not, so a rejection on the
29
+ * first room threw straight out of the loop — rooms 2..N kept their `Member` record,
30
+ * each holding a live Session, and `#peerRooms.delete(id)` never ran. Nothing retries,
31
+ * because `conn.closed` resolves exactly once. A later `to(room).emit()` then fanned
32
+ * frames into a session that was already gone.
33
+ *
34
+ * The local half is what this node's correctness depends on, so it is unconditional.
35
+ * The bus half is best-effort by nature: the peer's connection is already gone, and a
36
+ * bus that cannot be told now will not be told by us failing here.
37
+ */
38
+ removePeer(id: PeerId): Promise<void>;
39
+ rooms(id: PeerId): readonly string[];
40
+ memberCount(room: string): number;
41
+ broadcast(room: string, event: string, payload: unknown, args: BroadcastArgs): Promise<void>;
42
+ }
43
+ //# sourceMappingURL=hub.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hub.d.ts","sourceRoot":"","sources":["../src/hub.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,OAAO,EAAQ,MAAM,EAAE,MAAM,cAAc,CAAA;AAEzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAK/C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAO3C,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CACpC;AAED,qBAAa,GAAG;;IAOd,YAAY,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,EAY9C;IAEK,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAiCpE;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAUnD;IAED;;;;;;;;;;;;OAYG;IACG,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAS1C;IAED,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAEnC;IAED,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEhC;IAEK,SAAS,CACb,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,OAAO,EAChB,IAAI,EAAE,aAAa,GAClB,OAAO,CAAC,IAAI,CAAC,CAqCf;CAyBF"}
package/dist/hub.js ADDED
@@ -0,0 +1,149 @@
1
+ import { encodePayload } from './codec.js';
2
+ import { encodeDatagram } from './datagram.js';
3
+ import { TransportError } from './errors.js';
4
+ import { encodeFrame } from './framer.js';
5
+ import { Codec, DATAGRAM_CONSERVATIVE_FLOOR, FrameType } from './protocol.js';
6
+ export class Hub {
7
+ #adapter;
8
+ #table;
9
+ #rooms = new Map();
10
+ #peerRooms = new Map();
11
+ #seqs = new Map();
12
+ constructor(adapter, table) {
13
+ this.#adapter = adapter;
14
+ this.#table = table;
15
+ // A node receiving its own publish back is normal, so core dedupes by origin node
16
+ // rather than relying on the adapter to suppress it — and dedupes against the
17
+ // *adapter's* id, which is the one stamped into the envelope. It used to compare
18
+ // against the Server's separate `nodeId`, so any deployment where those differed
19
+ // delivered every local broadcast twice, in silence.
20
+ this.#adapter.onRemote((e) => {
21
+ if (e.nodeId === this.#adapter.nodeId)
22
+ return;
23
+ this.#deliverLocal(e.room, e.frame, e.lane, e.except ?? []);
24
+ });
25
+ }
26
+ async join(room, id, session) {
27
+ // `onSession(async peer => { await lookup(); await peer.join(room) })` is the pattern
28
+ // the README teaches, so a client dropping during the lookup lands here routinely.
29
+ // Such a join used to succeed and be retained for ever: the JOIN notify write died in
30
+ // the emit path's swallowing catch, so nothing surfaced, and the teardown that would
31
+ // have removed it had already run.
32
+ if (session.disposed) {
33
+ throw new TransportError('WT_SESSION_CLOSED', `peer ${id} disconnected before it could join '${room}'`, 'Check the peer is still connected after any await, or ignore this — it is routine.');
34
+ }
35
+ let members = this.#rooms.get(room);
36
+ if (members === undefined) {
37
+ members = new Map();
38
+ this.#rooms.set(room, members);
39
+ }
40
+ // The bus first, local state second. Mutating before the await left a rejected join
41
+ // half-applied: the hub fanned broadcasts to a peer the bus had no record of, and the
42
+ // client was never notified it had joined. For a room whose join is gated on
43
+ // authorization, that is traffic reaching someone who was refused — permanently,
44
+ // because nothing rolls it back and nothing retries.
45
+ await this.#adapter.join(room, id);
46
+ members.set(id, { id, session });
47
+ let rooms = this.#peerRooms.get(id);
48
+ if (rooms === undefined) {
49
+ rooms = new Set();
50
+ this.#peerRooms.set(id, rooms);
51
+ }
52
+ rooms.add(room);
53
+ this.#notify(session, FrameType.JOIN, room);
54
+ }
55
+ async leave(room, id) {
56
+ const members = this.#rooms.get(room);
57
+ const member = members?.get(id);
58
+ members?.delete(id);
59
+ if (members !== undefined && members.size === 0)
60
+ this.#rooms.delete(room);
61
+ this.#peerRooms.get(id)?.delete(room);
62
+ // Local state and the peer are settled before the bus is told, so a bus that rejects
63
+ // leaves this node consistent. The rejection still reaches the caller, who asked.
64
+ if (member !== undefined)
65
+ this.#notify(member.session, FrameType.LEAVE, room);
66
+ await this.#adapter.leave(room, id);
67
+ }
68
+ /**
69
+ * Teardown runs to completion whatever the bus does.
70
+ *
71
+ * `broadcast` already wrapped its adapter call; this did not, so a rejection on the
72
+ * first room threw straight out of the loop — rooms 2..N kept their `Member` record,
73
+ * each holding a live Session, and `#peerRooms.delete(id)` never ran. Nothing retries,
74
+ * because `conn.closed` resolves exactly once. A later `to(room).emit()` then fanned
75
+ * frames into a session that was already gone.
76
+ *
77
+ * The local half is what this node's correctness depends on, so it is unconditional.
78
+ * The bus half is best-effort by nature: the peer's connection is already gone, and a
79
+ * bus that cannot be told now will not be told by us failing here.
80
+ */
81
+ async removePeer(id) {
82
+ const rooms = [...(this.#peerRooms.get(id) ?? [])];
83
+ this.#peerRooms.delete(id);
84
+ for (const room of rooms) {
85
+ const members = this.#rooms.get(room);
86
+ members?.delete(id);
87
+ if (members !== undefined && members.size === 0)
88
+ this.#rooms.delete(room);
89
+ }
90
+ await Promise.allSettled(rooms.map((room) => this.#adapter.leave(room, id)));
91
+ }
92
+ rooms(id) {
93
+ return [...(this.#peerRooms.get(id) ?? [])];
94
+ }
95
+ memberCount(room) {
96
+ return this.#rooms.get(room)?.size ?? 0;
97
+ }
98
+ async broadcast(room, event, payload, args) {
99
+ const entry = this.#table.byName(event);
100
+ if (entry === undefined) {
101
+ throw new TransportError('WT_UNKNOWN_EVENT', `'${event}' is not in the contract`, 'Add it to the contract, or check the spelling.');
102
+ }
103
+ const body = encodePayload(payload);
104
+ const except = args.except ?? [];
105
+ const bytes = entry.lane === 'datagram'
106
+ ? encodeDatagram({
107
+ eventId: entry.id,
108
+ origin: args.origin,
109
+ sequence: this.#nextSeq(entry.id),
110
+ payload: body,
111
+ }, DATAGRAM_CONSERVATIVE_FLOOR)
112
+ : encodeFrame({
113
+ type: FrameType.EMIT,
114
+ codec: Codec.JSON,
115
+ eventId: entry.id,
116
+ payload: body,
117
+ });
118
+ this.#deliverLocal(room, bytes, entry.lane, except);
119
+ try {
120
+ await this.#adapter.broadcast(room, bytes, { lane: entry.lane, except });
121
+ }
122
+ catch {
123
+ // Any adapter method may reject. Core degrades rather than crashing: local members
124
+ // already have the message and every session stays up.
125
+ }
126
+ }
127
+ #nextSeq(eventId) {
128
+ const n = ((this.#seqs.get(eventId) ?? 0) + 1) >>> 0 || 1;
129
+ this.#seqs.set(eventId, n);
130
+ return n;
131
+ }
132
+ #notify(session, type, room) {
133
+ session.sendFrame({ type, codec: Codec.JSON, eventId: 0, payload: encodePayload({ room }) });
134
+ }
135
+ #deliverLocal(room, bytes, lane, except) {
136
+ const members = this.#rooms.get(room);
137
+ if (members === undefined)
138
+ return; // no local members is not an error
139
+ for (const m of members.values()) {
140
+ if (except.includes(m.id))
141
+ continue;
142
+ if (lane === 'datagram')
143
+ m.session.sendDatagramBytes(bytes);
144
+ else
145
+ m.session.sendEncodedFrame(bytes);
146
+ }
147
+ }
148
+ }
149
+ //# sourceMappingURL=hub.js.map