eventsourcingdb 1.9.0 → 1.10.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/README.md +77 -6
- package/dist/Client.d.ts +7 -6
- package/dist/Client.js +189 -204
- package/dist/ClientOptions.d.ts +4 -0
- package/dist/ClientOptions.js +1 -0
- package/dist/ConnectionStalledError.d.ts +4 -0
- package/dist/ConnectionStalledError.js +13 -0
- package/dist/Event.d.ts +2 -1
- package/dist/Event.js +58 -11
- package/dist/HashVerificationFailedError.d.ts +4 -0
- package/dist/HashVerificationFailedError.js +7 -0
- package/dist/HeartbeatTimeoutError.d.ts +1 -1
- package/dist/HeartbeatTimeoutError.js +4 -2
- package/dist/SignatureVerificationFailedError.d.ts +4 -0
- package/dist/SignatureVerificationFailedError.js +7 -0
- package/dist/UnsupportedHashVersionError.d.ts +4 -0
- package/dist/UnsupportedHashVersionError.js +7 -0
- package/dist/convertCloudEventToEvent.d.ts +1 -1
- package/dist/convertCloudEventToEvent.js +6 -1
- package/dist/getImageTag.d.ts +2 -0
- package/dist/getImageTag.js +13 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +5 -1
- package/dist/json/getRawJsonElements.d.ts +2 -0
- package/dist/json/getRawJsonElements.js +22 -0
- package/dist/json/getRawJsonMember.d.ts +2 -0
- package/dist/json/getRawJsonMember.js +29 -0
- package/dist/json/scanJson.d.ts +4 -0
- package/dist/json/scanJson.js +65 -0
- package/dist/ndjson/readNdJsonStream.d.ts +7 -1
- package/dist/ndjson/readNdJsonStream.js +7 -28
- package/dist/stall/StallGuard.d.ts +14 -0
- package/dist/stall/StallGuard.js +83 -0
- package/dist/stall/createFetchTimeoutError.d.ts +2 -0
- package/dist/stall/createFetchTimeoutError.js +16 -0
- package/dist/stall/defaultStallTimeout.d.ts +3 -0
- package/dist/stall/defaultStallTimeout.js +6 -0
- package/dist/stall/formatDuration.d.ts +2 -0
- package/dist/stall/formatDuration.js +9 -0
- package/dist/stall/readResponseText.d.ts +3 -0
- package/dist/stall/readResponseText.js +26 -0
- package/dist/stall/runWithStallGuard.d.ts +6 -0
- package/dist/stall/runWithStallGuard.js +18 -0
- package/package.json +4 -4
- package/dist/heartbeatTimeout.d.ts +0 -4
- package/dist/heartbeatTimeout.js +0 -6
package/dist/Event.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { HashVerificationFailedError } from './HashVerificationFailedError.js';
|
|
4
|
+
import { SignatureVerificationFailedError } from './SignatureVerificationFailedError.js';
|
|
5
|
+
import { UnsupportedHashVersionError } from './UnsupportedHashVersionError.js';
|
|
6
|
+
// A hash of version 2 starts with this prefix. A hash without a prefix is of
|
|
7
|
+
// version 1, which predates the prefix.
|
|
8
|
+
const hashVersion2Prefix = 'esdb:hash:v2:';
|
|
9
|
+
const hashVersionRegex = /^esdb:hash:(?<version>[^:]*):/u;
|
|
10
|
+
// The input of a hash of version 2 writes a field that the event does not
|
|
11
|
+
// have like this.
|
|
12
|
+
const missingField = '-';
|
|
13
|
+
const getSha256 = (input) => crypto.createHash('sha256').update(input).digest('hex');
|
|
2
14
|
class Event {
|
|
3
15
|
specversion;
|
|
4
16
|
id;
|
|
@@ -9,12 +21,13 @@ class Event {
|
|
|
9
21
|
type;
|
|
10
22
|
datacontenttype;
|
|
11
23
|
data;
|
|
24
|
+
#dataFromServer;
|
|
12
25
|
hash;
|
|
13
26
|
predecessorhash;
|
|
14
27
|
traceparent;
|
|
15
28
|
tracestate;
|
|
16
29
|
signature;
|
|
17
|
-
constructor({ specversion, id, time, timeFromServer, source, subject, type, datacontenttype, data, hash, predecessorhash, traceparent, tracestate, signature, }) {
|
|
30
|
+
constructor({ specversion, id, time, timeFromServer, source, subject, type, datacontenttype, data, dataFromServer, hash, predecessorhash, traceparent, tracestate, signature, }) {
|
|
18
31
|
this.specversion = specversion;
|
|
19
32
|
this.id = id;
|
|
20
33
|
this.time = time;
|
|
@@ -24,22 +37,56 @@ class Event {
|
|
|
24
37
|
this.type = type;
|
|
25
38
|
this.datacontenttype = datacontenttype;
|
|
26
39
|
this.data = data;
|
|
40
|
+
this.#dataFromServer = dataFromServer;
|
|
27
41
|
this.hash = hash;
|
|
28
42
|
this.predecessorhash = predecessorhash;
|
|
29
43
|
this.traceparent = traceparent;
|
|
30
44
|
this.tracestate = tracestate;
|
|
31
45
|
this.signature = signature;
|
|
32
46
|
}
|
|
33
|
-
|
|
47
|
+
#computeHashOfVersion1() {
|
|
34
48
|
const metadata = `${this.specversion}|${this.id}|${this.predecessorhash}|${this.#timeFromServer}|${this.source}|${this.subject}|${this.type}|${this.datacontenttype}`;
|
|
35
|
-
const metadataHash =
|
|
36
|
-
const dataHash =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
const metadataHash = getSha256(metadata);
|
|
50
|
+
const dataHash = getSha256(this.#dataFromServer);
|
|
51
|
+
return getSha256(`${metadataHash}${dataHash}`);
|
|
52
|
+
}
|
|
53
|
+
#computeHashOfVersion2() {
|
|
54
|
+
const input = [
|
|
55
|
+
hashVersion2Prefix,
|
|
56
|
+
this.specversion,
|
|
57
|
+
this.id,
|
|
58
|
+
this.predecessorhash,
|
|
59
|
+
this.#timeFromServer,
|
|
60
|
+
this.source,
|
|
61
|
+
this.subject,
|
|
62
|
+
this.type,
|
|
63
|
+
this.datacontenttype,
|
|
64
|
+
this.traceparent ?? missingField,
|
|
65
|
+
this.tracestate ?? missingField,
|
|
66
|
+
this.#dataFromServer,
|
|
67
|
+
].join('\n');
|
|
68
|
+
return `${hashVersion2Prefix}${getSha256(input)}`;
|
|
69
|
+
}
|
|
70
|
+
// An event keeps the version of the hash it was written with, so the hash
|
|
71
|
+
// is computed in the version it names.
|
|
72
|
+
#computeHash() {
|
|
73
|
+
const version = hashVersionRegex.exec(this.hash)?.groups?.version;
|
|
74
|
+
if (version === undefined) {
|
|
75
|
+
return this.#computeHashOfVersion1();
|
|
76
|
+
}
|
|
77
|
+
if (`esdb:hash:${version}:` === hashVersion2Prefix) {
|
|
78
|
+
return this.#computeHashOfVersion2();
|
|
79
|
+
}
|
|
80
|
+
throw new UnsupportedHashVersionError(version);
|
|
81
|
+
}
|
|
82
|
+
verifyHash() {
|
|
83
|
+
if (this.#computeHash() !== this.hash) {
|
|
84
|
+
throw new HashVerificationFailedError();
|
|
85
|
+
}
|
|
86
|
+
// The hash covers the data as the server sent it, so data that has been
|
|
87
|
+
// changed since would go unnoticed without comparing the two.
|
|
88
|
+
if (!isDeepStrictEqual(this.data, JSON.parse(this.#dataFromServer))) {
|
|
89
|
+
throw new HashVerificationFailedError();
|
|
43
90
|
}
|
|
44
91
|
}
|
|
45
92
|
verifySignature(verificationKey) {
|
|
@@ -56,7 +103,7 @@ class Event {
|
|
|
56
103
|
const hashBytes = Buffer.from(this.hash, 'utf8');
|
|
57
104
|
const isSignatureValid = crypto.verify(null, hashBytes, verificationKey, signatureBytes);
|
|
58
105
|
if (!isSignatureValid) {
|
|
59
|
-
throw new
|
|
106
|
+
throw new SignatureVerificationFailedError();
|
|
60
107
|
}
|
|
61
108
|
}
|
|
62
109
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { defaultStallTimeout } from './stall/defaultStallTimeout.js';
|
|
2
|
+
import { formatDuration } from './stall/formatDuration.js';
|
|
1
3
|
class HeartbeatTimeoutError extends Error {
|
|
2
|
-
constructor() {
|
|
3
|
-
super(
|
|
4
|
+
constructor(stallTimeout = defaultStallTimeout) {
|
|
5
|
+
super(`No event and no heartbeat arrived for ${formatDuration(stallTimeout)}.`);
|
|
4
6
|
this.name = 'HeartbeatTimeoutError';
|
|
5
7
|
}
|
|
6
8
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { CloudEvent } from './CloudEvent.js';
|
|
2
2
|
import { Event } from './Event.js';
|
|
3
|
-
declare const convertCloudEventToEvent: (cloudEvent: CloudEvent) => Event;
|
|
3
|
+
declare const convertCloudEventToEvent: (cloudEvent: CloudEvent, cloudEventText?: string) => Event;
|
|
4
4
|
export { convertCloudEventToEvent };
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { Event } from './Event.js';
|
|
2
|
-
|
|
2
|
+
import { getRawJsonMember } from './json/getRawJsonMember.js';
|
|
3
|
+
// The hash of an event covers its data exactly as the server sent it, so the
|
|
4
|
+
// event keeps the data from the text of the cloud event, byte for byte.
|
|
5
|
+
const convertCloudEventToEvent = (cloudEvent, cloudEventText) => new Event({
|
|
3
6
|
specversion: cloudEvent.specversion,
|
|
4
7
|
id: cloudEvent.id,
|
|
5
8
|
time: new Date(cloudEvent.time),
|
|
@@ -9,6 +12,8 @@ const convertCloudEventToEvent = (cloudEvent) => new Event({
|
|
|
9
12
|
type: cloudEvent.type,
|
|
10
13
|
datacontenttype: cloudEvent.datacontenttype,
|
|
11
14
|
data: cloudEvent.data,
|
|
15
|
+
dataFromServer: (cloudEventText === undefined ? undefined : getRawJsonMember(cloudEventText, 'data')) ??
|
|
16
|
+
JSON.stringify(cloudEvent.data),
|
|
12
17
|
hash: cloudEvent.hash,
|
|
13
18
|
predecessorhash: cloudEvent.predecessorhash,
|
|
14
19
|
traceparent: cloudEvent.traceparent,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { getImageVersionFromDockerfile } from './getImageVersionFromDockerfile.js';
|
|
3
|
+
// The tests run against the image tag that EVENTSOURCINGDB_IMAGE_TAG names, so
|
|
4
|
+
// that the pipeline can run them against several tags. Without the variable,
|
|
5
|
+
// they run against the tag that the Dockerfile names.
|
|
6
|
+
const getImageTag = () => {
|
|
7
|
+
const imageTag = process.env.EVENTSOURCINGDB_IMAGE_TAG;
|
|
8
|
+
if (imageTag !== undefined && imageTag !== '') {
|
|
9
|
+
return imageTag;
|
|
10
|
+
}
|
|
11
|
+
return getImageVersionFromDockerfile();
|
|
12
|
+
};
|
|
13
|
+
export { getImageTag };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { Client } from './Client.js';
|
|
2
|
+
import type { ClientOptions } from './ClientOptions.js';
|
|
3
|
+
import { ConnectionStalledError } from './ConnectionStalledError.js';
|
|
2
4
|
import { Container } from './Container.js';
|
|
3
5
|
import type { Event } from './Event.js';
|
|
4
6
|
import type { EventCandidate } from './EventCandidate.js';
|
|
5
7
|
import type { EventType } from './EventType.js';
|
|
8
|
+
import { HashVerificationFailedError } from './HashVerificationFailedError.js';
|
|
6
9
|
import { HeartbeatTimeoutError } from './HeartbeatTimeoutError.js';
|
|
7
10
|
import { isEventQlQueryTrue } from './isEventQlQueryTrue.js';
|
|
8
11
|
import { isSubjectOnEventId } from './isSubjectOnEventId.js';
|
|
@@ -11,5 +14,7 @@ import { isSubjectPristine } from './isSubjectPristine.js';
|
|
|
11
14
|
import type { ObserveEventsOptions } from './ObserveEventsOptions.js';
|
|
12
15
|
import type { Precondition } from './Precondition.js';
|
|
13
16
|
import type { ReadEventsOptions } from './ReadEventsOptions.js';
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
import { SignatureVerificationFailedError } from './SignatureVerificationFailedError.js';
|
|
18
|
+
import { UnsupportedHashVersionError } from './UnsupportedHashVersionError.js';
|
|
19
|
+
export type { ClientOptions, Event, EventCandidate, EventType, ObserveEventsOptions, Precondition, ReadEventsOptions, };
|
|
20
|
+
export { Client, ConnectionStalledError, Container, HashVerificationFailedError, HeartbeatTimeoutError, isEventQlQueryTrue, isSubjectOnEventId, isSubjectPopulated, isSubjectPristine, SignatureVerificationFailedError, UnsupportedHashVersionError, };
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { Client } from './Client.js';
|
|
2
|
+
import { ConnectionStalledError } from './ConnectionStalledError.js';
|
|
2
3
|
import { Container } from './Container.js';
|
|
4
|
+
import { HashVerificationFailedError } from './HashVerificationFailedError.js';
|
|
3
5
|
import { HeartbeatTimeoutError } from './HeartbeatTimeoutError.js';
|
|
4
6
|
import { isEventQlQueryTrue } from './isEventQlQueryTrue.js';
|
|
5
7
|
import { isSubjectOnEventId } from './isSubjectOnEventId.js';
|
|
6
8
|
import { isSubjectPopulated } from './isSubjectPopulated.js';
|
|
7
9
|
import { isSubjectPristine } from './isSubjectPristine.js';
|
|
8
|
-
|
|
10
|
+
import { SignatureVerificationFailedError } from './SignatureVerificationFailedError.js';
|
|
11
|
+
import { UnsupportedHashVersionError } from './UnsupportedHashVersionError.js';
|
|
12
|
+
export { Client, ConnectionStalledError, Container, HashVerificationFailedError, HeartbeatTimeoutError, isEventQlQueryTrue, isSubjectOnEventId, isSubjectPopulated, isSubjectPristine, SignatureVerificationFailedError, UnsupportedHashVersionError, };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { scanJsonValue, skipJsonWhitespace } from './scanJson.js';
|
|
2
|
+
// Returns the elements of a JSON array exactly as the text contains them, or
|
|
3
|
+
// an empty array if the text is not an array.
|
|
4
|
+
const getRawJsonElements = (text) => {
|
|
5
|
+
const elements = [];
|
|
6
|
+
let position = skipJsonWhitespace(text, 0);
|
|
7
|
+
if (text[position] !== '[') {
|
|
8
|
+
return elements;
|
|
9
|
+
}
|
|
10
|
+
position = skipJsonWhitespace(text, position + 1);
|
|
11
|
+
while (position < text.length && text[position] !== ']') {
|
|
12
|
+
const elementEnd = scanJsonValue(text, position);
|
|
13
|
+
elements.push(text.slice(position, elementEnd));
|
|
14
|
+
position = skipJsonWhitespace(text, elementEnd);
|
|
15
|
+
if (text[position] !== ',') {
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
position = skipJsonWhitespace(text, position + 1);
|
|
19
|
+
}
|
|
20
|
+
return elements;
|
|
21
|
+
};
|
|
22
|
+
export { getRawJsonElements };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { scanJsonString, scanJsonValue, skipJsonWhitespace } from './scanJson.js';
|
|
2
|
+
// Returns the value of a member of a JSON object exactly as the text contains
|
|
3
|
+
// it, or undefined if the text is not an object or has no such member. If the
|
|
4
|
+
// object has the member more than once, the last one wins, as with JSON.parse.
|
|
5
|
+
const getRawJsonMember = (text, key) => {
|
|
6
|
+
let position = skipJsonWhitespace(text, 0);
|
|
7
|
+
if (text[position] !== '{') {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
position = skipJsonWhitespace(text, position + 1);
|
|
11
|
+
let rawValue;
|
|
12
|
+
while (text[position] === '"') {
|
|
13
|
+
const keyEnd = scanJsonString(text, position);
|
|
14
|
+
const currentKey = JSON.parse(text.slice(position, keyEnd));
|
|
15
|
+
const colon = skipJsonWhitespace(text, keyEnd);
|
|
16
|
+
const valueStart = skipJsonWhitespace(text, colon + 1);
|
|
17
|
+
const valueEnd = scanJsonValue(text, valueStart);
|
|
18
|
+
if (currentKey === key) {
|
|
19
|
+
rawValue = text.slice(valueStart, valueEnd);
|
|
20
|
+
}
|
|
21
|
+
position = skipJsonWhitespace(text, valueEnd);
|
|
22
|
+
if (text[position] !== ',') {
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
position = skipJsonWhitespace(text, position + 1);
|
|
26
|
+
}
|
|
27
|
+
return rawValue;
|
|
28
|
+
};
|
|
29
|
+
export { getRawJsonMember };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
declare const skipJsonWhitespace: (text: string, start: number) => number;
|
|
2
|
+
declare const scanJsonString: (text: string, start: number) => number;
|
|
3
|
+
declare const scanJsonValue: (text: string, start: number) => number;
|
|
4
|
+
export { scanJsonString, scanJsonValue, skipJsonWhitespace };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// These functions find where a value starts and ends in a JSON text, so that
|
|
2
|
+
// the SDK can take a value exactly as the server sent it, byte for byte. They
|
|
3
|
+
// expect valid JSON, since the SDK only scans texts that JSON.parse accepted.
|
|
4
|
+
const isJsonWhitespace = (character) => character === ' ' || character === '\t' || character === '\n' || character === '\r';
|
|
5
|
+
const isJsonDelimiter = (character) => character === ',' || character === '}' || character === ']' || isJsonWhitespace(character);
|
|
6
|
+
const skipJsonWhitespace = (text, start) => {
|
|
7
|
+
let position = start;
|
|
8
|
+
while (isJsonWhitespace(text[position])) {
|
|
9
|
+
position += 1;
|
|
10
|
+
}
|
|
11
|
+
return position;
|
|
12
|
+
};
|
|
13
|
+
const scanJsonString = (text, start) => {
|
|
14
|
+
let position = start + 1;
|
|
15
|
+
while (position < text.length) {
|
|
16
|
+
const character = text[position];
|
|
17
|
+
if (character === '\\') {
|
|
18
|
+
position += 2;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (character === '"') {
|
|
22
|
+
return position + 1;
|
|
23
|
+
}
|
|
24
|
+
position += 1;
|
|
25
|
+
}
|
|
26
|
+
throw new Error('Failed to scan JSON string.');
|
|
27
|
+
};
|
|
28
|
+
const scanJsonContainer = (text, start) => {
|
|
29
|
+
let depth = 0;
|
|
30
|
+
let position = start;
|
|
31
|
+
while (position < text.length) {
|
|
32
|
+
const character = text[position];
|
|
33
|
+
if (character === '"') {
|
|
34
|
+
position = scanJsonString(text, position);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (character === '{' || character === '[') {
|
|
38
|
+
depth += 1;
|
|
39
|
+
}
|
|
40
|
+
if (character === '}' || character === ']') {
|
|
41
|
+
depth -= 1;
|
|
42
|
+
if (depth === 0) {
|
|
43
|
+
return position + 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
position += 1;
|
|
47
|
+
}
|
|
48
|
+
throw new Error('Failed to scan JSON container.');
|
|
49
|
+
};
|
|
50
|
+
// Returns the position right after the value that starts at the given position.
|
|
51
|
+
const scanJsonValue = (text, start) => {
|
|
52
|
+
const character = text[start];
|
|
53
|
+
if (character === '"') {
|
|
54
|
+
return scanJsonString(text, start);
|
|
55
|
+
}
|
|
56
|
+
if (character === '{' || character === '[') {
|
|
57
|
+
return scanJsonContainer(text, start);
|
|
58
|
+
}
|
|
59
|
+
let position = start;
|
|
60
|
+
while (position < text.length && !isJsonDelimiter(text[position])) {
|
|
61
|
+
position += 1;
|
|
62
|
+
}
|
|
63
|
+
return position;
|
|
64
|
+
};
|
|
65
|
+
export { scanJsonString, scanJsonValue, skipJsonWhitespace };
|
|
@@ -1,2 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import type { StallGuard } from '../stall/StallGuard.js';
|
|
2
|
+
interface NdJsonLine {
|
|
3
|
+
text: string;
|
|
4
|
+
value: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
declare const readNdJsonStream: (stream: ReadableStream<Uint8Array>, signal: AbortSignal, stallGuard?: StallGuard) => AsyncGenerator<NdJsonLine, void, void>;
|
|
7
|
+
export type { NdJsonLine };
|
|
2
8
|
export { readNdJsonStream };
|
|
@@ -1,27 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
const readNdJsonStream = async function* (stream, signal, heartbeatTimeoutInMilliseconds) {
|
|
1
|
+
const readNdJsonStream = async function* (stream, signal, stallGuard) {
|
|
3
2
|
const reader = stream.getReader();
|
|
4
3
|
const decoder = new TextDecoder('utf-8');
|
|
5
4
|
let buffer = '';
|
|
6
|
-
let heartbeatTimer;
|
|
7
|
-
let hasHeartbeatTimedOut = false;
|
|
8
|
-
// The timer only runs while waiting for the next line, not while the caller
|
|
9
|
-
// handles a line, so a slow caller does not cause a heartbeat timeout.
|
|
10
|
-
const startHeartbeatTimer = () => {
|
|
11
|
-
if (heartbeatTimeoutInMilliseconds === undefined || heartbeatTimer !== undefined) {
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
heartbeatTimer = setTimeout(() => {
|
|
15
|
-
hasHeartbeatTimedOut = true;
|
|
16
|
-
reader.cancel().catch(() => {
|
|
17
|
-
// Intentionally left blank.
|
|
18
|
-
});
|
|
19
|
-
}, heartbeatTimeoutInMilliseconds);
|
|
20
|
-
};
|
|
21
|
-
const stopHeartbeatTimer = () => {
|
|
22
|
-
clearTimeout(heartbeatTimer);
|
|
23
|
-
heartbeatTimer = undefined;
|
|
24
|
-
};
|
|
25
5
|
const onAbort = () => {
|
|
26
6
|
reader.cancel().catch(() => {
|
|
27
7
|
// Intentionally left blank.
|
|
@@ -36,7 +16,9 @@ const readNdJsonStream = async function* (stream, signal, heartbeatTimeoutInMill
|
|
|
36
16
|
signal.addEventListener('abort', onAbort);
|
|
37
17
|
try {
|
|
38
18
|
while (!signal.aborted) {
|
|
39
|
-
|
|
19
|
+
// The clock only runs while waiting for the next chunk, not while the
|
|
20
|
+
// caller handles a line, so a slow caller does not cause a stall.
|
|
21
|
+
stallGuard?.restart();
|
|
40
22
|
// biome-ignore lint/performance/noAwaitInLoops: Awaiting the result is fine here, although we are in a loop.
|
|
41
23
|
const { done, value } = await reader.read();
|
|
42
24
|
if (done) {
|
|
@@ -48,18 +30,15 @@ const readNdJsonStream = async function* (stream, signal, heartbeatTimeoutInMill
|
|
|
48
30
|
const line = buffer.slice(0, index).trim();
|
|
49
31
|
buffer = buffer.slice(index + 1);
|
|
50
32
|
if (line) {
|
|
51
|
-
|
|
52
|
-
yield JSON.parse(line);
|
|
33
|
+
stallGuard?.pause();
|
|
34
|
+
yield { text: line, value: JSON.parse(line) };
|
|
53
35
|
}
|
|
54
36
|
index = buffer.indexOf('\n');
|
|
55
37
|
}
|
|
56
38
|
}
|
|
57
|
-
if (hasHeartbeatTimedOut) {
|
|
58
|
-
throw new HeartbeatTimeoutError();
|
|
59
|
-
}
|
|
60
39
|
}
|
|
61
40
|
finally {
|
|
62
|
-
|
|
41
|
+
stallGuard?.pause();
|
|
63
42
|
signal.removeEventListener('abort', onAbort);
|
|
64
43
|
await reader.cancel().catch(() => {
|
|
65
44
|
// Intentionally left blank.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
declare class StallGuard {
|
|
2
|
+
#private;
|
|
3
|
+
constructor(stallTimeout: number, callerSignal?: AbortSignal);
|
|
4
|
+
get signal(): AbortSignal;
|
|
5
|
+
send(url: string, init: RequestInit): Promise<Response>;
|
|
6
|
+
onStall(createStallError: () => Error): void;
|
|
7
|
+
restart(): void;
|
|
8
|
+
pause(): void;
|
|
9
|
+
throwIfStalled(): void;
|
|
10
|
+
dispose({ abortRequest }: {
|
|
11
|
+
abortRequest: boolean;
|
|
12
|
+
}): void;
|
|
13
|
+
}
|
|
14
|
+
export { StallGuard };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ConnectionStalledError } from '../ConnectionStalledError.js';
|
|
2
|
+
import { createFetchTimeoutError } from './createFetchTimeoutError.js';
|
|
3
|
+
// Ends a request once nothing arrives from the server for the stall timeout,
|
|
4
|
+
// from sending the request until its end, including connecting and waiting
|
|
5
|
+
// for the response headers. Everything that arrives restarts the clock, and
|
|
6
|
+
// the clock stops while the caller handles an item of a stream. Aborting the
|
|
7
|
+
// signal of the caller aborts the request as well.
|
|
8
|
+
class StallGuard {
|
|
9
|
+
#stallTimeout;
|
|
10
|
+
#controller = new AbortController();
|
|
11
|
+
#callerSignal;
|
|
12
|
+
#createStallError;
|
|
13
|
+
#phase = 'headers';
|
|
14
|
+
#stallError;
|
|
15
|
+
#timer;
|
|
16
|
+
#onCallerAbort = () => {
|
|
17
|
+
this.#controller.abort(this.#callerSignal?.reason);
|
|
18
|
+
};
|
|
19
|
+
constructor(stallTimeout, callerSignal) {
|
|
20
|
+
this.#stallTimeout = stallTimeout;
|
|
21
|
+
this.#callerSignal = callerSignal;
|
|
22
|
+
this.#createStallError = () => new ConnectionStalledError(stallTimeout, {
|
|
23
|
+
cause: createFetchTimeoutError(this.#phase),
|
|
24
|
+
});
|
|
25
|
+
if (callerSignal?.aborted) {
|
|
26
|
+
this.#onCallerAbort();
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
callerSignal?.addEventListener('abort', this.#onCallerAbort, { once: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// The signal of the request, which aborts once the request stalls or the
|
|
33
|
+
// caller aborts.
|
|
34
|
+
get signal() {
|
|
35
|
+
return this.#controller.signal;
|
|
36
|
+
}
|
|
37
|
+
// Sends the request with the signal of the guard. Once the headers of the
|
|
38
|
+
// response have arrived, the clock starts anew.
|
|
39
|
+
async send(url, init) {
|
|
40
|
+
const response = await fetch(url, { ...init, signal: this.#controller.signal });
|
|
41
|
+
this.#phase = 'body';
|
|
42
|
+
this.restart();
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
// From now on, a stall ends the request with the error that this creates.
|
|
46
|
+
onStall(createStallError) {
|
|
47
|
+
this.#createStallError = createStallError;
|
|
48
|
+
}
|
|
49
|
+
// Starts the clock, or starts it anew, since something arrived.
|
|
50
|
+
restart() {
|
|
51
|
+
if (this.#controller.signal.aborted) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// Starts a new timer rather than refreshing the running one, since the
|
|
55
|
+
// mocked timers of node:test do not move a refreshed timer.
|
|
56
|
+
this.pause();
|
|
57
|
+
this.#timer = setTimeout(() => {
|
|
58
|
+
this.#timer = undefined;
|
|
59
|
+
this.#stallError = this.#createStallError();
|
|
60
|
+
this.#controller.abort(this.#stallError);
|
|
61
|
+
}, this.#stallTimeout);
|
|
62
|
+
}
|
|
63
|
+
// Stops the clock, while the caller handles an item of a stream.
|
|
64
|
+
pause() {
|
|
65
|
+
clearTimeout(this.#timer);
|
|
66
|
+
this.#timer = undefined;
|
|
67
|
+
}
|
|
68
|
+
throwIfStalled() {
|
|
69
|
+
if (this.#stallError !== undefined) {
|
|
70
|
+
throw this.#stallError;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Stops watching the request. Aborting it as well closes its connection,
|
|
74
|
+
// which a stream needs, since it may end before its body does.
|
|
75
|
+
dispose({ abortRequest }) {
|
|
76
|
+
this.pause();
|
|
77
|
+
this.#callerSignal?.removeEventListener('abort', this.#onCallerAbort);
|
|
78
|
+
if (abortRequest) {
|
|
79
|
+
this.#controller.abort();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export { StallGuard };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Creates an error like the one that the fetch built into Node.js gives as
|
|
2
|
+
// the cause of its TypeError when it times out, with the same name, code, and
|
|
3
|
+
// message, so that code that checks the code of the cause keeps working.
|
|
4
|
+
const createFetchTimeoutError = (phase) => {
|
|
5
|
+
if (phase === 'headers') {
|
|
6
|
+
return Object.assign(new Error('Headers Timeout Error'), {
|
|
7
|
+
name: 'HeadersTimeoutError',
|
|
8
|
+
code: 'UND_ERR_HEADERS_TIMEOUT',
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return Object.assign(new Error('Body Timeout Error'), {
|
|
12
|
+
name: 'BodyTimeoutError',
|
|
13
|
+
code: 'UND_ERR_BODY_TIMEOUT',
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
export { createFetchTimeoutError };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// A request fails once nothing arrives from the server for this many
|
|
2
|
+
// milliseconds, unless the client is configured otherwise.
|
|
3
|
+
const defaultStallTimeout = 30_000;
|
|
4
|
+
// Node.js runs a timer for at most this many milliseconds.
|
|
5
|
+
const maximumStallTimeout = 2_147_483_647;
|
|
6
|
+
export { defaultStallTimeout, maximumStallTimeout };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const millisecondsPerSecond = 1000;
|
|
2
|
+
const formatDuration = (milliseconds) => {
|
|
3
|
+
if (milliseconds % millisecondsPerSecond === 0) {
|
|
4
|
+
const seconds = milliseconds / millisecondsPerSecond;
|
|
5
|
+
return `${seconds} ${seconds === 1 ? 'second' : 'seconds'}`;
|
|
6
|
+
}
|
|
7
|
+
return `${milliseconds} ${milliseconds === 1 ? 'millisecond' : 'milliseconds'}`;
|
|
8
|
+
};
|
|
9
|
+
export { formatDuration };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Reads the body of a response as text, like response.text(), but restarts
|
|
2
|
+
// the clock of the stall guard with every chunk that arrives.
|
|
3
|
+
const readResponseText = async (response, stallGuard) => {
|
|
4
|
+
if (!response.body) {
|
|
5
|
+
return '';
|
|
6
|
+
}
|
|
7
|
+
const reader = response.body.getReader();
|
|
8
|
+
const decoder = new TextDecoder('utf-8');
|
|
9
|
+
let text = '';
|
|
10
|
+
try {
|
|
11
|
+
stallGuard.restart();
|
|
12
|
+
let result = await reader.read();
|
|
13
|
+
while (!result.done) {
|
|
14
|
+
text += decoder.decode(result.value, { stream: true });
|
|
15
|
+
stallGuard.restart();
|
|
16
|
+
// biome-ignore lint/performance/noAwaitInLoops: Awaiting the result is fine here, although we are in a loop.
|
|
17
|
+
result = await reader.read();
|
|
18
|
+
}
|
|
19
|
+
text += decoder.decode();
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
reader.releaseLock();
|
|
23
|
+
}
|
|
24
|
+
return text;
|
|
25
|
+
};
|
|
26
|
+
export { readResponseText };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { StallGuard } from './StallGuard.js';
|
|
2
|
+
declare const runWithStallGuard: <TResult>({ stallTimeout, signal }: {
|
|
3
|
+
stallTimeout: number;
|
|
4
|
+
signal?: AbortSignal;
|
|
5
|
+
}, run: (stallGuard: StallGuard) => Promise<TResult>) => Promise<TResult>;
|
|
6
|
+
export { runWithStallGuard };
|