@tsplc/server 0.0.0-stage → 0.1.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.
@@ -0,0 +1,111 @@
1
+ const escape = (value: string) =>
2
+ value.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
3
+
4
+ /** The operator's page: self-contained, and redrawn from `/hmi/state`. */
5
+ export const hmiPage = (title: string) => `<!doctype html>
6
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <title>${escape(title)}</title>
8
+ <style>
9
+ :root{--bg:#f6f7f9;--panel:#fff;--fg:#1c2026;--muted:#6b7280;--border:#d8dce3;--on:#16a34a;--off:#dc2626;--box:#2563eb;--empty:#e7e9ee;--diverter:#c9ced8}
10
+ @media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#191c22;--fg:#e6e8ec;--muted:#9aa1ad;--border:#2c313a;--empty:#262a32;--diverter:#3a404b}}
11
+ *{box-sizing:border-box}body{margin:0;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg)}
12
+ header{display:flex;gap:14px;align-items:center;padding:12px 16px;background:var(--panel);border-bottom:1px solid var(--border)}
13
+ h1{font-size:19px;margin:0;flex:1}h2{font-size:14px;margin:0 0 10px;color:var(--muted);font-weight:600}
14
+ .pill{padding:4px 12px;border-radius:99px;color:#fff;font-weight:600;background:var(--off)}.pill.on{background:var(--on)}
15
+ #stale{display:none;padding:12px 16px;background:var(--off);color:#fff;font-weight:600}
16
+ main{display:grid;gap:14px;padding:14px;grid-template-columns:minmax(0,1fr) minmax(280px,380px)}
17
+ @media (max-width:800px){main{grid-template-columns:minmax(0,1fr)}}
18
+ section{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:14px}
19
+ .controls{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
20
+ button{font:600 17px system-ui;padding:14px 28px;border:0;border-radius:9px;background:var(--diverter);color:var(--fg);cursor:pointer;touch-action:manipulation}
21
+ button.start{background:var(--on);color:#fff}button.stop{background:var(--off);color:#fff}button:disabled{opacity:.45;cursor:default}
22
+ label{display:flex;gap:10px;align-items:center;flex:1 1 280px}input[type=range]{flex:1;height:30px}output{min-width:3ch;font-weight:600;text-align:right}
23
+ .strip{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:14px}.strip:last-child{margin-bottom:0}
24
+ .zone{min-width:30px;height:38px;flex:1 0 30px;display:grid;place-items:center;border-radius:4px;background:var(--empty);color:var(--muted);font-size:11px}
25
+ .zone.diverter{outline:2px solid var(--diverter);outline-offset:-2px}.zone.occupied{background:var(--box);color:#fff;font-weight:600}.zone.stuck{background:var(--off)}
26
+ table{width:100%;border-collapse:collapse}td,th{padding:5px 6px;text-align:left;border-bottom:1px solid var(--border);font-size:13px}
27
+ .toggle{display:flex;flex:none;padding:7px 0;justify-content:space-between;border-bottom:1px solid var(--border)}.toggle input{width:26px;height:26px}#error{color:var(--off);font-weight:600;min-height:1.3em;margin-top:10px}
28
+ </style></head><body>
29
+ <header><h1>${escape(title)}</h1><span class="pill" id="running"></span><span class="pill" id="plc"></span></header>
30
+ <div id="stale"></div>
31
+ <main><div>
32
+ <section><h2>Controls</h2><div class="controls" id="controls"></div><div id="error"></div></section>
33
+ <section><h2>Line</h2><div id="line"></div></section>
34
+ </div><div>
35
+ <section><h2>Blocked for over a minute</h2><table><tbody id="blocked"></tbody></table></section>
36
+ <section><h2>Switches</h2><div id="toggles"></div></section>
37
+ </div></main>
38
+ <script>
39
+ const $ = (id) => document.getElementById(id);
40
+ const row = (cells) => { const tr = document.createElement("tr"); for (const cell of cells) { const td = document.createElement("td"); td.textContent = cell; tr.append(td); } return tr; };
41
+ let built = false, dragging = false;
42
+ const send = async (body) => {
43
+ $("error").textContent = "";
44
+ try {
45
+ const response = await fetch("/hmi/control", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
46
+ if (!response.ok) $("error").textContent = (await response.json()).message;
47
+ } catch { $("error").textContent = "Could not reach the server"; }
48
+ };
49
+ const build = (controls) => {
50
+ for (const control of controls) {
51
+ if (control.kind === "toggle") {
52
+ const label = document.createElement("label"), input = document.createElement("input");
53
+ label.className = "toggle"; label.append(control.label, input);
54
+ input.type = "checkbox"; input.id = "control" + control.index;
55
+ input.onchange = () => send({ index: control.index, value: input.checked ? 1 : 0 });
56
+ $("toggles").append(label);
57
+ } else if (control.kind === "button") {
58
+ const button = document.createElement("button");
59
+ button.textContent = control.label; button.id = "control" + control.index;
60
+ if (control.tone) button.className = control.tone;
61
+ button.onclick = () => { if (!control.confirm || confirm(control.label + "?")) send({ index: control.index }); };
62
+ $("controls").append(button);
63
+ } else {
64
+ const label = document.createElement("label"), input = document.createElement("input"), output = document.createElement("output");
65
+ label.append(control.label, input, output);
66
+ input.type = "range"; input.min = control.min; input.max = control.max; input.id = "control" + control.index;
67
+ input.onpointerdown = () => { dragging = true; };
68
+ input.oninput = () => { output.textContent = input.value; };
69
+ input.onchange = () => { dragging = false; send({ index: control.index, value: Number(input.value) }); };
70
+ $("controls").append(label);
71
+ }
72
+ }
73
+ };
74
+ const render = (state) => {
75
+ if (!built) { build(state.controls); built = true; }
76
+ $("plc").textContent = state.connected ? "PLC connected" : "PLC disconnected"; $("plc").className = "pill" + (state.connected ? " on" : "");
77
+ $("running").textContent = state.running ? "Running" : "Stopped"; $("running").className = "pill" + (state.running ? " on" : "");
78
+ const stale = state.reportAgeMs === null || state.reportAgeMs > 2000;
79
+ const problem = !state.connected ? "No connection to the PLC: controls and values below may be out of date." : stale ? "No data from the PLC: the line view below may be out of date." : "";
80
+ $("stale").textContent = problem; $("stale").style.display = problem ? "block" : "none";
81
+ for (const control of state.controls) {
82
+ const element = $("control" + control.index);
83
+ // Stop stays available: a failed press reports its error rather than hiding the button.
84
+ element.disabled = !state.connected && control.tone !== "stop";
85
+ if (control.kind === "toggle" && control.value !== null) element.checked = control.value;
86
+ if (control.kind === "slider" && !dragging && control.value !== null && document.activeElement !== element) {
87
+ element.value = control.value; element.nextElementSibling.textContent = control.value;
88
+ }
89
+ }
90
+ const blocked = [];
91
+ $("line").replaceChildren(...state.conveyors.flatMap((conveyor) => {
92
+ const name = document.createElement("h2"); name.textContent = conveyor.label;
93
+ const strip = document.createElement("div"); strip.className = "strip";
94
+ conveyor.zones.forEach(({ occupied, blockedMs }, position) => {
95
+ const zone = document.createElement("div"), stuck = blockedMs > 60000;
96
+ zone.className = "zone" + (conveyor.diverters.includes(position) ? " diverter" : "") + (occupied ? " occupied" : "") + (stuck ? " stuck" : "");
97
+ zone.textContent = position + 1; zone.title = "Position " + (position + 1);
98
+ if (stuck) blocked.push(row([conveyor.label, "Position " + (position + 1), Math.round(blockedMs / 60000) + " min"]));
99
+ strip.append(zone);
100
+ });
101
+ return [name, strip];
102
+ }));
103
+ $("blocked").replaceChildren(...(blocked.length ? blocked : [row(["Nothing"])]));
104
+ };
105
+ const poll = async () => {
106
+ try { render(await (await fetch("/hmi/state")).json()); }
107
+ catch { $("stale").textContent = "No connection to the server."; $("stale").style.display = "block"; }
108
+ setTimeout(poll, 500);
109
+ };
110
+ poll();
111
+ </script></body></html>`;
package/src/hmi.ts ADDED
@@ -0,0 +1,127 @@
1
+ import type { ResolvedHmiConfig } from "@tsplc/config";
2
+ import { hmiSchema, type HmiConfig } from "@tsplc/config/schema";
3
+ import { z } from "zod";
4
+ import { iec } from "./contract";
5
+
6
+ const pressMs = 200;
7
+
8
+ export interface HmiPlc {
9
+ snapshot: () => {
10
+ connected: boolean;
11
+ values?: Record<string, { value: unknown; quality: string } | undefined>;
12
+ };
13
+ write: (values: ReadonlyMap<string, unknown>) => Promise<unknown>;
14
+ }
15
+
16
+ /** What the PLC sends: whether each position's sensor is blocked right now. */
17
+ export interface HmiReport {
18
+ running: boolean;
19
+ occupied: readonly boolean[];
20
+ }
21
+
22
+ type Tuple<N extends number, T extends unknown[] = []> = T["length"] extends N
23
+ ? T
24
+ : Tuple<N, [...T, unknown]>;
25
+ /** The positions in the type, so the PLC compiler reads the report's array length from `defineConfig`'s literals. */
26
+ type Positions<Conveyors, Total extends unknown[] = []> = Conveyors extends readonly [
27
+ { positions: infer N extends number },
28
+ ...infer Rest,
29
+ ]
30
+ ? number extends N
31
+ ? number
32
+ : Positions<Rest, [...Total, ...Tuple<N>]>
33
+ : Conveyors extends readonly []
34
+ ? Total["length"]
35
+ : number;
36
+
37
+ /** The state behind a generated HMI page: the latest report, and how long each zone has stayed blocked. */
38
+ export const createHmi = <const Input extends HmiConfig>(input: Input) => {
39
+ const config: ResolvedHmiConfig = hmiSchema.parse(input);
40
+ const positions = config.conveyors.reduce((total, { positions }) => total + positions, 0);
41
+ /* Only counts time with the line running: a stopped line holds its zones by design. */
42
+ const blockedSince = new Map<number, number>();
43
+ let report: (HmiReport & { at: number }) | undefined;
44
+
45
+ const applyReport = (input: HmiReport) => {
46
+ const now = Date.now();
47
+ input.occupied.forEach((occupied, index) => {
48
+ if (!occupied || !input.running) blockedSince.delete(index);
49
+ else if (!blockedSince.has(index)) blockedSince.set(index, now);
50
+ });
51
+ report = { ...input, at: now };
52
+ return { received: true };
53
+ };
54
+
55
+ return {
56
+ title: config.title,
57
+ /** Input and output schemas for the PLC procedure that receives reports. */
58
+ reportInput: z.strictObject({
59
+ running: z.boolean(),
60
+ occupied: iec.FixedArray(
61
+ z.boolean(),
62
+ // The runtime sum of the same literals the type adds up.
63
+ positions as Positions<Input["conveyors"]>,
64
+ ),
65
+ }),
66
+ reportOutput: z.strictObject({ received: z.boolean() }),
67
+ applyReport,
68
+ state: (plc: HmiPlc) => {
69
+ const snapshot = plc.snapshot();
70
+ const now = Date.now();
71
+ let offset = 0;
72
+ return {
73
+ title: config.title,
74
+ connected: snapshot.connected,
75
+ reportAgeMs: report ? now - report.at : null,
76
+ running: report?.running ?? false,
77
+ controls: config.controls.map((control, index) =>
78
+ control.kind === "button"
79
+ ? { ...control, index }
80
+ : { ...control, index, value: snapshot.values?.[control.target]?.value ?? null },
81
+ ),
82
+ conveyors: config.conveyors.map(({ diverters, label, positions }) => {
83
+ const first = offset;
84
+ offset += positions;
85
+ return {
86
+ diverters,
87
+ label,
88
+ zones: Array.from({ length: positions }, (_, position) => {
89
+ const since = blockedSince.get(first + position);
90
+ return {
91
+ occupied: report?.occupied[first + position] ?? false,
92
+ blockedMs: since === undefined ? 0 : now - since,
93
+ };
94
+ }),
95
+ };
96
+ }),
97
+ };
98
+ },
99
+ /** Runs one of the configured controls: nothing else can be written from the page. */
100
+ control: async (plc: HmiPlc, input: { index: number; value?: number | undefined }) => {
101
+ const control = config.controls[input.index];
102
+ if (!control) throw new RangeError("Unknown HMI control");
103
+ if (control.kind === "slider") {
104
+ const { value } = input;
105
+ if (value === undefined || !Number.isInteger(value))
106
+ throw new RangeError("A slider needs an integer value");
107
+ if (value < control.min || value > control.max)
108
+ throw new RangeError(`${control.label} must be ${control.min} to ${control.max}`);
109
+ await plc.write(new Map([[control.target, value]]));
110
+ return;
111
+ }
112
+ if (control.kind === "toggle") {
113
+ if (input.value !== 0 && input.value !== 1) throw new RangeError("A toggle needs 0 or 1");
114
+ await plc.write(new Map([[control.target, input.value === 1]]));
115
+ return;
116
+ }
117
+ await plc.write(new Map([[control.target, true]]));
118
+ try {
119
+ await new Promise((resolve) => setTimeout(resolve, pressMs));
120
+ } finally {
121
+ await plc.write(new Map([[control.target, false]]));
122
+ }
123
+ },
124
+ };
125
+ };
126
+
127
+ export type Hmi = ReturnType<typeof createHmi>;
package/src/index.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { TRPCError, type AnyRouter, type inferRouterContext } from "@trpc/server";
2
+ import { serviceContracts, servicePath, validateWireRecord } from "./contract";
3
+ export {
4
+ iec,
5
+ isIntegerType,
6
+ serviceContract,
7
+ serviceContracts,
8
+ servicePath,
9
+ wireLayout,
10
+ wireRecord,
11
+ type IntegerType,
12
+ type PlcMeta,
13
+ type Wire,
14
+ type ServiceContract,
15
+ type WireField,
16
+ type WireRecord,
17
+ type WireScalar,
18
+ } from "./contract";
19
+
20
+ /** Mount in an authenticated Fetch host; createContext must resolve the caller's identity. */
21
+ export const createServiceHandler = <Router extends AnyRouter>(options: {
22
+ router: Router;
23
+ createContext: (
24
+ request: Request,
25
+ ) => inferRouterContext<Router> | Promise<inferRouterContext<Router>>;
26
+ onError?: (error: unknown) => void;
27
+ }) => {
28
+ const contracts = serviceContracts(options.router);
29
+ const paths = new Map(contracts.map((contract) => [servicePath(contract), contract]));
30
+ const response = (value: unknown, status = 200) => {
31
+ const body = JSON.stringify(value);
32
+ return new Response(body, {
33
+ status,
34
+ headers: {
35
+ "Content-Type": "application/json; charset=utf-8",
36
+ "Content-Length": String(new TextEncoder().encode(body).length),
37
+ "Cache-Control": "no-store",
38
+ },
39
+ });
40
+ };
41
+ return async (request: Request): Promise<Response> => {
42
+ const path = new URL(request.url).pathname;
43
+ const contract = paths.get(path);
44
+ if (!contract) return response({ error: "Unknown procedure or incompatible contract" }, 409);
45
+ if (request.method !== "POST") return response({ error: "Use POST" }, 405);
46
+ const length = request.headers.get("content-length");
47
+ if (length !== null && (!/^\d+$/.test(length) || Number(length) > 2048))
48
+ return response({ error: "Request exceeds the PLC wire limit" }, 413);
49
+ const reader = request.body?.getReader();
50
+ const chunks: Uint8Array[] = [];
51
+ let size = 0;
52
+ if (reader)
53
+ try {
54
+ for (;;) {
55
+ const chunk = await reader.read();
56
+ if (chunk.done) break;
57
+ size += chunk.value.length;
58
+ if (size > 2048) {
59
+ await reader.cancel();
60
+ return response({ error: "Request exceeds the PLC wire limit" }, 413);
61
+ }
62
+ chunks.push(chunk.value);
63
+ }
64
+ } finally {
65
+ reader.releaseLock();
66
+ }
67
+ const bytes = new Uint8Array(size);
68
+ let offset = 0;
69
+ for (const chunk of chunks) {
70
+ bytes.set(chunk, offset);
71
+ offset += chunk.length;
72
+ }
73
+ let input: unknown;
74
+ try {
75
+ input = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
76
+ validateWireRecord(contract.input, input);
77
+ } catch {
78
+ return response({ error: "Invalid procedure input" }, 400);
79
+ }
80
+ try {
81
+ const context = await options.createContext(request);
82
+ const caller = options.router.createCaller(context, { signal: request.signal });
83
+ const invoke: unknown = Reflect.get(caller, contract.path);
84
+ if (typeof invoke !== "function") throw new Error("Invalid tRPC caller.");
85
+ const output: unknown = await invoke(input);
86
+ validateWireRecord(contract.output, output);
87
+ return response(output);
88
+ } catch (cause) {
89
+ if (cause instanceof TRPCError) {
90
+ const statuses: Partial<Record<TRPCError["code"], number>> = {
91
+ UNAUTHORIZED: 401,
92
+ FORBIDDEN: 403,
93
+ BAD_REQUEST: 400,
94
+ NOT_FOUND: 404,
95
+ CONFLICT: 409,
96
+ TOO_MANY_REQUESTS: 429,
97
+ };
98
+ const status = statuses[cause.code];
99
+ if (status === undefined) options.onError?.(cause);
100
+ return response({ error: cause.code }, status ?? 500);
101
+ }
102
+ options.onError?.(cause);
103
+ return response({ error: "Procedure failed" }, 500);
104
+ }
105
+ };
106
+ };
package/src/opcua.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import type { AnyRouter, inferRouterContext } from "@trpc/server";
4
+ import { AttributeIds, DataType, type ClientSession, discoverVariables } from "@tsplc/opcua";
5
+ import { createServiceHandler } from "./index";
6
+
7
+ export interface ServiceChannel {
8
+ path: string;
9
+ request: string;
10
+ requestId: string;
11
+ reply: string;
12
+ replyId: string;
13
+ epoch: string;
14
+ }
15
+
16
+ /** One authenticated gateway owns these channels. Handler effects are never retried. */
17
+ export const runOpcUaServices = async <Router extends AnyRouter>(options: {
18
+ router: Router;
19
+ channels: readonly ServiceChannel[];
20
+ getSession: () => ClientSession;
21
+ createContext: () => inferRouterContext<Router> | Promise<inferRouterContext<Router>>;
22
+ signal: AbortSignal;
23
+ onError: (error: unknown) => void;
24
+ onReady?: (session: ClientSession) => void;
25
+ }) => {
26
+ if (!options.channels.length) return;
27
+ const dispatch = createServiceHandler(options);
28
+ const pending = new Map<
29
+ string,
30
+ { identity: string; controller: AbortController; done: Promise<void> }
31
+ >();
32
+ const invocations = new Set<Promise<Response>>();
33
+ const writers = new Map<string, Promise<void>>();
34
+ const handled = new Map<string, string>();
35
+ const epochs = new Map<string, string>();
36
+ let activeSession: ClientSession | undefined;
37
+ let connectionError: string | undefined;
38
+ let nodes = new Map<string, string>();
39
+ const write = async (session: ClientSession, path: string, value: string) => {
40
+ const nodeId = nodes.get(path);
41
+ if (!nodeId) throw new Error(`Missing OPC UA service channel: ${path}`);
42
+ const status = await session.write({
43
+ nodeId,
44
+ attributeId: AttributeIds.Value,
45
+ value: { value: { dataType: DataType.String, value } },
46
+ });
47
+ if (!status.isGood()) throw new Error(`OPC UA service write failed: ${status.toString()}`);
48
+ };
49
+ const read = async (session: ClientSession, paths: readonly string[]) => {
50
+ const requests = paths.map((path) => {
51
+ const nodeId = nodes.get(path);
52
+ if (!nodeId) throw new Error(`Missing OPC UA service channel: ${path}`);
53
+ return { nodeId, attributeId: AttributeIds.Value };
54
+ });
55
+ const values = await session.read(requests, 0);
56
+ if (values.length !== requests.length || values.some((value) => !value.statusCode.isGood()))
57
+ throw new Error("OPC UA service read failed");
58
+ return values.map((value): unknown => value.value.value);
59
+ };
60
+ const reply = async (
61
+ session: ClientSession,
62
+ channel: ServiceChannel,
63
+ identity: string,
64
+ body: string,
65
+ signal: AbortSignal,
66
+ ) => {
67
+ const previous = writers.get(channel.reply) ?? Promise.resolve();
68
+ const writing = previous
69
+ .catch(() => undefined)
70
+ .then(async () => {
71
+ if (signal.aborted || activeSession !== session) return;
72
+ await write(session, channel.reply, body);
73
+ // Commit after the entire payload write finishes. PLC readers ignore an uncommitted string.
74
+ await write(session, channel.replyId, identity);
75
+ });
76
+ writers.set(channel.reply, writing);
77
+ try {
78
+ await writing;
79
+ } finally {
80
+ if (writers.get(channel.reply) === writing) writers.delete(channel.reply);
81
+ }
82
+ };
83
+ try {
84
+ while (!options.signal.aborted) {
85
+ try {
86
+ const session = options.getSession();
87
+ if (session !== activeSession) {
88
+ for (const work of pending.values()) work.controller.abort();
89
+ nodes = await discoverVariables(
90
+ session,
91
+ options.channels.flatMap((channel) => [
92
+ channel.request,
93
+ channel.requestId,
94
+ channel.reply,
95
+ channel.replyId,
96
+ channel.epoch,
97
+ ]),
98
+ );
99
+ epochs.clear();
100
+ for (const channel of options.channels) {
101
+ await write(session, channel.replyId, "");
102
+ const epoch = randomBytes(16).toString("hex");
103
+ await write(session, channel.epoch, epoch);
104
+ epochs.set(channel.request, epoch);
105
+ }
106
+ activeSession = session;
107
+ handled.clear();
108
+ options.onReady?.(session);
109
+ }
110
+ // A commit is sampled on both sides of its payload to reject a concurrent PLC update.
111
+ const before = await read(
112
+ session,
113
+ options.channels.map((channel) => channel.requestId),
114
+ );
115
+ const batch = await read(
116
+ session,
117
+ options.channels.flatMap((channel) => [channel.request, channel.epoch]),
118
+ );
119
+ const after = await read(
120
+ session,
121
+ options.channels.map((channel) => channel.requestId),
122
+ );
123
+ for (const [index, channel] of options.channels.entries()) {
124
+ const identity = after[index];
125
+ if (typeof identity !== "string") throw new Error("Invalid OPC UA service identity type");
126
+ const current = pending.get(channel.request);
127
+ if (current && current.identity !== identity) current.controller.abort();
128
+ const request = batch[index * 2],
129
+ epochValue = batch[index * 2 + 1];
130
+ if (epochValue === "") {
131
+ const epoch = randomBytes(16).toString("hex");
132
+ await write(session, channel.epoch, epoch);
133
+ epochs.set(channel.request, epoch);
134
+ continue;
135
+ }
136
+ if (before[index] !== identity || !identity) continue;
137
+ const epoch = epochs.get(channel.request);
138
+ if (epochValue !== epoch) throw new Error("OPC UA service gateway ownership changed");
139
+ if (
140
+ typeof request !== "string" ||
141
+ !identity.startsWith(epoch + ":") ||
142
+ !request.startsWith(identity + "|")
143
+ )
144
+ continue;
145
+ if (handled.get(channel.request) === identity) continue;
146
+ handled.set(channel.request, identity);
147
+ const controller = new AbortController();
148
+ const signal = AbortSignal.any([options.signal, controller.signal]);
149
+ const operation = (async () => {
150
+ // Uncooperative cancelled handlers still occupy bounded capacity until they settle.
151
+ if (invocations.size >= options.channels.length * 2) {
152
+ await reply(session, channel, identity, `${identity}|1|{}`, signal);
153
+ return;
154
+ }
155
+ const invocation = dispatch(
156
+ new Request("http://tsplc.internal" + channel.path, {
157
+ method: "POST",
158
+ body: request.slice(identity.length + 1),
159
+ signal,
160
+ }),
161
+ );
162
+ invocations.add(invocation);
163
+ void invocation.then(
164
+ () => invocations.delete(invocation),
165
+ () => invocations.delete(invocation),
166
+ );
167
+ const timer = new AbortController();
168
+ const response = await Promise.race([
169
+ invocation,
170
+ delay(5000, undefined, { signal: AbortSignal.any([signal, timer.signal]) }).then(
171
+ () => {
172
+ throw new Error("OPC UA service handler timed out");
173
+ },
174
+ ),
175
+ ]).finally(() => timer.abort());
176
+ const body = `${identity}|${response.ok ? 0 : 1}|${response.ok ? await response.text() : "{}"}`;
177
+ if (Buffer.byteLength(body, "utf8") > 2048)
178
+ throw new Error("OPC UA service reply exceeds 2048 bytes");
179
+ await reply(session, channel, identity, body, signal);
180
+ })()
181
+ .catch((error) => {
182
+ if (!signal.aborted) options.onError(error);
183
+ })
184
+ .finally(() => {
185
+ if (pending.get(channel.request)?.controller === controller)
186
+ pending.delete(channel.request);
187
+ });
188
+ pending.set(channel.request, { identity, controller, done: operation });
189
+ }
190
+ connectionError = undefined;
191
+ } catch (error) {
192
+ activeSession = undefined;
193
+ for (const work of pending.values()) work.controller.abort();
194
+ const message = error instanceof Error ? error.message : String(error);
195
+ if (connectionError !== message) {
196
+ connectionError = message;
197
+ options.onError(error);
198
+ }
199
+ await delay(1000, undefined, { signal: options.signal });
200
+ }
201
+ await delay(20, undefined, { signal: options.signal });
202
+ }
203
+ } catch (error) {
204
+ if (!options.signal.aborted) throw error;
205
+ } finally {
206
+ activeSession = undefined;
207
+ for (const work of pending.values()) work.controller.abort();
208
+ await Promise.all([...pending.values()].map((work) => work.done));
209
+ await Promise.allSettled(writers.values());
210
+ }
211
+ };