@elysra/tungsten-runtime 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ import { hasOwn } from "./util.js";
2
+ /** The key of {@link EventStream.on} handlers that receives every event no
3
+ * other handler took. */
4
+ export const ANY_EVENT = "*";
5
+ export class EventStream {
6
+ #source;
7
+ #abort;
8
+ #cancelled = false;
9
+ /** @param source the items of the stream
10
+ * @param abort aborts the request the source reads (closes the connection) */
11
+ constructor(source, abort) {
12
+ this.#source = source;
13
+ this.#abort = abort;
14
+ }
15
+ /** Whether {@link cancel} was called. */
16
+ get cancelled() {
17
+ return this.#cancelled;
18
+ }
19
+ /** Close the connection and end the stream: a pending or later read ends
20
+ * without a failure item. Safe to call more than once. */
21
+ cancel() {
22
+ if (this.#cancelled)
23
+ return;
24
+ this.#cancelled = true;
25
+ this.#abort();
26
+ this.#source.return(undefined).catch(() => undefined);
27
+ }
28
+ async *[Symbol.asyncIterator]() {
29
+ try {
30
+ for (;;) {
31
+ const next = await this.#source.next();
32
+ if (next.done === true || this.#cancelled)
33
+ return;
34
+ yield next.value;
35
+ }
36
+ }
37
+ finally {
38
+ this.#abort();
39
+ await this.#source.return(undefined).catch(() => undefined);
40
+ }
41
+ }
42
+ /** Read the stream to its end and call the handler named like each event
43
+ * (`handlers.message_delta` for `event: message_delta`; events without a
44
+ * name are `message`), else `handlers["*"]`. A handler that throws or
45
+ * rejects ends the stream (the connection is closed) and the error
46
+ * propagates. Resolves to the number of events delivered. */
47
+ async on(handlers) {
48
+ let count = 0;
49
+ let reconnects = 0;
50
+ for await (const item of this) {
51
+ if (!item.ok)
52
+ return { ok: false, error: item.error, partial: count, reconnects };
53
+ reconnects = item.meta.reconnects;
54
+ const handler = hasOwn(handlers, item.event) ? handlers[item.event] : handlers[ANY_EVENT];
55
+ count += 1;
56
+ await handler?.(item.value, { value: item.value, event: item.event, id: item.id, retry: item.retry });
57
+ }
58
+ return { ok: true, value: count, reconnects };
59
+ }
60
+ /** Read the stream to its end and fold the events into one value
61
+ * (concatenating text deltas, say). `partial` of a failure is the value
62
+ * folded so far. */
63
+ async reduce(fold, initial) {
64
+ let accumulator = initial;
65
+ let reconnects = 0;
66
+ for await (const item of this) {
67
+ if (!item.ok)
68
+ return { ok: false, error: item.error, partial: accumulator, reconnects };
69
+ reconnects = item.meta.reconnects;
70
+ accumulator = fold(accumulator, item.value, { value: item.value, event: item.event, id: item.id, retry: item.retry });
71
+ }
72
+ return { ok: true, value: accumulator, reconnects };
73
+ }
74
+ /** Read the stream to its end into a list of the events' `data`. */
75
+ collect() {
76
+ return this.reduce((all, value) => {
77
+ all.push(value);
78
+ return all;
79
+ }, []);
80
+ }
81
+ /** The first event for which `predicate` holds (any event without one),
82
+ * then close the stream; `value` is null when the stream ends without
83
+ * one. */
84
+ async first(predicate) {
85
+ let reconnects = 0;
86
+ for await (const item of this) {
87
+ if (!item.ok)
88
+ return { ok: false, error: item.error, partial: null, reconnects };
89
+ reconnects = item.meta.reconnects;
90
+ const event = { value: item.value, event: item.event, id: item.id, retry: item.retry };
91
+ if (predicate === undefined || predicate(item.value, event))
92
+ return { ok: true, value: event, reconnects };
93
+ }
94
+ return { ok: true, value: null, reconnects };
95
+ }
96
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * One HTTP attempt: per-attempt timeout combined with the caller's signal,
3
+ * the body read inside the same deadline, and network failures classified
4
+ * by whether the request can have reached the server.
5
+ */
6
+ import type { HttpMethod } from "./types.js";
7
+ export interface AttemptRequest {
8
+ url: string;
9
+ method: HttpMethod;
10
+ headers: Record<string, string>;
11
+ body: BodyInit | null;
12
+ redirect: RequestRedirect;
13
+ timeoutMs: number;
14
+ signal: AbortSignal | undefined;
15
+ /** Hand a `text/event-stream` answer with a 2xx status back unread, as a
16
+ * {@link StreamBody}; any other answer is read as usual. */
17
+ stream?: boolean;
18
+ /** With `stream`: the silence, in milliseconds, after which a read of the
19
+ * body reports a timeout. Default `timeoutMs`. */
20
+ idleMs?: number | undefined;
21
+ maxResponseBytes?: number | undefined;
22
+ }
23
+ /** What one read of an event stream gives. */
24
+ export type StreamRead = {
25
+ kind: "chunk";
26
+ bytes: Uint8Array;
27
+ } | {
28
+ kind: "end";
29
+ }
30
+ /** No bytes arrived within the attempt timeout. */
31
+ | {
32
+ kind: "timeout";
33
+ }
34
+ /** The caller's signal aborted. */
35
+ | {
36
+ kind: "aborted";
37
+ }
38
+ /** The connection failed or was closed before the stream ended. */
39
+ | {
40
+ kind: "lost";
41
+ };
42
+ /** The unread body of an event stream: chunks of bytes with an idle
43
+ * timeout (the attempt timeout, restarted by every read) and the caller's
44
+ * signal. */
45
+ export declare class StreamBody {
46
+ #private;
47
+ constructor(reader: ReadableStreamDefaultReader<Uint8Array>, controller: AbortController, signal: AbortSignal | undefined, onAbort: () => void, idleMs: number);
48
+ read(): Promise<StreamRead>;
49
+ /** Release the connection. Safe to call more than once. */
50
+ close(): void;
51
+ }
52
+ export type AttemptOutcome = {
53
+ kind: "response";
54
+ response: Response;
55
+ status: number;
56
+ headers: Record<string, string>;
57
+ /** The body, or null when reading it failed. */
58
+ body: Uint8Array | null;
59
+ /** Why the body could not be read: deadline, caller abort or a broken connection. */
60
+ bodyFailure: "timeout" | "aborted" | "broken" | "oversized" | null;
61
+ /** The unread body of an event stream (`AttemptRequest.stream`); `body` is then null. */
62
+ stream?: StreamBody;
63
+ }
64
+ /** The request cannot have reached the server (DNS, refused, TLS). */
65
+ | {
66
+ kind: "not_sent";
67
+ detail: string;
68
+ }
69
+ /** Sent or possibly sent, then the connection failed without a response. */
70
+ | {
71
+ kind: "lost";
72
+ detail: string;
73
+ } | {
74
+ kind: "timeout";
75
+ }
76
+ /** The caller's signal aborted; `sent` is false only when it was already aborted. */
77
+ | {
78
+ kind: "aborted";
79
+ sent: boolean;
80
+ };
81
+ /** Send once. Never throws. */
82
+ export declare function attempt(fetchImpl: typeof fetch, req: AttemptRequest): Promise<AttemptOutcome>;
83
+ /** Whether a `Content-Type` value is `text/event-stream` (parameters and case ignored). */
84
+ export declare function isEventStream(contentType: string | undefined): boolean;
85
+ /** Retry-After in milliseconds (delta seconds or HTTP date), or null. */
86
+ export declare function parseRetryAfter(value: string | undefined, now: number): number | null;
87
+ /** Next page URL from a `Link` header (`rel="next"`), or null. */
88
+ export declare function nextLink(header: string | undefined): string | null;
@@ -0,0 +1,245 @@
1
+ import { isRecord } from "./util.js";
2
+ /** The unread body of an event stream: chunks of bytes with an idle
3
+ * timeout (the attempt timeout, restarted by every read) and the caller's
4
+ * signal. */
5
+ export class StreamBody {
6
+ #reader;
7
+ #controller;
8
+ #signal;
9
+ #onAbort;
10
+ #idleMs;
11
+ #cancelRead = () => { void this.#reader.cancel().catch(() => undefined); };
12
+ #timedOut = false;
13
+ #closed = false;
14
+ constructor(reader, controller, signal, onAbort, idleMs) {
15
+ this.#reader = reader;
16
+ this.#controller = controller;
17
+ this.#signal = signal;
18
+ this.#onAbort = onAbort;
19
+ this.#idleMs = Math.max(1, idleMs);
20
+ signal?.addEventListener("abort", this.#cancelRead, { once: true });
21
+ if (signal?.aborted)
22
+ this.#cancelRead();
23
+ }
24
+ async read() {
25
+ if (this.#closed)
26
+ return { kind: "end" };
27
+ const timer = setTimeout(() => {
28
+ this.#timedOut = true;
29
+ this.#controller.abort();
30
+ this.#cancelRead();
31
+ }, this.#idleMs);
32
+ try {
33
+ const chunk = await this.#reader.read();
34
+ if (this.#timedOut)
35
+ return { kind: "timeout" };
36
+ if (this.#signal?.aborted)
37
+ return { kind: "aborted" };
38
+ if (chunk.done)
39
+ return { kind: "end" };
40
+ return { kind: "chunk", bytes: chunk.value };
41
+ }
42
+ catch {
43
+ if (this.#timedOut)
44
+ return { kind: "timeout" };
45
+ return this.#signal?.aborted === true ? { kind: "aborted" } : { kind: "lost" };
46
+ }
47
+ finally {
48
+ clearTimeout(timer);
49
+ }
50
+ }
51
+ /** Release the connection. Safe to call more than once. */
52
+ close() {
53
+ if (this.#closed)
54
+ return;
55
+ this.#closed = true;
56
+ this.#signal?.removeEventListener("abort", this.#onAbort);
57
+ this.#signal?.removeEventListener("abort", this.#cancelRead);
58
+ this.#reader.cancel().catch(() => undefined);
59
+ this.#controller.abort();
60
+ }
61
+ }
62
+ /** Error codes (Node, undici, Bun, Deno) that mean nothing was sent. */
63
+ const NOT_SENT_CODES = new Set([
64
+ "ECONNREFUSED",
65
+ "ENOTFOUND",
66
+ "EAI_AGAIN",
67
+ "EAI_FAIL",
68
+ "EAI_NONAME",
69
+ "EHOSTUNREACH",
70
+ "ENETUNREACH",
71
+ "ENETDOWN",
72
+ "EHOSTDOWN",
73
+ "EADDRNOTAVAIL",
74
+ "UND_ERR_CONNECT_TIMEOUT",
75
+ "UND_ERR_INVALID_ARG",
76
+ "ERR_INVALID_URL",
77
+ "ConnectionRefused",
78
+ "FailedToOpenSocket",
79
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
80
+ "SELF_SIGNED_CERT_IN_CHAIN",
81
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
82
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
83
+ "CERT_HAS_EXPIRED",
84
+ "ERR_TLS_CERT_ALTNAME_INVALID",
85
+ ]);
86
+ function errorCodes(error) {
87
+ const codes = [];
88
+ const seen = new Set();
89
+ const visit = (e, depth) => {
90
+ if (depth > 8 || !isRecord(e) || seen.has(e))
91
+ return;
92
+ seen.add(e);
93
+ const record = e;
94
+ if (typeof record.code === "string")
95
+ codes.push(record.code);
96
+ if (typeof record.name === "string" && record.name === "NotFound")
97
+ codes.push("ENOTFOUND");
98
+ visit(record.cause, depth + 1);
99
+ if (Array.isArray(record.errors))
100
+ for (const inner of record.errors)
101
+ visit(inner, depth + 1);
102
+ };
103
+ visit(error, 0);
104
+ return codes;
105
+ }
106
+ function detail(error) {
107
+ const codes = errorCodes(error);
108
+ if (codes.length > 0)
109
+ return codes.join(", ");
110
+ return error instanceof Error ? error.name : "network error";
111
+ }
112
+ function isNotSent(error) {
113
+ return errorCodes(error).some((code) => NOT_SENT_CODES.has(code) || code.startsWith("ERR_TLS_") || code.startsWith("CERT_"));
114
+ }
115
+ /** Send once. Never throws. */
116
+ export async function attempt(fetchImpl, req) {
117
+ if (req.signal?.aborted)
118
+ return { kind: "aborted", sent: false };
119
+ const controller = new AbortController();
120
+ let timedOut = false;
121
+ const timer = setTimeout(() => {
122
+ timedOut = true;
123
+ controller.abort();
124
+ }, Math.max(1, req.timeoutMs));
125
+ const onAbort = () => controller.abort();
126
+ req.signal?.addEventListener("abort", onAbort, { once: true });
127
+ let handedOver = false;
128
+ try {
129
+ let response;
130
+ try {
131
+ response = await fetchImpl(req.url, {
132
+ method: req.method,
133
+ headers: req.headers,
134
+ body: req.body,
135
+ redirect: req.redirect,
136
+ signal: controller.signal,
137
+ });
138
+ }
139
+ catch (error) {
140
+ if (timedOut)
141
+ return { kind: "timeout" };
142
+ if (req.signal?.aborted)
143
+ return { kind: "aborted", sent: true };
144
+ return isNotSent(error) ? { kind: "not_sent", detail: detail(error) } : { kind: "lost", detail: detail(error) };
145
+ }
146
+ const headers = {};
147
+ response.headers.forEach((value, name) => {
148
+ headers[name.toLowerCase()] = value;
149
+ });
150
+ if (req.stream === true && response.status >= 200 && response.status <= 299 && response.body !== null && isEventStream(headers["content-type"])) {
151
+ handedOver = true;
152
+ clearTimeout(timer);
153
+ const stream = new StreamBody(response.body.getReader(), controller, req.signal, onAbort, req.idleMs ?? req.timeoutMs);
154
+ return { kind: "response", response, status: response.status, headers, body: null, bodyFailure: null, stream };
155
+ }
156
+ let body = null;
157
+ let bodyFailure = null;
158
+ try {
159
+ if (req.maxResponseBytes === undefined || response.body === null) {
160
+ body = new Uint8Array(await response.arrayBuffer());
161
+ }
162
+ else {
163
+ const reader = response.body.getReader();
164
+ const cancel = () => { void reader.cancel().catch(() => undefined); };
165
+ controller.signal.addEventListener("abort", cancel, { once: true });
166
+ const chunks = [];
167
+ let size = 0;
168
+ try {
169
+ const length = headers["content-length"];
170
+ if (length !== undefined && /^\d+$/.test(length) && Number(length) > req.maxResponseBytes)
171
+ bodyFailure = "oversized";
172
+ while (bodyFailure === null) {
173
+ const chunk = await reader.read();
174
+ if (controller.signal.aborted) {
175
+ bodyFailure = timedOut ? "timeout" : "aborted";
176
+ break;
177
+ }
178
+ if (chunk.done)
179
+ break;
180
+ size += chunk.value.byteLength;
181
+ if (size > req.maxResponseBytes) {
182
+ bodyFailure = "oversized";
183
+ break;
184
+ }
185
+ chunks.push(chunk.value);
186
+ }
187
+ if (bodyFailure === null) {
188
+ body = new Uint8Array(size);
189
+ let offset = 0;
190
+ for (const chunk of chunks) {
191
+ body.set(chunk, offset);
192
+ offset += chunk.byteLength;
193
+ }
194
+ }
195
+ }
196
+ finally {
197
+ controller.signal.removeEventListener("abort", cancel);
198
+ if (bodyFailure !== null)
199
+ await reader.cancel().catch(() => undefined);
200
+ reader.releaseLock();
201
+ }
202
+ }
203
+ }
204
+ catch {
205
+ bodyFailure = timedOut ? "timeout" : req.signal?.aborted ? "aborted" : "broken";
206
+ }
207
+ return { kind: "response", response, status: response.status, headers, body, bodyFailure };
208
+ }
209
+ finally {
210
+ if (!handedOver) {
211
+ clearTimeout(timer);
212
+ req.signal?.removeEventListener("abort", onAbort);
213
+ }
214
+ }
215
+ }
216
+ /** Whether a `Content-Type` value is `text/event-stream` (parameters and case ignored). */
217
+ export function isEventStream(contentType) {
218
+ return (contentType ?? "").split(";")[0]?.trim().toLowerCase() === "text/event-stream";
219
+ }
220
+ /** Retry-After in milliseconds (delta seconds or HTTP date), or null. */
221
+ export function parseRetryAfter(value, now) {
222
+ if (value === undefined)
223
+ return null;
224
+ const trimmed = value.trim();
225
+ if (/^\d+$/.test(trimmed))
226
+ return Number(trimmed) * 1000;
227
+ const date = Date.parse(trimmed);
228
+ if (Number.isNaN(date))
229
+ return null;
230
+ return Math.max(0, Math.round(date - now));
231
+ }
232
+ /** Next page URL from a `Link` header (`rel="next"`), or null. */
233
+ export function nextLink(header) {
234
+ if (!header)
235
+ return null;
236
+ for (const part of header.split(/,(?=\s*<)/)) {
237
+ const match = /^\s*<([^>]*)>(.*)$/.exec(part);
238
+ if (!match)
239
+ continue;
240
+ const params = match[2] ?? "";
241
+ if (/;\s*rel\s*=\s*"?([^";]*\s)?next(\s[^";]*)?"?\s*(;|$)/i.test(params))
242
+ return match[1] ?? null;
243
+ }
244
+ return null;
245
+ }