@elysra/tungsten-runtime 0.0.0-bootstrap.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +55 -0
- package/dist/auth.d.ts +50 -0
- package/dist/auth.js +264 -0
- package/dist/classify.d.ts +64 -0
- package/dist/classify.js +301 -0
- package/dist/client.d.ts +49 -0
- package/dist/client.js +2309 -0
- package/dist/confirm.d.ts +7 -0
- package/dist/confirm.js +32 -0
- package/dist/define.d.ts +13 -0
- package/dist/define.js +12 -0
- package/dist/envelope.d.ts +24 -0
- package/dist/envelope.js +152 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.js +75 -0
- package/dist/expr.d.ts +24 -0
- package/dist/expr.js +198 -0
- package/dist/idempotency.d.ts +27 -0
- package/dist/idempotency.js +56 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +19 -0
- package/dist/node.d.ts +17 -0
- package/dist/node.js +98 -0
- package/dist/oauth.d.ts +124 -0
- package/dist/oauth.js +367 -0
- package/dist/serialize.d.ts +55 -0
- package/dist/serialize.js +323 -0
- package/dist/sse.d.ts +42 -0
- package/dist/sse.js +166 -0
- package/dist/streams.d.ts +65 -0
- package/dist/streams.js +96 -0
- package/dist/transport.d.ts +88 -0
- package/dist/transport.js +245 -0
- package/dist/types.d.ts +667 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +73 -0
- package/dist/util.js +317 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +4 -0
- package/package.json +55 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parameter serialization (OpenAPI 3 `style`/`explode` for path, query,
|
|
3
|
+
* header and cookie parameters) and request body encoding.
|
|
4
|
+
*/
|
|
5
|
+
import type { BodyDescriptor, OperationDescriptor, ParamDescriptor } from "./types.js";
|
|
6
|
+
/** Problem found while serializing an argument; becomes VALIDATION_FAILED. */
|
|
7
|
+
export declare class SerializationError extends Error {
|
|
8
|
+
readonly parameter: string;
|
|
9
|
+
readonly expected: string;
|
|
10
|
+
readonly value: unknown;
|
|
11
|
+
constructor(parameter: string, expected: string, value: unknown);
|
|
12
|
+
}
|
|
13
|
+
/** One path parameter value, already percent-encoded (`simple`, `label`, `matrix`). */
|
|
14
|
+
export declare function serializePathParam(p: ParamDescriptor, value: unknown): string;
|
|
15
|
+
/** Query string parts (`name=value`, encoded) for one query parameter. */
|
|
16
|
+
export declare function serializeQueryParam(p: ParamDescriptor, value: unknown): string[];
|
|
17
|
+
/** Header value for one header parameter (`simple` style, not encoded). */
|
|
18
|
+
export declare function serializeHeaderParam(p: ParamDescriptor, value: unknown): string;
|
|
19
|
+
/** `name=value` for one cookie parameter (`form` style, values encoded). */
|
|
20
|
+
export declare function serializeCookieParam(p: ParamDescriptor, value: unknown): string;
|
|
21
|
+
/** An encoded request body. */
|
|
22
|
+
export interface EncodedBody {
|
|
23
|
+
/** What fetch sends; null for no body. */
|
|
24
|
+
body: BodyInit | null;
|
|
25
|
+
/** Content-Type to set, or null (multipart: fetch sets the boundary). */
|
|
26
|
+
contentType: string | null;
|
|
27
|
+
/** JSON-friendly rendering for previews (binary summarised), redacted. */
|
|
28
|
+
display: unknown;
|
|
29
|
+
/** The same rendering before redaction (never shown). */
|
|
30
|
+
raw: unknown;
|
|
31
|
+
/** Bytes or text whose SHA-256 is the `content_hash` key. */
|
|
32
|
+
hashMaterial: string | Uint8Array | null;
|
|
33
|
+
}
|
|
34
|
+
/** The body value before encoding: merged fields picked from the args, or
|
|
35
|
+
* the whole `args[arg]`; wrapped in the rpc envelope when the operation
|
|
36
|
+
* has one. `undefined` means no body. */
|
|
37
|
+
export declare function bodyValue(op: OperationDescriptor, args: Record<string, unknown>, paramNames: ReadonlySet<string>): unknown;
|
|
38
|
+
/** Encode `value` per the body descriptor. Throws `SerializationError` for
|
|
39
|
+
* values the encoding cannot carry. */
|
|
40
|
+
export declare function encodeBody(descriptor: BodyDescriptor | null, value: unknown, redactDisplay: (display: unknown) => unknown): Promise<EncodedBody>;
|
|
41
|
+
export declare function validHeaderName(name: string): boolean;
|
|
42
|
+
export declare function validHeaderValue(value: string): boolean;
|
|
43
|
+
/** Case-insensitive header collection that remembers which values are
|
|
44
|
+
* secrets, so previews and middleware see them redacted. */
|
|
45
|
+
export declare class HeaderBag {
|
|
46
|
+
#private;
|
|
47
|
+
set(name: string, value: string, secret?: boolean): void;
|
|
48
|
+
get(name: string): string | undefined;
|
|
49
|
+
has(name: string): boolean;
|
|
50
|
+
isSecret(name: string): boolean;
|
|
51
|
+
/** Plain record; secret values replaced by `<redacted>` when `redact`. */
|
|
52
|
+
toRecord(redact: boolean): Record<string, string>;
|
|
53
|
+
/** Secret values, for scrubbing diagnostics. */
|
|
54
|
+
secrets(): string[];
|
|
55
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { binarySize, canonicalJson, isBinary, isBlob, isNamedBinary, isRecord, REDACTED, utf8 } from "./util.js";
|
|
2
|
+
/** Problem found while serializing an argument; becomes VALIDATION_FAILED. */
|
|
3
|
+
export class SerializationError extends Error {
|
|
4
|
+
parameter;
|
|
5
|
+
expected;
|
|
6
|
+
value;
|
|
7
|
+
constructor(parameter, expected, value) {
|
|
8
|
+
super(`${parameter}: expected ${expected}`);
|
|
9
|
+
this.parameter = parameter;
|
|
10
|
+
this.expected = expected;
|
|
11
|
+
this.value = value;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function scalar(value) {
|
|
15
|
+
if (typeof value === "string")
|
|
16
|
+
return value;
|
|
17
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint")
|
|
18
|
+
return String(value);
|
|
19
|
+
if (value instanceof Date)
|
|
20
|
+
return value.toISOString();
|
|
21
|
+
return JSON.stringify(value) ?? "";
|
|
22
|
+
}
|
|
23
|
+
function enc(text) {
|
|
24
|
+
return encodeURIComponent(text);
|
|
25
|
+
}
|
|
26
|
+
function entries(value) {
|
|
27
|
+
return Object.entries(value).filter(([, v]) => v !== undefined && v !== null);
|
|
28
|
+
}
|
|
29
|
+
function present(value) {
|
|
30
|
+
return value !== undefined && value !== null;
|
|
31
|
+
}
|
|
32
|
+
/** One path parameter value, already percent-encoded (`simple`, `label`, `matrix`). */
|
|
33
|
+
export function serializePathParam(p, value) {
|
|
34
|
+
const name = enc(p.wire);
|
|
35
|
+
const style = p.style === "label" || p.style === "matrix" ? p.style : "simple";
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
const items = value.filter(present).map((v) => enc(scalar(v)));
|
|
38
|
+
if (style === "label")
|
|
39
|
+
return `.${items.join(p.explode ? "." : ",")}`;
|
|
40
|
+
if (style === "matrix")
|
|
41
|
+
return p.explode ? items.map((i) => `;${name}=${i}`).join("") : `;${name}=${items.join(",")}`;
|
|
42
|
+
return items.join(",");
|
|
43
|
+
}
|
|
44
|
+
if (isRecord(value) && !(value instanceof Date)) {
|
|
45
|
+
const pairs = entries(value).map(([k, v]) => [enc(k), enc(scalar(v))]);
|
|
46
|
+
const exploded = pairs.map(([k, v]) => `${k}=${v}`);
|
|
47
|
+
const flat = pairs.flat();
|
|
48
|
+
if (style === "label")
|
|
49
|
+
return p.explode ? `.${exploded.join(".")}` : `.${flat.join(",")}`;
|
|
50
|
+
if (style === "matrix")
|
|
51
|
+
return p.explode ? exploded.map((e) => `;${e}`).join("") : `;${name}=${flat.join(",")}`;
|
|
52
|
+
return p.explode ? exploded.join(",") : flat.join(",");
|
|
53
|
+
}
|
|
54
|
+
const v = enc(scalar(value));
|
|
55
|
+
if (style === "label")
|
|
56
|
+
return `.${v}`;
|
|
57
|
+
if (style === "matrix")
|
|
58
|
+
return `;${name}=${v}`;
|
|
59
|
+
return v;
|
|
60
|
+
}
|
|
61
|
+
function deepObjectPairs(prefix, value, out, depth) {
|
|
62
|
+
if (depth > 16 || !present(value))
|
|
63
|
+
return;
|
|
64
|
+
if (Array.isArray(value)) {
|
|
65
|
+
for (const item of value)
|
|
66
|
+
deepObjectPairs(prefix, item, out, depth + 1);
|
|
67
|
+
}
|
|
68
|
+
else if (isRecord(value) && !(value instanceof Date)) {
|
|
69
|
+
for (const [k, v] of entries(value))
|
|
70
|
+
deepObjectPairs(`${prefix}[${enc(k)}]`, v, out, depth + 1);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
out.push(`${prefix}=${enc(scalar(value))}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Query string parts (`name=value`, encoded) for one query parameter. */
|
|
77
|
+
export function serializeQueryParam(p, value) {
|
|
78
|
+
if (!present(value))
|
|
79
|
+
return [];
|
|
80
|
+
const name = enc(p.wire);
|
|
81
|
+
const delimiter = p.style === "space_delimited" ? "%20" : p.style === "pipe_delimited" ? "|" : ",";
|
|
82
|
+
if (p.style === "deep_object" && isRecord(value) && !(value instanceof Date)) {
|
|
83
|
+
const out = [];
|
|
84
|
+
deepObjectPairs(name, value, out, 0);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
const items = value.filter(present).map((v) => enc(scalar(v)));
|
|
89
|
+
if (items.length === 0)
|
|
90
|
+
return [];
|
|
91
|
+
if (p.explode || p.style === "deep_object")
|
|
92
|
+
return items.map((i) => `${name}=${i}`);
|
|
93
|
+
return [`${name}=${items.join(delimiter)}`];
|
|
94
|
+
}
|
|
95
|
+
if (isRecord(value) && !(value instanceof Date)) {
|
|
96
|
+
const pairs = entries(value).map(([k, v]) => [enc(k), enc(scalar(v))]);
|
|
97
|
+
if (p.explode)
|
|
98
|
+
return pairs.map(([k, v]) => `${k}=${v}`);
|
|
99
|
+
return [`${name}=${pairs.flat().join(delimiter)}`];
|
|
100
|
+
}
|
|
101
|
+
return [`${name}=${enc(scalar(value))}`];
|
|
102
|
+
}
|
|
103
|
+
/** Header value for one header parameter (`simple` style, not encoded). */
|
|
104
|
+
export function serializeHeaderParam(p, value) {
|
|
105
|
+
if (Array.isArray(value))
|
|
106
|
+
return value.filter(present).map(scalar).join(",");
|
|
107
|
+
if (isRecord(value) && !(value instanceof Date)) {
|
|
108
|
+
const pairs = entries(value).map(([k, v]) => [k, scalar(v)]);
|
|
109
|
+
return p.explode ? pairs.map(([k, v]) => `${k}=${v}`).join(",") : pairs.flat().join(",");
|
|
110
|
+
}
|
|
111
|
+
return scalar(value);
|
|
112
|
+
}
|
|
113
|
+
/** `name=value` for one cookie parameter (`form` style, values encoded). */
|
|
114
|
+
export function serializeCookieParam(p, value) {
|
|
115
|
+
if (Array.isArray(value))
|
|
116
|
+
return `${p.wire}=${value.filter(present).map((v) => enc(scalar(v))).join(",")}`;
|
|
117
|
+
if (isRecord(value) && !(value instanceof Date)) {
|
|
118
|
+
return `${p.wire}=${entries(value)
|
|
119
|
+
.flatMap(([k, v]) => [enc(k), enc(scalar(v))])
|
|
120
|
+
.join(",")}`;
|
|
121
|
+
}
|
|
122
|
+
return `${p.wire}=${enc(scalar(value))}`;
|
|
123
|
+
}
|
|
124
|
+
const NO_BODY = { body: null, contentType: null, display: null, raw: null, hashMaterial: null };
|
|
125
|
+
/** The body value before encoding: merged fields picked from the args, or
|
|
126
|
+
* the whole `args[arg]`; wrapped in the rpc envelope when the operation
|
|
127
|
+
* has one. `undefined` means no body. */
|
|
128
|
+
export function bodyValue(op, args, paramNames) {
|
|
129
|
+
let value;
|
|
130
|
+
const body = op.body;
|
|
131
|
+
if (body) {
|
|
132
|
+
if (body.shape.kind === "merged") {
|
|
133
|
+
const picked = {};
|
|
134
|
+
for (const field of body.shape.fields) {
|
|
135
|
+
if (args[field] !== undefined)
|
|
136
|
+
picked[field] = args[field];
|
|
137
|
+
}
|
|
138
|
+
value = Object.keys(picked).length > 0 || body.required || op.rpc ? picked : undefined;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
value = args[body.shape.arg];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (op.rpc) {
|
|
145
|
+
let params = value;
|
|
146
|
+
if (!body) {
|
|
147
|
+
const rest = {};
|
|
148
|
+
for (const [k, v] of Object.entries(args)) {
|
|
149
|
+
if (!paramNames.has(k) && v !== undefined)
|
|
150
|
+
rest[k] = v;
|
|
151
|
+
}
|
|
152
|
+
params = rest;
|
|
153
|
+
}
|
|
154
|
+
const envelope = { ...(op.rpc.constants ?? {}) };
|
|
155
|
+
envelope[op.rpc.field] = op.rpc.value;
|
|
156
|
+
envelope[op.rpc.paramsField] = params ?? {};
|
|
157
|
+
return envelope;
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
function formPairs(value, parameter) {
|
|
162
|
+
if (!isRecord(value))
|
|
163
|
+
throw new SerializationError(parameter, "an object of form fields", value);
|
|
164
|
+
const out = [];
|
|
165
|
+
for (const [k, v] of entries(value)) {
|
|
166
|
+
if (Array.isArray(v)) {
|
|
167
|
+
for (const item of v)
|
|
168
|
+
if (present(item))
|
|
169
|
+
out.push([k, scalar(item)]);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
out.push([k, scalar(v)]);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
function toBlob(value, type) {
|
|
178
|
+
if (isBlob(value))
|
|
179
|
+
return type !== "" && value.type !== type ? value.slice(0, value.size, type) : value;
|
|
180
|
+
const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value;
|
|
181
|
+
const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength));
|
|
182
|
+
copy.set(bytes);
|
|
183
|
+
return new Blob([copy], type ? { type } : {});
|
|
184
|
+
}
|
|
185
|
+
function binaryDisplay(value, mediaType) {
|
|
186
|
+
return `<${binarySize(value)} bytes${mediaType ? ` of ${mediaType}` : ""}>`;
|
|
187
|
+
}
|
|
188
|
+
/** Encode `value` per the body descriptor. Throws `SerializationError` for
|
|
189
|
+
* values the encoding cannot carry. */
|
|
190
|
+
export async function encodeBody(descriptor, value, redactDisplay) {
|
|
191
|
+
if (value === undefined)
|
|
192
|
+
return NO_BODY;
|
|
193
|
+
const encoding = descriptor?.encoding ?? "json";
|
|
194
|
+
const mediaType = descriptor?.mediaType ?? "application/json";
|
|
195
|
+
const parameter = "body";
|
|
196
|
+
switch (encoding) {
|
|
197
|
+
case "json": {
|
|
198
|
+
let text;
|
|
199
|
+
try {
|
|
200
|
+
text = JSON.stringify(value);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
throw new SerializationError(parameter, "a JSON-serializable value", value);
|
|
204
|
+
}
|
|
205
|
+
if (text === undefined)
|
|
206
|
+
throw new SerializationError(parameter, "a JSON-serializable value", value);
|
|
207
|
+
const raw = JSON.parse(text);
|
|
208
|
+
return {
|
|
209
|
+
body: text,
|
|
210
|
+
contentType: mediaType,
|
|
211
|
+
display: redactDisplay(raw),
|
|
212
|
+
raw,
|
|
213
|
+
hashMaterial: canonicalJson(JSON.parse(text)),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
case "form": {
|
|
217
|
+
const params = new URLSearchParams(formPairs(value, parameter));
|
|
218
|
+
const text = params.toString();
|
|
219
|
+
const raw = Object.fromEntries(params.entries());
|
|
220
|
+
return {
|
|
221
|
+
body: text,
|
|
222
|
+
contentType: mediaType,
|
|
223
|
+
display: redactDisplay(raw),
|
|
224
|
+
raw,
|
|
225
|
+
hashMaterial: text,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
case "multipart": {
|
|
229
|
+
if (!isRecord(value))
|
|
230
|
+
throw new SerializationError(parameter, "an object of multipart fields", value);
|
|
231
|
+
const form = new FormData();
|
|
232
|
+
const display = {};
|
|
233
|
+
for (const [k, v] of entries(value)) {
|
|
234
|
+
const items = Array.isArray(v) ? v.filter(present) : [v];
|
|
235
|
+
const shown = [];
|
|
236
|
+
for (const item of items) {
|
|
237
|
+
if (isBinary(item) || isNamedBinary(item)) {
|
|
238
|
+
const { data, filename, contentType } = isNamedBinary(item) ? item : { data: item, filename: undefined, contentType: undefined };
|
|
239
|
+
const blob = toBlob(data, contentType ?? "");
|
|
240
|
+
const name = filename ?? (typeof File !== "undefined" && data instanceof File ? data.name : k);
|
|
241
|
+
form.append(k, blob, name);
|
|
242
|
+
shown.push(binaryDisplay(data, blob.type));
|
|
243
|
+
}
|
|
244
|
+
else if (isRecord(item) && !(item instanceof Date)) {
|
|
245
|
+
form.append(k, new Blob([JSON.stringify(item)], { type: "application/json" }));
|
|
246
|
+
shown.push(item);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
form.append(k, scalar(item));
|
|
250
|
+
shown.push(scalar(item));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
display[k] = Array.isArray(v) ? shown : shown[0];
|
|
254
|
+
}
|
|
255
|
+
return { body: form, contentType: null, display: redactDisplay(display), raw: display, hashMaterial: canonicalJson(value) };
|
|
256
|
+
}
|
|
257
|
+
case "bytes": {
|
|
258
|
+
let bytes;
|
|
259
|
+
if (value instanceof Uint8Array)
|
|
260
|
+
bytes = value;
|
|
261
|
+
else if (value instanceof ArrayBuffer)
|
|
262
|
+
bytes = new Uint8Array(value);
|
|
263
|
+
else if (isBlob(value))
|
|
264
|
+
bytes = value;
|
|
265
|
+
else if (typeof value === "string")
|
|
266
|
+
bytes = utf8(value);
|
|
267
|
+
else
|
|
268
|
+
throw new SerializationError(parameter, "binary data (Uint8Array, ArrayBuffer or Blob)", value);
|
|
269
|
+
const material = isBlob(bytes) ? new Uint8Array(await bytes.arrayBuffer()) : bytes;
|
|
270
|
+
return {
|
|
271
|
+
body: toBlob(material, mediaType),
|
|
272
|
+
contentType: mediaType,
|
|
273
|
+
display: binaryDisplay(material, mediaType),
|
|
274
|
+
raw: binaryDisplay(material, mediaType),
|
|
275
|
+
hashMaterial: material,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
case "text": {
|
|
279
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
280
|
+
throw new SerializationError(parameter, "a string", value);
|
|
281
|
+
}
|
|
282
|
+
const text = String(value);
|
|
283
|
+
return { body: text, contentType: mediaType, display: text, raw: text, hashMaterial: text };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** Header names and values that `fetch` accepts, checked before sending. */
|
|
288
|
+
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
289
|
+
const HEADER_VALUE = /^[\t\x20-\x7e\x80-\xff]*$/;
|
|
290
|
+
export function validHeaderName(name) {
|
|
291
|
+
return TOKEN.test(name);
|
|
292
|
+
}
|
|
293
|
+
export function validHeaderValue(value) {
|
|
294
|
+
return HEADER_VALUE.test(value);
|
|
295
|
+
}
|
|
296
|
+
/** Case-insensitive header collection that remembers which values are
|
|
297
|
+
* secrets, so previews and middleware see them redacted. */
|
|
298
|
+
export class HeaderBag {
|
|
299
|
+
#entries = new Map();
|
|
300
|
+
set(name, value, secret = false) {
|
|
301
|
+
this.#entries.set(name.toLowerCase(), { name, value, secret });
|
|
302
|
+
}
|
|
303
|
+
get(name) {
|
|
304
|
+
return this.#entries.get(name.toLowerCase())?.value;
|
|
305
|
+
}
|
|
306
|
+
has(name) {
|
|
307
|
+
return this.#entries.has(name.toLowerCase());
|
|
308
|
+
}
|
|
309
|
+
isSecret(name) {
|
|
310
|
+
return this.#entries.get(name.toLowerCase())?.secret ?? false;
|
|
311
|
+
}
|
|
312
|
+
/** Plain record; secret values replaced by `<redacted>` when `redact`. */
|
|
313
|
+
toRecord(redact) {
|
|
314
|
+
const out = {};
|
|
315
|
+
for (const { name, value, secret } of this.#entries.values())
|
|
316
|
+
out[name] = redact && secret ? REDACTED : value;
|
|
317
|
+
return out;
|
|
318
|
+
}
|
|
319
|
+
/** Secret values, for scrubbing diagnostics. */
|
|
320
|
+
secrets() {
|
|
321
|
+
return [...this.#entries.values()].filter((e) => e.secret).map((e) => e.value);
|
|
322
|
+
}
|
|
323
|
+
}
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A server-sent events parser (WHATWG HTML 9.2 "Server-sent events"):
|
|
3
|
+
* lines end with CRLF, LF or CR; a line is a comment (`:`), a field
|
|
4
|
+
* (`event`, `data`, `id`, `retry`; the value loses one leading space) or
|
|
5
|
+
* the blank line that dispatches the event. Several `data` lines are
|
|
6
|
+
* joined with LF; an event without `data` lines is not dispatched; an
|
|
7
|
+
* event still open when the stream ends is discarded.
|
|
8
|
+
*
|
|
9
|
+
* An event may hold at most `maxEventBytes` (default {@link DEFAULT_MAX_EVENT_BYTES},
|
|
10
|
+
* 1 MiB): the UTF-8 bytes of its field lines (each counted with a one-byte
|
|
11
|
+
* terminator; a finished comment line is not kept and does not count) plus
|
|
12
|
+
* the line being read, whatever it turns out to be, so a comment longer than
|
|
13
|
+
* the limit with no line end also stops the parser. A parser that sees more stops: {@link SseParser.exceeded}
|
|
14
|
+
* becomes true, the events completed before the oversize one are still
|
|
15
|
+
* returned, and later input is ignored, so nothing is buffered without bound.
|
|
16
|
+
*/
|
|
17
|
+
/** The default size limit of one event, in UTF-8 bytes. */
|
|
18
|
+
export declare const DEFAULT_MAX_EVENT_BYTES: number;
|
|
19
|
+
/** The number of bytes of `text` in UTF-8. */
|
|
20
|
+
export declare function utf8Length(text: string): number;
|
|
21
|
+
export interface SseEvent {
|
|
22
|
+
/** The `event` field, `message` when the event has none. */
|
|
23
|
+
event: string;
|
|
24
|
+
data: string;
|
|
25
|
+
/** The last event id seen so far (it survives across events), or null. */
|
|
26
|
+
id: string | null;
|
|
27
|
+
/** The last valid `retry` value seen so far, in milliseconds, or null. */
|
|
28
|
+
retry: number | null;
|
|
29
|
+
}
|
|
30
|
+
export declare class SseParser {
|
|
31
|
+
#private;
|
|
32
|
+
constructor(maxEventBytes?: number);
|
|
33
|
+
/** The event being read is larger than the limit: the parser has stopped. */
|
|
34
|
+
get exceeded(): boolean;
|
|
35
|
+
/** Feed decoded text; returns the events it completes. A CR at the end
|
|
36
|
+
* of the text is held back until the next text shows whether an LF
|
|
37
|
+
* follows. */
|
|
38
|
+
push(chunk: string): SseEvent[];
|
|
39
|
+
/** The stream ended: a CR held back ends its line. Whatever is still
|
|
40
|
+
* open is discarded. */
|
|
41
|
+
end(): SseEvent[];
|
|
42
|
+
}
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* A server-sent events parser (WHATWG HTML 9.2 "Server-sent events"):
|
|
4
|
+
* lines end with CRLF, LF or CR; a line is a comment (`:`), a field
|
|
5
|
+
* (`event`, `data`, `id`, `retry`; the value loses one leading space) or
|
|
6
|
+
* the blank line that dispatches the event. Several `data` lines are
|
|
7
|
+
* joined with LF; an event without `data` lines is not dispatched; an
|
|
8
|
+
* event still open when the stream ends is discarded.
|
|
9
|
+
*
|
|
10
|
+
* An event may hold at most `maxEventBytes` (default {@link DEFAULT_MAX_EVENT_BYTES},
|
|
11
|
+
* 1 MiB): the UTF-8 bytes of its field lines (each counted with a one-byte
|
|
12
|
+
* terminator; a finished comment line is not kept and does not count) plus
|
|
13
|
+
* the line being read, whatever it turns out to be, so a comment longer than
|
|
14
|
+
* the limit with no line end also stops the parser. A parser that sees more stops: {@link SseParser.exceeded}
|
|
15
|
+
* becomes true, the events completed before the oversize one are still
|
|
16
|
+
* returned, and later input is ignored, so nothing is buffered without bound.
|
|
17
|
+
*/
|
|
18
|
+
/** The default size limit of one event, in UTF-8 bytes. */
|
|
19
|
+
export const DEFAULT_MAX_EVENT_BYTES = 1024 * 1024;
|
|
20
|
+
/** The number of bytes of `text` in UTF-8. */
|
|
21
|
+
export function utf8Length(text) {
|
|
22
|
+
let bytes = 0;
|
|
23
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
24
|
+
const c = text.charCodeAt(i);
|
|
25
|
+
if (c < 0x80)
|
|
26
|
+
bytes += 1;
|
|
27
|
+
else if (c < 0x800)
|
|
28
|
+
bytes += 2;
|
|
29
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
|
|
30
|
+
bytes += 4;
|
|
31
|
+
i += 1;
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
bytes += 3;
|
|
35
|
+
}
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
38
|
+
export class SseParser {
|
|
39
|
+
#buffer = "";
|
|
40
|
+
#started = false;
|
|
41
|
+
#event = "";
|
|
42
|
+
#data = "";
|
|
43
|
+
#hasData = false;
|
|
44
|
+
#id = null;
|
|
45
|
+
#retry = null;
|
|
46
|
+
#eventBytes = 0;
|
|
47
|
+
#bufferBytes = 0;
|
|
48
|
+
#exceeded = false;
|
|
49
|
+
#max;
|
|
50
|
+
constructor(maxEventBytes = DEFAULT_MAX_EVENT_BYTES) {
|
|
51
|
+
this.#max = maxEventBytes;
|
|
52
|
+
}
|
|
53
|
+
/** The event being read is larger than the limit: the parser has stopped. */
|
|
54
|
+
get exceeded() {
|
|
55
|
+
return this.#exceeded;
|
|
56
|
+
}
|
|
57
|
+
/** Feed decoded text; returns the events it completes. A CR at the end
|
|
58
|
+
* of the text is held back until the next text shows whether an LF
|
|
59
|
+
* follows. */
|
|
60
|
+
push(chunk) {
|
|
61
|
+
if (this.#exceeded)
|
|
62
|
+
return [];
|
|
63
|
+
let text = this.#buffer + chunk;
|
|
64
|
+
this.#buffer = "";
|
|
65
|
+
let chunkBytes = utf8Length(chunk);
|
|
66
|
+
if (!this.#started && text.length > 0) {
|
|
67
|
+
this.#started = true;
|
|
68
|
+
if (text.charCodeAt(0) === 0xfeff) {
|
|
69
|
+
text = text.slice(1);
|
|
70
|
+
chunkBytes -= 3;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const out = [];
|
|
74
|
+
let start = 0;
|
|
75
|
+
let completed = false;
|
|
76
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
77
|
+
const c = text.charCodeAt(i);
|
|
78
|
+
if (c !== 10 && c !== 13)
|
|
79
|
+
continue;
|
|
80
|
+
if (c === 13 && i + 1 === text.length) {
|
|
81
|
+
this.#hold(text, start, completed, chunkBytes);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
const line = text.slice(start, i);
|
|
85
|
+
if (c === 13 && text.charCodeAt(i + 1) === 10)
|
|
86
|
+
i += 1;
|
|
87
|
+
start = i + 1;
|
|
88
|
+
completed = true;
|
|
89
|
+
this.#line(line, out);
|
|
90
|
+
if (this.#exceeded)
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
this.#hold(text, start, completed, chunkBytes);
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
/** Keep the unfinished line `text[start..]`; its bytes are counted from
|
|
97
|
+
* the chunk alone unless a line ended in this push. Too much ends the parser. */
|
|
98
|
+
#hold(text, start, completed, chunkBytes) {
|
|
99
|
+
this.#buffer = text.slice(start);
|
|
100
|
+
this.#bufferBytes = completed ? utf8Length(this.#buffer) : this.#bufferBytes + chunkBytes;
|
|
101
|
+
if (this.#eventBytes + this.#bufferBytes > this.#max) {
|
|
102
|
+
this.#exceeded = true;
|
|
103
|
+
this.#buffer = "";
|
|
104
|
+
this.#bufferBytes = 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** The stream ended: a CR held back ends its line. Whatever is still
|
|
108
|
+
* open is discarded. */
|
|
109
|
+
end() {
|
|
110
|
+
const out = [];
|
|
111
|
+
if (!this.#exceeded && this.#buffer.endsWith("\r"))
|
|
112
|
+
this.#line(this.#buffer.slice(0, -1), out);
|
|
113
|
+
this.#buffer = "";
|
|
114
|
+
this.#bufferBytes = 0;
|
|
115
|
+
this.#event = "";
|
|
116
|
+
this.#data = "";
|
|
117
|
+
this.#hasData = false;
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
#line(line, out) {
|
|
121
|
+
if (line === "") {
|
|
122
|
+
if (this.#hasData) {
|
|
123
|
+
out.push({ event: this.#event === "" ? "message" : this.#event, data: this.#data.slice(0, -1), id: this.#id, retry: this.#retry });
|
|
124
|
+
}
|
|
125
|
+
this.#event = "";
|
|
126
|
+
this.#data = "";
|
|
127
|
+
this.#hasData = false;
|
|
128
|
+
this.#eventBytes = 0;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (line.startsWith(":"))
|
|
132
|
+
return;
|
|
133
|
+
this.#eventBytes += utf8Length(line) + 1;
|
|
134
|
+
if (this.#eventBytes > this.#max) {
|
|
135
|
+
this.#exceeded = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const colon = line.indexOf(":");
|
|
139
|
+
const field = colon < 0 ? line : line.slice(0, colon);
|
|
140
|
+
let value = colon < 0 ? "" : line.slice(colon + 1);
|
|
141
|
+
if (value.startsWith(" "))
|
|
142
|
+
value = value.slice(1);
|
|
143
|
+
switch (field) {
|
|
144
|
+
case "event":
|
|
145
|
+
this.#event = value;
|
|
146
|
+
break;
|
|
147
|
+
case "data":
|
|
148
|
+
this.#data += `${value}\n`;
|
|
149
|
+
this.#hasData = true;
|
|
150
|
+
break;
|
|
151
|
+
case "id":
|
|
152
|
+
if (!value.includes("\0"))
|
|
153
|
+
this.#id = value;
|
|
154
|
+
break;
|
|
155
|
+
case "retry":
|
|
156
|
+
if (/^[0-9]+$/.test(value)) {
|
|
157
|
+
const ms = Number(value);
|
|
158
|
+
if (Number.isSafeInteger(ms))
|
|
159
|
+
this.#retry = ms;
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `EventStream`: what `ClientCore.stream` returns. It is the async iterable
|
|
3
|
+
* of {@link StreamItem}s it always was, plus helpers that consume it:
|
|
4
|
+
* {@link EventStream.on} (dispatch by event name), {@link EventStream.collect},
|
|
5
|
+
* {@link EventStream.reduce}, {@link EventStream.first} and
|
|
6
|
+
* {@link EventStream.cancel}. A stream is read once; every helper closes the
|
|
7
|
+
* connection when it returns.
|
|
8
|
+
*/
|
|
9
|
+
import type { Diagnostic, StreamItem } from "./types.js";
|
|
10
|
+
/** One delivered event. */
|
|
11
|
+
export interface StreamEvent<T> {
|
|
12
|
+
value: T;
|
|
13
|
+
/** The `event` field, `message` when the server named none. */
|
|
14
|
+
event: string;
|
|
15
|
+
/** The last event id the stream has set so far, or null. */
|
|
16
|
+
id: string | null;
|
|
17
|
+
/** The last `retry` value (milliseconds) the stream has set so far, or null. */
|
|
18
|
+
retry: number | null;
|
|
19
|
+
}
|
|
20
|
+
/** What a helper resolves to: the result and the number of reconnects the
|
|
21
|
+
* stream made, or the failure that ended the stream with what was gathered
|
|
22
|
+
* before it as `partial`. */
|
|
23
|
+
export type StreamResult<R, P = R> = {
|
|
24
|
+
ok: true;
|
|
25
|
+
value: R;
|
|
26
|
+
reconnects: number;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: Diagnostic;
|
|
30
|
+
partial: P;
|
|
31
|
+
reconnects: number;
|
|
32
|
+
};
|
|
33
|
+
/** A handler of {@link EventStream.on}: receives the decoded `data` and the event. */
|
|
34
|
+
export type EventHandler<T> = (value: T, event: StreamEvent<T>) => void | Promise<void>;
|
|
35
|
+
/** The key of {@link EventStream.on} handlers that receives every event no
|
|
36
|
+
* other handler took. */
|
|
37
|
+
export declare const ANY_EVENT = "*";
|
|
38
|
+
export declare class EventStream<T> implements AsyncIterable<StreamItem<T>> {
|
|
39
|
+
#private;
|
|
40
|
+
/** @param source the items of the stream
|
|
41
|
+
* @param abort aborts the request the source reads (closes the connection) */
|
|
42
|
+
constructor(source: AsyncGenerator<StreamItem<T>>, abort: () => void);
|
|
43
|
+
/** Whether {@link cancel} was called. */
|
|
44
|
+
get cancelled(): boolean;
|
|
45
|
+
/** Close the connection and end the stream: a pending or later read ends
|
|
46
|
+
* without a failure item. Safe to call more than once. */
|
|
47
|
+
cancel(): void;
|
|
48
|
+
[Symbol.asyncIterator](): AsyncGenerator<StreamItem<T>>;
|
|
49
|
+
/** Read the stream to its end and call the handler named like each event
|
|
50
|
+
* (`handlers.message_delta` for `event: message_delta`; events without a
|
|
51
|
+
* name are `message`), else `handlers["*"]`. A handler that throws or
|
|
52
|
+
* rejects ends the stream (the connection is closed) and the error
|
|
53
|
+
* propagates. Resolves to the number of events delivered. */
|
|
54
|
+
on(handlers: Record<string, EventHandler<T> | undefined>): Promise<StreamResult<number, number>>;
|
|
55
|
+
/** Read the stream to its end and fold the events into one value
|
|
56
|
+
* (concatenating text deltas, say). `partial` of a failure is the value
|
|
57
|
+
* folded so far. */
|
|
58
|
+
reduce<A>(fold: (accumulator: A, value: T, event: StreamEvent<T>) => A, initial: A): Promise<StreamResult<A>>;
|
|
59
|
+
/** Read the stream to its end into a list of the events' `data`. */
|
|
60
|
+
collect(): Promise<StreamResult<T[]>>;
|
|
61
|
+
/** The first event for which `predicate` holds (any event without one),
|
|
62
|
+
* then close the stream; `value` is null when the stream ends without
|
|
63
|
+
* one. */
|
|
64
|
+
first(predicate?: (value: T, event: StreamEvent<T>) => boolean): Promise<StreamResult<StreamEvent<T> | null, null>>;
|
|
65
|
+
}
|