@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.
package/dist/serve.js ADDED
@@ -0,0 +1,711 @@
1
+ // packages/server/src/serve.ts
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { setTimeout as delay2 } from "node:timers/promises";
4
+ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
+ import { z as z2 } from "zod";
6
+
7
+ // packages/server/src/hmi-page.ts
8
+ var escape = (value) => value.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
9
+ var hmiPage = (title) => `<!doctype html>
10
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
11
+ <title>${escape(title)}</title>
12
+ <style>
13
+ :root{--bg:#f6f7f9;--panel:#fff;--fg:#1c2026;--muted:#6b7280;--border:#d8dce3;--on:#16a34a;--off:#dc2626;--box:#2563eb;--empty:#e7e9ee;--diverter:#c9ced8}
14
+ @media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#191c22;--fg:#e6e8ec;--muted:#9aa1ad;--border:#2c313a;--empty:#262a32;--diverter:#3a404b}}
15
+ *{box-sizing:border-box}body{margin:0;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg)}
16
+ header{display:flex;gap:14px;align-items:center;padding:12px 16px;background:var(--panel);border-bottom:1px solid var(--border)}
17
+ h1{font-size:19px;margin:0;flex:1}h2{font-size:14px;margin:0 0 10px;color:var(--muted);font-weight:600}
18
+ .pill{padding:4px 12px;border-radius:99px;color:#fff;font-weight:600;background:var(--off)}.pill.on{background:var(--on)}
19
+ #stale{display:none;padding:12px 16px;background:var(--off);color:#fff;font-weight:600}
20
+ main{display:grid;gap:14px;padding:14px;grid-template-columns:minmax(0,1fr) minmax(280px,380px)}
21
+ @media (max-width:800px){main{grid-template-columns:minmax(0,1fr)}}
22
+ section{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:14px}
23
+ .controls{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
24
+ 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}
25
+ button.start{background:var(--on);color:#fff}button.stop{background:var(--off);color:#fff}button:disabled{opacity:.45;cursor:default}
26
+ 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}
27
+ .strip{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:14px}.strip:last-child{margin-bottom:0}
28
+ .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}
29
+ .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)}
30
+ table{width:100%;border-collapse:collapse}td,th{padding:5px 6px;text-align:left;border-bottom:1px solid var(--border);font-size:13px}
31
+ .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}
32
+ </style></head><body>
33
+ <header><h1>${escape(title)}</h1><span class="pill" id="running"></span><span class="pill" id="plc"></span></header>
34
+ <div id="stale"></div>
35
+ <main><div>
36
+ <section><h2>Controls</h2><div class="controls" id="controls"></div><div id="error"></div></section>
37
+ <section><h2>Line</h2><div id="line"></div></section>
38
+ </div><div>
39
+ <section><h2>Blocked for over a minute</h2><table><tbody id="blocked"></tbody></table></section>
40
+ <section><h2>Switches</h2><div id="toggles"></div></section>
41
+ </div></main>
42
+ <script>
43
+ const $ = (id) => document.getElementById(id);
44
+ 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; };
45
+ let built = false, dragging = false;
46
+ const send = async (body) => {
47
+ $("error").textContent = "";
48
+ try {
49
+ const response = await fetch("/hmi/control", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
50
+ if (!response.ok) $("error").textContent = (await response.json()).message;
51
+ } catch { $("error").textContent = "Could not reach the server"; }
52
+ };
53
+ const build = (controls) => {
54
+ for (const control of controls) {
55
+ if (control.kind === "toggle") {
56
+ const label = document.createElement("label"), input = document.createElement("input");
57
+ label.className = "toggle"; label.append(control.label, input);
58
+ input.type = "checkbox"; input.id = "control" + control.index;
59
+ input.onchange = () => send({ index: control.index, value: input.checked ? 1 : 0 });
60
+ $("toggles").append(label);
61
+ } else if (control.kind === "button") {
62
+ const button = document.createElement("button");
63
+ button.textContent = control.label; button.id = "control" + control.index;
64
+ if (control.tone) button.className = control.tone;
65
+ button.onclick = () => { if (!control.confirm || confirm(control.label + "?")) send({ index: control.index }); };
66
+ $("controls").append(button);
67
+ } else {
68
+ const label = document.createElement("label"), input = document.createElement("input"), output = document.createElement("output");
69
+ label.append(control.label, input, output);
70
+ input.type = "range"; input.min = control.min; input.max = control.max; input.id = "control" + control.index;
71
+ input.onpointerdown = () => { dragging = true; };
72
+ input.oninput = () => { output.textContent = input.value; };
73
+ input.onchange = () => { dragging = false; send({ index: control.index, value: Number(input.value) }); };
74
+ $("controls").append(label);
75
+ }
76
+ }
77
+ };
78
+ const render = (state) => {
79
+ if (!built) { build(state.controls); built = true; }
80
+ $("plc").textContent = state.connected ? "PLC connected" : "PLC disconnected"; $("plc").className = "pill" + (state.connected ? " on" : "");
81
+ $("running").textContent = state.running ? "Running" : "Stopped"; $("running").className = "pill" + (state.running ? " on" : "");
82
+ const stale = state.reportAgeMs === null || state.reportAgeMs > 2000;
83
+ 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." : "";
84
+ $("stale").textContent = problem; $("stale").style.display = problem ? "block" : "none";
85
+ for (const control of state.controls) {
86
+ const element = $("control" + control.index);
87
+ // Stop stays available: a failed press reports its error rather than hiding the button.
88
+ element.disabled = !state.connected && control.tone !== "stop";
89
+ if (control.kind === "toggle" && control.value !== null) element.checked = control.value;
90
+ if (control.kind === "slider" && !dragging && control.value !== null && document.activeElement !== element) {
91
+ element.value = control.value; element.nextElementSibling.textContent = control.value;
92
+ }
93
+ }
94
+ const blocked = [];
95
+ $("line").replaceChildren(...state.conveyors.flatMap((conveyor) => {
96
+ const name = document.createElement("h2"); name.textContent = conveyor.label;
97
+ const strip = document.createElement("div"); strip.className = "strip";
98
+ conveyor.zones.forEach(({ occupied, blockedMs }, position) => {
99
+ const zone = document.createElement("div"), stuck = blockedMs > 60000;
100
+ zone.className = "zone" + (conveyor.diverters.includes(position) ? " diverter" : "") + (occupied ? " occupied" : "") + (stuck ? " stuck" : "");
101
+ zone.textContent = position + 1; zone.title = "Position " + (position + 1);
102
+ if (stuck) blocked.push(row([conveyor.label, "Position " + (position + 1), Math.round(blockedMs / 60000) + " min"]));
103
+ strip.append(zone);
104
+ });
105
+ return [name, strip];
106
+ }));
107
+ $("blocked").replaceChildren(...(blocked.length ? blocked : [row(["Nothing"])]));
108
+ };
109
+ const poll = async () => {
110
+ try { render(await (await fetch("/hmi/state")).json()); }
111
+ catch { $("stale").textContent = "No connection to the server."; $("stale").style.display = "block"; }
112
+ setTimeout(poll, 500);
113
+ };
114
+ poll();
115
+ </script></body></html>`;
116
+
117
+ // packages/server/src/index.ts
118
+ import { TRPCError } from "@trpc/server";
119
+
120
+ // packages/server/src/contract.ts
121
+ import { createHash } from "node:crypto";
122
+ import { z } from "zod";
123
+ var integerTypes = {
124
+ SINT: [-128, 127],
125
+ USINT: [0, 255],
126
+ BYTE: [0, 255],
127
+ INT: [-32768, 32767],
128
+ UINT: [0, 65535],
129
+ WORD: [0, 65535],
130
+ DINT: [-2147483648, 2147483647],
131
+ UDINT: [0, 4294967295],
132
+ DWORD: [0, 4294967295]
133
+ };
134
+ var integer = (type) => (bounds = {}) => z.number().int().min(bounds.min ?? integerTypes[type][0]).max(bounds.max ?? integerTypes[type][1]).meta({ iec: type });
135
+ var iec = {
136
+ SINT: integer("SINT"),
137
+ USINT: integer("USINT"),
138
+ BYTE: integer("BYTE"),
139
+ INT: integer("INT"),
140
+ UINT: integer("UINT"),
141
+ WORD: integer("WORD"),
142
+ DINT: integer("DINT"),
143
+ UDINT: integer("UDINT"),
144
+ DWORD: integer("DWORD"),
145
+ STRING: (capacity) => z.string().max(capacity),
146
+ FixedArray: (element, length) => z.array(element).length(length)
147
+ };
148
+ var identifier = /^(?!__proto__$)[A-Za-z_][A-Za-z0-9_]*$/;
149
+ var isIntegerType = (value) => Object.hasOwn(integerTypes, value);
150
+ var wireScalar = (field, label) => {
151
+ switch (field.kind) {
152
+ case "boolean":
153
+ return { kind: "boolean" };
154
+ case "string":
155
+ if (!Number.isInteger(field.capacity) || field.capacity < 1 || field.capacity > 2048)
156
+ throw new Error(`${label}: string capacity must be 1..2048 bytes.`);
157
+ return { kind: "string", capacity: field.capacity };
158
+ case "integer": {
159
+ const [minimum, maximum] = integerTypes[field.iec];
160
+ if (!Number.isInteger(field.minimum) || !Number.isInteger(field.maximum))
161
+ throw new Error(`${label}: integer bounds must be integers.`);
162
+ if (field.minimum > field.maximum)
163
+ throw new Error(`${label}: integer bounds are contradictory.`);
164
+ if (field.minimum < minimum || field.maximum > maximum)
165
+ throw new Error(`${label}: integer bounds do not fit ${field.iec}.`);
166
+ return { kind: "integer", iec: field.iec, minimum: field.minimum, maximum: field.maximum };
167
+ }
168
+ case "enum": {
169
+ const values = Object.entries(field.members).sort((left, right) => {
170
+ if (left[1] === 0)
171
+ return -1;
172
+ if (right[1] === 0)
173
+ return 1;
174
+ return left[1] - right[1];
175
+ });
176
+ if (!values.length || values.some(([name, value]) => !identifier.test(name) || !Number.isInteger(value) || value < -2147483648 || value > 2147483647))
177
+ throw new Error(`${label}: PLC enums require named DINT values.`);
178
+ if (new Set(values.map(([, value]) => value)).size !== values.length)
179
+ throw new Error(`${label}: PLC enum values must be distinct.`);
180
+ return { kind: "enum", members: Object.fromEntries(values) };
181
+ }
182
+ }
183
+ };
184
+ var wireRecord = (fields, label, output) => {
185
+ const checked = {};
186
+ for (const key of Object.keys(fields).sort()) {
187
+ const field = fields[key];
188
+ if (!identifier.test(key))
189
+ throw new Error(`${label}.${key}: use an IEC field identifier.`);
190
+ if (field.kind !== "array") {
191
+ checked[key] = wireScalar(field, `${label}.${key}`);
192
+ continue;
193
+ }
194
+ if (output)
195
+ throw new Error(`${label}.${key}: the native HTTP decoder currently supports scalar response fields only.`);
196
+ if (!Number.isInteger(field.length) || field.length < 1 || field.length > 256)
197
+ throw new Error(`${label}.${key}: arrays require one fixed length from 1 to 256.`);
198
+ checked[key] = {
199
+ kind: "array",
200
+ length: field.length,
201
+ element: wireScalar(field.element, `${label}.${key}`)
202
+ };
203
+ }
204
+ if (Object.keys(checked).length > 31 || output && !Object.keys(checked).length)
205
+ throw new Error(`${label}: response records require 1..31 fields; requests allow 0..31.`);
206
+ return { fields: checked };
207
+ };
208
+ var serviceContract = (path, kind, input, output) => {
209
+ if (path.split(".").some((part) => !identifier.test(part)))
210
+ throw new Error(`${path}: PLC procedure paths require identifier segments.`);
211
+ const version = createHash("sha256").update(JSON.stringify({ abi: 2, path, kind, input, output })).digest("hex").slice(0, 24);
212
+ return { path, kind, input, output, version };
213
+ };
214
+ var schemaValue = (value) => typeof value === "object" && value !== null && ("_zod" in value) && ("safeParse" in value);
215
+ var schema = (value, label) => {
216
+ if (!schemaValue(value))
217
+ throw new Error(`${label}: provide a bounded Zod input/output schema; TypeScript number/string inference has no IEC memory layout.`);
218
+ return value;
219
+ };
220
+ var jsonSchema = z.looseObject({
221
+ type: z.string().optional(),
222
+ maxLength: z.number().optional(),
223
+ minItems: z.number().optional(),
224
+ maxItems: z.number().optional(),
225
+ minimum: z.number().optional(),
226
+ maximum: z.number().optional(),
227
+ iec: z.string().optional()
228
+ });
229
+ var metadata = (value) => jsonSchema.parse(value.meta() ?? {});
230
+ var refSchema = z.looseObject({
231
+ $ref: z.string().optional(),
232
+ $defs: z.record(z.string(), z.unknown()).optional()
233
+ });
234
+ var jsonFor = (value, options) => {
235
+ const json = refSchema.parse(z.toJSONSchema(value, options));
236
+ const definition = json.$ref?.startsWith("#/$defs/") ? json.$defs?.[json.$ref.slice("#/$defs/".length)] : json;
237
+ return jsonSchema.parse(definition);
238
+ };
239
+ var scalar = (value, label) => {
240
+ const definition = value._zod.def;
241
+ if (["optional", "nullable", "union", "default", "prefault", "pipe", "catch"].includes(definition.type))
242
+ throw new Error(`${label}: optional, nullable, transformed and defaulted wire fields are unsupported.`);
243
+ if (definition.type === "enum") {
244
+ const entries = z.record(z.string(), z.union([z.number().int(), z.string()])).parse(Reflect.get(definition, "entries"));
245
+ const members = {};
246
+ for (const [name, value] of Object.entries(entries)) {
247
+ if (typeof value === "number")
248
+ members[name] = value;
249
+ else if (identifier.test(name) && entries[value] !== name)
250
+ throw new Error(`${label}: PLC enums require only numeric members.`);
251
+ }
252
+ return { kind: "enum", members };
253
+ }
254
+ const json = jsonFor(value, { io: "input", unrepresentable: "throw" });
255
+ if (json.type === "boolean")
256
+ return { kind: "boolean" };
257
+ if (json.type === "string" && json.maxLength !== undefined)
258
+ return { kind: "string", capacity: json.maxLength };
259
+ const type = metadata(value).iec;
260
+ if (json.type === "integer" && type !== undefined && isIntegerType(type) && json.minimum !== undefined && json.maximum !== undefined)
261
+ return { kind: "integer", iec: type, minimum: json.minimum, maximum: json.maximum };
262
+ throw new Error(`${label}: use z.boolean(), z.enum() of a numeric enum, iec.STRING(), or an integer such as iec.INT(). Optional, nullable, union, float and dynamic layouts are unsupported.`);
263
+ };
264
+ var record = (declared, label, output) => {
265
+ const piped = declared._zod.def;
266
+ const value = !output && piped.type === "pipe" ? schema(Reflect.get(piped, "in"), label) : declared;
267
+ const def = value._zod.def;
268
+ if (def.type !== "object")
269
+ throw new Error(`${label}: HTTP services require a closed object schema.`);
270
+ const shape = z.record(z.string(), z.custom(schemaValue)).parse(Reflect.get(def, "shape"));
271
+ const fields = {};
272
+ for (const [key, item] of Object.entries(shape)) {
273
+ if (item._zod.def.type !== "array") {
274
+ fields[key] = scalar(item, `${label}.${key}`);
275
+ continue;
276
+ }
277
+ const bounds = jsonFor(item, { io: "input" });
278
+ if (bounds.maxItems === undefined || bounds.maxItems !== bounds.minItems)
279
+ throw new Error(`${label}.${key}: arrays require one fixed length from 1 to 256.`);
280
+ fields[key] = {
281
+ kind: "array",
282
+ length: bounds.maxItems,
283
+ element: scalar(schema(Reflect.get(item._zod.def, "element"), `${label}.${key}`), `${label}.${key}`)
284
+ };
285
+ }
286
+ return wireRecord(fields, label, output);
287
+ };
288
+ var serviceContracts = (router) => {
289
+ const contracts = [];
290
+ const procedures = z.record(z.string(), z.unknown()).parse(router._def.procedures);
291
+ for (const [path, procedure] of Object.entries(procedures)) {
292
+ if (typeof procedure !== "function" || !("_def" in procedure))
293
+ throw new Error("Unsupported tRPC router metadata.");
294
+ const reflected = z.custom((value) => typeof value === "function" && ("_def" in value)).parse(procedure);
295
+ const def = reflected._def;
296
+ if (typeof def.meta !== "object" || def.meta === null || !("plc" in def.meta) || def.meta.plc !== true)
297
+ continue;
298
+ if (def.type !== "query" && def.type !== "mutation")
299
+ throw new Error(`${path}: subscriptions use a different transport; expose a query or mutation to the PLC.`);
300
+ if (def.inputs.length > 1)
301
+ throw new Error(`${path}: merge the input into one bounded object schema.`);
302
+ const input = record(def.inputs.length ? schema(def.inputs[0], `${path}.input`) : z.strictObject({}), `${path}.Input`, false);
303
+ const output = record(schema(Reflect.get(def, "output"), `${path}.output`), `${path}.Output`, true);
304
+ contracts.push(serviceContract(path, def.type, input, output));
305
+ }
306
+ return contracts.sort((a, b) => a.path.localeCompare(b.path));
307
+ };
308
+ var servicePath = (contract) => `/tsplc/services/${contract.version}/${contract.path}`;
309
+ var validateWireRecord = (record, value) => {
310
+ const scalarValue = (field, value) => {
311
+ switch (field.kind) {
312
+ case "boolean":
313
+ return typeof value === "boolean";
314
+ case "integer":
315
+ return typeof value === "number" && Number.isInteger(value) && value >= field.minimum && value <= field.maximum;
316
+ case "string":
317
+ return typeof value === "string" && !value.includes("\x00") && new TextEncoder().encode(value).length <= field.capacity;
318
+ case "enum":
319
+ return typeof value === "number" && Object.values(field.members).includes(value);
320
+ }
321
+ };
322
+ if (typeof value !== "object" || value === null || Array.isArray(value))
323
+ throw new Error("Expected a closed wire record.");
324
+ const entries = Object.entries(value);
325
+ if (entries.length !== Object.keys(record.fields).length)
326
+ throw new Error("Wire record fields differ from the contract.");
327
+ for (const [name, field] of Object.entries(record.fields)) {
328
+ const item = Reflect.get(value, name);
329
+ const valid = field.kind === "array" ? Array.isArray(item) && item.length === field.length && item.every((value) => scalarValue(field.element, value)) : scalarValue(field, item);
330
+ if (!valid)
331
+ throw new Error(`Wire field ${name} violates its PLC representation.`);
332
+ }
333
+ if (new TextEncoder().encode(JSON.stringify(value)).length > 2048)
334
+ throw new Error("Wire record exceeds the 2048-byte native JSON limit.");
335
+ };
336
+
337
+ // packages/server/src/index.ts
338
+ var createServiceHandler = (options) => {
339
+ const contracts = serviceContracts(options.router);
340
+ const paths = new Map(contracts.map((contract) => [servicePath(contract), contract]));
341
+ const response = (value, status = 200) => {
342
+ const body = JSON.stringify(value);
343
+ return new Response(body, {
344
+ status,
345
+ headers: {
346
+ "Content-Type": "application/json; charset=utf-8",
347
+ "Content-Length": String(new TextEncoder().encode(body).length),
348
+ "Cache-Control": "no-store"
349
+ }
350
+ });
351
+ };
352
+ return async (request) => {
353
+ const path = new URL(request.url).pathname;
354
+ const contract = paths.get(path);
355
+ if (!contract)
356
+ return response({ error: "Unknown procedure or incompatible contract" }, 409);
357
+ if (request.method !== "POST")
358
+ return response({ error: "Use POST" }, 405);
359
+ const length = request.headers.get("content-length");
360
+ if (length !== null && (!/^\d+$/.test(length) || Number(length) > 2048))
361
+ return response({ error: "Request exceeds the PLC wire limit" }, 413);
362
+ const reader = request.body?.getReader();
363
+ const chunks = [];
364
+ let size = 0;
365
+ if (reader)
366
+ try {
367
+ for (;; ) {
368
+ const chunk = await reader.read();
369
+ if (chunk.done)
370
+ break;
371
+ size += chunk.value.length;
372
+ if (size > 2048) {
373
+ await reader.cancel();
374
+ return response({ error: "Request exceeds the PLC wire limit" }, 413);
375
+ }
376
+ chunks.push(chunk.value);
377
+ }
378
+ } finally {
379
+ reader.releaseLock();
380
+ }
381
+ const bytes = new Uint8Array(size);
382
+ let offset = 0;
383
+ for (const chunk of chunks) {
384
+ bytes.set(chunk, offset);
385
+ offset += chunk.length;
386
+ }
387
+ let input;
388
+ try {
389
+ input = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
390
+ validateWireRecord(contract.input, input);
391
+ } catch {
392
+ return response({ error: "Invalid procedure input" }, 400);
393
+ }
394
+ try {
395
+ const context = await options.createContext(request);
396
+ const caller = options.router.createCaller(context, { signal: request.signal });
397
+ const invoke = Reflect.get(caller, contract.path);
398
+ if (typeof invoke !== "function")
399
+ throw new Error("Invalid tRPC caller.");
400
+ const output = await invoke(input);
401
+ validateWireRecord(contract.output, output);
402
+ return response(output);
403
+ } catch (cause) {
404
+ if (cause instanceof TRPCError) {
405
+ const statuses = {
406
+ UNAUTHORIZED: 401,
407
+ FORBIDDEN: 403,
408
+ BAD_REQUEST: 400,
409
+ NOT_FOUND: 404,
410
+ CONFLICT: 409,
411
+ TOO_MANY_REQUESTS: 429
412
+ };
413
+ const status = statuses[cause.code];
414
+ if (status === undefined)
415
+ options.onError?.(cause);
416
+ return response({ error: cause.code }, status ?? 500);
417
+ }
418
+ options.onError?.(cause);
419
+ return response({ error: "Procedure failed" }, 500);
420
+ }
421
+ };
422
+ };
423
+
424
+ // packages/server/src/opcua.ts
425
+ import { randomBytes } from "node:crypto";
426
+ import { setTimeout as delay } from "node:timers/promises";
427
+ import { AttributeIds, DataType, discoverVariables } from "@tsplc/opcua";
428
+ var runOpcUaServices = async (options) => {
429
+ if (!options.channels.length)
430
+ return;
431
+ const dispatch = createServiceHandler(options);
432
+ const pending = new Map;
433
+ const invocations = new Set;
434
+ const writers = new Map;
435
+ const handled = new Map;
436
+ const epochs = new Map;
437
+ let activeSession;
438
+ let connectionError;
439
+ let nodes = new Map;
440
+ const write = async (session, path, value) => {
441
+ const nodeId = nodes.get(path);
442
+ if (!nodeId)
443
+ throw new Error(`Missing OPC UA service channel: ${path}`);
444
+ const status = await session.write({
445
+ nodeId,
446
+ attributeId: AttributeIds.Value,
447
+ value: { value: { dataType: DataType.String, value } }
448
+ });
449
+ if (!status.isGood())
450
+ throw new Error(`OPC UA service write failed: ${status.toString()}`);
451
+ };
452
+ const read = async (session, paths) => {
453
+ const requests = paths.map((path) => {
454
+ const nodeId = nodes.get(path);
455
+ if (!nodeId)
456
+ throw new Error(`Missing OPC UA service channel: ${path}`);
457
+ return { nodeId, attributeId: AttributeIds.Value };
458
+ });
459
+ const values = await session.read(requests, 0);
460
+ if (values.length !== requests.length || values.some((value) => !value.statusCode.isGood()))
461
+ throw new Error("OPC UA service read failed");
462
+ return values.map((value) => value.value.value);
463
+ };
464
+ const reply = async (session, channel, identity, body, signal) => {
465
+ const previous = writers.get(channel.reply) ?? Promise.resolve();
466
+ const writing = previous.catch(() => {
467
+ return;
468
+ }).then(async () => {
469
+ if (signal.aborted || activeSession !== session)
470
+ return;
471
+ await write(session, channel.reply, body);
472
+ await write(session, channel.replyId, identity);
473
+ });
474
+ writers.set(channel.reply, writing);
475
+ try {
476
+ await writing;
477
+ } finally {
478
+ if (writers.get(channel.reply) === writing)
479
+ writers.delete(channel.reply);
480
+ }
481
+ };
482
+ try {
483
+ while (!options.signal.aborted) {
484
+ try {
485
+ const session = options.getSession();
486
+ if (session !== activeSession) {
487
+ for (const work of pending.values())
488
+ work.controller.abort();
489
+ nodes = await discoverVariables(session, options.channels.flatMap((channel) => [
490
+ channel.request,
491
+ channel.requestId,
492
+ channel.reply,
493
+ channel.replyId,
494
+ channel.epoch
495
+ ]));
496
+ epochs.clear();
497
+ for (const channel of options.channels) {
498
+ await write(session, channel.replyId, "");
499
+ const epoch = randomBytes(16).toString("hex");
500
+ await write(session, channel.epoch, epoch);
501
+ epochs.set(channel.request, epoch);
502
+ }
503
+ activeSession = session;
504
+ handled.clear();
505
+ options.onReady?.(session);
506
+ }
507
+ const before = await read(session, options.channels.map((channel) => channel.requestId));
508
+ const batch = await read(session, options.channels.flatMap((channel) => [channel.request, channel.epoch]));
509
+ const after = await read(session, options.channels.map((channel) => channel.requestId));
510
+ for (const [index, channel] of options.channels.entries()) {
511
+ const identity = after[index];
512
+ if (typeof identity !== "string")
513
+ throw new Error("Invalid OPC UA service identity type");
514
+ const current = pending.get(channel.request);
515
+ if (current && current.identity !== identity)
516
+ current.controller.abort();
517
+ const request = batch[index * 2], epochValue = batch[index * 2 + 1];
518
+ if (epochValue === "") {
519
+ const epoch = randomBytes(16).toString("hex");
520
+ await write(session, channel.epoch, epoch);
521
+ epochs.set(channel.request, epoch);
522
+ continue;
523
+ }
524
+ if (before[index] !== identity || !identity)
525
+ continue;
526
+ const epoch = epochs.get(channel.request);
527
+ if (epochValue !== epoch)
528
+ throw new Error("OPC UA service gateway ownership changed");
529
+ if (typeof request !== "string" || !identity.startsWith(epoch + ":") || !request.startsWith(identity + "|"))
530
+ continue;
531
+ if (handled.get(channel.request) === identity)
532
+ continue;
533
+ handled.set(channel.request, identity);
534
+ const controller = new AbortController;
535
+ const signal = AbortSignal.any([options.signal, controller.signal]);
536
+ const operation = (async () => {
537
+ if (invocations.size >= options.channels.length * 2) {
538
+ await reply(session, channel, identity, `${identity}|1|{}`, signal);
539
+ return;
540
+ }
541
+ const invocation = dispatch(new Request("http://tsplc.internal" + channel.path, {
542
+ method: "POST",
543
+ body: request.slice(identity.length + 1),
544
+ signal
545
+ }));
546
+ invocations.add(invocation);
547
+ invocation.then(() => invocations.delete(invocation), () => invocations.delete(invocation));
548
+ const timer = new AbortController;
549
+ const response = await Promise.race([
550
+ invocation,
551
+ delay(5000, undefined, { signal: AbortSignal.any([signal, timer.signal]) }).then(() => {
552
+ throw new Error("OPC UA service handler timed out");
553
+ })
554
+ ]).finally(() => timer.abort());
555
+ const body = `${identity}|${response.ok ? 0 : 1}|${response.ok ? await response.text() : "{}"}`;
556
+ if (Buffer.byteLength(body, "utf8") > 2048)
557
+ throw new Error("OPC UA service reply exceeds 2048 bytes");
558
+ await reply(session, channel, identity, body, signal);
559
+ })().catch((error) => {
560
+ if (!signal.aborted)
561
+ options.onError(error);
562
+ }).finally(() => {
563
+ if (pending.get(channel.request)?.controller === controller)
564
+ pending.delete(channel.request);
565
+ });
566
+ pending.set(channel.request, { identity, controller, done: operation });
567
+ }
568
+ connectionError = undefined;
569
+ } catch (error) {
570
+ activeSession = undefined;
571
+ for (const work of pending.values())
572
+ work.controller.abort();
573
+ const message = error instanceof Error ? error.message : String(error);
574
+ if (connectionError !== message) {
575
+ connectionError = message;
576
+ options.onError(error);
577
+ }
578
+ await delay(1000, undefined, { signal: options.signal });
579
+ }
580
+ await delay(20, undefined, { signal: options.signal });
581
+ }
582
+ } catch (error) {
583
+ if (!options.signal.aborted)
584
+ throw error;
585
+ } finally {
586
+ activeSession = undefined;
587
+ for (const work of pending.values())
588
+ work.controller.abort();
589
+ await Promise.all([...pending.values()].map((work) => work.done));
590
+ await Promise.allSettled(writers.values());
591
+ }
592
+ };
593
+
594
+ // packages/server/src/serve.ts
595
+ var hmiControlRequest = z2.strictObject({
596
+ index: z2.number().int().nonnegative(),
597
+ value: z2.number().optional()
598
+ });
599
+ var json = (body, status = 200) => Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
600
+ var sleep = (ms, signal) => delay2(ms, undefined, { signal }).catch(() => {
601
+ return;
602
+ });
603
+ var serve = (options) => {
604
+ const controller = new AbortController;
605
+ const { signal } = controller;
606
+ const expected = Buffer.from(`Bearer ${options.auth.bearerToken}`);
607
+ const protect = (handler) => async (request) => {
608
+ const actual = Buffer.from(request.headers.get("authorization") ?? "");
609
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
610
+ return json({ message: "Unauthorized" }, 401);
611
+ const origin = request.headers.get("origin");
612
+ if (origin !== null && !options.auth.allowedOrigins.includes(origin))
613
+ return json({ message: "Origin is not allowed" }, 403);
614
+ try {
615
+ return await handler(request);
616
+ } catch (error) {
617
+ options.log.warn(`Request failed: ${new URL(request.url).pathname}`, error);
618
+ return json({ message: "Internal server error" }, 500);
619
+ }
620
+ };
621
+ const routes = {
622
+ "/health": () => json({ plcConnected: options.plc.snapshot().connected, running: true }),
623
+ "/trpc/*": protect((request) => fetchRequestHandler({
624
+ createContext: options.createContext,
625
+ endpoint: "/trpc",
626
+ req: request,
627
+ router: options.router
628
+ })),
629
+ "/tsplc/services/*": protect(createServiceHandler({
630
+ router: options.router,
631
+ createContext: options.createContext,
632
+ onError: (error) => options.log.warn("PLC service call failed", error)
633
+ }))
634
+ };
635
+ if (options.hmi) {
636
+ const { hmi, plc } = options.hmi;
637
+ routes["/"] = () => new Response(hmiPage(hmi.title), { headers: { "Content-Type": "text/html; charset=utf-8" } });
638
+ routes["/hmi/state"] = () => json(hmi.state(plc));
639
+ routes["/hmi/control"] = async (request) => {
640
+ const origin = request.headers.get("origin");
641
+ if (request.method !== "POST" || origin !== null && origin !== new URL(request.url).origin && !options.auth.allowedOrigins.includes(origin))
642
+ return json({ message: "Control request not allowed" }, 403);
643
+ const parsed = hmiControlRequest.safeParse(await request.json().catch(() => null));
644
+ if (!parsed.success)
645
+ return json({ message: "Invalid control request" }, 400);
646
+ try {
647
+ await hmi.control(plc, parsed.data);
648
+ return json({ ok: true });
649
+ } catch (error) {
650
+ if (error instanceof RangeError)
651
+ return json({ message: error.message }, 400);
652
+ options.log.warn("HMI control failed", error);
653
+ return json({ message: "The PLC did not accept this command" }, 503);
654
+ }
655
+ };
656
+ }
657
+ const server = Bun.serve({
658
+ ...options.tls ? { tls: options.tls } : {},
659
+ hostname: options.hostname,
660
+ port: options.port,
661
+ routes,
662
+ fetch: () => json({ message: "Not found" }, 404)
663
+ });
664
+ const stop = () => controller.abort();
665
+ process.once("SIGINT", stop);
666
+ process.once("SIGTERM", stop);
667
+ options.log.info(`Listening on ${server.url}`);
668
+ const closed = Promise.all([
669
+ options.plc.run(signal),
670
+ runOpcUaServices({
671
+ router: options.router,
672
+ createContext: options.createContext,
673
+ channels: options.channels,
674
+ getSession: options.plc.session,
675
+ onError: (error) => options.log.warn("PLC service gateway unavailable", error),
676
+ onReady: () => options.log.info("PLC service gateway ready"),
677
+ signal
678
+ }),
679
+ ...(options.jobs ?? []).map(async (job) => {
680
+ while (!signal.aborted) {
681
+ try {
682
+ await job.run();
683
+ } catch (error) {
684
+ options.log.warn(`${job.name} failed`, error);
685
+ }
686
+ await sleep(job.intervalMs, signal);
687
+ }
688
+ })
689
+ ]).finally(async () => {
690
+ controller.abort();
691
+ process.off("SIGINT", stop);
692
+ process.off("SIGTERM", stop);
693
+ await server.stop(true);
694
+ });
695
+ return {
696
+ url: server.url,
697
+ closed: closed.then(() => {
698
+ return;
699
+ }),
700
+ stop: async () => {
701
+ stop();
702
+ await closed;
703
+ }
704
+ };
705
+ };
706
+ export {
707
+ serve
708
+ };
709
+
710
+ //# debugId=757C86D2676275DF64756E2164756E21
711
+ //# sourceMappingURL=serve.js.map