@tsplc/debug 0.0.0-stage → 0.2.17

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/src/http.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { z } from "zod";
3
+ import type { Interest, Sample } from "./api.ts";
4
+ import { DebugError, type Debugger, type StreamMessage } from "./debugger.ts";
5
+ import { debugManifest } from "./manifest.ts";
6
+ import { loadUi } from "./ui/index.ts";
7
+ import { watchGroupsSchema } from "./watch.ts";
8
+
9
+ /** Comments keep proxies and Bun's idle timeout from closing a quiet stream. */
10
+ const heartbeatMs = 5000;
11
+ const encoder = new TextEncoder();
12
+
13
+ const json = (body: unknown, status = 200) =>
14
+ Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
15
+
16
+ const list = (value: string | null) =>
17
+ (value ?? "")
18
+ .split(",")
19
+ .map((item) => item.trim())
20
+ .filter(Boolean);
21
+
22
+ export const loadManifest = async (file: string) =>
23
+ debugManifest.parse(JSON.parse(await readFile(file, "utf8")));
24
+
25
+ /** What a stream's page shows, from its query. */
26
+ const interest = (search: URLSearchParams): Interest => {
27
+ const jobs = list(search.get("jobs")).map(Number);
28
+ if (!jobs.every(Number.isInteger)) throw new DebugError("jobs must be job ids", 400);
29
+ return {
30
+ watch: list(search.get("watch")),
31
+ jobs,
32
+ health: list(search.get("health")),
33
+ devices: list(search.get("devices")),
34
+ };
35
+ };
36
+
37
+ /** Every stream event, plus samples of the watched paths and states of the shown jobs and devices. */
38
+ const stream = (debug: Debugger, { watch, ...shown }: Interest) => {
39
+ // Refuse a watch that matches too much before the stream starts.
40
+ debug.paths.expand(watch);
41
+ let stop = () => {};
42
+ return new Response(
43
+ new ReadableStream<Uint8Array>({
44
+ start: (controller) => {
45
+ let open = true;
46
+ const write = (text: string) => {
47
+ if (open) controller.enqueue(encoder.encode(text));
48
+ };
49
+ const send = ({ event, data }: StreamMessage) =>
50
+ write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
51
+ send({ event: "session", data: debug.session() });
52
+ const latest = new Map<string, { sample: Sample; sentAt: number }>();
53
+ const sendSample = (sample: Sample) => {
54
+ latest.set(sample.path, { sample, sentAt: Date.now() });
55
+ send({ event: "sample", data: sample });
56
+ };
57
+ const unsubscribe = debug.subscribe(send);
58
+ const unwatch = debug.watch(watch, sendSample);
59
+ const unshow = debug.show(shown);
60
+ const heartbeat = setInterval(() => write(": keepalive\n\n"), heartbeatMs);
61
+ // Over OPC UA, samples come only on change. While the session holds, an unchanged value
62
+ // is still current, so it is sent again each interval; clients mark samples stale by their age.
63
+ const { intervalMs } = debug.session().reading;
64
+ const refresh = setInterval(() => {
65
+ if (debug.session().connection.state !== "connected") return;
66
+ const now = Date.now();
67
+ for (const { sample, sentAt } of latest.values())
68
+ if (now - sentAt >= intervalMs)
69
+ sendSample({ ...sample, receivedAt: new Date(now).toISOString() });
70
+ }, intervalMs);
71
+ stop = () => {
72
+ open = false;
73
+ clearInterval(heartbeat);
74
+ clearInterval(refresh);
75
+ unsubscribe();
76
+ unwatch();
77
+ unshow();
78
+ };
79
+ },
80
+ cancel: () => stop(),
81
+ }),
82
+ { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store" } },
83
+ );
84
+ };
85
+
86
+ /**
87
+ * The debugger's routes (docs/design/debugging.md), with `route` the request's path below where
88
+ * the host mounts them. There is no route that writes to the controller; hosts authenticate.
89
+ */
90
+ export const createDebugHandler =
91
+ (debug: Debugger, log: (message: string) => void) =>
92
+ async (request: Request, route: string): Promise<Response> => {
93
+ const { searchParams } = new URL(request.url);
94
+ const key = `${request.method} ${route}`;
95
+ try {
96
+ switch (key) {
97
+ case "GET /":
98
+ return new Response((await loadUi()).html, {
99
+ headers: { "Content-Type": "text/html; charset=utf-8" },
100
+ });
101
+ case "GET /ui.js":
102
+ return new Response((await loadUi()).script, {
103
+ headers: { "Content-Type": "text/javascript; charset=utf-8" },
104
+ });
105
+ case "GET /api/session":
106
+ return json(debug.session());
107
+ case "GET /api/manifest":
108
+ return json(debug.manifest);
109
+ case "GET /api/stream":
110
+ return stream(debug, interest(searchParams));
111
+ case "GET /api/values":
112
+ return json(await debug.values(list(searchParams.get("paths"))));
113
+ case "GET /api/jobs":
114
+ return json(debug.jobs());
115
+ case "GET /api/hardware":
116
+ return json(debug.hardware());
117
+ case "GET /api/timeline":
118
+ return json(debug.timeline(searchParams.get("since") ?? undefined));
119
+ case "GET /api/watch":
120
+ return json(debug.watchGroups());
121
+ case "PUT /api/watch":
122
+ return json(await debug.saveWatchGroups(watchGroupsSchema.parse(await request.json())));
123
+ }
124
+ return json({ error: `No route ${key}` }, 404);
125
+ } catch (error) {
126
+ if (error instanceof DebugError) return json({ error: error.message }, error.status);
127
+ // RangeError: a watch matches too many values; SyntaxError: a body isn't JSON.
128
+ if (error instanceof RangeError || error instanceof SyntaxError)
129
+ return json({ error: error.message }, 400);
130
+ if (error instanceof z.ZodError) return json({ error: z.prettifyError(error) }, 400);
131
+ log(`${key} failed: ${error instanceof Error ? error.stack : String(error)}`);
132
+ return json({ error: "The debugger failed; see its log" }, 500);
133
+ }
134
+ };
135
+
136
+ /**
137
+ * Serves the debugger on 127.0.0.1 without a token. Requests that name another host are refused,
138
+ * so no website can reach it by rebinding a domain name to 127.0.0.1.
139
+ */
140
+ export const serveDebugger = (
141
+ debug: Debugger,
142
+ options: { port: number; log: (message: string) => void },
143
+ ) => {
144
+ const handle = createDebugHandler(debug, options.log);
145
+ return Bun.serve({
146
+ hostname: "127.0.0.1",
147
+ port: options.port,
148
+ fetch: (request) => {
149
+ const host = request.headers.get("host")?.replace(/:\d+$/, "");
150
+ if (host !== "127.0.0.1" && host !== "localhost")
151
+ return json({ error: "Use http://127.0.0.1 or http://localhost" }, 403);
152
+ return handle(request, new URL(request.url).pathname);
153
+ },
154
+ });
155
+ };
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type * from "./api.ts";
2
+ export type { Connection, TargetAdapter, TargetRead, TargetSample } from "./adapter.ts";
3
+ export { createDebugger, DebugError, type Debugger, type DebuggerOptions } from "./debugger.ts";
4
+ export { createDebugHandler, loadManifest, serveDebugger } from "./http.ts";
5
+ export { createLoginAdapter, LoginUnavailable, noLogin } from "./login.ts";
6
+ export { createOpcUaAdapter, opcUaConnection } from "./opcua.ts";
7
+ export { createPaths, type PathNode, type Paths, type ResolvedPath } from "./paths.ts";
8
+ export { timelineEntries } from "./timeline.ts";
package/src/jobs.ts ADDED
@@ -0,0 +1,115 @@
1
+ import type { TargetRead, TargetSample } from "./adapter.ts";
2
+ import type { JobState, RequestTrace, Session } from "./api.ts";
3
+ import type { DebugJob, DebugType } from "./manifest.ts";
4
+
5
+ const scalar = (iec: string): DebugType => ({ kind: "scalar", iec });
6
+
7
+ /** What the trace said of a job: whether it is running, and when it last started. */
8
+ export interface TracedJob {
9
+ busy: boolean;
10
+ startedAt?: string;
11
+ }
12
+
13
+ /** The job's `busy` flag, read while a page shows the job and the trace says nothing of it. */
14
+ export const busyRead = (job: DebugJob): TargetRead => ({
15
+ target: `${job.target}.busy`,
16
+ type: scalar("BOOL"),
17
+ });
18
+
19
+ /** What a busy job shows: where it waits, since when, and its latest request. */
20
+ export const detailReads = (job: DebugJob): TargetRead[] => [
21
+ { target: `${job.target}.step`, type: scalar("UINT") },
22
+ { target: `${job.target}.cancelling`, type: scalar("BOOL") },
23
+ ...(job.startedAt ? [{ target: job.startedAt, type: scalar("UDINT") }] : []),
24
+ ...job.requests.map(({ sequence }) => ({ target: sequence, type: scalar("UDINT") })),
25
+ ];
26
+
27
+ /**
28
+ * From the last read, else from the trace; null when neither knows. A read wins: a job's caller
29
+ * stops running it once it finishes, so the trace can miss the finish.
30
+ */
31
+ export const busyOf = (
32
+ job: DebugJob,
33
+ value: (target: string) => TargetSample | undefined,
34
+ traced: TracedJob | undefined,
35
+ ) => {
36
+ const read = value(busyRead(job).target);
37
+ return read?.quality === "good" ? read.value === true : (traced?.busy ?? null);
38
+ };
39
+
40
+ const numberOf = (sample: TargetSample | undefined) =>
41
+ sample?.quality === "good" && typeof sample.value === "number" ? sample.value : undefined;
42
+
43
+ /**
44
+ * A job as the debugger shows it at host time `now`. Details are shown only while it is busy;
45
+ * its last start stays, so a later run tells a failure apart from the current state. Without a
46
+ * clock estimate there is no start time from the PLC's own `startedAt`.
47
+ */
48
+ export const jobState = (
49
+ job: DebugJob,
50
+ inputs: {
51
+ value: (target: string) => TargetSample | undefined;
52
+ traced: TracedJob | undefined;
53
+ failure: JobState["failure"];
54
+ /** Busy, but its details aren't read for want of room in the poll. */
55
+ unread: boolean;
56
+ clock: Session["clock"];
57
+ now: number;
58
+ /** The server's trace of a call, which the PLC tags with its job and the call site's count. */
59
+ request: (call: { job: number; service: string; sequence: number }) => RequestTrace | undefined;
60
+ },
61
+ ): JobState => {
62
+ const { value, traced, clock, now } = inputs;
63
+ const busy = busyOf(job, value, traced);
64
+ const startedPlc = job.startedAt === undefined ? undefined : numberOf(value(job.startedAt));
65
+ const startedAt =
66
+ traced?.startedAt ??
67
+ (clock && startedPlc !== undefined
68
+ ? new Date(startedPlc - clock.offsetMs).toISOString()
69
+ : undefined);
70
+ const base: JobState = {
71
+ id: job.id,
72
+ quality: value(busyRead(job).target)?.quality ?? (traced ? "good" : "unavailable"),
73
+ busy,
74
+ ...(startedAt && { startedAt }),
75
+ ...(inputs.failure && { failure: inputs.failure }),
76
+ };
77
+ if (!busy) return base;
78
+ if (inputs.unread) return { ...base, unread: true };
79
+
80
+ const step = numberOf(value(`${job.target}.step`));
81
+ const current = job.steps.find((candidate) => candidate.step === step);
82
+ // Each call site counts its own requests, so the latest is the one the server saw start last,
83
+ // or the one counted furthest while the server has yet to report them.
84
+ const calls = job.requests
85
+ .map((call) => {
86
+ const sequence = numberOf(value(call.sequence)) ?? 0;
87
+ const trace = inputs.request({ job: call.job ?? job.id, service: call.service, sequence });
88
+ return { service: call.service, sequence, trace };
89
+ })
90
+ .filter(({ sequence }) => sequence > 0);
91
+ const later = (call: (typeof calls)[number], than: (typeof calls)[number]) =>
92
+ call.trace && than.trace
93
+ ? call.trace.startedAt > than.trace.startedAt
94
+ : call.sequence > than.sequence;
95
+ const latest = calls.reduce<(typeof calls)[number] | undefined>(
96
+ (latest, call) => (latest && !later(call, latest) ? latest : call),
97
+ undefined,
98
+ );
99
+ const cancelling = value(`${job.target}.cancelling`);
100
+
101
+ return {
102
+ ...base,
103
+ ...(cancelling?.quality === "good" && { cancelling: cancelling.value === true }),
104
+ ...(step !== undefined && { step }),
105
+ ...(current && { current: { kind: current.kind, range: current.range, text: current.text } }),
106
+ ...(startedAt && { elapsedMs: Math.max(0, now - Date.parse(startedAt)) }),
107
+ ...(latest && {
108
+ request: {
109
+ service: latest.service,
110
+ sequence: latest.sequence,
111
+ ...(latest.trace && { trace: latest.trace }),
112
+ },
113
+ }),
114
+ };
115
+ };
package/src/login.ts ADDED
@@ -0,0 +1,284 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import type { Connection, TargetAdapter, TargetRead, TargetSample } from "./adapter.ts";
3
+ import type { DebugType } from "./manifest.ts";
4
+
5
+ /** Why the login can't read now; `state` is what the debugger shows meanwhile. */
6
+ export class LoginUnavailable extends Error {
7
+ constructor(message: string, options?: ErrorOptions & { state?: "connecting" | "unavailable" }) {
8
+ super(message, options);
9
+ this.state = options?.state ?? "unavailable";
10
+ }
11
+
12
+ readonly state: "connecting" | "unavailable";
13
+ }
14
+
15
+ export const noLogin = "Live values need an engineering login: run `tsplc login`.";
16
+
17
+ /** How long to wait between attempts while there is no login to read through. */
18
+ const retryMs = 5000;
19
+
20
+ const integerTypes = new Set([
21
+ ...["SINT", "INT", "DINT", "LINT", "USINT", "UINT", "UDINT", "ULINT"],
22
+ ...["BYTE", "WORD", "DWORD", "LWORD"],
23
+ ]);
24
+ const timeUnits: Record<string, number> = {
25
+ d: 86_400_000,
26
+ h: 3_600_000,
27
+ m: 60_000,
28
+ s: 1000,
29
+ ms: 1,
30
+ us: 0.001,
31
+ ns: 0.000_001,
32
+ };
33
+
34
+ const typeName = (type: DebugType) => (type.kind === "scalar" ? type.iec : type.kind);
35
+
36
+ const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
37
+
38
+ /** `16#FF`, `2#1010`, `-42`, with IEC's digit separators; beyond doubles' exact range, a string. */
39
+ const integer = (text: string) => {
40
+ const radix = /^(2|8|16)#([0-9a-f_]+)$/i.exec(text);
41
+ const digits = radix ? radix[2]!.replaceAll("_", "") : text.replaceAll("_", "");
42
+ if (!radix && !/^[+-]?\d+$/.test(digits)) return undefined;
43
+ const value = BigInt(radix ? `0${{ 2: "b", 8: "o", 16: "x" }[radix[1]!]}${digits}` : digits);
44
+ return Number.isSafeInteger(Number(value)) ? Number(value) : String(value);
45
+ };
46
+
47
+ /** `T#1h2m3s4ms`, `TIME#-1.5s`, `LTIME#5us`, in milliseconds. */
48
+ const duration = (text: string) => {
49
+ const body = /^L?T(?:IME)?#(-?)(.+)$/i.exec(text);
50
+ if (!body) return undefined;
51
+ const parts = [...body[2]!.replaceAll("_", "").matchAll(/(\d+(?:\.\d+)?)(ms|us|ns|d|h|m|s)/gi)];
52
+ if (parts.map(([part]) => part).join("") !== body[2]!.replaceAll("_", "")) return undefined;
53
+ const ms = parts.reduce(
54
+ (total, [, amount, unit]) => total + Number(amount) * timeUnits[unit!.toLowerCase()]!,
55
+ 0,
56
+ );
57
+ return body[1] ? -ms : ms;
58
+ };
59
+
60
+ /** `'it$'s'` or `"wide"`, with IEC's `$` escapes. */
61
+ const quoted = (text: string) => {
62
+ const body = /^(['"])(.*)\1$/s.exec(text);
63
+ if (!body) return undefined;
64
+ return body[2]!.replace(/\$([0-9a-f]{2}|[$'"lnprt])/gi, (_, code: string) => {
65
+ if (code.length === 2) return String.fromCharCode(parseInt(code, 16));
66
+ const named: Record<string, string> = { l: "\n", n: "\n", p: "\f", r: "\r", t: "\t" };
67
+ return named[code.toLowerCase()] ?? code;
68
+ });
69
+ };
70
+
71
+ /**
72
+ * A value as `read_value` returns it, in the form the debugger decodes: BOOL as a boolean,
73
+ * numbers and TIME (in ms) as numbers, enums as their member's number (or its name when the
74
+ * manifest doesn't list it), STRING unquoted, and arrays read whole as arrays. Undefined when the
75
+ * text doesn't fit the type.
76
+ */
77
+ export const parseLoginValue = (type: DebugType, text: string): unknown => {
78
+ const trimmed = text.trim();
79
+ // Typed literals such as `INT#5`; TIME's own `T#` prefix is kept.
80
+ const untyped = trimmed.replace(/^(?!L?T(?:IME)?#)[a-z_]+#(?=.)/i, "");
81
+ switch (type.kind) {
82
+ case "enum": {
83
+ const number = integer(untyped);
84
+ if (typeof number === "number") return number;
85
+ const named = /^(?:\w+\.)*(\w+)(?:\s*\((-?\d+)\))?$/.exec(untyped);
86
+ if (!named) return undefined;
87
+ return type.members[named[1]!] ?? (named[2] === undefined ? named[1] : Number(named[2]));
88
+ }
89
+ case "array": {
90
+ const items = /^\[?(.*?)\]?$/s.exec(untyped)![1]!.trim();
91
+ if (!items) return [];
92
+ // Only raw assembly bytes are read whole; their elements are scalars named by IEC type.
93
+ const element = { kind: "scalar", iec: type.element } as const;
94
+ const values = items.split(/\s*,\s*/).map((item) => parseLoginValue(element, item));
95
+ return values.length === type.length && values.every((value) => value !== undefined)
96
+ ? values
97
+ : undefined;
98
+ }
99
+ case "record":
100
+ return undefined;
101
+ case "scalar":
102
+ break;
103
+ }
104
+ const iec = type.iec.toUpperCase();
105
+ if (iec === "BOOL") return { TRUE: true, FALSE: false, 1: true, 0: false }[untyped.toUpperCase()];
106
+ if (integerTypes.has(iec)) return integer(untyped);
107
+ if (iec === "REAL" || iec === "LREAL") {
108
+ const number = Number(untyped);
109
+ return untyped !== "" && !Number.isNaN(number) ? number : undefined;
110
+ }
111
+ if (iec === "TIME" || iec === "LTIME") return duration(trimmed);
112
+ if (/^W?STRING\b/.test(iec)) return quoted(trimmed) ?? trimmed;
113
+ return trimmed;
114
+ };
115
+
116
+ /**
117
+ * Reads through an engineering login that answers `read(expressions)` with CODESYS's own text
118
+ * for each value: no publication is needed, and nothing is subscribed. Watched targets are polled
119
+ * every `samplingMs`, one command per poll, and a poll that overruns delays the next by as long.
120
+ *
121
+ * A read fails as a whole when one expression can't be read, so a failing read is retried with
122
+ * `probe` alone: if that fails too the login is lost; otherwise the read is split until the
123
+ * expressions that fail are found, and those are reported bad until the next login.
124
+ */
125
+ export const createLoginAdapter = (options: {
126
+ read: (expressions: string[]) => Promise<Record<string, string>>;
127
+ /** A target that always reads, such as the build id. */
128
+ probe: string;
129
+ endpoint: string;
130
+ samplingMs?: number;
131
+ }): TargetAdapter => {
132
+ const samplingMs = options.samplingMs ?? 1000;
133
+ let connection: Connection = { state: "connecting", since: new Date().toISOString() };
134
+ const connectionListeners = new Set<(connection: Connection) => void>();
135
+ const subscriptions = new Set<{
136
+ targets: readonly TargetRead[];
137
+ listener: (target: string, sample: TargetSample) => void;
138
+ intervalMs: number;
139
+ due: number;
140
+ }>();
141
+ /** Expressions the controller refused, with its reason, until the login is lost. */
142
+ const rejected = new Map<string, string>();
143
+ const controller = new AbortController();
144
+
145
+ const setConnection = (state: Connection["state"], error?: string) => {
146
+ if (controller.signal.aborted) return;
147
+ if (connection.state === state && connection.error === error) return;
148
+ if (state !== "connected") rejected.clear();
149
+ connection = { state, since: new Date().toISOString(), ...(error && { error }) };
150
+ for (const listener of connectionListeners) listener(connection);
151
+ };
152
+
153
+ const split = async (expressions: string[]): Promise<Record<string, string>> => {
154
+ try {
155
+ return await options.read(expressions);
156
+ } catch (error) {
157
+ if (error instanceof LoginUnavailable) throw error;
158
+ if (expressions.length === 1) {
159
+ rejected.set(expressions[0]!, message(error));
160
+ return {};
161
+ }
162
+ const half = Math.ceil(expressions.length / 2);
163
+ return {
164
+ ...(await split(expressions.slice(0, half))),
165
+ ...(await split(expressions.slice(half))),
166
+ };
167
+ }
168
+ };
169
+
170
+ const readText = async (expressions: string[]) => {
171
+ try {
172
+ return await options.read(expressions);
173
+ } catch (error) {
174
+ if (error instanceof LoginUnavailable) throw error;
175
+ // One expression the controller can't read fails the whole read; a lost login fails the probe too.
176
+ await options.read([options.probe]);
177
+ return split(expressions);
178
+ }
179
+ };
180
+
181
+ /** Never fails: while the login can't read, every target is unavailable. */
182
+ const read = async (targets: readonly TargetRead[]) => {
183
+ const unique = [...new Map(targets.map((target) => [target.target, target])).values()];
184
+ const expressions = unique
185
+ .map(({ target }) => target)
186
+ .filter((target) => !rejected.has(target));
187
+ const samples = new Map<string, TargetSample>();
188
+ let values: Record<string, string>;
189
+ const overdue = setTimeout(
190
+ () => setConnection("lost", "The engineering login stopped answering"),
191
+ 3 * samplingMs,
192
+ );
193
+ try {
194
+ values = expressions.length ? await readText(expressions) : {};
195
+ setConnection("connected");
196
+ } catch (error) {
197
+ setConnection(error instanceof LoginUnavailable ? error.state : "lost", message(error));
198
+ const receivedAt = new Date().toISOString();
199
+ for (const { target } of unique)
200
+ samples.set(target, {
201
+ value: null,
202
+ quality: "unavailable",
203
+ status: message(error),
204
+ receivedAt,
205
+ });
206
+ return samples;
207
+ } finally {
208
+ clearTimeout(overdue);
209
+ }
210
+ const receivedAt = new Date().toISOString();
211
+ for (const { target, type } of unique) {
212
+ const text = values[target];
213
+ const refused = rejected.get(target);
214
+ const value = text === undefined ? undefined : parseLoginValue(type, text);
215
+ samples.set(
216
+ target,
217
+ value !== undefined
218
+ ? { value, quality: "good", status: "Good", receivedAt }
219
+ : {
220
+ value: text ?? null,
221
+ quality: "bad",
222
+ status:
223
+ refused ??
224
+ (text === undefined ? "Not returned by the login" : `Not a ${typeName(type)}`),
225
+ receivedAt,
226
+ },
227
+ );
228
+ }
229
+ return samples;
230
+ };
231
+
232
+ const poll = async () => {
233
+ while (!controller.signal.aborted) {
234
+ const started = Date.now();
235
+ // Timers fire a little early or late; a subscription due within half an interval is read now.
236
+ const due = [...subscriptions].filter(({ due }) => due <= started + samplingMs / 2);
237
+ if (due.length) {
238
+ for (const subscription of due) subscription.due = started + subscription.intervalMs;
239
+ // Closing the debugger must not wait for an engineering worker that stopped answering.
240
+ const stopped = Promise.withResolvers<void>();
241
+ const stop = () => stopped.resolve();
242
+ controller.signal.addEventListener("abort", stop, { once: true });
243
+ const samples = await Promise.race([
244
+ read(due.flatMap(({ targets }) => targets)),
245
+ stopped.promise,
246
+ ]).finally(() => controller.signal.removeEventListener("abort", stop));
247
+ if (!samples || controller.signal.aborted) break;
248
+ for (const subscription of due)
249
+ if (subscriptions.has(subscription))
250
+ for (const { target } of subscription.targets)
251
+ subscription.listener(target, samples.get(target)!);
252
+ }
253
+ const took = Date.now() - started;
254
+ const waiting = connection.state !== "connected" && connection.state !== "connecting";
255
+ await delay(waiting ? retryMs : took > samplingMs ? took : samplingMs - took, undefined, {
256
+ signal: controller.signal,
257
+ }).catch(() => undefined);
258
+ }
259
+ };
260
+ const running = poll();
261
+
262
+ return {
263
+ method: "engineering login",
264
+ endpoint: options.endpoint,
265
+ samplingMs,
266
+ connection: () => connection,
267
+ onConnection: (listener) => {
268
+ connectionListeners.add(listener);
269
+ return () => connectionListeners.delete(listener);
270
+ },
271
+ read,
272
+ subscribe: (targets, listener, intervalMs = samplingMs) => {
273
+ const subscription = { targets, listener, intervalMs, due: 0 };
274
+ subscriptions.add(subscription);
275
+ return () => subscriptions.delete(subscription);
276
+ },
277
+ close: async () => {
278
+ controller.abort();
279
+ subscriptions.clear();
280
+ connectionListeners.clear();
281
+ await running;
282
+ },
283
+ };
284
+ };