@inf-minds/streams-protocol 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.
- package/dist/headers.d.ts +53 -0
- package/dist/headers.d.ts.map +1 -0
- package/dist/headers.js +78 -0
- package/dist/headers.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/offset.d.ts +37 -0
- package/dist/offset.d.ts.map +1 -0
- package/dist/offset.js +69 -0
- package/dist/offset.js.map +1 -0
- package/dist/sse.d.ts +61 -0
- package/dist/sse.d.ts.map +1 -0
- package/dist/sse.js +114 -0
- package/dist/sse.js.map +1 -0
- package/dist/websocket.d.ts +121 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +63 -0
- package/dist/websocket.js.map +1 -0
- package/package.json +31 -0
- package/src/headers.ts +112 -0
- package/src/index.ts +55 -0
- package/src/offset.ts +80 -0
- package/src/sse.ts +164 -0
- package/src/websocket.ts +187 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header name for the next offset to read from.
|
|
3
|
+
*/
|
|
4
|
+
export declare const HEADER_NEXT_OFFSET = "Stream-Next-Offset";
|
|
5
|
+
/**
|
|
6
|
+
* Header name indicating stream is caught up to current tail.
|
|
7
|
+
*/
|
|
8
|
+
export declare const HEADER_UP_TO_DATE = "Stream-Up-To-Date";
|
|
9
|
+
/**
|
|
10
|
+
* Header name indicating stream is permanently closed.
|
|
11
|
+
*/
|
|
12
|
+
export declare const HEADER_CLOSED = "Stream-Closed";
|
|
13
|
+
/**
|
|
14
|
+
* Header name for opaque cursor (CDN collapsing support).
|
|
15
|
+
*/
|
|
16
|
+
export declare const HEADER_CURSOR = "Stream-Cursor";
|
|
17
|
+
/**
|
|
18
|
+
* Default polling interval in milliseconds for catch-up mode.
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEFAULT_POLL_INTERVAL = 500;
|
|
21
|
+
/**
|
|
22
|
+
* Parsed stream response headers.
|
|
23
|
+
*/
|
|
24
|
+
export interface StreamResponseHeaders {
|
|
25
|
+
/** Next offset to read from */
|
|
26
|
+
nextOffset: string;
|
|
27
|
+
/** True when caught up to stream tail */
|
|
28
|
+
upToDate?: boolean;
|
|
29
|
+
/** True when stream is permanently closed */
|
|
30
|
+
closed?: boolean;
|
|
31
|
+
/** Opaque cursor for CDN request collapsing */
|
|
32
|
+
cursor?: string;
|
|
33
|
+
/** ETag for caching */
|
|
34
|
+
etag?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parses stream headers from an HTTP response.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* const headers = response.headers;
|
|
41
|
+
* const stream = parseStreamHeaders(headers);
|
|
42
|
+
* console.log(stream.nextOffset); // '210'
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseStreamHeaders(headers: Headers): StreamResponseHeaders;
|
|
45
|
+
/**
|
|
46
|
+
* Formats stream headers for an HTTP response.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* const headers = formatStreamHeaders({ nextOffset: '210', upToDate: true });
|
|
50
|
+
* response.headers = headers;
|
|
51
|
+
*/
|
|
52
|
+
export declare function formatStreamHeaders(stream: StreamResponseHeaders): Headers;
|
|
53
|
+
//# sourceMappingURL=headers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,eAAO,MAAM,kBAAkB,uBAAuB,CAAC;AAEvD;;GAEG;AACH,eAAO,MAAM,iBAAiB,sBAAsB,CAAC;AAErD;;GAEG;AACH,eAAO,MAAM,aAAa,kBAAkB,CAAC;AAE7C;;GAEG;AACH,eAAO,MAAM,aAAa,kBAAkB,CAAC;AAE7C;;GAEG;AACH,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,+BAA+B;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uBAAuB;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,OAAO,GAAG,qBAAqB,CA4B1E;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAsB1E"}
|
package/dist/headers.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ABOUTME: HTTP header parsing and formatting utilities for Durable Streams
|
|
2
|
+
// ABOUTME: Handles Stream-* headers per protocol specification
|
|
3
|
+
/**
|
|
4
|
+
* Header name for the next offset to read from.
|
|
5
|
+
*/
|
|
6
|
+
export const HEADER_NEXT_OFFSET = 'Stream-Next-Offset';
|
|
7
|
+
/**
|
|
8
|
+
* Header name indicating stream is caught up to current tail.
|
|
9
|
+
*/
|
|
10
|
+
export const HEADER_UP_TO_DATE = 'Stream-Up-To-Date';
|
|
11
|
+
/**
|
|
12
|
+
* Header name indicating stream is permanently closed.
|
|
13
|
+
*/
|
|
14
|
+
export const HEADER_CLOSED = 'Stream-Closed';
|
|
15
|
+
/**
|
|
16
|
+
* Header name for opaque cursor (CDN collapsing support).
|
|
17
|
+
*/
|
|
18
|
+
export const HEADER_CURSOR = 'Stream-Cursor';
|
|
19
|
+
/**
|
|
20
|
+
* Default polling interval in milliseconds for catch-up mode.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_POLL_INTERVAL = 500;
|
|
23
|
+
/**
|
|
24
|
+
* Parses stream headers from an HTTP response.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const headers = response.headers;
|
|
28
|
+
* const stream = parseStreamHeaders(headers);
|
|
29
|
+
* console.log(stream.nextOffset); // '210'
|
|
30
|
+
*/
|
|
31
|
+
export function parseStreamHeaders(headers) {
|
|
32
|
+
const nextOffset = headers.get(HEADER_NEXT_OFFSET) ?? '';
|
|
33
|
+
const upToDateStr = headers.get(HEADER_UP_TO_DATE);
|
|
34
|
+
const closedStr = headers.get(HEADER_CLOSED);
|
|
35
|
+
const cursor = headers.get(HEADER_CURSOR) ?? undefined;
|
|
36
|
+
const etag = headers.get('ETag') ?? undefined;
|
|
37
|
+
const result = {
|
|
38
|
+
nextOffset,
|
|
39
|
+
};
|
|
40
|
+
if (upToDateStr !== null) {
|
|
41
|
+
result.upToDate = upToDateStr === 'true';
|
|
42
|
+
}
|
|
43
|
+
if (closedStr !== null) {
|
|
44
|
+
result.closed = closedStr === 'true';
|
|
45
|
+
}
|
|
46
|
+
if (cursor) {
|
|
47
|
+
result.cursor = cursor;
|
|
48
|
+
}
|
|
49
|
+
if (etag) {
|
|
50
|
+
result.etag = etag;
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Formats stream headers for an HTTP response.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* const headers = formatStreamHeaders({ nextOffset: '210', upToDate: true });
|
|
59
|
+
* response.headers = headers;
|
|
60
|
+
*/
|
|
61
|
+
export function formatStreamHeaders(stream) {
|
|
62
|
+
const headers = new Headers();
|
|
63
|
+
headers.set(HEADER_NEXT_OFFSET, stream.nextOffset);
|
|
64
|
+
if (stream.upToDate !== undefined) {
|
|
65
|
+
headers.set(HEADER_UP_TO_DATE, String(stream.upToDate));
|
|
66
|
+
}
|
|
67
|
+
if (stream.closed !== undefined) {
|
|
68
|
+
headers.set(HEADER_CLOSED, String(stream.closed));
|
|
69
|
+
}
|
|
70
|
+
if (stream.cursor !== undefined) {
|
|
71
|
+
headers.set(HEADER_CURSOR, stream.cursor);
|
|
72
|
+
}
|
|
73
|
+
if (stream.etag !== undefined) {
|
|
74
|
+
headers.set('ETag', stream.etag);
|
|
75
|
+
}
|
|
76
|
+
return headers;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=headers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,+DAA+D;AAE/D;;GAEG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,oBAAoB,CAAC;AAEvD;;GAEG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,mBAAmB,CAAC;AAErD;;GAEG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,eAAe,CAAC;AAE7C;;GAEG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,eAAe,CAAC;AAE7C;;GAEG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAkBzC;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAgB;IACjD,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,IAAI,EAAE,CAAC;IACzD,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnD,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;IAC7C,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,IAAI,SAAS,CAAC;IACvD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,SAAS,CAAC;IAE9C,MAAM,MAAM,GAA0B;QACpC,UAAU;KACX,CAAC;IAEF,IAAI,WAAW,KAAK,IAAI,EAAE,CAAC;QACzB,MAAM,CAAC,QAAQ,GAAG,WAAW,KAAK,MAAM,CAAC;IAC3C,CAAC;IAED,IAAI,SAAS,KAAK,IAAI,EAAE,CAAC;QACvB,MAAM,CAAC,MAAM,GAAG,SAAS,KAAK,MAAM,CAAC;IACvC,CAAC;IAED,IAAI,MAAM,EAAE,CAAC;QACX,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC;IACzB,CAAC;IAED,IAAI,IAAI,EAAE,CAAC;QACT,MAAM,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAA6B;IAC/D,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAE9B,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,MAAM,CAAC,UAAU,CAAC,CAAC;IAEnD,IAAI,MAAM,CAAC,QAAQ,KAAK,SAAS,EAAE,CAAC;QAClC,OAAO,CAAC,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;IAC1D,CAAC;IAED,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAChC,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC;IACpD,CAAC;IAED,IAAI,MAAM,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;QAChC,OAAO,CAAC,GAAG,CAAC,aAAa,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;IAC5C,CAAC;IAED,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC9B,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;IACnC,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { encodeOffset, decodeOffset, OFFSET_BEGINNING, OFFSET_NOW, } from './offset.js';
|
|
2
|
+
export { formatSSEDataEvent, formatSSEControlEvent, parseSSEEvent, SSE_RETRY_MS, type SSEDataEvent, type SSEControlEvent, type SSEEvent, type SSEControlPayload, } from './sse.js';
|
|
3
|
+
export { parseStreamHeaders, formatStreamHeaders, HEADER_NEXT_OFFSET, HEADER_UP_TO_DATE, HEADER_CLOSED, HEADER_CURSOR, DEFAULT_POLL_INTERVAL, type StreamResponseHeaders, } from './headers.js';
|
|
4
|
+
export { isWSClientMessage, isWSServerMessage, parseWSClientMessage, parseWSServerMessage, serializeWSMessage, type WSClientMessage, type WSServerMessage, type WSConnectedMessage, type WSSubscribeMessage, type WSUnsubscribeMessage, type WSPingMessage, type WSSubscribedMessage, type WSUnsubscribedMessage, type WSEventMessage, type WSStatusMessage, type WSErrorMessage, type WSPongMessage, } from './websocket.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,aAAa,EACb,YAAY,EACZ,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,QAAQ,EACb,KAAK,iBAAiB,GACvB,MAAM,UAAU,CAAC;AAGlB,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,EACjB,aAAa,EACb,aAAa,EACb,qBAAqB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,oBAAoB,EACpB,oBAAoB,EACpB,kBAAkB,EAClB,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,aAAa,GACnB,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// ABOUTME: Durable Streams protocol types and utilities
|
|
2
|
+
// ABOUTME: Zero-dependency package for protocol compliance across clients and servers
|
|
3
|
+
// Offset encoding/decoding
|
|
4
|
+
export { encodeOffset, decodeOffset, OFFSET_BEGINNING, OFFSET_NOW, } from './offset.js';
|
|
5
|
+
// SSE formatting and parsing
|
|
6
|
+
export { formatSSEDataEvent, formatSSEControlEvent, parseSSEEvent, SSE_RETRY_MS, } from './sse.js';
|
|
7
|
+
// HTTP header utilities
|
|
8
|
+
export { parseStreamHeaders, formatStreamHeaders, HEADER_NEXT_OFFSET, HEADER_UP_TO_DATE, HEADER_CLOSED, HEADER_CURSOR, DEFAULT_POLL_INTERVAL, } from './headers.js';
|
|
9
|
+
// WebSocket protocol types
|
|
10
|
+
export { isWSClientMessage, isWSServerMessage, parseWSClientMessage, parseWSServerMessage, serializeWSMessage, } from './websocket.js';
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,wDAAwD;AACxD,sFAAsF;AAEtF,2BAA2B;AAC3B,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,aAAa,CAAC;AAErB,6BAA6B;AAC7B,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,aAAa,EACb,YAAY,GAKb,MAAM,UAAU,CAAC;AAElB,wBAAwB;AACxB,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,EACjB,aAAa,EACb,aAAa,EACb,qBAAqB,GAEtB,MAAM,cAAc,CAAC;AAEtB,2BAA2B;AAC3B,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,oBAAoB,EACpB,oBAAoB,EACpB,kBAAkB,GAanB,MAAM,gBAAgB,CAAC"}
|
package/dist/offset.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Special offset value indicating "from beginning" of stream.
|
|
3
|
+
*/
|
|
4
|
+
export declare const OFFSET_BEGINNING = "-1";
|
|
5
|
+
/**
|
|
6
|
+
* Special offset value indicating "from current tail" (live mode).
|
|
7
|
+
*/
|
|
8
|
+
export declare const OFFSET_NOW = "now";
|
|
9
|
+
/**
|
|
10
|
+
* Encodes a bigint event ID to a lexicographically sortable offset string.
|
|
11
|
+
*
|
|
12
|
+
* Uses length-prefixed base-36 encoding:
|
|
13
|
+
* - First character indicates the length of the base-36 representation
|
|
14
|
+
* - Remaining characters are the base-36 encoded value
|
|
15
|
+
*
|
|
16
|
+
* This ensures that smaller numeric values always sort before larger ones
|
|
17
|
+
* when using string comparison.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* encodeOffset(0n) // '10' (length 1, value '0')
|
|
21
|
+
* encodeOffset(35n) // '1z' (length 1, value 'z')
|
|
22
|
+
* encodeOffset(36n) // '210' (length 2, value '10')
|
|
23
|
+
* encodeOffset(1295n) // '2zz' (length 2, value 'zz')
|
|
24
|
+
*/
|
|
25
|
+
export declare function encodeOffset(id: bigint): string;
|
|
26
|
+
/**
|
|
27
|
+
* Decodes a lexicographically sortable offset string back to a bigint.
|
|
28
|
+
*
|
|
29
|
+
* Inverse of encodeOffset.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* decodeOffset('10') // 0n
|
|
33
|
+
* decodeOffset('1z') // 35n
|
|
34
|
+
* decodeOffset('210') // 36n
|
|
35
|
+
*/
|
|
36
|
+
export declare function decodeOffset(offset: string): bigint;
|
|
37
|
+
//# sourceMappingURL=offset.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offset.d.ts","sourceRoot":"","sources":["../src/offset.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC;;GAEG;AACH,eAAO,MAAM,UAAU,QAAQ,CAAC;AAEhC;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAgB/C;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAsBnD"}
|
package/dist/offset.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ABOUTME: Offset encoding/decoding utilities for Durable Streams protocol
|
|
2
|
+
// ABOUTME: Uses length-prefixed base-36 encoding for lexicographic ordering
|
|
3
|
+
/**
|
|
4
|
+
* Special offset value indicating "from beginning" of stream.
|
|
5
|
+
*/
|
|
6
|
+
export const OFFSET_BEGINNING = '-1';
|
|
7
|
+
/**
|
|
8
|
+
* Special offset value indicating "from current tail" (live mode).
|
|
9
|
+
*/
|
|
10
|
+
export const OFFSET_NOW = 'now';
|
|
11
|
+
/**
|
|
12
|
+
* Encodes a bigint event ID to a lexicographically sortable offset string.
|
|
13
|
+
*
|
|
14
|
+
* Uses length-prefixed base-36 encoding:
|
|
15
|
+
* - First character indicates the length of the base-36 representation
|
|
16
|
+
* - Remaining characters are the base-36 encoded value
|
|
17
|
+
*
|
|
18
|
+
* This ensures that smaller numeric values always sort before larger ones
|
|
19
|
+
* when using string comparison.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* encodeOffset(0n) // '10' (length 1, value '0')
|
|
23
|
+
* encodeOffset(35n) // '1z' (length 1, value 'z')
|
|
24
|
+
* encodeOffset(36n) // '210' (length 2, value '10')
|
|
25
|
+
* encodeOffset(1295n) // '2zz' (length 2, value 'zz')
|
|
26
|
+
*/
|
|
27
|
+
export function encodeOffset(id) {
|
|
28
|
+
if (id < 0n) {
|
|
29
|
+
throw new Error('Offset ID must be non-negative');
|
|
30
|
+
}
|
|
31
|
+
const base36 = id.toString(36);
|
|
32
|
+
const length = base36.length;
|
|
33
|
+
// Use base-36 character for length prefix (supports up to 35 digits)
|
|
34
|
+
// 35 base-36 digits can represent numbers up to 36^35 - 1, which is enormous
|
|
35
|
+
if (length > 35) {
|
|
36
|
+
throw new Error('Offset ID too large');
|
|
37
|
+
}
|
|
38
|
+
const lengthPrefix = length.toString(36);
|
|
39
|
+
return lengthPrefix + base36;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Decodes a lexicographically sortable offset string back to a bigint.
|
|
43
|
+
*
|
|
44
|
+
* Inverse of encodeOffset.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* decodeOffset('10') // 0n
|
|
48
|
+
* decodeOffset('1z') // 35n
|
|
49
|
+
* decodeOffset('210') // 36n
|
|
50
|
+
*/
|
|
51
|
+
export function decodeOffset(offset) {
|
|
52
|
+
if (offset.length < 2) {
|
|
53
|
+
throw new Error('Invalid offset format');
|
|
54
|
+
}
|
|
55
|
+
const lengthPrefix = offset[0];
|
|
56
|
+
const expectedLength = parseInt(lengthPrefix, 36);
|
|
57
|
+
const base36Value = offset.slice(1);
|
|
58
|
+
if (base36Value.length !== expectedLength) {
|
|
59
|
+
throw new Error(`Invalid offset format: expected ${expectedLength} digits, got ${base36Value.length}`);
|
|
60
|
+
}
|
|
61
|
+
// Parse base-36 string to bigint
|
|
62
|
+
// BigInt doesn't have native base-36 parsing, so we do it manually
|
|
63
|
+
let result = 0n;
|
|
64
|
+
for (const char of base36Value) {
|
|
65
|
+
result = result * 36n + BigInt(parseInt(char, 36));
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=offset.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offset.js","sourceRoot":"","sources":["../src/offset.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,4EAA4E;AAE5E;;GAEG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAErC;;GAEG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,CAAC;AAEhC;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,YAAY,CAAC,EAAU;IACrC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IACpD,CAAC;IAED,MAAM,MAAM,GAAG,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAE7B,qEAAqE;IACrE,6EAA6E;IAC7E,IAAI,MAAM,GAAG,EAAE,EAAE,CAAC;QAChB,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;IACzC,CAAC;IAED,MAAM,YAAY,GAAG,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;IACzC,OAAO,YAAY,GAAG,MAAM,CAAC;AAC/B,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc;IACzC,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CAAC,uBAAuB,CAAC,CAAC;IAC3C,CAAC;IAED,MAAM,YAAY,GAAG,MAAM,CAAC,CAAC,CAAE,CAAC;IAChC,MAAM,cAAc,GAAG,QAAQ,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;IAClD,MAAM,WAAW,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAEpC,IAAI,WAAW,CAAC,MAAM,KAAK,cAAc,EAAE,CAAC;QAC1C,MAAM,IAAI,KAAK,CACb,mCAAmC,cAAc,gBAAgB,WAAW,CAAC,MAAM,EAAE,CACtF,CAAC;IACJ,CAAC;IAED,iCAAiC;IACjC,mEAAmE;IACnE,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;QAC/B,MAAM,GAAG,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC;IACrD,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default retry interval in milliseconds for SSE connections.
|
|
3
|
+
*/
|
|
4
|
+
export declare const SSE_RETRY_MS = 3000;
|
|
5
|
+
/**
|
|
6
|
+
* SSE data event containing actual stream payload.
|
|
7
|
+
*/
|
|
8
|
+
export interface SSEDataEvent<T = unknown> {
|
|
9
|
+
event: 'data';
|
|
10
|
+
data: T;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* SSE control event containing stream metadata.
|
|
14
|
+
*/
|
|
15
|
+
export interface SSEControlEvent {
|
|
16
|
+
event: 'control';
|
|
17
|
+
streamNextOffset: string;
|
|
18
|
+
streamCursor?: string;
|
|
19
|
+
upToDate?: boolean;
|
|
20
|
+
streamClosed?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Union type for all SSE events in the Durable Streams protocol.
|
|
24
|
+
*/
|
|
25
|
+
export type SSEEvent<T = unknown> = SSEDataEvent<T> | SSEControlEvent;
|
|
26
|
+
/**
|
|
27
|
+
* Control event payload (without the 'event' discriminator).
|
|
28
|
+
*/
|
|
29
|
+
export interface SSEControlPayload {
|
|
30
|
+
streamNextOffset: string;
|
|
31
|
+
streamCursor?: string;
|
|
32
|
+
upToDate?: boolean;
|
|
33
|
+
streamClosed?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Formats a data payload as an SSE data event string.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* formatSSEDataEvent({ type: 'message' })
|
|
40
|
+
* // Returns: 'event: data\ndata: {"type":"message"}\n\n'
|
|
41
|
+
*/
|
|
42
|
+
export declare function formatSSEDataEvent(data: unknown): string;
|
|
43
|
+
/**
|
|
44
|
+
* Formats control metadata as an SSE control event string.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* formatSSEControlEvent({ streamNextOffset: '210', upToDate: true })
|
|
48
|
+
* // Returns: 'event: control\ndata: {"streamNextOffset":"210","upToDate":true}\n\n'
|
|
49
|
+
*/
|
|
50
|
+
export declare function formatSSEControlEvent(control: SSEControlPayload): string;
|
|
51
|
+
/**
|
|
52
|
+
* Parses an SSE event block (lines between double newlines).
|
|
53
|
+
*
|
|
54
|
+
* Returns null for empty input, comments, or non-data/control events.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* parseSSEEvent('event: data\ndata: {"type":"message"}')
|
|
58
|
+
* // Returns: { event: 'data', data: { type: 'message' } }
|
|
59
|
+
*/
|
|
60
|
+
export declare function parseSSEEvent(block: string): SSEEvent | null;
|
|
61
|
+
//# sourceMappingURL=sse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,eAAO,MAAM,YAAY,OAAO,CAAC;AAEjC;;GAEG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,CAAC,CAAC;CACT;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,SAAS,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;GAEG;AACH,MAAM,MAAM,QAAQ,CAAC,CAAC,GAAG,OAAO,IAAI,YAAY,CAAC,CAAC,CAAC,GAAG,eAAe,CAAC;AAEtE;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAGxD;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,CAkBxE;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAyE5D"}
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// ABOUTME: SSE (Server-Sent Events) formatting and parsing utilities
|
|
2
|
+
// ABOUTME: Implements two-event model (data + control) per Durable Streams spec
|
|
3
|
+
/**
|
|
4
|
+
* Default retry interval in milliseconds for SSE connections.
|
|
5
|
+
*/
|
|
6
|
+
export const SSE_RETRY_MS = 3000;
|
|
7
|
+
/**
|
|
8
|
+
* Formats a data payload as an SSE data event string.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* formatSSEDataEvent({ type: 'message' })
|
|
12
|
+
* // Returns: 'event: data\ndata: {"type":"message"}\n\n'
|
|
13
|
+
*/
|
|
14
|
+
export function formatSSEDataEvent(data) {
|
|
15
|
+
const json = JSON.stringify(data);
|
|
16
|
+
return `event: data\ndata: ${json}\n\n`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Formats control metadata as an SSE control event string.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* formatSSEControlEvent({ streamNextOffset: '210', upToDate: true })
|
|
23
|
+
* // Returns: 'event: control\ndata: {"streamNextOffset":"210","upToDate":true}\n\n'
|
|
24
|
+
*/
|
|
25
|
+
export function formatSSEControlEvent(control) {
|
|
26
|
+
// Only include defined fields
|
|
27
|
+
const payload = {
|
|
28
|
+
streamNextOffset: control.streamNextOffset,
|
|
29
|
+
};
|
|
30
|
+
if (control.streamCursor !== undefined) {
|
|
31
|
+
payload.streamCursor = control.streamCursor;
|
|
32
|
+
}
|
|
33
|
+
if (control.upToDate !== undefined) {
|
|
34
|
+
payload.upToDate = control.upToDate;
|
|
35
|
+
}
|
|
36
|
+
if (control.streamClosed !== undefined) {
|
|
37
|
+
payload.streamClosed = control.streamClosed;
|
|
38
|
+
}
|
|
39
|
+
const json = JSON.stringify(payload);
|
|
40
|
+
return `event: control\ndata: ${json}\n\n`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Parses an SSE event block (lines between double newlines).
|
|
44
|
+
*
|
|
45
|
+
* Returns null for empty input, comments, or non-data/control events.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* parseSSEEvent('event: data\ndata: {"type":"message"}')
|
|
49
|
+
* // Returns: { event: 'data', data: { type: 'message' } }
|
|
50
|
+
*/
|
|
51
|
+
export function parseSSEEvent(block) {
|
|
52
|
+
if (!block || block.trim() === '') {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const lines = block.split('\n');
|
|
56
|
+
let eventType = null;
|
|
57
|
+
const dataLines = [];
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
// Skip comments
|
|
60
|
+
if (line.startsWith(':')) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// Parse event type
|
|
64
|
+
if (line.startsWith('event:')) {
|
|
65
|
+
eventType = line.slice(6).trim();
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Parse data lines
|
|
69
|
+
if (line.startsWith('data:')) {
|
|
70
|
+
dataLines.push(line.slice(5).trim());
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Handle "data:" without space (per SSE spec)
|
|
74
|
+
if (line.startsWith('data')) {
|
|
75
|
+
const colonIndex = line.indexOf(':');
|
|
76
|
+
if (colonIndex !== -1) {
|
|
77
|
+
dataLines.push(line.slice(colonIndex + 1).trim());
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Only process data and control events
|
|
82
|
+
if (eventType !== 'data' && eventType !== 'control') {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (dataLines.length === 0) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
// Concatenate data lines (per SSE spec, they should be joined with newlines)
|
|
89
|
+
const dataString = dataLines.join('\n');
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(dataString);
|
|
92
|
+
if (eventType === 'data') {
|
|
93
|
+
return {
|
|
94
|
+
event: 'data',
|
|
95
|
+
data: parsed,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (eventType === 'control') {
|
|
99
|
+
return {
|
|
100
|
+
event: 'control',
|
|
101
|
+
streamNextOffset: parsed.streamNextOffset,
|
|
102
|
+
streamCursor: parsed.streamCursor,
|
|
103
|
+
upToDate: parsed.upToDate,
|
|
104
|
+
streamClosed: parsed.streamClosed,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// Invalid JSON - return null
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=sse.js.map
|
package/dist/sse.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.js","sourceRoot":"","sources":["../src/sse.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages sent from client to server over WebSocket.
|
|
3
|
+
*/
|
|
4
|
+
export type WSClientMessage = WSSubscribeMessage | WSUnsubscribeMessage | WSPingMessage;
|
|
5
|
+
/**
|
|
6
|
+
* Subscribe to a stream from a given offset.
|
|
7
|
+
*/
|
|
8
|
+
export interface WSSubscribeMessage {
|
|
9
|
+
type: 'subscribe';
|
|
10
|
+
/** Stream ID to subscribe to */
|
|
11
|
+
streamId: string;
|
|
12
|
+
/** Starting offset, defaults to '-1' (beginning) */
|
|
13
|
+
offset?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Unsubscribe from a stream.
|
|
17
|
+
*/
|
|
18
|
+
export interface WSUnsubscribeMessage {
|
|
19
|
+
type: 'unsubscribe';
|
|
20
|
+
/** Stream ID to unsubscribe from */
|
|
21
|
+
streamId: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Ping message for keepalive.
|
|
25
|
+
*/
|
|
26
|
+
export interface WSPingMessage {
|
|
27
|
+
type: 'ping';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Messages sent from server to client over WebSocket.
|
|
31
|
+
*/
|
|
32
|
+
export type WSServerMessage = WSConnectedMessage | WSSubscribedMessage | WSUnsubscribedMessage | WSEventMessage | WSStatusMessage | WSErrorMessage | WSPongMessage;
|
|
33
|
+
/**
|
|
34
|
+
* Connection acknowledgment with assigned client ID.
|
|
35
|
+
*/
|
|
36
|
+
export interface WSConnectedMessage {
|
|
37
|
+
type: 'connected';
|
|
38
|
+
/** Client ID for this connection */
|
|
39
|
+
clientId: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Confirmation of successful subscription.
|
|
43
|
+
*/
|
|
44
|
+
export interface WSSubscribedMessage {
|
|
45
|
+
type: 'subscribed';
|
|
46
|
+
/** Stream ID subscribed to */
|
|
47
|
+
streamId: string;
|
|
48
|
+
/** Current offset position */
|
|
49
|
+
offset: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Confirmation of unsubscription.
|
|
53
|
+
*/
|
|
54
|
+
export interface WSUnsubscribedMessage {
|
|
55
|
+
type: 'unsubscribed';
|
|
56
|
+
/** Stream ID unsubscribed from */
|
|
57
|
+
streamId: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Stream event delivery.
|
|
61
|
+
*/
|
|
62
|
+
export interface WSEventMessage {
|
|
63
|
+
type: 'event';
|
|
64
|
+
/** Stream ID this event belongs to */
|
|
65
|
+
streamId: string;
|
|
66
|
+
/** Event payload */
|
|
67
|
+
event: unknown;
|
|
68
|
+
/** Offset after this event */
|
|
69
|
+
nextOffset: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Stream status update.
|
|
73
|
+
*/
|
|
74
|
+
export interface WSStatusMessage {
|
|
75
|
+
type: 'status';
|
|
76
|
+
/** Stream ID */
|
|
77
|
+
streamId: string;
|
|
78
|
+
/** True when caught up to tail */
|
|
79
|
+
upToDate: boolean;
|
|
80
|
+
/** True when stream is permanently closed */
|
|
81
|
+
closed?: boolean;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Error message.
|
|
85
|
+
*/
|
|
86
|
+
export interface WSErrorMessage {
|
|
87
|
+
type: 'error';
|
|
88
|
+
/** Human-readable error message */
|
|
89
|
+
message: string;
|
|
90
|
+
/** Machine-readable error code */
|
|
91
|
+
code?: string;
|
|
92
|
+
/** Stream ID if error is stream-specific */
|
|
93
|
+
streamId?: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Pong response to ping.
|
|
97
|
+
*/
|
|
98
|
+
export interface WSPongMessage {
|
|
99
|
+
type: 'pong';
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Type guard for client messages.
|
|
103
|
+
*/
|
|
104
|
+
export declare function isWSClientMessage(msg: unknown): msg is WSClientMessage;
|
|
105
|
+
/**
|
|
106
|
+
* Type guard for server messages.
|
|
107
|
+
*/
|
|
108
|
+
export declare function isWSServerMessage(msg: unknown): msg is WSServerMessage;
|
|
109
|
+
/**
|
|
110
|
+
* Parse a JSON string as a client message.
|
|
111
|
+
*/
|
|
112
|
+
export declare function parseWSClientMessage(json: string): WSClientMessage | null;
|
|
113
|
+
/**
|
|
114
|
+
* Parse a JSON string as a server message.
|
|
115
|
+
*/
|
|
116
|
+
export declare function parseWSServerMessage(json: string): WSServerMessage | null;
|
|
117
|
+
/**
|
|
118
|
+
* Serialize a message to JSON string.
|
|
119
|
+
*/
|
|
120
|
+
export declare function serializeWSMessage(msg: WSClientMessage | WSServerMessage): string;
|
|
121
|
+
//# sourceMappingURL=websocket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,MAAM,MAAM,eAAe,GACvB,kBAAkB,GAClB,oBAAoB,GACpB,aAAa,CAAC;AAElB;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,WAAW,CAAC;IAClB,gCAAgC;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,oDAAoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,aAAa,CAAC;IACpB,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,MAAM,MAAM,eAAe,GACvB,kBAAkB,GAClB,mBAAmB,GACnB,qBAAqB,GACrB,cAAc,GACd,eAAe,GACf,cAAc,GACd,aAAa,CAAC;AAElB;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,WAAW,CAAC;IAClB,oCAAoC;IACpC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,YAAY,CAAC;IACnB,8BAA8B;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,cAAc,CAAC;IACrB,kCAAkC;IAClC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,CAAC;IACd,sCAAsC;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB;IACpB,KAAK,EAAE,OAAO,CAAC;IACf,8BAA8B;IAC9B,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,QAAQ,CAAC;IACf,gBAAgB;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,kCAAkC;IAClC,QAAQ,EAAE,OAAO,CAAC;IAClB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,OAAO,CAAC;IACd,mCAAmC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,eAAe,CAItE;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,eAAe,CAYtE;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAUzE;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAUzE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,eAAe,GAAG,eAAe,GAAG,MAAM,CAEjF"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ABOUTME: WebSocket protocol types for Durable Streams
|
|
2
|
+
// ABOUTME: Defines client-to-server and server-to-client message formats
|
|
3
|
+
/**
|
|
4
|
+
* Type guard for client messages.
|
|
5
|
+
*/
|
|
6
|
+
export function isWSClientMessage(msg) {
|
|
7
|
+
if (typeof msg !== 'object' || msg === null)
|
|
8
|
+
return false;
|
|
9
|
+
const { type } = msg;
|
|
10
|
+
return type === 'subscribe' || type === 'unsubscribe' || type === 'ping';
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Type guard for server messages.
|
|
14
|
+
*/
|
|
15
|
+
export function isWSServerMessage(msg) {
|
|
16
|
+
if (typeof msg !== 'object' || msg === null)
|
|
17
|
+
return false;
|
|
18
|
+
const { type } = msg;
|
|
19
|
+
return (type === 'connected' ||
|
|
20
|
+
type === 'subscribed' ||
|
|
21
|
+
type === 'unsubscribed' ||
|
|
22
|
+
type === 'event' ||
|
|
23
|
+
type === 'status' ||
|
|
24
|
+
type === 'error' ||
|
|
25
|
+
type === 'pong');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse a JSON string as a client message.
|
|
29
|
+
*/
|
|
30
|
+
export function parseWSClientMessage(json) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(json);
|
|
33
|
+
if (isWSClientMessage(parsed)) {
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Parse a JSON string as a server message.
|
|
44
|
+
*/
|
|
45
|
+
export function parseWSServerMessage(json) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(json);
|
|
48
|
+
if (isWSServerMessage(parsed)) {
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Serialize a message to JSON string.
|
|
59
|
+
*/
|
|
60
|
+
export function serializeWSMessage(msg) {
|
|
61
|
+
return JSON.stringify(msg);
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=websocket.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"websocket.js","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA,wDAAwD;AACxD,yEAAyE;AA4HzE;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAY;IAC5C,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC1D,MAAM,EAAE,IAAI,EAAE,GAAG,GAAyB,CAAC;IAC3C,OAAO,IAAI,KAAK,WAAW,IAAI,IAAI,KAAK,aAAa,IAAI,IAAI,KAAK,MAAM,CAAC;AAC3E,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAAC,GAAY;IAC5C,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC1D,MAAM,EAAE,IAAI,EAAE,GAAG,GAAyB,CAAC;IAC3C,OAAO,CACL,IAAI,KAAK,WAAW;QACpB,IAAI,KAAK,YAAY;QACrB,IAAI,KAAK,cAAc;QACvB,IAAI,KAAK,OAAO;QAChB,IAAI,KAAK,QAAQ;QACjB,IAAI,KAAK,OAAO;QAChB,IAAI,KAAK,MAAM,CAChB,CAAC;AACJ,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,iBAAiB,CAAC,MAAM,CAAC,EAAE,CAAC;YAC9B,OAAO,MAAM,CAAC;QAChB,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAChC,IAAI,iBAAiB,CAAC,MAAM,CAAC,EAAE,CAAC;YAC9B,OAAO,MAAM,CAAC;QAChB,CAAC;QACD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAsC;IACvE,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;AAC7B,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inf-minds/streams-protocol",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Durable Streams protocol types and utilities",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"lint": "echo 'No linter configured yet'"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "^5.3.0",
|
|
29
|
+
"vitest": "^2.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/headers.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// ABOUTME: HTTP header parsing and formatting utilities for Durable Streams
|
|
2
|
+
// ABOUTME: Handles Stream-* headers per protocol specification
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Header name for the next offset to read from.
|
|
6
|
+
*/
|
|
7
|
+
export const HEADER_NEXT_OFFSET = 'Stream-Next-Offset';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Header name indicating stream is caught up to current tail.
|
|
11
|
+
*/
|
|
12
|
+
export const HEADER_UP_TO_DATE = 'Stream-Up-To-Date';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Header name indicating stream is permanently closed.
|
|
16
|
+
*/
|
|
17
|
+
export const HEADER_CLOSED = 'Stream-Closed';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Header name for opaque cursor (CDN collapsing support).
|
|
21
|
+
*/
|
|
22
|
+
export const HEADER_CURSOR = 'Stream-Cursor';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Default polling interval in milliseconds for catch-up mode.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_POLL_INTERVAL = 500;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parsed stream response headers.
|
|
31
|
+
*/
|
|
32
|
+
export interface StreamResponseHeaders {
|
|
33
|
+
/** Next offset to read from */
|
|
34
|
+
nextOffset: string;
|
|
35
|
+
/** True when caught up to stream tail */
|
|
36
|
+
upToDate?: boolean;
|
|
37
|
+
/** True when stream is permanently closed */
|
|
38
|
+
closed?: boolean;
|
|
39
|
+
/** Opaque cursor for CDN request collapsing */
|
|
40
|
+
cursor?: string;
|
|
41
|
+
/** ETag for caching */
|
|
42
|
+
etag?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parses stream headers from an HTTP response.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* const headers = response.headers;
|
|
50
|
+
* const stream = parseStreamHeaders(headers);
|
|
51
|
+
* console.log(stream.nextOffset); // '210'
|
|
52
|
+
*/
|
|
53
|
+
export function parseStreamHeaders(headers: Headers): StreamResponseHeaders {
|
|
54
|
+
const nextOffset = headers.get(HEADER_NEXT_OFFSET) ?? '';
|
|
55
|
+
const upToDateStr = headers.get(HEADER_UP_TO_DATE);
|
|
56
|
+
const closedStr = headers.get(HEADER_CLOSED);
|
|
57
|
+
const cursor = headers.get(HEADER_CURSOR) ?? undefined;
|
|
58
|
+
const etag = headers.get('ETag') ?? undefined;
|
|
59
|
+
|
|
60
|
+
const result: StreamResponseHeaders = {
|
|
61
|
+
nextOffset,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (upToDateStr !== null) {
|
|
65
|
+
result.upToDate = upToDateStr === 'true';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (closedStr !== null) {
|
|
69
|
+
result.closed = closedStr === 'true';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (cursor) {
|
|
73
|
+
result.cursor = cursor;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (etag) {
|
|
77
|
+
result.etag = etag;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Formats stream headers for an HTTP response.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* const headers = formatStreamHeaders({ nextOffset: '210', upToDate: true });
|
|
88
|
+
* response.headers = headers;
|
|
89
|
+
*/
|
|
90
|
+
export function formatStreamHeaders(stream: StreamResponseHeaders): Headers {
|
|
91
|
+
const headers = new Headers();
|
|
92
|
+
|
|
93
|
+
headers.set(HEADER_NEXT_OFFSET, stream.nextOffset);
|
|
94
|
+
|
|
95
|
+
if (stream.upToDate !== undefined) {
|
|
96
|
+
headers.set(HEADER_UP_TO_DATE, String(stream.upToDate));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (stream.closed !== undefined) {
|
|
100
|
+
headers.set(HEADER_CLOSED, String(stream.closed));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (stream.cursor !== undefined) {
|
|
104
|
+
headers.set(HEADER_CURSOR, stream.cursor);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (stream.etag !== undefined) {
|
|
108
|
+
headers.set('ETag', stream.etag);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return headers;
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// ABOUTME: Durable Streams protocol types and utilities
|
|
2
|
+
// ABOUTME: Zero-dependency package for protocol compliance across clients and servers
|
|
3
|
+
|
|
4
|
+
// Offset encoding/decoding
|
|
5
|
+
export {
|
|
6
|
+
encodeOffset,
|
|
7
|
+
decodeOffset,
|
|
8
|
+
OFFSET_BEGINNING,
|
|
9
|
+
OFFSET_NOW,
|
|
10
|
+
} from './offset.js';
|
|
11
|
+
|
|
12
|
+
// SSE formatting and parsing
|
|
13
|
+
export {
|
|
14
|
+
formatSSEDataEvent,
|
|
15
|
+
formatSSEControlEvent,
|
|
16
|
+
parseSSEEvent,
|
|
17
|
+
SSE_RETRY_MS,
|
|
18
|
+
type SSEDataEvent,
|
|
19
|
+
type SSEControlEvent,
|
|
20
|
+
type SSEEvent,
|
|
21
|
+
type SSEControlPayload,
|
|
22
|
+
} from './sse.js';
|
|
23
|
+
|
|
24
|
+
// HTTP header utilities
|
|
25
|
+
export {
|
|
26
|
+
parseStreamHeaders,
|
|
27
|
+
formatStreamHeaders,
|
|
28
|
+
HEADER_NEXT_OFFSET,
|
|
29
|
+
HEADER_UP_TO_DATE,
|
|
30
|
+
HEADER_CLOSED,
|
|
31
|
+
HEADER_CURSOR,
|
|
32
|
+
DEFAULT_POLL_INTERVAL,
|
|
33
|
+
type StreamResponseHeaders,
|
|
34
|
+
} from './headers.js';
|
|
35
|
+
|
|
36
|
+
// WebSocket protocol types
|
|
37
|
+
export {
|
|
38
|
+
isWSClientMessage,
|
|
39
|
+
isWSServerMessage,
|
|
40
|
+
parseWSClientMessage,
|
|
41
|
+
parseWSServerMessage,
|
|
42
|
+
serializeWSMessage,
|
|
43
|
+
type WSClientMessage,
|
|
44
|
+
type WSServerMessage,
|
|
45
|
+
type WSConnectedMessage,
|
|
46
|
+
type WSSubscribeMessage,
|
|
47
|
+
type WSUnsubscribeMessage,
|
|
48
|
+
type WSPingMessage,
|
|
49
|
+
type WSSubscribedMessage,
|
|
50
|
+
type WSUnsubscribedMessage,
|
|
51
|
+
type WSEventMessage,
|
|
52
|
+
type WSStatusMessage,
|
|
53
|
+
type WSErrorMessage,
|
|
54
|
+
type WSPongMessage,
|
|
55
|
+
} from './websocket.js';
|
package/src/offset.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ABOUTME: Offset encoding/decoding utilities for Durable Streams protocol
|
|
2
|
+
// ABOUTME: Uses length-prefixed base-36 encoding for lexicographic ordering
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Special offset value indicating "from beginning" of stream.
|
|
6
|
+
*/
|
|
7
|
+
export const OFFSET_BEGINNING = '-1';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Special offset value indicating "from current tail" (live mode).
|
|
11
|
+
*/
|
|
12
|
+
export const OFFSET_NOW = 'now';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Encodes a bigint event ID to a lexicographically sortable offset string.
|
|
16
|
+
*
|
|
17
|
+
* Uses length-prefixed base-36 encoding:
|
|
18
|
+
* - First character indicates the length of the base-36 representation
|
|
19
|
+
* - Remaining characters are the base-36 encoded value
|
|
20
|
+
*
|
|
21
|
+
* This ensures that smaller numeric values always sort before larger ones
|
|
22
|
+
* when using string comparison.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* encodeOffset(0n) // '10' (length 1, value '0')
|
|
26
|
+
* encodeOffset(35n) // '1z' (length 1, value 'z')
|
|
27
|
+
* encodeOffset(36n) // '210' (length 2, value '10')
|
|
28
|
+
* encodeOffset(1295n) // '2zz' (length 2, value 'zz')
|
|
29
|
+
*/
|
|
30
|
+
export function encodeOffset(id: bigint): string {
|
|
31
|
+
if (id < 0n) {
|
|
32
|
+
throw new Error('Offset ID must be non-negative');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const base36 = id.toString(36);
|
|
36
|
+
const length = base36.length;
|
|
37
|
+
|
|
38
|
+
// Use base-36 character for length prefix (supports up to 35 digits)
|
|
39
|
+
// 35 base-36 digits can represent numbers up to 36^35 - 1, which is enormous
|
|
40
|
+
if (length > 35) {
|
|
41
|
+
throw new Error('Offset ID too large');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const lengthPrefix = length.toString(36);
|
|
45
|
+
return lengthPrefix + base36;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Decodes a lexicographically sortable offset string back to a bigint.
|
|
50
|
+
*
|
|
51
|
+
* Inverse of encodeOffset.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* decodeOffset('10') // 0n
|
|
55
|
+
* decodeOffset('1z') // 35n
|
|
56
|
+
* decodeOffset('210') // 36n
|
|
57
|
+
*/
|
|
58
|
+
export function decodeOffset(offset: string): bigint {
|
|
59
|
+
if (offset.length < 2) {
|
|
60
|
+
throw new Error('Invalid offset format');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lengthPrefix = offset[0]!;
|
|
64
|
+
const expectedLength = parseInt(lengthPrefix, 36);
|
|
65
|
+
const base36Value = offset.slice(1);
|
|
66
|
+
|
|
67
|
+
if (base36Value.length !== expectedLength) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Invalid offset format: expected ${expectedLength} digits, got ${base36Value.length}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Parse base-36 string to bigint
|
|
74
|
+
// BigInt doesn't have native base-36 parsing, so we do it manually
|
|
75
|
+
let result = 0n;
|
|
76
|
+
for (const char of base36Value) {
|
|
77
|
+
result = result * 36n + BigInt(parseInt(char, 36));
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// ABOUTME: SSE (Server-Sent Events) formatting and parsing utilities
|
|
2
|
+
// ABOUTME: Implements two-event model (data + control) per Durable Streams spec
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default retry interval in milliseconds for SSE connections.
|
|
6
|
+
*/
|
|
7
|
+
export const SSE_RETRY_MS = 3000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SSE data event containing actual stream payload.
|
|
11
|
+
*/
|
|
12
|
+
export interface SSEDataEvent<T = unknown> {
|
|
13
|
+
event: 'data';
|
|
14
|
+
data: T;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SSE control event containing stream metadata.
|
|
19
|
+
*/
|
|
20
|
+
export interface SSEControlEvent {
|
|
21
|
+
event: 'control';
|
|
22
|
+
streamNextOffset: string;
|
|
23
|
+
streamCursor?: string;
|
|
24
|
+
upToDate?: boolean;
|
|
25
|
+
streamClosed?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Union type for all SSE events in the Durable Streams protocol.
|
|
30
|
+
*/
|
|
31
|
+
export type SSEEvent<T = unknown> = SSEDataEvent<T> | SSEControlEvent;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Control event payload (without the 'event' discriminator).
|
|
35
|
+
*/
|
|
36
|
+
export interface SSEControlPayload {
|
|
37
|
+
streamNextOffset: string;
|
|
38
|
+
streamCursor?: string;
|
|
39
|
+
upToDate?: boolean;
|
|
40
|
+
streamClosed?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Formats a data payload as an SSE data event string.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* formatSSEDataEvent({ type: 'message' })
|
|
48
|
+
* // Returns: 'event: data\ndata: {"type":"message"}\n\n'
|
|
49
|
+
*/
|
|
50
|
+
export function formatSSEDataEvent(data: unknown): string {
|
|
51
|
+
const json = JSON.stringify(data);
|
|
52
|
+
return `event: data\ndata: ${json}\n\n`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Formats control metadata as an SSE control event string.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* formatSSEControlEvent({ streamNextOffset: '210', upToDate: true })
|
|
60
|
+
* // Returns: 'event: control\ndata: {"streamNextOffset":"210","upToDate":true}\n\n'
|
|
61
|
+
*/
|
|
62
|
+
export function formatSSEControlEvent(control: SSEControlPayload): string {
|
|
63
|
+
// Only include defined fields
|
|
64
|
+
const payload: Record<string, unknown> = {
|
|
65
|
+
streamNextOffset: control.streamNextOffset,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (control.streamCursor !== undefined) {
|
|
69
|
+
payload.streamCursor = control.streamCursor;
|
|
70
|
+
}
|
|
71
|
+
if (control.upToDate !== undefined) {
|
|
72
|
+
payload.upToDate = control.upToDate;
|
|
73
|
+
}
|
|
74
|
+
if (control.streamClosed !== undefined) {
|
|
75
|
+
payload.streamClosed = control.streamClosed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const json = JSON.stringify(payload);
|
|
79
|
+
return `event: control\ndata: ${json}\n\n`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parses an SSE event block (lines between double newlines).
|
|
84
|
+
*
|
|
85
|
+
* Returns null for empty input, comments, or non-data/control events.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* parseSSEEvent('event: data\ndata: {"type":"message"}')
|
|
89
|
+
* // Returns: { event: 'data', data: { type: 'message' } }
|
|
90
|
+
*/
|
|
91
|
+
export function parseSSEEvent(block: string): SSEEvent | null {
|
|
92
|
+
if (!block || block.trim() === '') {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const lines = block.split('\n');
|
|
97
|
+
let eventType: string | null = null;
|
|
98
|
+
const dataLines: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
// Skip comments
|
|
102
|
+
if (line.startsWith(':')) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Parse event type
|
|
107
|
+
if (line.startsWith('event:')) {
|
|
108
|
+
eventType = line.slice(6).trim();
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Parse data lines
|
|
113
|
+
if (line.startsWith('data:')) {
|
|
114
|
+
dataLines.push(line.slice(5).trim());
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Handle "data:" without space (per SSE spec)
|
|
119
|
+
if (line.startsWith('data')) {
|
|
120
|
+
const colonIndex = line.indexOf(':');
|
|
121
|
+
if (colonIndex !== -1) {
|
|
122
|
+
dataLines.push(line.slice(colonIndex + 1).trim());
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Only process data and control events
|
|
128
|
+
if (eventType !== 'data' && eventType !== 'control') {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (dataLines.length === 0) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Concatenate data lines (per SSE spec, they should be joined with newlines)
|
|
137
|
+
const dataString = dataLines.join('\n');
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const parsed = JSON.parse(dataString);
|
|
141
|
+
|
|
142
|
+
if (eventType === 'data') {
|
|
143
|
+
return {
|
|
144
|
+
event: 'data',
|
|
145
|
+
data: parsed,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (eventType === 'control') {
|
|
150
|
+
return {
|
|
151
|
+
event: 'control',
|
|
152
|
+
streamNextOffset: parsed.streamNextOffset,
|
|
153
|
+
streamCursor: parsed.streamCursor,
|
|
154
|
+
upToDate: parsed.upToDate,
|
|
155
|
+
streamClosed: parsed.streamClosed,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Invalid JSON - return null
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return null;
|
|
164
|
+
}
|
package/src/websocket.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// ABOUTME: WebSocket protocol types for Durable Streams
|
|
2
|
+
// ABOUTME: Defines client-to-server and server-to-client message formats
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Messages sent from client to server over WebSocket.
|
|
6
|
+
*/
|
|
7
|
+
export type WSClientMessage =
|
|
8
|
+
| WSSubscribeMessage
|
|
9
|
+
| WSUnsubscribeMessage
|
|
10
|
+
| WSPingMessage;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Subscribe to a stream from a given offset.
|
|
14
|
+
*/
|
|
15
|
+
export interface WSSubscribeMessage {
|
|
16
|
+
type: 'subscribe';
|
|
17
|
+
/** Stream ID to subscribe to */
|
|
18
|
+
streamId: string;
|
|
19
|
+
/** Starting offset, defaults to '-1' (beginning) */
|
|
20
|
+
offset?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Unsubscribe from a stream.
|
|
25
|
+
*/
|
|
26
|
+
export interface WSUnsubscribeMessage {
|
|
27
|
+
type: 'unsubscribe';
|
|
28
|
+
/** Stream ID to unsubscribe from */
|
|
29
|
+
streamId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ping message for keepalive.
|
|
34
|
+
*/
|
|
35
|
+
export interface WSPingMessage {
|
|
36
|
+
type: 'ping';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Messages sent from server to client over WebSocket.
|
|
41
|
+
*/
|
|
42
|
+
export type WSServerMessage =
|
|
43
|
+
| WSConnectedMessage
|
|
44
|
+
| WSSubscribedMessage
|
|
45
|
+
| WSUnsubscribedMessage
|
|
46
|
+
| WSEventMessage
|
|
47
|
+
| WSStatusMessage
|
|
48
|
+
| WSErrorMessage
|
|
49
|
+
| WSPongMessage;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Connection acknowledgment with assigned client ID.
|
|
53
|
+
*/
|
|
54
|
+
export interface WSConnectedMessage {
|
|
55
|
+
type: 'connected';
|
|
56
|
+
/** Client ID for this connection */
|
|
57
|
+
clientId: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Confirmation of successful subscription.
|
|
62
|
+
*/
|
|
63
|
+
export interface WSSubscribedMessage {
|
|
64
|
+
type: 'subscribed';
|
|
65
|
+
/** Stream ID subscribed to */
|
|
66
|
+
streamId: string;
|
|
67
|
+
/** Current offset position */
|
|
68
|
+
offset: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Confirmation of unsubscription.
|
|
73
|
+
*/
|
|
74
|
+
export interface WSUnsubscribedMessage {
|
|
75
|
+
type: 'unsubscribed';
|
|
76
|
+
/** Stream ID unsubscribed from */
|
|
77
|
+
streamId: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stream event delivery.
|
|
82
|
+
*/
|
|
83
|
+
export interface WSEventMessage {
|
|
84
|
+
type: 'event';
|
|
85
|
+
/** Stream ID this event belongs to */
|
|
86
|
+
streamId: string;
|
|
87
|
+
/** Event payload */
|
|
88
|
+
event: unknown;
|
|
89
|
+
/** Offset after this event */
|
|
90
|
+
nextOffset: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Stream status update.
|
|
95
|
+
*/
|
|
96
|
+
export interface WSStatusMessage {
|
|
97
|
+
type: 'status';
|
|
98
|
+
/** Stream ID */
|
|
99
|
+
streamId: string;
|
|
100
|
+
/** True when caught up to tail */
|
|
101
|
+
upToDate: boolean;
|
|
102
|
+
/** True when stream is permanently closed */
|
|
103
|
+
closed?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Error message.
|
|
108
|
+
*/
|
|
109
|
+
export interface WSErrorMessage {
|
|
110
|
+
type: 'error';
|
|
111
|
+
/** Human-readable error message */
|
|
112
|
+
message: string;
|
|
113
|
+
/** Machine-readable error code */
|
|
114
|
+
code?: string;
|
|
115
|
+
/** Stream ID if error is stream-specific */
|
|
116
|
+
streamId?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pong response to ping.
|
|
121
|
+
*/
|
|
122
|
+
export interface WSPongMessage {
|
|
123
|
+
type: 'pong';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Type guard for client messages.
|
|
128
|
+
*/
|
|
129
|
+
export function isWSClientMessage(msg: unknown): msg is WSClientMessage {
|
|
130
|
+
if (typeof msg !== 'object' || msg === null) return false;
|
|
131
|
+
const { type } = msg as { type?: unknown };
|
|
132
|
+
return type === 'subscribe' || type === 'unsubscribe' || type === 'ping';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Type guard for server messages.
|
|
137
|
+
*/
|
|
138
|
+
export function isWSServerMessage(msg: unknown): msg is WSServerMessage {
|
|
139
|
+
if (typeof msg !== 'object' || msg === null) return false;
|
|
140
|
+
const { type } = msg as { type?: unknown };
|
|
141
|
+
return (
|
|
142
|
+
type === 'connected' ||
|
|
143
|
+
type === 'subscribed' ||
|
|
144
|
+
type === 'unsubscribed' ||
|
|
145
|
+
type === 'event' ||
|
|
146
|
+
type === 'status' ||
|
|
147
|
+
type === 'error' ||
|
|
148
|
+
type === 'pong'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse a JSON string as a client message.
|
|
154
|
+
*/
|
|
155
|
+
export function parseWSClientMessage(json: string): WSClientMessage | null {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(json);
|
|
158
|
+
if (isWSClientMessage(parsed)) {
|
|
159
|
+
return parsed;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse a JSON string as a server message.
|
|
169
|
+
*/
|
|
170
|
+
export function parseWSServerMessage(json: string): WSServerMessage | null {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(json);
|
|
173
|
+
if (isWSServerMessage(parsed)) {
|
|
174
|
+
return parsed;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Serialize a message to JSON string.
|
|
184
|
+
*/
|
|
185
|
+
export function serializeWSMessage(msg: WSClientMessage | WSServerMessage): string {
|
|
186
|
+
return JSON.stringify(msg);
|
|
187
|
+
}
|