@suveren/gateway 0.2.2 → 0.2.4
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/control-plane/index.mjs +2 -2
- package/dist/mcp-server/http.mjs +48 -17
- package/dist/ui/assets/{index-C0DMnnek.js → index-CIhRJ6MD.js} +23 -22
- package/dist/ui/index.html +1 -1
- package/dist/ui/mockups/intent-redesign.html +1 -1
- package/node_modules/@hap/core/dist/index.d.mts +36 -1
- package/node_modules/@hap/core/dist/index.d.ts +36 -1
- package/node_modules/@hap/core/dist/index.js +28 -1
- package/node_modules/@hap/core/dist/index.mjs +27 -1
- package/node_modules/@hap/core/package.json +12 -11
- package/node_modules/@hap/core/src/attestation.ts +4 -1
- package/node_modules/@hap/core/src/canonicalize.ts +65 -0
- package/node_modules/@hap/core/src/index.ts +1 -0
- package/node_modules/eventsource-parser/README.md +31 -0
- package/node_modules/eventsource-parser/dist/index.cjs +21 -10
- package/node_modules/eventsource-parser/dist/index.cjs.map +1 -1
- package/node_modules/eventsource-parser/dist/index.d.cts +33 -10
- package/node_modules/eventsource-parser/dist/index.d.ts +33 -10
- package/node_modules/eventsource-parser/dist/index.js +21 -10
- package/node_modules/eventsource-parser/dist/index.js.map +1 -1
- package/node_modules/eventsource-parser/dist/stream.cjs +4 -3
- package/node_modules/eventsource-parser/dist/stream.cjs.map +1 -1
- package/node_modules/eventsource-parser/dist/stream.d.cts +16 -3
- package/node_modules/eventsource-parser/dist/stream.d.ts +16 -3
- package/node_modules/eventsource-parser/dist/stream.js +4 -3
- package/node_modules/eventsource-parser/dist/stream.js.map +1 -1
- package/node_modules/eventsource-parser/package.json +8 -8
- package/node_modules/eventsource-parser/src/errors.ts +1 -1
- package/node_modules/eventsource-parser/src/index.ts +6 -1
- package/node_modules/eventsource-parser/src/parse.ts +55 -13
- package/node_modules/eventsource-parser/src/stream.ts +24 -5
- package/node_modules/eventsource-parser/src/types.ts +25 -0
- package/node_modules/hasown/CHANGELOG.md +7 -0
- package/node_modules/hasown/index.d.ts +0 -1
- package/node_modules/hasown/package.json +4 -5
- package/package.json +2 -2
package/dist/ui/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Suveren</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-CIhRJ6MD.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-B8OeLWqp.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>
|
|
6
|
+
<title>Suveren — Intent gate redesign mockup</title>
|
|
7
7
|
<style>
|
|
8
8
|
/* Gateway design tokens — dark theme, copied from design-system.css. */
|
|
9
9
|
:root[data-theme="dark"] {
|
|
@@ -401,6 +401,41 @@ type GatekeeperResult = {
|
|
|
401
401
|
errors: GatekeeperError[];
|
|
402
402
|
};
|
|
403
403
|
|
|
404
|
+
/**
|
|
405
|
+
* JCS — JSON Canonicalization (RFC 8785) for signing.
|
|
406
|
+
*
|
|
407
|
+
* Both attestation and receipt signatures are computed over a canonical byte
|
|
408
|
+
* serialization of the payload, so that two independent implementations (an AS,
|
|
409
|
+
* a gateway, an external verifier — in any language) agree on exactly which
|
|
410
|
+
* bytes were signed regardless of how they happened to construct the object.
|
|
411
|
+
*
|
|
412
|
+
* `JSON.stringify` is NOT sufficient: it preserves key *insertion* order, so the
|
|
413
|
+
* same logical payload built with keys in a different order serializes to
|
|
414
|
+
* different bytes and the signature fails to verify. Canonicalization removes
|
|
415
|
+
* that dependency by sorting keys.
|
|
416
|
+
*
|
|
417
|
+
* This is RFC 8785-compatible and matches the v0.5 spec's normative signing
|
|
418
|
+
* canonicalization (core.md "Signing Canonicalization"):
|
|
419
|
+
* 1. UTF-8.
|
|
420
|
+
* 2. Object keys sorted (RFC 8785 sorts by UTF-16 code units — exactly what
|
|
421
|
+
* JavaScript's default String comparison does, so `Object.keys().sort()` is
|
|
422
|
+
* correct here).
|
|
423
|
+
* 3. No insignificant whitespace.
|
|
424
|
+
* 4. Numbers in the shortest round-trippable form — the ECMAScript
|
|
425
|
+
* Number-to-String algorithm, which `JSON.stringify(number)` produces and
|
|
426
|
+
* RFC 8785 adopts verbatim.
|
|
427
|
+
* 5. Strings escaped per RFC 8259; non-ASCII passed through as UTF-8 (this is
|
|
428
|
+
* `JSON.stringify`'s behaviour for strings).
|
|
429
|
+
* 6. Array order preserved.
|
|
430
|
+
*
|
|
431
|
+
* Because it relies only on `JSON.stringify` for leaves plus `Object.keys`,
|
|
432
|
+
* `Array`, and `String` sort — all of which are environment-independent in
|
|
433
|
+
* JavaScript — Node and the browser produce byte-identical output. The
|
|
434
|
+
* conformance test pins a (payload → canonical bytes) vector that any other
|
|
435
|
+
* implementation can check against.
|
|
436
|
+
*/
|
|
437
|
+
declare function canonicalize(value: unknown): string;
|
|
438
|
+
|
|
404
439
|
/**
|
|
405
440
|
* Frame Canonicalization for Agent Profiles
|
|
406
441
|
*
|
|
@@ -598,4 +633,4 @@ declare function listProfiles(): string[];
|
|
|
598
633
|
declare function getAllProfiles(): AgentProfile[];
|
|
599
634
|
declare function clearProfiles(): void;
|
|
600
635
|
|
|
601
|
-
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
636
|
+
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
@@ -401,6 +401,41 @@ type GatekeeperResult = {
|
|
|
401
401
|
errors: GatekeeperError[];
|
|
402
402
|
};
|
|
403
403
|
|
|
404
|
+
/**
|
|
405
|
+
* JCS — JSON Canonicalization (RFC 8785) for signing.
|
|
406
|
+
*
|
|
407
|
+
* Both attestation and receipt signatures are computed over a canonical byte
|
|
408
|
+
* serialization of the payload, so that two independent implementations (an AS,
|
|
409
|
+
* a gateway, an external verifier — in any language) agree on exactly which
|
|
410
|
+
* bytes were signed regardless of how they happened to construct the object.
|
|
411
|
+
*
|
|
412
|
+
* `JSON.stringify` is NOT sufficient: it preserves key *insertion* order, so the
|
|
413
|
+
* same logical payload built with keys in a different order serializes to
|
|
414
|
+
* different bytes and the signature fails to verify. Canonicalization removes
|
|
415
|
+
* that dependency by sorting keys.
|
|
416
|
+
*
|
|
417
|
+
* This is RFC 8785-compatible and matches the v0.5 spec's normative signing
|
|
418
|
+
* canonicalization (core.md "Signing Canonicalization"):
|
|
419
|
+
* 1. UTF-8.
|
|
420
|
+
* 2. Object keys sorted (RFC 8785 sorts by UTF-16 code units — exactly what
|
|
421
|
+
* JavaScript's default String comparison does, so `Object.keys().sort()` is
|
|
422
|
+
* correct here).
|
|
423
|
+
* 3. No insignificant whitespace.
|
|
424
|
+
* 4. Numbers in the shortest round-trippable form — the ECMAScript
|
|
425
|
+
* Number-to-String algorithm, which `JSON.stringify(number)` produces and
|
|
426
|
+
* RFC 8785 adopts verbatim.
|
|
427
|
+
* 5. Strings escaped per RFC 8259; non-ASCII passed through as UTF-8 (this is
|
|
428
|
+
* `JSON.stringify`'s behaviour for strings).
|
|
429
|
+
* 6. Array order preserved.
|
|
430
|
+
*
|
|
431
|
+
* Because it relies only on `JSON.stringify` for leaves plus `Object.keys`,
|
|
432
|
+
* `Array`, and `String` sort — all of which are environment-independent in
|
|
433
|
+
* JavaScript — Node and the browser produce byte-identical output. The
|
|
434
|
+
* conformance test pins a (payload → canonical bytes) vector that any other
|
|
435
|
+
* implementation can check against.
|
|
436
|
+
*/
|
|
437
|
+
declare function canonicalize(value: unknown): string;
|
|
438
|
+
|
|
404
439
|
/**
|
|
405
440
|
* Frame Canonicalization for Agent Profiles
|
|
406
441
|
*
|
|
@@ -598,4 +633,4 @@ declare function listProfiles(): string[];
|
|
|
598
633
|
declare function getAllProfiles(): AgentProfile[];
|
|
599
634
|
declare function clearProfiles(): void;
|
|
600
635
|
|
|
601
|
-
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
636
|
+
export { type AgentBoundsParams, type AgentContextParams, type AgentFrameParams, type AgentProfile, type Attestation, type AttestationHeader, type AttestationPayload, type BoundType, type CumulativeFieldDef, type CumulativeWindow, type DeclaredFieldDef, type ExecutionContextFieldDef, type ExecutionLogEntry, type ExecutionLogQuery, type ExecutionMappingTransform, type ExecutionMappingValue, type ExecutionPath, type FieldConstraint, type FieldUnit, type GateQuestion, type GatekeeperError, type GatekeeperRequest, type GatekeeperResult, type ProfileBoundsField, type ProfileContextField, type ProfileFrameField, type ProfileToolGating, type ProfileToolGatingEntry, type ResolvedDomain, attestationId, canonicalBounds, canonicalContext, canonicalFrame, canonicalize, checkAttestationExpiry, clearProfiles, computeBoundsHash, computeContextHash, computeFrameHash, decodeAttestationBlob, encodeAttestationBlob, frameHash, getAllProfiles, getProfile, isV4Attestation, listProfiles, registerProfile, validateBoundsParams, validateContextParams, validateFrameParams, verify, verifyAttestation, verifyAttestationSignature, verifyAttestationV4, verifyBoundsHash, verifyContextHash, verifyFrameHash };
|
|
@@ -34,6 +34,7 @@ __export(index_exports, {
|
|
|
34
34
|
canonicalBounds: () => canonicalBounds,
|
|
35
35
|
canonicalContext: () => canonicalContext,
|
|
36
36
|
canonicalFrame: () => canonicalFrame,
|
|
37
|
+
canonicalize: () => canonicalize,
|
|
37
38
|
checkAttestationExpiry: () => checkAttestationExpiry,
|
|
38
39
|
clearProfiles: () => clearProfiles,
|
|
39
40
|
computeBoundsHash: () => computeBoundsHash,
|
|
@@ -60,6 +61,31 @@ __export(index_exports, {
|
|
|
60
61
|
});
|
|
61
62
|
module.exports = __toCommonJS(index_exports);
|
|
62
63
|
|
|
64
|
+
// src/canonicalize.ts
|
|
65
|
+
function canonicalize(value) {
|
|
66
|
+
if (value === void 0) {
|
|
67
|
+
throw new Error("canonicalize: undefined is not serializable");
|
|
68
|
+
}
|
|
69
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
70
|
+
throw new Error(`canonicalize: ${value} is not a valid JSON number`);
|
|
71
|
+
}
|
|
72
|
+
if (value === null || typeof value !== "object") {
|
|
73
|
+
return JSON.stringify(value);
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
const items = value.map((el) => el === void 0 ? "null" : canonicalize(el));
|
|
77
|
+
return "[" + items.join(",") + "]";
|
|
78
|
+
}
|
|
79
|
+
const obj = value;
|
|
80
|
+
const parts = [];
|
|
81
|
+
for (const key of Object.keys(obj).sort()) {
|
|
82
|
+
const v = obj[key];
|
|
83
|
+
if (v === void 0) continue;
|
|
84
|
+
parts.push(JSON.stringify(key) + ":" + canonicalize(v));
|
|
85
|
+
}
|
|
86
|
+
return "{" + parts.join(",") + "}";
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
// src/frame.ts
|
|
64
90
|
var import_crypto = require("crypto");
|
|
65
91
|
function validateFrameParams(params, profile) {
|
|
@@ -215,7 +241,7 @@ function attestationId(blob) {
|
|
|
215
241
|
}
|
|
216
242
|
async function verifyAttestationSignature(attestation, publicKeyHex) {
|
|
217
243
|
try {
|
|
218
|
-
const payloadJson =
|
|
244
|
+
const payloadJson = canonicalize(attestation.payload);
|
|
219
245
|
const payloadBytes = new TextEncoder().encode(payloadJson);
|
|
220
246
|
const signatureBytes = Buffer.from(attestation.signature, "base64");
|
|
221
247
|
const publicKeyBytes = Buffer.from(publicKeyHex, "hex");
|
|
@@ -719,6 +745,7 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
719
745
|
canonicalBounds,
|
|
720
746
|
canonicalContext,
|
|
721
747
|
canonicalFrame,
|
|
748
|
+
canonicalize,
|
|
722
749
|
checkAttestationExpiry,
|
|
723
750
|
clearProfiles,
|
|
724
751
|
computeBoundsHash,
|
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
// src/canonicalize.ts
|
|
2
|
+
function canonicalize(value) {
|
|
3
|
+
if (value === void 0) {
|
|
4
|
+
throw new Error("canonicalize: undefined is not serializable");
|
|
5
|
+
}
|
|
6
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
7
|
+
throw new Error(`canonicalize: ${value} is not a valid JSON number`);
|
|
8
|
+
}
|
|
9
|
+
if (value === null || typeof value !== "object") {
|
|
10
|
+
return JSON.stringify(value);
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(value)) {
|
|
13
|
+
const items = value.map((el) => el === void 0 ? "null" : canonicalize(el));
|
|
14
|
+
return "[" + items.join(",") + "]";
|
|
15
|
+
}
|
|
16
|
+
const obj = value;
|
|
17
|
+
const parts = [];
|
|
18
|
+
for (const key of Object.keys(obj).sort()) {
|
|
19
|
+
const v = obj[key];
|
|
20
|
+
if (v === void 0) continue;
|
|
21
|
+
parts.push(JSON.stringify(key) + ":" + canonicalize(v));
|
|
22
|
+
}
|
|
23
|
+
return "{" + parts.join(",") + "}";
|
|
24
|
+
}
|
|
25
|
+
|
|
1
26
|
// src/frame.ts
|
|
2
27
|
import { createHash } from "crypto";
|
|
3
28
|
function validateFrameParams(params, profile) {
|
|
@@ -153,7 +178,7 @@ function attestationId(blob) {
|
|
|
153
178
|
}
|
|
154
179
|
async function verifyAttestationSignature(attestation, publicKeyHex) {
|
|
155
180
|
try {
|
|
156
|
-
const payloadJson =
|
|
181
|
+
const payloadJson = canonicalize(attestation.payload);
|
|
157
182
|
const payloadBytes = new TextEncoder().encode(payloadJson);
|
|
158
183
|
const signatureBytes = Buffer.from(attestation.signature, "base64");
|
|
159
184
|
const publicKeyBytes = Buffer.from(publicKeyHex, "hex");
|
|
@@ -656,6 +681,7 @@ export {
|
|
|
656
681
|
canonicalBounds,
|
|
657
682
|
canonicalContext,
|
|
658
683
|
canonicalFrame,
|
|
684
|
+
canonicalize,
|
|
659
685
|
checkAttestationExpiry,
|
|
660
686
|
clearProfiles,
|
|
661
687
|
computeBoundsHash,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanagencyp/hap-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,22 +21,23 @@
|
|
|
21
21
|
"dist",
|
|
22
22
|
"src"
|
|
23
23
|
],
|
|
24
|
-
"dependencies": {
|
|
25
|
-
"@noble/ed25519": "^2.1.0"
|
|
26
|
-
},
|
|
27
|
-
"devDependencies": {
|
|
28
|
-
"@types/node": "^20.10.0",
|
|
29
|
-
"tsup": "^8.0.0",
|
|
30
|
-
"typescript": "^5.3.0",
|
|
31
|
-
"vitest": "^1.0.0"
|
|
32
|
-
},
|
|
33
24
|
"scripts": {
|
|
34
25
|
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
35
26
|
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
36
27
|
"test": "vitest run",
|
|
37
28
|
"test:watch": "vitest",
|
|
29
|
+
"prepublishOnly": "pnpm build",
|
|
38
30
|
"release:patch": "pnpm version patch && git push --follow-tags",
|
|
39
31
|
"release:minor": "pnpm version minor && git push --follow-tags",
|
|
40
32
|
"release:major": "pnpm version major && git push --follow-tags"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@noble/ed25519": "^2.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^20.10.0",
|
|
39
|
+
"tsup": "^8.0.0",
|
|
40
|
+
"typescript": "^5.3.0",
|
|
41
|
+
"vitest": "^1.0.0"
|
|
41
42
|
}
|
|
42
|
-
}
|
|
43
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { createHash } from 'crypto';
|
|
8
8
|
import * as ed from '@noble/ed25519';
|
|
9
9
|
import type { Attestation, AttestationPayload } from './types';
|
|
10
|
+
import { canonicalize } from './canonicalize';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Decodes a base64url-encoded attestation blob.
|
|
@@ -50,7 +51,9 @@ export async function verifyAttestationSignature(
|
|
|
50
51
|
publicKeyHex: string
|
|
51
52
|
): Promise<void> {
|
|
52
53
|
try {
|
|
53
|
-
|
|
54
|
+
// JCS-canonical bytes (RFC 8785) — must match the canonicalization the
|
|
55
|
+
// signer used. The AS signs the same canonical form (see keys.ts).
|
|
56
|
+
const payloadJson = canonicalize(attestation.payload);
|
|
54
57
|
const payloadBytes = new TextEncoder().encode(payloadJson);
|
|
55
58
|
const signatureBytes = Buffer.from(attestation.signature, 'base64');
|
|
56
59
|
const publicKeyBytes = Buffer.from(publicKeyHex, 'hex');
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JCS — JSON Canonicalization (RFC 8785) for signing.
|
|
3
|
+
*
|
|
4
|
+
* Both attestation and receipt signatures are computed over a canonical byte
|
|
5
|
+
* serialization of the payload, so that two independent implementations (an AS,
|
|
6
|
+
* a gateway, an external verifier — in any language) agree on exactly which
|
|
7
|
+
* bytes were signed regardless of how they happened to construct the object.
|
|
8
|
+
*
|
|
9
|
+
* `JSON.stringify` is NOT sufficient: it preserves key *insertion* order, so the
|
|
10
|
+
* same logical payload built with keys in a different order serializes to
|
|
11
|
+
* different bytes and the signature fails to verify. Canonicalization removes
|
|
12
|
+
* that dependency by sorting keys.
|
|
13
|
+
*
|
|
14
|
+
* This is RFC 8785-compatible and matches the v0.5 spec's normative signing
|
|
15
|
+
* canonicalization (core.md "Signing Canonicalization"):
|
|
16
|
+
* 1. UTF-8.
|
|
17
|
+
* 2. Object keys sorted (RFC 8785 sorts by UTF-16 code units — exactly what
|
|
18
|
+
* JavaScript's default String comparison does, so `Object.keys().sort()` is
|
|
19
|
+
* correct here).
|
|
20
|
+
* 3. No insignificant whitespace.
|
|
21
|
+
* 4. Numbers in the shortest round-trippable form — the ECMAScript
|
|
22
|
+
* Number-to-String algorithm, which `JSON.stringify(number)` produces and
|
|
23
|
+
* RFC 8785 adopts verbatim.
|
|
24
|
+
* 5. Strings escaped per RFC 8259; non-ASCII passed through as UTF-8 (this is
|
|
25
|
+
* `JSON.stringify`'s behaviour for strings).
|
|
26
|
+
* 6. Array order preserved.
|
|
27
|
+
*
|
|
28
|
+
* Because it relies only on `JSON.stringify` for leaves plus `Object.keys`,
|
|
29
|
+
* `Array`, and `String` sort — all of which are environment-independent in
|
|
30
|
+
* JavaScript — Node and the browser produce byte-identical output. The
|
|
31
|
+
* conformance test pins a (payload → canonical bytes) vector that any other
|
|
32
|
+
* implementation can check against.
|
|
33
|
+
*/
|
|
34
|
+
export function canonicalize(value: unknown): string {
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
throw new Error('canonicalize: undefined is not serializable');
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
39
|
+
throw new Error(`canonicalize: ${value} is not a valid JSON number`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Primitives: string, finite number, boolean, null — JSON.stringify is already
|
|
43
|
+
// canonical (RFC 8259 string escaping, ECMAScript number formatting).
|
|
44
|
+
if (value === null || typeof value !== 'object') {
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
// Array order is preserved; undefined elements become null (matching
|
|
50
|
+
// JSON.stringify), which keeps array length stable across serializers.
|
|
51
|
+
const items = value.map((el) => (el === undefined ? 'null' : canonicalize(el)));
|
|
52
|
+
return '[' + items.join(',') + ']';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Plain object: sort keys, omit undefined-valued properties (as JSON.stringify
|
|
56
|
+
// does), recurse on values.
|
|
57
|
+
const obj = value as Record<string, unknown>;
|
|
58
|
+
const parts: string[] = [];
|
|
59
|
+
for (const key of Object.keys(obj).sort()) {
|
|
60
|
+
const v = obj[key];
|
|
61
|
+
if (v === undefined) continue;
|
|
62
|
+
parts.push(JSON.stringify(key) + ':' + canonicalize(v));
|
|
63
|
+
}
|
|
64
|
+
return '{' + parts.join(',') + '}';
|
|
65
|
+
}
|
|
@@ -109,6 +109,28 @@ const parser = createParser({
|
|
|
109
109
|
> [!NOTE]
|
|
110
110
|
> Leading whitespace is not stripped from comments, eg `: comment` will give ` comment` as the comment value, not `comment` (note the leading space).
|
|
111
111
|
|
|
112
|
+
### Limiting buffered memory (`maxBufferSize`)
|
|
113
|
+
|
|
114
|
+
By default the parser buffers data indefinitely until a server completes an event. A server (or proxy) that never terminates a line, or that keeps appending `data:` lines without ever sending a blank line to dispatch the event, can therefore grow the parser's buffers without bound.
|
|
115
|
+
|
|
116
|
+
Pass a `maxBufferSize` (in characters) to `createParser` to cap this. If the combined size of the pending line buffer and the in-progress event's data buffer exceeds the limit, the parser emits a `ParseError` with `type: 'max-buffer-size-exceeded'` and becomes terminated: subsequent calls to `feed()` will throw until `reset()` is called.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const parser = createParser({
|
|
120
|
+
maxBufferSize: 1024 * 1024, // 1 MB
|
|
121
|
+
onEvent(event) {
|
|
122
|
+
// …
|
|
123
|
+
},
|
|
124
|
+
onError(error) {
|
|
125
|
+
if (error.type === 'max-buffer-size-exceeded') {
|
|
126
|
+
// Stream peer is misbehaving — typically you'd close the connection.
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The same option is available on the [stream variant](#stream-usage); the stream is always errored when this limit is exceeded, regardless of the `onError` setting (since the underlying parser is unrecoverable without a `reset()`).
|
|
133
|
+
|
|
112
134
|
## Stream usage
|
|
113
135
|
|
|
114
136
|
```ts
|
|
@@ -119,6 +141,15 @@ const eventStream = response.body
|
|
|
119
141
|
.pipeThrough(new EventSourceParserStream())
|
|
120
142
|
```
|
|
121
143
|
|
|
144
|
+
The stream constructor accepts a subset of the `createParser` options (`onComment`, `onRetry`, `maxBufferSize`) plus an `onError` that can either be a function or set to `'terminate'` to error the stream on parse errors. Events are delivered through the stream itself rather than via an `onEvent` callback:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
new EventSourceParserStream({
|
|
148
|
+
maxBufferSize: 1024 * 1024,
|
|
149
|
+
onError: 'terminate',
|
|
150
|
+
})
|
|
151
|
+
```
|
|
152
|
+
|
|
122
153
|
Note that the TransformStream is exposed under a separate export (`eventsource-parser/stream`), in order to maximize compatibility with environments that do not have the `TransformStream` constructor available.
|
|
123
154
|
|
|
124
155
|
## License
|
|
@@ -8,29 +8,40 @@ class ParseError extends Error {
|
|
|
8
8
|
const LF = 10, CR = 13, SPACE = 32;
|
|
9
9
|
function noop(_arg) {
|
|
10
10
|
}
|
|
11
|
-
function createParser(
|
|
12
|
-
if (typeof
|
|
11
|
+
function createParser(config) {
|
|
12
|
+
if (typeof config == "function")
|
|
13
13
|
throw new TypeError(
|
|
14
|
-
"`
|
|
14
|
+
"`config` must be an object, got a function instead. Did you mean `createParser({onEvent: fn})`?"
|
|
15
15
|
);
|
|
16
|
-
const { onEvent = noop, onError = noop, onRetry = noop, onComment } =
|
|
17
|
-
let isFirstChunk = !0, id, data = "", dataLines = 0, eventType;
|
|
16
|
+
const { onEvent = noop, onError = noop, onRetry = noop, onComment, maxBufferSize } = config, pendingFragments = [];
|
|
17
|
+
let pendingFragmentsLength = 0, isFirstChunk = !0, id, data = "", dataLines = 0, eventType, terminated = !1;
|
|
18
18
|
function feed(chunk) {
|
|
19
|
+
if (terminated)
|
|
20
|
+
throw new Error(
|
|
21
|
+
"Cannot feed parser: it was terminated after exceeding the configured max buffer size. Call `reset()` to resume parsing."
|
|
22
|
+
);
|
|
19
23
|
if (isFirstChunk && (isFirstChunk = !1, chunk.charCodeAt(0) === 239 && chunk.charCodeAt(1) === 187 && chunk.charCodeAt(2) === 191 && (chunk = chunk.slice(3))), pendingFragments.length === 0) {
|
|
20
24
|
const trailing2 = processLines(chunk);
|
|
21
|
-
trailing2 !== "" && pendingFragments.push(trailing2);
|
|
25
|
+
trailing2 !== "" && (pendingFragments.push(trailing2), pendingFragmentsLength = trailing2.length), checkBufferSize();
|
|
22
26
|
return;
|
|
23
27
|
}
|
|
24
28
|
if (chunk.indexOf(`
|
|
25
29
|
`) === -1 && chunk.indexOf("\r") === -1) {
|
|
26
|
-
pendingFragments.push(chunk);
|
|
30
|
+
pendingFragments.push(chunk), pendingFragmentsLength += chunk.length, checkBufferSize();
|
|
27
31
|
return;
|
|
28
32
|
}
|
|
29
33
|
pendingFragments.push(chunk);
|
|
30
34
|
const input = pendingFragments.join("");
|
|
31
|
-
pendingFragments.length = 0;
|
|
35
|
+
pendingFragments.length = 0, pendingFragmentsLength = 0;
|
|
32
36
|
const trailing = processLines(input);
|
|
33
|
-
trailing !== "" && pendingFragments.push(trailing);
|
|
37
|
+
trailing !== "" && (pendingFragments.push(trailing), pendingFragmentsLength = trailing.length), checkBufferSize();
|
|
38
|
+
}
|
|
39
|
+
function checkBufferSize() {
|
|
40
|
+
maxBufferSize !== void 0 && (pendingFragmentsLength + data.length <= maxBufferSize || (terminated = !0, pendingFragments.length = 0, pendingFragmentsLength = 0, id = void 0, data = "", dataLines = 0, eventType = void 0, onError(
|
|
41
|
+
new ParseError(`Buffered data exceeded max buffer size of ${maxBufferSize} characters`, {
|
|
42
|
+
type: "max-buffer-size-exceeded"
|
|
43
|
+
})
|
|
44
|
+
)));
|
|
34
45
|
}
|
|
35
46
|
function processLines(chunk) {
|
|
36
47
|
let searchIndex = 0;
|
|
@@ -151,7 +162,7 @@ ${value}`, dataLines++;
|
|
|
151
162
|
const incompleteLine = pendingFragments.join("");
|
|
152
163
|
parseLine(incompleteLine, 0, incompleteLine.length);
|
|
153
164
|
}
|
|
154
|
-
isFirstChunk = !0, id = void 0, data = "", dataLines = 0, eventType = void 0, pendingFragments.length = 0;
|
|
165
|
+
isFirstChunk = !0, id = void 0, data = "", dataLines = 0, eventType = void 0, pendingFragments.length = 0, pendingFragmentsLength = 0, terminated = !1;
|
|
155
166
|
}
|
|
156
167
|
return { feed, reset };
|
|
157
168
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","sources":["../src/errors.ts","../src/parse.ts"],"sourcesContent":["/**\n * The type of error that occurred.\n * @public\n */\nexport type ErrorType = 'invalid-retry' | 'unknown-field'\n\n/**\n * Error thrown when encountering an issue during parsing.\n *\n * @public\n */\nexport class ParseError extends Error {\n /**\n * The type of error that occurred.\n */\n type: ErrorType\n\n /**\n * In the case of an unknown field encountered in the stream, this will be the field name.\n */\n field?: string | undefined\n\n /**\n * In the case of an unknown field encountered in the stream, this will be the value of the field.\n */\n value?: string | undefined\n\n /**\n * The line that caused the error, if available.\n */\n line?: string | undefined\n\n constructor(\n message: string,\n options: {type: ErrorType; field?: string; value?: string; line?: string},\n ) {\n super(message)\n this.name = 'ParseError'\n this.type = options.type\n this.field = options.field\n this.value = options.value\n this.line = options.line\n }\n}\n","/**\n * EventSource/Server-Sent Events parser\n * @see https://html.spec.whatwg.org/multipage/server-sent-events.html\n */\nimport {ParseError} from './errors.ts'\nimport type {EventSourceParser, ParserCallbacks} from './types.ts'\n\n// ASCII codes used in the hot parsing paths.\nconst LF = 10\nconst CR = 13\nconst SPACE = 32\n\n// oxlint-disable-next-line no-unused-vars\nfunction noop(_arg: unknown) {\n // intentional noop\n}\n\n/**\n * Creates a new EventSource parser.\n *\n * @param callbacks - Callbacks to invoke on different parsing events:\n * - `onEvent` when a new event is parsed\n * - `onError` when an error occurs\n * - `onRetry` when a new reconnection interval has been sent from the server\n * - `onComment` when a comment is encountered in the stream\n *\n * @returns A new EventSource parser, with `parse` and `reset` methods.\n * @public\n */\nexport function createParser(callbacks: ParserCallbacks): EventSourceParser {\n if (typeof callbacks === 'function') {\n throw new TypeError(\n '`callbacks` must be an object, got a function instead. Did you mean `{onEvent: fn}`?',\n )\n }\n\n const {onEvent = noop, onError = noop, onRetry = noop, onComment} = callbacks\n\n // Trailing bytes from prior `feed()` calls that did not yet form a complete line.\n // Stored as an array of fragments and only joined when a line terminator arrives.\n // Concatenating per-feed (`prefix + chunk`) is O(N²) when a single SSE line spans\n // many chunks (e.g. a large `data:` payload streamed in tiny slices, or an MCP-style\n // server that emits one giant content block). Buffering as fragments + joining once\n // makes the same workload linear.\n const pendingFragments: string[] = []\n\n let isFirstChunk = true\n let id: string | undefined\n let data = ''\n let dataLines = 0\n let eventType: string | undefined\n\n /**\n * Feeds a chunk of the SSE stream to the parser. Any trailing bytes that do\n * not yet form a complete line are held back and prepended to the next chunk,\n * so callers can pass arbitrary slices of the stream without worrying about\n * line boundaries.\n *\n * Per the SSE spec, a UTF-8 BOM (0xEF 0xBB 0xBF) at the start of the very\n * first chunk is stripped before parsing.\n *\n * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream\n */\n function feed(chunk: string) {\n if (isFirstChunk) {\n isFirstChunk = false\n // Match and strip UTF-8 BOM from the start of the stream, if present.\n // (Per the spec, this is only valid at the very start of the stream)\n if (\n chunk.charCodeAt(0) === 0xef &&\n chunk.charCodeAt(1) === 0xbb &&\n chunk.charCodeAt(2) === 0xbf\n ) {\n chunk = chunk.slice(3)\n }\n }\n\n // Hot path: no buffered prefix from a prior partial line. Hand the chunk\n // straight to `processLines`, exactly like the original implementation.\n // Zero new work in the common case (every chunk ends with `\\n\\n`).\n if (pendingFragments.length === 0) {\n const trailing = processLines(chunk)\n if (trailing !== '') pendingFragments.push(trailing)\n return\n }\n\n // We have a buffered prefix. If this chunk also has no terminator, append\n // to the buffer without concatenating — that's the O(N²) trap we're\n // avoiding (large single `data:` payload split across many tiny chunks).\n if (chunk.indexOf('\\n') === -1 && chunk.indexOf('\\r') === -1) {\n pendingFragments.push(chunk)\n return\n }\n\n // Terminator arrived. Join the accumulated fragments + this chunk once,\n // process, and buffer any new trailing partial line.\n pendingFragments.push(chunk)\n const input = pendingFragments.join('')\n pendingFragments.length = 0\n const trailing = processLines(input)\n if (trailing !== '') pendingFragments.push(trailing)\n }\n\n /**\n * Splits `chunk` into SSE lines and dispatches each to the appropriate handler.\n * Returns any trailing bytes that did not terminate with a line break, so the\n * caller can prepend them to the next chunk.\n *\n * The SSE spec permits three line terminators: `\\n`, `\\r`, and `\\r\\n`. Real-world\n * streams almost always use plain `\\n`, so we take a fast path when no `\\r` is\n * present in the chunk. The slow path is spec-correct but does more work per line.\n */\n function processLines(chunk: string): string {\n let searchIndex = 0\n\n // Fast path: LF-only chunk (the common case for typical SSE servers).\n // We can scan forward with a single `indexOf('\\n')` per line and inline\n // the hot-path branches for `data:` and `event:` without the CR bookkeeping\n // the slow path needs.\n if (chunk.indexOf('\\r') === -1) {\n let lfIndex = chunk.indexOf('\\n', searchIndex)\n while (lfIndex !== -1) {\n // Blank line: end-of-event marker. Dispatch the accumulated event (if any)\n // and reset the buffered fields. This is hoisted out of `parseLine` because\n // it's the single most common line shape after `data:` lines.\n if (searchIndex === lfIndex) {\n if (dataLines > 0) {\n onEvent({id, event: eventType, data})\n }\n id = undefined\n data = ''\n dataLines = 0\n eventType = undefined\n searchIndex = lfIndex + 1\n lfIndex = chunk.indexOf('\\n', searchIndex)\n continue\n }\n const firstCharCode = chunk.charCodeAt(searchIndex)\n if (isDataPrefix(chunk, searchIndex, firstCharCode)) {\n // `data:` line — append the value to the event's data buffer.\n // 'data:'.length === 5, 'data: '.length === 6\n const valueStart =\n chunk.charCodeAt(searchIndex + 5) === SPACE ? searchIndex + 6 : searchIndex + 5\n const value = chunk.slice(valueStart, lfIndex)\n // Fast path within a fast path: if this is the first data line AND the\n // next char is another LF (i.e. `data:foo\\n\\n`), dispatch immediately\n // without ever writing to the `data` buffer. This is the shape of a\n // typical single-line SSE event (ChatGPT-style streams, etc.) and is\n // hot enough to be worth the duplication.\n if (dataLines === 0 && chunk.charCodeAt(lfIndex + 1) === LF) {\n onEvent({id, event: eventType, data: value})\n id = undefined\n data = ''\n eventType = undefined\n searchIndex = lfIndex + 2\n lfIndex = chunk.indexOf('\\n', searchIndex)\n continue\n }\n // Multi-line data: concatenate with newline separator per spec.\n data = dataLines === 0 ? value : `${data}\\n${value}`\n dataLines++\n } else if (isEventPrefix(chunk, searchIndex, firstCharCode)) {\n // `event:` line — set the event type for the next dispatch. Per spec,\n // an empty value resets `event type` to its default (undefined here).\n // 'event:'.length === 6, 'event: '.length === 7\n eventType =\n chunk.slice(\n chunk.charCodeAt(searchIndex + 6) === SPACE ? searchIndex + 7 : searchIndex + 6,\n lfIndex,\n ) || undefined\n } else {\n // Everything else: `id:`, `retry:`, comment lines (`:` prefix), unknown\n // fields, or malformed lines. These are rarer and go through the full\n // per-line parser, which handles the SSE field grammar in detail.\n parseLine(chunk, searchIndex, lfIndex)\n }\n searchIndex = lfIndex + 1\n lfIndex = chunk.indexOf('\\n', searchIndex)\n }\n return chunk.slice(searchIndex)\n }\n\n // Slow path: the chunk contains at least one `\\r`, so lines may be terminated\n // by `\\r`, `\\n`, or `\\r\\n`. We locate the next terminator by looking at both\n // the nearest `\\r` and `\\n` and picking whichever comes first.\n while (searchIndex < chunk.length) {\n const crIndex = chunk.indexOf('\\r', searchIndex)\n const lfIndex = chunk.indexOf('\\n', searchIndex)\n\n let lineEnd = -1\n if (crIndex !== -1 && lfIndex !== -1) {\n lineEnd = crIndex < lfIndex ? crIndex : lfIndex\n } else if (crIndex !== -1) {\n // A trailing `\\r` at the very end of the chunk is ambiguous: it could be\n // a bare-CR terminator, or the first half of a `\\r\\n` whose `\\n` arrives\n // in the next chunk. Defer until we see more input.\n if (crIndex === chunk.length - 1) {\n lineEnd = -1\n } else {\n lineEnd = crIndex\n }\n } else if (lfIndex !== -1) {\n lineEnd = lfIndex\n }\n\n if (lineEnd === -1) {\n break\n }\n\n parseLine(chunk, searchIndex, lineEnd)\n searchIndex = lineEnd + 1\n // If we just consumed a `\\r` and the next char is `\\n`, skip it so the\n // pair is treated as a single terminator rather than an empty line.\n if (chunk.charCodeAt(searchIndex - 1) === CR && chunk.charCodeAt(searchIndex) === LF) {\n searchIndex++\n }\n }\n\n return chunk.slice(searchIndex)\n }\n\n function parseLine(chunk: string, start: number, end: number) {\n if (start === end) {\n dispatchEvent()\n return\n }\n\n const firstCharCode = chunk.charCodeAt(start)\n\n if (isDataPrefix(chunk, start, firstCharCode)) {\n // 'data:'.length === 5, 'data: '.length === 6\n const valueStart = chunk.charCodeAt(start + 5) === SPACE ? start + 6 : start + 5\n const value = chunk.slice(valueStart, end)\n data = dataLines === 0 ? value : `${data}\\n${value}`\n dataLines++\n return\n }\n\n if (isEventPrefix(chunk, start, firstCharCode)) {\n // 'event:'.length === 6, 'event: '.length === 7\n eventType =\n chunk.slice(chunk.charCodeAt(start + 6) === SPACE ? start + 7 : start + 6, end) || undefined\n return\n }\n\n // Fast path for \"id:\" — 'i' = 105, 'd' = 100, ':' = 58\n if (\n firstCharCode === 105 &&\n chunk.charCodeAt(start + 1) === 100 &&\n chunk.charCodeAt(start + 2) === 58\n ) {\n // 'id:'.length === 3, 'id: '.length === 4\n const value = chunk.slice(chunk.charCodeAt(start + 3) === SPACE ? start + 4 : start + 3, end)\n id = value.includes('\\0') ? undefined : value\n return\n }\n\n // Comment line — ':' = 58\n if (firstCharCode === 58) {\n if (onComment) {\n const line = chunk.slice(start, end)\n // skip ':' (+1), or ': ' (+2) when a space follows\n onComment(line.slice(chunk.charCodeAt(start + 1) === SPACE ? 2 : 1))\n }\n return\n }\n\n const line = chunk.slice(start, end)\n const fieldSeparatorIndex = line.indexOf(':')\n if (fieldSeparatorIndex === -1) {\n processField(line, '', line)\n return\n }\n\n const field = line.slice(0, fieldSeparatorIndex)\n // skip ':' (+1), or ': ' (+2) when a space follows\n const offset = line.charCodeAt(fieldSeparatorIndex + 1) === SPACE ? 2 : 1\n const value = line.slice(fieldSeparatorIndex + offset)\n processField(field, value, line)\n }\n\n function processField(field: string, value: string, line: string) {\n // Field names must be compared literally, with no case folding performed.\n switch (field) {\n case 'event':\n // Set the `event type` buffer to field value\n eventType = value || undefined\n break\n case 'data':\n data = dataLines === 0 ? value : `${data}\\n${value}`\n dataLines++\n break\n case 'id':\n // If the field value does not contain U+0000 NULL, then set the `ID` buffer to\n // the field value. Otherwise, ignore the field.\n id = value.includes('\\0') ? undefined : value\n break\n case 'retry':\n // If the field value consists of only ASCII digits, then interpret the field value as an\n // integer in base ten, and set the event stream's reconnection time to that integer.\n // Otherwise, ignore the field.\n if (/^\\d+$/.test(value)) {\n onRetry(parseInt(value, 10))\n } else {\n onError(\n new ParseError(`Invalid \\`retry\\` value: \"${value}\"`, {\n type: 'invalid-retry',\n value,\n line,\n }),\n )\n }\n break\n default:\n // Otherwise, the field is ignored.\n onError(\n new ParseError(\n `Unknown field \"${field.length > 20 ? `${field.slice(0, 20)}…` : field}\"`,\n {type: 'unknown-field', field, value, line},\n ),\n )\n break\n }\n }\n\n function dispatchEvent() {\n if (dataLines > 0) {\n onEvent({\n id,\n event: eventType,\n data,\n })\n }\n\n id = undefined\n data = ''\n dataLines = 0\n eventType = undefined\n }\n\n function reset(options: {consume?: boolean} = {}) {\n if (options.consume && pendingFragments.length > 0) {\n const incompleteLine = pendingFragments.join('')\n parseLine(incompleteLine, 0, incompleteLine.length)\n }\n\n isFirstChunk = true\n id = undefined\n data = ''\n dataLines = 0\n eventType = undefined\n pendingFragments.length = 0\n }\n\n return {feed, reset}\n}\n\n/**\n * Checks if `chunk` starts with the literal `data:` at index `i`.\n *\n * Equivalent to `chunk.startsWith('data:', i)`, but benchmarks show this\n * hand-unrolled char-code comparison is ~20% faster on common event types.\n * The caller passes `firstCharCode` (the code at `i`) so it can be reused\n * across prefix checks.\n *\n * ASCII: 'd' = 100, 'a' = 97, 't' = 116, 'a' = 97, ':' = 58\n */\nfunction isDataPrefix(chunk: string, i: number, firstCharCode: number): boolean {\n return (\n firstCharCode === 100 &&\n chunk.charCodeAt(i + 1) === 97 &&\n chunk.charCodeAt(i + 2) === 116 &&\n chunk.charCodeAt(i + 3) === 97 &&\n chunk.charCodeAt(i + 4) === 58\n )\n}\n\n/**\n * Checks if `chunk` starts with the literal `event:` at index `i`.\n *\n * See {@link isDataPrefix} for why this is hand-unrolled rather than using\n * `String.prototype.startsWith`.\n *\n * ASCII: 'e' = 101, 'v' = 118, 'e' = 101, 'n' = 110, 't' = 116, ':' = 58\n */\nfunction isEventPrefix(chunk: string, i: number, firstCharCode: number): boolean {\n return (\n firstCharCode === 101 &&\n chunk.charCodeAt(i + 1) === 118 &&\n chunk.charCodeAt(i + 2) === 101 &&\n chunk.charCodeAt(i + 3) === 110 &&\n chunk.charCodeAt(i + 4) === 116 &&\n chunk.charCodeAt(i + 5) === 58\n )\n}\n"],"names":["trailing","value","line"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","sources":["../src/errors.ts","../src/parse.ts"],"sourcesContent":["/**\n * The type of error that occurred.\n * @public\n */\nexport type ErrorType = 'invalid-retry' | 'unknown-field' | 'max-buffer-size-exceeded'\n\n/**\n * Error thrown when encountering an issue during parsing.\n *\n * @public\n */\nexport class ParseError extends Error {\n /**\n * The type of error that occurred.\n */\n type: ErrorType\n\n /**\n * In the case of an unknown field encountered in the stream, this will be the field name.\n */\n field?: string | undefined\n\n /**\n * In the case of an unknown field encountered in the stream, this will be the value of the field.\n */\n value?: string | undefined\n\n /**\n * The line that caused the error, if available.\n */\n line?: string | undefined\n\n constructor(\n message: string,\n options: {type: ErrorType; field?: string; value?: string; line?: string},\n ) {\n super(message)\n this.name = 'ParseError'\n this.type = options.type\n this.field = options.field\n this.value = options.value\n this.line = options.line\n }\n}\n","/**\n * EventSource/Server-Sent Events parser\n * @see https://html.spec.whatwg.org/multipage/server-sent-events.html\n */\nimport {ParseError} from './errors.ts'\nimport type {EventSourceParser, ParserConfig} from './types.ts'\n\n// ASCII codes used in the hot parsing paths.\nconst LF = 10\nconst CR = 13\nconst SPACE = 32\n\n// oxlint-disable-next-line no-unused-vars\nfunction noop(_arg: unknown) {\n // intentional noop\n}\n\n/**\n * Creates a new EventSource parser.\n *\n * @param config - Parser configuration. Accepts callbacks (see {@link ParserCallbacks})\n * and options like `maxBufferSize` (see {@link ParserConfig}).\n *\n * @returns A new EventSource parser, with `feed` and `reset` methods.\n * @public\n */\nexport function createParser(config: ParserConfig): EventSourceParser {\n if (typeof config === 'function') {\n throw new TypeError(\n '`config` must be an object, got a function instead. Did you mean `createParser({onEvent: fn})`?',\n )\n }\n\n const {onEvent = noop, onError = noop, onRetry = noop, onComment, maxBufferSize} = config\n\n // Trailing bytes from prior `feed()` calls that did not yet form a complete line.\n // Stored as an array of fragments and only joined when a line terminator arrives.\n // Concatenating per-feed (`prefix + chunk`) is O(N²) when a single SSE line spans\n // many chunks (e.g. a large `data:` payload streamed in tiny slices, or an MCP-style\n // server that emits one giant content block). Buffering as fragments + joining once\n // makes the same workload linear.\n const pendingFragments: string[] = []\n\n // Running total of `pendingFragments` lengths, kept in sync with the array so the\n // `maxBufferSize` check doesn't have to walk the fragment list on every feed.\n let pendingFragmentsLength = 0\n\n let isFirstChunk = true\n let id: string | undefined\n let data = ''\n let dataLines = 0\n let eventType: string | undefined\n\n // Set after a `maxBufferSize` overflow. Once tripped, `feed()` throws until\n // `reset()` is called — see the comment on `maxBufferSize` in `ParserConfig`.\n let terminated = false\n\n /**\n * Feeds a chunk of the SSE stream to the parser. Any trailing bytes that do\n * not yet form a complete line are held back and prepended to the next chunk,\n * so callers can pass arbitrary slices of the stream without worrying about\n * line boundaries.\n *\n * Per the SSE spec, a UTF-8 BOM (0xEF 0xBB 0xBF) at the start of the very\n * first chunk is stripped before parsing.\n *\n * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream\n */\n function feed(chunk: string) {\n if (terminated) {\n throw new Error(\n 'Cannot feed parser: it was terminated after exceeding the configured max buffer size. Call `reset()` to resume parsing.',\n )\n }\n\n if (isFirstChunk) {\n isFirstChunk = false\n // Match and strip UTF-8 BOM from the start of the stream, if present.\n // (Per the spec, this is only valid at the very start of the stream)\n if (\n chunk.charCodeAt(0) === 0xef &&\n chunk.charCodeAt(1) === 0xbb &&\n chunk.charCodeAt(2) === 0xbf\n ) {\n chunk = chunk.slice(3)\n }\n }\n\n // Hot path: no buffered prefix from a prior partial line. Hand the chunk\n // straight to `processLines`, exactly like the original implementation.\n // Zero new work in the common case (every chunk ends with `\\n\\n`).\n if (pendingFragments.length === 0) {\n const trailing = processLines(chunk)\n if (trailing !== '') {\n pendingFragments.push(trailing)\n pendingFragmentsLength = trailing.length\n }\n checkBufferSize()\n return\n }\n\n // We have a buffered prefix. If this chunk also has no terminator, append\n // to the buffer without concatenating — that's the O(N²) trap we're\n // avoiding (large single `data:` payload split across many tiny chunks).\n if (chunk.indexOf('\\n') === -1 && chunk.indexOf('\\r') === -1) {\n pendingFragments.push(chunk)\n pendingFragmentsLength += chunk.length\n checkBufferSize()\n return\n }\n\n // Terminator arrived. Join the accumulated fragments + this chunk once,\n // process, and buffer any new trailing partial line.\n pendingFragments.push(chunk)\n const input = pendingFragments.join('')\n pendingFragments.length = 0\n pendingFragmentsLength = 0\n const trailing = processLines(input)\n if (trailing !== '') {\n pendingFragments.push(trailing)\n pendingFragmentsLength = trailing.length\n }\n checkBufferSize()\n }\n\n function checkBufferSize() {\n if (maxBufferSize === undefined) return\n if (pendingFragmentsLength + data.length <= maxBufferSize) return\n\n terminated = true\n pendingFragments.length = 0\n pendingFragmentsLength = 0\n id = undefined\n data = ''\n dataLines = 0\n eventType = undefined\n onError(\n new ParseError(`Buffered data exceeded max buffer size of ${maxBufferSize} characters`, {\n type: 'max-buffer-size-exceeded',\n }),\n )\n }\n\n /**\n * Splits `chunk` into SSE lines and dispatches each to the appropriate handler.\n * Returns any trailing bytes that did not terminate with a line break, so the\n * caller can prepend them to the next chunk.\n *\n * The SSE spec permits three line terminators: `\\n`, `\\r`, and `\\r\\n`. Real-world\n * streams almost always use plain `\\n`, so we take a fast path when no `\\r` is\n * present in the chunk. The slow path is spec-correct but does more work per line.\n */\n function processLines(chunk: string): string {\n let searchIndex = 0\n\n // Fast path: LF-only chunk (the common case for typical SSE servers).\n // We can scan forward with a single `indexOf('\\n')` per line and inline\n // the hot-path branches for `data:` and `event:` without the CR bookkeeping\n // the slow path needs.\n if (chunk.indexOf('\\r') === -1) {\n let lfIndex = chunk.indexOf('\\n', searchIndex)\n while (lfIndex !== -1) {\n // Blank line: end-of-event marker. Dispatch the accumulated event (if any)\n // and reset the buffered fields. This is hoisted out of `parseLine` because\n // it's the single most common line shape after `data:` lines.\n if (searchIndex === lfIndex) {\n if (dataLines > 0) {\n onEvent({id, event: eventType, data})\n }\n id = undefined\n data = ''\n dataLines = 0\n eventType = undefined\n searchIndex = lfIndex + 1\n lfIndex = chunk.indexOf('\\n', searchIndex)\n continue\n }\n const firstCharCode = chunk.charCodeAt(searchIndex)\n if (isDataPrefix(chunk, searchIndex, firstCharCode)) {\n // `data:` line — append the value to the event's data buffer.\n // 'data:'.length === 5, 'data: '.length === 6\n const valueStart =\n chunk.charCodeAt(searchIndex + 5) === SPACE ? searchIndex + 6 : searchIndex + 5\n const value = chunk.slice(valueStart, lfIndex)\n // Fast path within a fast path: if this is the first data line AND the\n // next char is another LF (i.e. `data:foo\\n\\n`), dispatch immediately\n // without ever writing to the `data` buffer. This is the shape of a\n // typical single-line SSE event (ChatGPT-style streams, etc.) and is\n // hot enough to be worth the duplication.\n if (dataLines === 0 && chunk.charCodeAt(lfIndex + 1) === LF) {\n onEvent({id, event: eventType, data: value})\n id = undefined\n data = ''\n eventType = undefined\n searchIndex = lfIndex + 2\n lfIndex = chunk.indexOf('\\n', searchIndex)\n continue\n }\n // Multi-line data: concatenate with newline separator per spec.\n data = dataLines === 0 ? value : `${data}\\n${value}`\n dataLines++\n } else if (isEventPrefix(chunk, searchIndex, firstCharCode)) {\n // `event:` line — set the event type for the next dispatch. Per spec,\n // an empty value resets `event type` to its default (undefined here).\n // 'event:'.length === 6, 'event: '.length === 7\n eventType =\n chunk.slice(\n chunk.charCodeAt(searchIndex + 6) === SPACE ? searchIndex + 7 : searchIndex + 6,\n lfIndex,\n ) || undefined\n } else {\n // Everything else: `id:`, `retry:`, comment lines (`:` prefix), unknown\n // fields, or malformed lines. These are rarer and go through the full\n // per-line parser, which handles the SSE field grammar in detail.\n parseLine(chunk, searchIndex, lfIndex)\n }\n searchIndex = lfIndex + 1\n lfIndex = chunk.indexOf('\\n', searchIndex)\n }\n return chunk.slice(searchIndex)\n }\n\n // Slow path: the chunk contains at least one `\\r`, so lines may be terminated\n // by `\\r`, `\\n`, or `\\r\\n`. We locate the next terminator by looking at both\n // the nearest `\\r` and `\\n` and picking whichever comes first.\n while (searchIndex < chunk.length) {\n const crIndex = chunk.indexOf('\\r', searchIndex)\n const lfIndex = chunk.indexOf('\\n', searchIndex)\n\n let lineEnd = -1\n if (crIndex !== -1 && lfIndex !== -1) {\n lineEnd = crIndex < lfIndex ? crIndex : lfIndex\n } else if (crIndex !== -1) {\n // A trailing `\\r` at the very end of the chunk is ambiguous: it could be\n // a bare-CR terminator, or the first half of a `\\r\\n` whose `\\n` arrives\n // in the next chunk. Defer until we see more input.\n if (crIndex === chunk.length - 1) {\n lineEnd = -1\n } else {\n lineEnd = crIndex\n }\n } else if (lfIndex !== -1) {\n lineEnd = lfIndex\n }\n\n if (lineEnd === -1) {\n break\n }\n\n parseLine(chunk, searchIndex, lineEnd)\n searchIndex = lineEnd + 1\n // If we just consumed a `\\r` and the next char is `\\n`, skip it so the\n // pair is treated as a single terminator rather than an empty line.\n if (chunk.charCodeAt(searchIndex - 1) === CR && chunk.charCodeAt(searchIndex) === LF) {\n searchIndex++\n }\n }\n\n return chunk.slice(searchIndex)\n }\n\n function parseLine(chunk: string, start: number, end: number) {\n if (start === end) {\n dispatchEvent()\n return\n }\n\n const firstCharCode = chunk.charCodeAt(start)\n\n if (isDataPrefix(chunk, start, firstCharCode)) {\n // 'data:'.length === 5, 'data: '.length === 6\n const valueStart = chunk.charCodeAt(start + 5) === SPACE ? start + 6 : start + 5\n const value = chunk.slice(valueStart, end)\n data = dataLines === 0 ? value : `${data}\\n${value}`\n dataLines++\n return\n }\n\n if (isEventPrefix(chunk, start, firstCharCode)) {\n // 'event:'.length === 6, 'event: '.length === 7\n eventType =\n chunk.slice(chunk.charCodeAt(start + 6) === SPACE ? start + 7 : start + 6, end) || undefined\n return\n }\n\n // Fast path for \"id:\" — 'i' = 105, 'd' = 100, ':' = 58\n if (\n firstCharCode === 105 &&\n chunk.charCodeAt(start + 1) === 100 &&\n chunk.charCodeAt(start + 2) === 58\n ) {\n // 'id:'.length === 3, 'id: '.length === 4\n const value = chunk.slice(chunk.charCodeAt(start + 3) === SPACE ? start + 4 : start + 3, end)\n id = value.includes('\\0') ? undefined : value\n return\n }\n\n // Comment line — ':' = 58\n if (firstCharCode === 58) {\n if (onComment) {\n const line = chunk.slice(start, end)\n // skip ':' (+1), or ': ' (+2) when a space follows\n onComment(line.slice(chunk.charCodeAt(start + 1) === SPACE ? 2 : 1))\n }\n return\n }\n\n const line = chunk.slice(start, end)\n const fieldSeparatorIndex = line.indexOf(':')\n if (fieldSeparatorIndex === -1) {\n processField(line, '', line)\n return\n }\n\n const field = line.slice(0, fieldSeparatorIndex)\n // skip ':' (+1), or ': ' (+2) when a space follows\n const offset = line.charCodeAt(fieldSeparatorIndex + 1) === SPACE ? 2 : 1\n const value = line.slice(fieldSeparatorIndex + offset)\n processField(field, value, line)\n }\n\n function processField(field: string, value: string, line: string) {\n // Field names must be compared literally, with no case folding performed.\n switch (field) {\n case 'event':\n // Set the `event type` buffer to field value\n eventType = value || undefined\n break\n case 'data':\n data = dataLines === 0 ? value : `${data}\\n${value}`\n dataLines++\n break\n case 'id':\n // If the field value does not contain U+0000 NULL, then set the `ID` buffer to\n // the field value. Otherwise, ignore the field.\n id = value.includes('\\0') ? undefined : value\n break\n case 'retry':\n // If the field value consists of only ASCII digits, then interpret the field value as an\n // integer in base ten, and set the event stream's reconnection time to that integer.\n // Otherwise, ignore the field.\n if (/^\\d+$/.test(value)) {\n onRetry(parseInt(value, 10))\n } else {\n onError(\n new ParseError(`Invalid \\`retry\\` value: \"${value}\"`, {\n type: 'invalid-retry',\n value,\n line,\n }),\n )\n }\n break\n default:\n // Otherwise, the field is ignored.\n onError(\n new ParseError(\n `Unknown field \"${field.length > 20 ? `${field.slice(0, 20)}…` : field}\"`,\n {type: 'unknown-field', field, value, line},\n ),\n )\n break\n }\n }\n\n function dispatchEvent() {\n if (dataLines > 0) {\n onEvent({\n id,\n event: eventType,\n data,\n })\n }\n\n id = undefined\n data = ''\n dataLines = 0\n eventType = undefined\n }\n\n function reset(options: {consume?: boolean} = {}) {\n if (options.consume && pendingFragments.length > 0) {\n const incompleteLine = pendingFragments.join('')\n parseLine(incompleteLine, 0, incompleteLine.length)\n }\n\n isFirstChunk = true\n id = undefined\n data = ''\n dataLines = 0\n eventType = undefined\n pendingFragments.length = 0\n pendingFragmentsLength = 0\n terminated = false\n }\n\n return {feed, reset}\n}\n\n/**\n * Checks if `chunk` starts with the literal `data:` at index `i`.\n *\n * Equivalent to `chunk.startsWith('data:', i)`, but benchmarks show this\n * hand-unrolled char-code comparison is ~20% faster on common event types.\n * The caller passes `firstCharCode` (the code at `i`) so it can be reused\n * across prefix checks.\n *\n * ASCII: 'd' = 100, 'a' = 97, 't' = 116, 'a' = 97, ':' = 58\n */\nfunction isDataPrefix(chunk: string, i: number, firstCharCode: number): boolean {\n return (\n firstCharCode === 100 &&\n chunk.charCodeAt(i + 1) === 97 &&\n chunk.charCodeAt(i + 2) === 116 &&\n chunk.charCodeAt(i + 3) === 97 &&\n chunk.charCodeAt(i + 4) === 58\n )\n}\n\n/**\n * Checks if `chunk` starts with the literal `event:` at index `i`.\n *\n * See {@link isDataPrefix} for why this is hand-unrolled rather than using\n * `String.prototype.startsWith`.\n *\n * ASCII: 'e' = 101, 'v' = 118, 'e' = 101, 'n' = 110, 't' = 116, ':' = 58\n */\nfunction isEventPrefix(chunk: string, i: number, firstCharCode: number): boolean {\n return (\n firstCharCode === 101 &&\n chunk.charCodeAt(i + 1) === 118 &&\n chunk.charCodeAt(i + 2) === 101 &&\n chunk.charCodeAt(i + 3) === 110 &&\n chunk.charCodeAt(i + 4) === 116 &&\n chunk.charCodeAt(i + 5) === 58\n )\n}\n"],"names":["trailing","value","line"],"mappings":";;AAWO,MAAM,mBAAmB,MAAM;AAAA,EAqBpC,YACE,SACA,SACA;AACA,UAAM,OAAO,GACb,KAAK,OAAO,cACZ,KAAK,OAAO,QAAQ,MACpB,KAAK,QAAQ,QAAQ,OACrB,KAAK,QAAQ,QAAQ,OACrB,KAAK,OAAO,QAAQ;AAAA,EACtB;AACF;ACnCA,MAAM,KAAK,IACL,KAAK,IACL,QAAQ;AAGd,SAAS,KAAK,MAAe;AAE7B;AAWO,SAAS,aAAa,QAAyC;AACpE,MAAI,OAAO,UAAW;AACpB,UAAM,IAAI;AAAA,MACR;AAAA,IAAA;AAIJ,QAAM,EAAC,UAAU,MAAM,UAAU,MAAM,UAAU,MAAM,WAAW,cAAA,IAAiB,QAQ7E,mBAA6B,CAAA;AAInC,MAAI,yBAAyB,GAEzB,eAAe,IACf,IACA,OAAO,IACP,YAAY,GACZ,WAIA,aAAa;AAajB,WAAS,KAAK,OAAe;AAC3B,QAAI;AACF,YAAM,IAAI;AAAA,QACR;AAAA,MAAA;AAoBJ,QAhBI,iBACF,eAAe,IAIb,MAAM,WAAW,CAAC,MAAM,OACxB,MAAM,WAAW,CAAC,MAAM,OACxB,MAAM,WAAW,CAAC,MAAM,QAExB,QAAQ,MAAM,MAAM,CAAC,KAOrB,iBAAiB,WAAW,GAAG;AACjC,YAAMA,YAAW,aAAa,KAAK;AAC/BA,oBAAa,OACf,iBAAiB,KAAKA,SAAQ,GAC9B,yBAAyBA,UAAS,SAEpC,gBAAA;AACA;AAAA,IACF;AAKA,QAAI,MAAM,QAAQ;AAAA,CAAI,MAAM,MAAM,MAAM,QAAQ,IAAI,MAAM,IAAI;AAC5D,uBAAiB,KAAK,KAAK,GAC3B,0BAA0B,MAAM,QAChC,gBAAA;AACA;AAAA,IACF;AAIA,qBAAiB,KAAK,KAAK;AAC3B,UAAM,QAAQ,iBAAiB,KAAK,EAAE;AACtC,qBAAiB,SAAS,GAC1B,yBAAyB;AACzB,UAAM,WAAW,aAAa,KAAK;AAC/B,iBAAa,OACf,iBAAiB,KAAK,QAAQ,GAC9B,yBAAyB,SAAS,SAEpC,gBAAA;AAAA,EACF;AAEA,WAAS,kBAAkB;AACrB,sBAAkB,WAClB,yBAAyB,KAAK,UAAU,kBAE5C,aAAa,IACb,iBAAiB,SAAS,GAC1B,yBAAyB,GACzB,KAAK,QACL,OAAO,IACP,YAAY,GACZ,YAAY,QACZ;AAAA,MACE,IAAI,WAAW,6CAA6C,aAAa,eAAe;AAAA,QACtF,MAAM;AAAA,MAAA,CACP;AAAA,IAAA;AAAA,EAEL;AAWA,WAAS,aAAa,OAAuB;AAC3C,QAAI,cAAc;AAMlB,QAAI,MAAM,QAAQ,IAAI,MAAM,IAAI;AAC9B,UAAI,UAAU,MAAM,QAAQ;AAAA,GAAM,WAAW;AAC7C,aAAO,YAAY,MAAI;AAIrB,YAAI,gBAAgB,SAAS;AACvB,sBAAY,KACd,QAAQ,EAAC,IAAI,OAAO,WAAW,KAAA,CAAK,GAEtC,KAAK,QACL,OAAO,IACP,YAAY,GACZ,YAAY,QACZ,cAAc,UAAU,GACxB,UAAU,MAAM,QAAQ;AAAA,GAAM,WAAW;AACzC;AAAA,QACF;AACA,cAAM,gBAAgB,MAAM,WAAW,WAAW;AAClD,YAAI,aAAa,OAAO,aAAa,aAAa,GAAG;AAGnD,gBAAM,aACJ,MAAM,WAAW,cAAc,CAAC,MAAM,QAAQ,cAAc,IAAI,cAAc,GAC1E,QAAQ,MAAM,MAAM,YAAY,OAAO;AAM7C,cAAI,cAAc,KAAK,MAAM,WAAW,UAAU,CAAC,MAAM,IAAI;AAC3D,oBAAQ,EAAC,IAAI,OAAO,WAAW,MAAM,MAAA,CAAM,GAC3C,KAAK,QACL,OAAO,IACP,YAAY,QACZ,cAAc,UAAU,GACxB,UAAU,MAAM,QAAQ;AAAA,GAAM,WAAW;AACzC;AAAA,UACF;AAEA,iBAAO,cAAc,IAAI,QAAQ,GAAG,IAAI;AAAA,EAAK,KAAK,IAClD;AAAA,QACF,MAAW,eAAc,OAAO,aAAa,aAAa,IAIxD,YACE,MAAM;AAAA,UACJ,MAAM,WAAW,cAAc,CAAC,MAAM,QAAQ,cAAc,IAAI,cAAc;AAAA,UAC9E;AAAA,QAAA,KACG,SAKP,UAAU,OAAO,aAAa,OAAO;AAEvC,sBAAc,UAAU,GACxB,UAAU,MAAM,QAAQ;AAAA,GAAM,WAAW;AAAA,MAC3C;AACA,aAAO,MAAM,MAAM,WAAW;AAAA,IAChC;AAKA,WAAO,cAAc,MAAM,UAAQ;AACjC,YAAM,UAAU,MAAM,QAAQ,MAAM,WAAW,GACzC,UAAU,MAAM,QAAQ;AAAA,GAAM,WAAW;AAE/C,UAAI,UAAU;AAgBd,UAfI,YAAY,MAAM,YAAY,KAChC,UAAU,UAAU,UAAU,UAAU,UAC/B,YAAY,KAIjB,YAAY,MAAM,SAAS,IAC7B,UAAU,KAEV,UAAU,UAEH,YAAY,OACrB,UAAU,UAGR,YAAY;AACd;AAGF,gBAAU,OAAO,aAAa,OAAO,GACrC,cAAc,UAAU,GAGpB,MAAM,WAAW,cAAc,CAAC,MAAM,MAAM,MAAM,WAAW,WAAW,MAAM,MAChF;AAAA,IAEJ;AAEA,WAAO,MAAM,MAAM,WAAW;AAAA,EAChC;AAEA,WAAS,UAAU,OAAe,OAAe,KAAa;AAC5D,QAAI,UAAU,KAAK;AACjB,oBAAA;AACA;AAAA,IACF;AAEA,UAAM,gBAAgB,MAAM,WAAW,KAAK;AAE5C,QAAI,aAAa,OAAO,OAAO,aAAa,GAAG;AAE7C,YAAM,aAAa,MAAM,WAAW,QAAQ,CAAC,MAAM,QAAQ,QAAQ,IAAI,QAAQ,GACzEC,SAAQ,MAAM,MAAM,YAAY,GAAG;AACzC,aAAO,cAAc,IAAIA,SAAQ,GAAG,IAAI;AAAA,EAAKA,MAAK,IAClD;AACA;AAAA,IACF;AAEA,QAAI,cAAc,OAAO,OAAO,aAAa,GAAG;AAE9C,kBACE,MAAM,MAAM,MAAM,WAAW,QAAQ,CAAC,MAAM,QAAQ,QAAQ,IAAI,QAAQ,GAAG,GAAG,KAAK;AACrF;AAAA,IACF;AAGA,QACE,kBAAkB,OAClB,MAAM,WAAW,QAAQ,CAAC,MAAM,OAChC,MAAM,WAAW,QAAQ,CAAC,MAAM,IAChC;AAEA,YAAMA,SAAQ,MAAM,MAAM,MAAM,WAAW,QAAQ,CAAC,MAAM,QAAQ,QAAQ,IAAI,QAAQ,GAAG,GAAG;AAC5F,WAAKA,OAAM,SAAS,IAAI,IAAI,SAAYA;AACxC;AAAA,IACF;AAGA,QAAI,kBAAkB,IAAI;AACxB,UAAI,WAAW;AACb,cAAMC,QAAO,MAAM,MAAM,OAAO,GAAG;AAEnC,kBAAUA,MAAK,MAAM,MAAM,WAAW,QAAQ,CAAC,MAAM,QAAQ,IAAI,CAAC,CAAC;AAAA,MACrE;AACA;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,MAAM,OAAO,GAAG,GAC7B,sBAAsB,KAAK,QAAQ,GAAG;AAC5C,QAAI,wBAAwB,IAAI;AAC9B,mBAAa,MAAM,IAAI,IAAI;AAC3B;AAAA,IACF;AAEA,UAAM,QAAQ,KAAK,MAAM,GAAG,mBAAmB,GAEzC,SAAS,KAAK,WAAW,sBAAsB,CAAC,MAAM,QAAQ,IAAI,GAClE,QAAQ,KAAK,MAAM,sBAAsB,MAAM;AACrD,iBAAa,OAAO,OAAO,IAAI;AAAA,EACjC;AAEA,WAAS,aAAa,OAAe,OAAe,MAAc;AAEhE,YAAQ,OAAA;AAAA,MACN,KAAK;AAEH,oBAAY,SAAS;AACrB;AAAA,MACF,KAAK;AACH,eAAO,cAAc,IAAI,QAAQ,GAAG,IAAI;AAAA,EAAK,KAAK,IAClD;AACA;AAAA,MACF,KAAK;AAGH,aAAK,MAAM,SAAS,IAAI,IAAI,SAAY;AACxC;AAAA,MACF,KAAK;AAIC,gBAAQ,KAAK,KAAK,IACpB,QAAQ,SAAS,OAAO,EAAE,CAAC,IAE3B;AAAA,UACE,IAAI,WAAW,6BAA6B,KAAK,KAAK;AAAA,YACpD,MAAM;AAAA,YACN;AAAA,YACA;AAAA,UAAA,CACD;AAAA,QAAA;AAGL;AAAA,MACF;AAEE;AAAA,UACE,IAAI;AAAA,YACF,kBAAkB,MAAM,SAAS,KAAK,GAAG,MAAM,MAAM,GAAG,EAAE,CAAC,WAAM,KAAK;AAAA,YACtE,EAAC,MAAM,iBAAiB,OAAO,OAAO,KAAA;AAAA,UAAI;AAAA,QAC5C;AAEF;AAAA,IAAA;AAAA,EAEN;AAEA,WAAS,gBAAgB;AACnB,gBAAY,KACd,QAAQ;AAAA,MACN;AAAA,MACA,OAAO;AAAA,MACP;AAAA,IAAA,CACD,GAGH,KAAK,QACL,OAAO,IACP,YAAY,GACZ,YAAY;AAAA,EACd;AAEA,WAAS,MAAM,UAA+B,IAAI;AAChD,QAAI,QAAQ,WAAW,iBAAiB,SAAS,GAAG;AAClD,YAAM,iBAAiB,iBAAiB,KAAK,EAAE;AAC/C,gBAAU,gBAAgB,GAAG,eAAe,MAAM;AAAA,IACpD;AAEA,mBAAe,IACf,KAAK,QACL,OAAO,IACP,YAAY,GACZ,YAAY,QACZ,iBAAiB,SAAS,GAC1B,yBAAyB,GACzB,aAAa;AAAA,EACf;AAEA,SAAO,EAAC,MAAM,MAAA;AAChB;AAYA,SAAS,aAAa,OAAe,GAAW,eAAgC;AAC9E,SACE,kBAAkB,OAClB,MAAM,WAAW,IAAI,CAAC,MAAM,MAC5B,MAAM,WAAW,IAAI,CAAC,MAAM,OAC5B,MAAM,WAAW,IAAI,CAAC,MAAM,MAC5B,MAAM,WAAW,IAAI,CAAC,MAAM;AAEhC;AAUA,SAAS,cAAc,OAAe,GAAW,eAAgC;AAC/E,SACE,kBAAkB,OAClB,MAAM,WAAW,IAAI,CAAC,MAAM,OAC5B,MAAM,WAAW,IAAI,CAAC,MAAM,OAC5B,MAAM,WAAW,IAAI,CAAC,MAAM,OAC5B,MAAM,WAAW,IAAI,CAAC,MAAM,OAC5B,MAAM,WAAW,IAAI,CAAC,MAAM;AAEhC;;;"}
|
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creates a new EventSource parser.
|
|
3
3
|
*
|
|
4
|
-
* @param
|
|
5
|
-
*
|
|
6
|
-
* - `onError` when an error occurs
|
|
7
|
-
* - `onRetry` when a new reconnection interval has been sent from the server
|
|
8
|
-
* - `onComment` when a comment is encountered in the stream
|
|
4
|
+
* @param config - Parser configuration. Accepts callbacks (see {@link ParserCallbacks})
|
|
5
|
+
* and options like `maxBufferSize` (see {@link ParserConfig}).
|
|
9
6
|
*
|
|
10
|
-
* @returns A new EventSource parser, with `
|
|
7
|
+
* @returns A new EventSource parser, with `feed` and `reset` methods.
|
|
11
8
|
* @public
|
|
12
9
|
*/
|
|
13
|
-
export declare function createParser(
|
|
14
|
-
callbacks: ParserCallbacks,
|
|
15
|
-
): EventSourceParser;
|
|
10
|
+
export declare function createParser(config: ParserConfig): EventSourceParser;
|
|
16
11
|
|
|
17
12
|
/**
|
|
18
13
|
* The type of error that occurred.
|
|
19
14
|
* @public
|
|
20
15
|
*/
|
|
21
|
-
export declare type ErrorType =
|
|
16
|
+
export declare type ErrorType =
|
|
17
|
+
| "invalid-retry"
|
|
18
|
+
| "unknown-field"
|
|
19
|
+
| "max-buffer-size-exceeded";
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
22
|
* A parsed EventSource message event
|
|
@@ -143,4 +141,29 @@ export declare interface ParserCallbacks {
|
|
|
143
141
|
onError?: ((error: ParseError) => void) | undefined;
|
|
144
142
|
}
|
|
145
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Configuration accepted by {@link createParser}. Extends {@link ParserCallbacks} with
|
|
146
|
+
* additional options that control parser behavior.
|
|
147
|
+
*
|
|
148
|
+
* @public
|
|
149
|
+
*/
|
|
150
|
+
export declare interface ParserConfig extends ParserCallbacks {
|
|
151
|
+
/**
|
|
152
|
+
* Maximum number of characters the parser is allowed to buffer across calls to `feed()`.
|
|
153
|
+
*
|
|
154
|
+
* Two unbounded surfaces exist in a streaming SSE parser:
|
|
155
|
+
* - A partial line that has not yet been terminated by `\n`, `\r`, or `\r\n`.
|
|
156
|
+
* - A multi-line event whose terminating blank line has not yet arrived (each `data:`
|
|
157
|
+
* field gets appended to the buffered event).
|
|
158
|
+
*
|
|
159
|
+
* When the combined size of these buffers exceeds `maxBufferSize`, the parser emits a
|
|
160
|
+
* `ParseError` with `type: 'max-buffer-size-exceeded'` to `onError` and becomes
|
|
161
|
+
* terminated — subsequent calls to `feed()` will throw until `reset()` is called.
|
|
162
|
+
* This protects against unbounded memory growth from malformed or malicious streams.
|
|
163
|
+
*
|
|
164
|
+
* @defaultValue `undefined` (unbounded)
|
|
165
|
+
*/
|
|
166
|
+
maxBufferSize?: number | undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
146
169
|
export {};
|