@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/README.md +6 -2
- package/dist/hmi.js +132 -0
- package/dist/hmi.js.map +11 -0
- package/dist/index.js +320 -0
- package/dist/index.js.map +11 -0
- package/dist/opcua.js +484 -0
- package/dist/opcua.js.map +12 -0
- package/dist/runtime.js +293 -0
- package/dist/runtime.js.map +12 -0
- package/dist/serve.js +711 -0
- package/dist/serve.js.map +14 -0
- package/package.json +48 -4
- package/src/contract.ts +366 -0
- package/src/hmi-page.ts +111 -0
- package/src/hmi.ts +127 -0
- package/src/index.ts +106 -0
- package/src/opcua.ts +211 -0
- package/src/serve.ts +170 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/serve.ts", "../src/hmi-page.ts", "../src/index.ts", "../src/contract.ts", "../src/opcua.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { timingSafeEqual } from \"node:crypto\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport type { AnyRouter, inferRouterContext } from \"@trpc/server\";\nimport { fetchRequestHandler } from \"@trpc/server/adapters/fetch\";\nimport type { ClientSession, PlcLog } from \"@tsplc/opcua\";\nimport type { TLSOptions } from \"bun\";\nimport { z } from \"zod\";\nimport type { Hmi, HmiPlc } from \"./hmi\";\nimport { hmiPage } from \"./hmi-page\";\nimport { createServiceHandler } from \"./index\";\nimport { runOpcUaServices, type ServiceChannel } from \"./opcua\";\n\ntype RouteHandler = (request: Request) => Response | Promise<Response>;\n\nexport interface ServeOptions<Router extends AnyRouter> {\n hostname: string;\n port: number;\n tls?: TLSOptions;\n /** Required on every route except `/health`. Browser requests also need an allowed origin. */\n auth: { bearerToken: string; allowedOrigins: readonly string[] };\n plc: {\n run: (signal: AbortSignal) => Promise<void>;\n snapshot: () => { connected: boolean };\n session: () => ClientSession;\n };\n /** Served at `/trpc/*`; its `plc` procedures also serve the PLC at `/tsplc/services/*`. */\n router: Router;\n /** Context for every call, after authentication. */\n createContext: () => inferRouterContext<Router> | Promise<inferRouterContext<Router>>;\n /** The generated OPC UA service channels; empty when the PLC calls over HTTP. */\n channels: readonly ServiceChannel[];\n /** Work repeated until shutdown. A failed run is logged and retried after its interval. */\n jobs?: ReadonlyArray<{ name: string; intervalMs: number; run: () => Promise<void> }>;\n /**\n * An operator page at `/`, with `/hmi/state` and `/hmi/control`. These don't need the bearer\n * token: only use it on a closed network. Controls still reject other sites' origins.\n */\n hmi?: { hmi: Hmi; plc: HmiPlc };\n log: PlcLog;\n}\n\nconst hmiControlRequest = z.strictObject({\n index: z.number().int().nonnegative(),\n value: z.number().optional(),\n});\n\nconst json = (body: unknown, status = 200) =>\n Response.json(body, { status, headers: { \"Cache-Control\": \"no-store\" } });\n\nconst sleep = (ms: number, signal: AbortSignal) =>\n delay(ms, undefined, { signal }).catch(() => undefined);\n\n/**\n * The PLC's host process: serves one tRPC router to applications and its PLC\n * procedures to the PLC, keeps the PLC session and background jobs running, and\n * stops all of them on SIGINT/SIGTERM or `stop()`.\n */\nexport const serve = <Router extends AnyRouter>(options: ServeOptions<Router>) => {\n const controller = new AbortController();\n const { signal } = controller;\n const expected = Buffer.from(`Bearer ${options.auth.bearerToken}`);\n const protect =\n (handler: RouteHandler): RouteHandler =>\n async (request) => {\n const actual = Buffer.from(request.headers.get(\"authorization\") ?? \"\");\n if (actual.length !== expected.length || !timingSafeEqual(actual, expected))\n return json({ message: \"Unauthorized\" }, 401);\n const origin = request.headers.get(\"origin\");\n if (origin !== null && !options.auth.allowedOrigins.includes(origin))\n return json({ message: \"Origin is not allowed\" }, 403);\n try {\n return await handler(request);\n } catch (error) {\n options.log.warn(`Request failed: ${new URL(request.url).pathname}`, error);\n return json({ message: \"Internal server error\" }, 500);\n }\n };\n const routes: Record<string, RouteHandler> = {\n \"/health\": () => json({ plcConnected: options.plc.snapshot().connected, running: true }),\n \"/trpc/*\": protect((request) =>\n fetchRequestHandler({\n createContext: options.createContext,\n endpoint: \"/trpc\",\n req: request,\n router: options.router,\n }),\n ),\n \"/tsplc/services/*\": protect(\n createServiceHandler({\n router: options.router,\n createContext: options.createContext,\n onError: (error) => options.log.warn(\"PLC service call failed\", error),\n }),\n ),\n };\n if (options.hmi) {\n const { hmi, plc } = options.hmi;\n routes[\"/\"] = () =>\n new Response(hmiPage(hmi.title), { headers: { \"Content-Type\": \"text/html; charset=utf-8\" } });\n routes[\"/hmi/state\"] = () => json(hmi.state(plc));\n routes[\"/hmi/control\"] = async (request) => {\n const origin = request.headers.get(\"origin\");\n if (\n request.method !== \"POST\" ||\n (origin !== null &&\n origin !== new URL(request.url).origin &&\n !options.auth.allowedOrigins.includes(origin))\n )\n return json({ message: \"Control request not allowed\" }, 403);\n const parsed = hmiControlRequest.safeParse(await request.json().catch(() => null));\n if (!parsed.success) return json({ message: \"Invalid control request\" }, 400);\n try {\n await hmi.control(plc, parsed.data);\n return json({ ok: true });\n } catch (error) {\n if (error instanceof RangeError) return json({ message: error.message }, 400);\n options.log.warn(\"HMI control failed\", error);\n return json({ message: \"The PLC did not accept this command\" }, 503);\n }\n };\n }\n const server = Bun.serve({\n ...(options.tls ? { tls: options.tls } : {}),\n hostname: options.hostname,\n port: options.port,\n routes,\n fetch: () => json({ message: \"Not found\" }, 404),\n });\n const stop = () => controller.abort();\n process.once(\"SIGINT\", stop);\n process.once(\"SIGTERM\", stop);\n options.log.info(`Listening on ${server.url}`);\n\n const closed = Promise.all([\n options.plc.run(signal),\n runOpcUaServices({\n router: options.router,\n createContext: options.createContext,\n channels: options.channels,\n getSession: options.plc.session,\n onError: (error) => options.log.warn(\"PLC service gateway unavailable\", error),\n onReady: () => options.log.info(\"PLC service gateway ready\"),\n signal,\n }),\n ...(options.jobs ?? []).map(async (job) => {\n while (!signal.aborted) {\n try {\n await job.run();\n } catch (error) {\n options.log.warn(`${job.name} failed`, error);\n }\n await sleep(job.intervalMs, signal);\n }\n }),\n ]).finally(async () => {\n controller.abort();\n process.off(\"SIGINT\", stop);\n process.off(\"SIGTERM\", stop);\n await server.stop(true);\n });\n\n return {\n url: server.url,\n closed: closed.then(() => undefined),\n stop: async () => {\n stop();\n await closed;\n },\n };\n};\n",
|
|
6
|
+
"const escape = (value: string) =>\n value.replace(/[&<>\"]/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\" })[c]!);\n\n/** The operator's page: self-contained, and redrawn from `/hmi/state`. */\nexport const hmiPage = (title: string) => `<!doctype html>\n<html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>${escape(title)}</title>\n<style>\n:root{--bg:#f6f7f9;--panel:#fff;--fg:#1c2026;--muted:#6b7280;--border:#d8dce3;--on:#16a34a;--off:#dc2626;--box:#2563eb;--empty:#e7e9ee;--diverter:#c9ced8}\n@media (prefers-color-scheme:dark){:root{--bg:#111318;--panel:#191c22;--fg:#e6e8ec;--muted:#9aa1ad;--border:#2c313a;--empty:#262a32;--diverter:#3a404b}}\n*{box-sizing:border-box}body{margin:0;font:15px system-ui,sans-serif;background:var(--bg);color:var(--fg)}\nheader{display:flex;gap:14px;align-items:center;padding:12px 16px;background:var(--panel);border-bottom:1px solid var(--border)}\nh1{font-size:19px;margin:0;flex:1}h2{font-size:14px;margin:0 0 10px;color:var(--muted);font-weight:600}\n.pill{padding:4px 12px;border-radius:99px;color:#fff;font-weight:600;background:var(--off)}.pill.on{background:var(--on)}\n#stale{display:none;padding:12px 16px;background:var(--off);color:#fff;font-weight:600}\nmain{display:grid;gap:14px;padding:14px;grid-template-columns:minmax(0,1fr) minmax(280px,380px)}\n@media (max-width:800px){main{grid-template-columns:minmax(0,1fr)}}\nsection{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:14px}\n.controls{display:flex;flex-wrap:wrap;gap:12px;align-items:center}\nbutton{font:600 17px system-ui;padding:14px 28px;border:0;border-radius:9px;background:var(--diverter);color:var(--fg);cursor:pointer;touch-action:manipulation}\nbutton.start{background:var(--on);color:#fff}button.stop{background:var(--off);color:#fff}button:disabled{opacity:.45;cursor:default}\nlabel{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}\n.strip{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:14px}.strip:last-child{margin-bottom:0}\n.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}\n.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)}\ntable{width:100%;border-collapse:collapse}td,th{padding:5px 6px;text-align:left;border-bottom:1px solid var(--border);font-size:13px}\n.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}\n</style></head><body>\n<header><h1>${escape(title)}</h1><span class=\"pill\" id=\"running\"></span><span class=\"pill\" id=\"plc\"></span></header>\n<div id=\"stale\"></div>\n<main><div>\n<section><h2>Controls</h2><div class=\"controls\" id=\"controls\"></div><div id=\"error\"></div></section>\n<section><h2>Line</h2><div id=\"line\"></div></section>\n</div><div>\n<section><h2>Blocked for over a minute</h2><table><tbody id=\"blocked\"></tbody></table></section>\n<section><h2>Switches</h2><div id=\"toggles\"></div></section>\n</div></main>\n<script>\nconst $ = (id) => document.getElementById(id);\nconst 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; };\nlet built = false, dragging = false;\nconst send = async (body) => {\n $(\"error\").textContent = \"\";\n try {\n const response = await fetch(\"/hmi/control\", { method: \"POST\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify(body) });\n if (!response.ok) $(\"error\").textContent = (await response.json()).message;\n } catch { $(\"error\").textContent = \"Could not reach the server\"; }\n};\nconst build = (controls) => {\n for (const control of controls) {\n if (control.kind === \"toggle\") {\n const label = document.createElement(\"label\"), input = document.createElement(\"input\");\n label.className = \"toggle\"; label.append(control.label, input);\n input.type = \"checkbox\"; input.id = \"control\" + control.index;\n input.onchange = () => send({ index: control.index, value: input.checked ? 1 : 0 });\n $(\"toggles\").append(label);\n } else if (control.kind === \"button\") {\n const button = document.createElement(\"button\");\n button.textContent = control.label; button.id = \"control\" + control.index;\n if (control.tone) button.className = control.tone;\n button.onclick = () => { if (!control.confirm || confirm(control.label + \"?\")) send({ index: control.index }); };\n $(\"controls\").append(button);\n } else {\n const label = document.createElement(\"label\"), input = document.createElement(\"input\"), output = document.createElement(\"output\");\n label.append(control.label, input, output);\n input.type = \"range\"; input.min = control.min; input.max = control.max; input.id = \"control\" + control.index;\n input.onpointerdown = () => { dragging = true; };\n input.oninput = () => { output.textContent = input.value; };\n input.onchange = () => { dragging = false; send({ index: control.index, value: Number(input.value) }); };\n $(\"controls\").append(label);\n }\n }\n};\nconst render = (state) => {\n if (!built) { build(state.controls); built = true; }\n $(\"plc\").textContent = state.connected ? \"PLC connected\" : \"PLC disconnected\"; $(\"plc\").className = \"pill\" + (state.connected ? \" on\" : \"\");\n $(\"running\").textContent = state.running ? \"Running\" : \"Stopped\"; $(\"running\").className = \"pill\" + (state.running ? \" on\" : \"\");\n const stale = state.reportAgeMs === null || state.reportAgeMs > 2000;\n 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.\" : \"\";\n $(\"stale\").textContent = problem; $(\"stale\").style.display = problem ? \"block\" : \"none\";\n for (const control of state.controls) {\n const element = $(\"control\" + control.index);\n // Stop stays available: a failed press reports its error rather than hiding the button.\n element.disabled = !state.connected && control.tone !== \"stop\";\n if (control.kind === \"toggle\" && control.value !== null) element.checked = control.value;\n if (control.kind === \"slider\" && !dragging && control.value !== null && document.activeElement !== element) {\n element.value = control.value; element.nextElementSibling.textContent = control.value;\n }\n }\n const blocked = [];\n $(\"line\").replaceChildren(...state.conveyors.flatMap((conveyor) => {\n const name = document.createElement(\"h2\"); name.textContent = conveyor.label;\n const strip = document.createElement(\"div\"); strip.className = \"strip\";\n conveyor.zones.forEach(({ occupied, blockedMs }, position) => {\n const zone = document.createElement(\"div\"), stuck = blockedMs > 60000;\n zone.className = \"zone\" + (conveyor.diverters.includes(position) ? \" diverter\" : \"\") + (occupied ? \" occupied\" : \"\") + (stuck ? \" stuck\" : \"\");\n zone.textContent = position + 1; zone.title = \"Position \" + (position + 1);\n if (stuck) blocked.push(row([conveyor.label, \"Position \" + (position + 1), Math.round(blockedMs / 60000) + \" min\"]));\n strip.append(zone);\n });\n return [name, strip];\n }));\n $(\"blocked\").replaceChildren(...(blocked.length ? blocked : [row([\"Nothing\"])]));\n};\nconst poll = async () => {\n try { render(await (await fetch(\"/hmi/state\")).json()); }\n catch { $(\"stale\").textContent = \"No connection to the server.\"; $(\"stale\").style.display = \"block\"; }\n setTimeout(poll, 500);\n};\npoll();\n</script></body></html>`;\n",
|
|
7
|
+
"import { TRPCError, type AnyRouter, type inferRouterContext } from \"@trpc/server\";\nimport { serviceContracts, servicePath, validateWireRecord } from \"./contract\";\nexport {\n iec,\n isIntegerType,\n serviceContract,\n serviceContracts,\n servicePath,\n wireLayout,\n wireRecord,\n type IntegerType,\n type PlcMeta,\n type Wire,\n type ServiceContract,\n type WireField,\n type WireRecord,\n type WireScalar,\n} from \"./contract\";\n\n/** Mount in an authenticated Fetch host; createContext must resolve the caller's identity. */\nexport const createServiceHandler = <Router extends AnyRouter>(options: {\n router: Router;\n createContext: (\n request: Request,\n ) => inferRouterContext<Router> | Promise<inferRouterContext<Router>>;\n onError?: (error: unknown) => void;\n}) => {\n const contracts = serviceContracts(options.router);\n const paths = new Map(contracts.map((contract) => [servicePath(contract), contract]));\n const response = (value: unknown, status = 200) => {\n const body = JSON.stringify(value);\n return new Response(body, {\n status,\n headers: {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Content-Length\": String(new TextEncoder().encode(body).length),\n \"Cache-Control\": \"no-store\",\n },\n });\n };\n return async (request: Request): Promise<Response> => {\n const path = new URL(request.url).pathname;\n const contract = paths.get(path);\n if (!contract) return response({ error: \"Unknown procedure or incompatible contract\" }, 409);\n if (request.method !== \"POST\") return response({ error: \"Use POST\" }, 405);\n const length = request.headers.get(\"content-length\");\n if (length !== null && (!/^\\d+$/.test(length) || Number(length) > 2048))\n return response({ error: \"Request exceeds the PLC wire limit\" }, 413);\n const reader = request.body?.getReader();\n const chunks: Uint8Array[] = [];\n let size = 0;\n if (reader)\n try {\n for (;;) {\n const chunk = await reader.read();\n if (chunk.done) break;\n size += chunk.value.length;\n if (size > 2048) {\n await reader.cancel();\n return response({ error: \"Request exceeds the PLC wire limit\" }, 413);\n }\n chunks.push(chunk.value);\n }\n } finally {\n reader.releaseLock();\n }\n const bytes = new Uint8Array(size);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.length;\n }\n let input: unknown;\n try {\n input = JSON.parse(new TextDecoder(\"utf-8\", { fatal: true }).decode(bytes));\n validateWireRecord(contract.input, input);\n } catch {\n return response({ error: \"Invalid procedure input\" }, 400);\n }\n try {\n const context = await options.createContext(request);\n const caller = options.router.createCaller(context, { signal: request.signal });\n const invoke: unknown = Reflect.get(caller, contract.path);\n if (typeof invoke !== \"function\") throw new Error(\"Invalid tRPC caller.\");\n const output: unknown = await invoke(input);\n validateWireRecord(contract.output, output);\n return response(output);\n } catch (cause) {\n if (cause instanceof TRPCError) {\n const statuses: Partial<Record<TRPCError[\"code\"], number>> = {\n UNAUTHORIZED: 401,\n FORBIDDEN: 403,\n BAD_REQUEST: 400,\n NOT_FOUND: 404,\n CONFLICT: 409,\n TOO_MANY_REQUESTS: 429,\n };\n const status = statuses[cause.code];\n if (status === undefined) options.onError?.(cause);\n return response({ error: cause.code }, status ?? 500);\n }\n options.onError?.(cause);\n return response({ error: \"Procedure failed\" }, 500);\n }\n };\n};\n",
|
|
8
|
+
"import { createHash } from \"node:crypto\";\nimport type { AnyRouter, AnyProcedure } from \"@trpc/server\";\nimport { z } from \"zod\";\n\n/** Marks a tRPC procedure as callable by the PLC; the PLC calls it through `createServiceClient`. */\nexport interface PlcMeta {\n plc?: boolean;\n}\n\nexport type WireScalar =\n | { kind: \"boolean\" }\n | { kind: \"integer\"; iec: IntegerType; minimum: number; maximum: number }\n | { kind: \"string\"; capacity: number }\n | { kind: \"enum\"; members: Record<string, number> };\nexport type WireField = WireScalar | { kind: \"array\"; length: number; element: WireScalar };\nexport interface WireRecord {\n fields: Record<string, WireField>;\n}\nexport interface ServiceContract {\n path: string;\n kind: \"query\" | \"mutation\";\n input: WireRecord;\n output: WireRecord;\n version: string;\n}\n\nconst integerTypes = {\n SINT: [-128, 127],\n USINT: [0, 255],\n BYTE: [0, 255],\n INT: [-32768, 32767],\n UINT: [0, 65535],\n WORD: [0, 65535],\n DINT: [-2147483648, 2147483647],\n UDINT: [0, 4294967295],\n DWORD: [0, 4294967295],\n} as const;\nexport type IntegerType = keyof typeof integerTypes;\n\n/** The type-only property that carries a wire value's PLC layout through tRPC's inference. */\nexport const wireLayout = \"~plc\";\n/** A wire value whose type names its PLC layout, so the PLC compiler reads it without running the server. */\nexport type Wire<T, Layout> = T & { readonly [wireLayout]?: Layout };\ntype Schema<T> = z.ZodType<T, T>;\n\nconst integer =\n <const Type extends IntegerType>(type: Type) =>\n <\n const Minimum extends number = (typeof integerTypes)[Type][0],\n const Maximum extends number = (typeof integerTypes)[Type][1],\n >(\n bounds: { min?: Minimum; max?: Maximum } = {},\n ): Schema<Wire<number, { iec: Type; minimum: Minimum; maximum: Maximum }>> =>\n z\n .number()\n .int()\n .min(bounds.min ?? integerTypes[type][0])\n .max(bounds.max ?? integerTypes[type][1])\n .meta({ iec: type });\n\n/**\n * Zod schemas for PLC wire values. Booleans are `z.boolean()` and enums are `z.enum()` of a\n * numeric TypeScript enum the PLC also imports; records are `z.strictObject()` of these.\n */\nexport const iec = {\n SINT: integer(\"SINT\"),\n USINT: integer(\"USINT\"),\n BYTE: integer(\"BYTE\"),\n INT: integer(\"INT\"),\n UINT: integer(\"UINT\"),\n WORD: integer(\"WORD\"),\n DINT: integer(\"DINT\"),\n UDINT: integer(\"UDINT\"),\n DWORD: integer(\"DWORD\"),\n /** A UTF-8 string of at most `capacity` bytes. */\n STRING: <const N extends number>(capacity: N): Schema<Wire<string, { capacity: N }>> =>\n z.string().max(capacity),\n FixedArray: <T, const N extends number>(\n element: Schema<T>,\n length: N,\n ): Schema<Wire<T[], { length: N }>> => z.array(element).length(length),\n};\n\nconst identifier = /^(?!__proto__$)[A-Za-z_][A-Za-z0-9_]*$/;\nexport const isIntegerType = (value: string): value is IntegerType =>\n Object.hasOwn(integerTypes, value);\n\n/**\n * Checks a scalar's layout and rebuilds it in canonical key order, with enum members zero first,\n * so the version digest does not depend on which side built it or on declaration order.\n */\nconst wireScalar = (field: WireScalar, label: string): WireScalar => {\n switch (field.kind) {\n case \"boolean\":\n return { kind: \"boolean\" };\n case \"string\":\n if (!Number.isInteger(field.capacity) || field.capacity < 1 || field.capacity > 2048)\n throw new Error(`${label}: string capacity must be 1..2048 bytes.`);\n return { kind: \"string\", capacity: field.capacity };\n case \"integer\": {\n const [minimum, maximum] = integerTypes[field.iec];\n if (!Number.isInteger(field.minimum) || !Number.isInteger(field.maximum))\n throw new Error(`${label}: integer bounds must be integers.`);\n if (field.minimum > field.maximum)\n throw new Error(`${label}: integer bounds are contradictory.`);\n if (field.minimum < minimum || field.maximum > maximum)\n throw new Error(`${label}: integer bounds do not fit ${field.iec}.`);\n return { kind: \"integer\", iec: field.iec, minimum: field.minimum, maximum: field.maximum };\n }\n case \"enum\": {\n const values = Object.entries(field.members).sort((left, right) => {\n if (left[1] === 0) return -1;\n if (right[1] === 0) return 1;\n return left[1] - right[1];\n });\n if (\n !values.length ||\n values.some(\n ([name, value]) =>\n !identifier.test(name) ||\n !Number.isInteger(value) ||\n value < -2147483648 ||\n value > 2147483647,\n )\n )\n throw new Error(`${label}: PLC enums require named DINT values.`);\n if (new Set(values.map(([, value]) => value)).size !== values.length)\n throw new Error(`${label}: PLC enum values must be distinct.`);\n return { kind: \"enum\", members: Object.fromEntries(values) };\n }\n }\n};\n\n/** Checks a record against the native JSON codec's limits, in canonical field order. */\nexport const wireRecord = (\n fields: Record<string, WireField>,\n label: string,\n output: boolean,\n): WireRecord => {\n const checked: Record<string, WireField> = {};\n for (const key of Object.keys(fields).sort()) {\n const field = fields[key]!;\n if (!identifier.test(key)) throw new Error(`${label}.${key}: use an IEC field identifier.`);\n if (field.kind !== \"array\") {\n checked[key] = wireScalar(field, `${label}.${key}`);\n continue;\n }\n if (output)\n throw new Error(\n `${label}.${key}: the native HTTP decoder currently supports scalar response fields only.`,\n );\n if (!Number.isInteger(field.length) || field.length < 1 || field.length > 256)\n throw new Error(`${label}.${key}: arrays require one fixed length from 1 to 256.`);\n checked[key] = {\n kind: \"array\",\n length: field.length,\n element: wireScalar(field.element, `${label}.${key}`),\n };\n }\n if (Object.keys(checked).length > 31 || (output && !Object.keys(checked).length))\n throw new Error(`${label}: response records require 1..31 fields; requests allow 0..31.`);\n return { fields: checked };\n};\n\n/** One owner of the path digest, so the PLC compiler and the host agree without sharing a file. */\nexport const serviceContract = (\n path: string,\n kind: ServiceContract[\"kind\"],\n input: WireRecord,\n output: WireRecord,\n): ServiceContract => {\n if (path.split(\".\").some((part) => !identifier.test(part)))\n throw new Error(`${path}: PLC procedure paths require identifier segments.`);\n const version = createHash(\"sha256\")\n .update(JSON.stringify({ abi: 2, path, kind, input, output }))\n .digest(\"hex\")\n .slice(0, 24);\n return { path, kind, input, output, version };\n};\n\nconst schemaValue = (value: unknown): value is z.ZodType =>\n typeof value === \"object\" && value !== null && \"_zod\" in value && \"safeParse\" in value;\nconst schema = (value: unknown, label: string): z.ZodType => {\n if (!schemaValue(value))\n throw new Error(\n `${label}: provide a bounded Zod input/output schema; TypeScript number/string inference has no IEC memory layout.`,\n );\n return value;\n};\nconst jsonSchema = z.looseObject({\n type: z.string().optional(),\n maxLength: z.number().optional(),\n minItems: z.number().optional(),\n maxItems: z.number().optional(),\n minimum: z.number().optional(),\n maximum: z.number().optional(),\n iec: z.string().optional(),\n});\n/** Schema metadata from the schema's own Zod instance; JSON Schema output varies by Zod version. */\nconst metadata = (value: z.ZodType) => jsonSchema.parse(value.meta() ?? {});\nconst refSchema = z.looseObject({\n $ref: z.string().optional(),\n $defs: z.record(z.string(), z.unknown()).optional(),\n});\n/** The JSON Schema of `value` itself, following the top-level `$ref` newer Zod emits for schemas with an id. */\nconst jsonFor = (value: z.ZodType, options: Parameters<typeof z.toJSONSchema>[1]) => {\n const json = refSchema.parse(z.toJSONSchema(value, options));\n const definition = json.$ref?.startsWith(\"#/$defs/\")\n ? json.$defs?.[json.$ref.slice(\"#/$defs/\".length)]\n : json;\n return jsonSchema.parse(definition);\n};\nconst scalar = (value: z.ZodType, label: string): WireScalar => {\n const definition = value._zod.def;\n if (\n [\"optional\", \"nullable\", \"union\", \"default\", \"prefault\", \"pipe\", \"catch\"].includes(\n definition.type,\n )\n )\n throw new Error(\n `${label}: optional, nullable, transformed and defaulted wire fields are unsupported.`,\n );\n if (definition.type === \"enum\") {\n const entries = z\n .record(z.string(), z.union([z.number().int(), z.string()]))\n .parse(Reflect.get(definition, \"entries\"));\n const members: Record<string, number> = {};\n // A TypeScript numeric enum also maps each value back to its name; those entries are skipped.\n for (const [name, value] of Object.entries(entries)) {\n if (typeof value === \"number\") members[name] = value;\n else if (identifier.test(name) && entries[value] !== name)\n throw new Error(`${label}: PLC enums require only numeric members.`);\n }\n return { kind: \"enum\", members };\n }\n const json = jsonFor(value, { io: \"input\", unrepresentable: \"throw\" });\n if (json.type === \"boolean\") return { kind: \"boolean\" };\n if (json.type === \"string\" && json.maxLength !== undefined)\n return { kind: \"string\", capacity: json.maxLength };\n const type = metadata(value).iec;\n if (\n json.type === \"integer\" &&\n type !== undefined &&\n isIntegerType(type) &&\n json.minimum !== undefined &&\n json.maximum !== undefined\n )\n return { kind: \"integer\", iec: type, minimum: json.minimum, maximum: json.maximum };\n throw new Error(\n `${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.`,\n );\n};\nconst record = (declared: z.ZodType, label: string, output: boolean): WireRecord => {\n // An input record may `.transform()` into the handler's shape; the wire keeps the record.\n const piped = declared._zod.def;\n const value =\n !output && piped.type === \"pipe\" ? schema(Reflect.get(piped, \"in\"), label) : declared;\n const def = value._zod.def;\n if (def.type !== \"object\")\n throw new Error(`${label}: HTTP services require a closed object schema.`);\n const shape = z\n .record(z.string(), z.custom<z.ZodType>(schemaValue))\n .parse(Reflect.get(def, \"shape\"));\n const fields: Record<string, WireField> = {};\n for (const [key, item] of Object.entries(shape)) {\n if (item._zod.def.type !== \"array\") {\n fields[key] = scalar(item, `${label}.${key}`);\n continue;\n }\n const bounds = jsonFor(item, { io: \"input\" });\n if (bounds.maxItems === undefined || bounds.maxItems !== bounds.minItems)\n throw new Error(`${label}.${key}: arrays require one fixed length from 1 to 256.`);\n fields[key] = {\n kind: \"array\",\n length: bounds.maxItems,\n element: scalar(\n schema(Reflect.get(item._zod.def, \"element\"), `${label}.${key}`),\n `${label}.${key}`,\n ),\n };\n }\n return wireRecord(fields, label, output);\n};\n\n/** Reflection is isolated here because tRPC v11 keeps parsers on procedure._def. */\nexport const serviceContracts = (router: AnyRouter): ServiceContract[] => {\n const contracts: ServiceContract[] = [];\n const procedures = z.record(z.string(), z.unknown()).parse(router._def.procedures);\n for (const [path, procedure] of Object.entries(procedures)) {\n if (typeof procedure !== \"function\" || !(\"_def\" in procedure))\n throw new Error(\"Unsupported tRPC router metadata.\");\n const reflected = z\n .custom<AnyProcedure>((value) => typeof value === \"function\" && \"_def\" in value)\n .parse(procedure);\n const def = reflected._def;\n if (\n typeof def.meta !== \"object\" ||\n def.meta === null ||\n !(\"plc\" in def.meta) ||\n def.meta.plc !== true\n )\n continue;\n if (def.type !== \"query\" && def.type !== \"mutation\")\n throw new Error(\n `${path}: subscriptions use a different transport; expose a query or mutation to the PLC.`,\n );\n if (def.inputs.length > 1)\n throw new Error(`${path}: merge the input into one bounded object schema.`);\n const input = record(\n def.inputs.length ? schema(def.inputs[0], `${path}.input`) : z.strictObject({}),\n `${path}.Input`,\n false,\n );\n const output = record(\n schema(Reflect.get(def, \"output\"), `${path}.output`),\n `${path}.Output`,\n true,\n );\n contracts.push(serviceContract(path, def.type, input, output));\n }\n return contracts.sort((a, b) => a.path.localeCompare(b.path));\n};\n\nexport const servicePath = (contract: ServiceContract) =>\n `/tsplc/services/${contract.version}/${contract.path}`;\n\nexport const validateWireRecord = (record: WireRecord, value: unknown): void => {\n const scalarValue = (field: WireScalar, value: unknown): boolean => {\n switch (field.kind) {\n case \"boolean\":\n return typeof value === \"boolean\";\n case \"integer\":\n return (\n typeof value === \"number\" &&\n Number.isInteger(value) &&\n value >= field.minimum &&\n value <= field.maximum\n );\n case \"string\":\n return (\n typeof value === \"string\" &&\n !value.includes(\"\\0\") &&\n new TextEncoder().encode(value).length <= field.capacity\n );\n case \"enum\":\n return typeof value === \"number\" && Object.values(field.members).includes(value);\n }\n };\n if (typeof value !== \"object\" || value === null || Array.isArray(value))\n throw new Error(\"Expected a closed wire record.\");\n const entries = Object.entries(value);\n if (entries.length !== Object.keys(record.fields).length)\n throw new Error(\"Wire record fields differ from the contract.\");\n for (const [name, field] of Object.entries(record.fields)) {\n const item: unknown = Reflect.get(value, name);\n const valid =\n field.kind === \"array\"\n ? Array.isArray(item) &&\n item.length === field.length &&\n item.every((value) => scalarValue(field.element, value))\n : scalarValue(field, item);\n if (!valid) throw new Error(`Wire field ${name} violates its PLC representation.`);\n }\n if (new TextEncoder().encode(JSON.stringify(value)).length > 2048)\n throw new Error(\"Wire record exceeds the 2048-byte native JSON limit.\");\n};\n",
|
|
9
|
+
"import { randomBytes } from \"node:crypto\";\nimport { setTimeout as delay } from \"node:timers/promises\";\nimport type { AnyRouter, inferRouterContext } from \"@trpc/server\";\nimport { AttributeIds, DataType, type ClientSession, discoverVariables } from \"@tsplc/opcua\";\nimport { createServiceHandler } from \"./index\";\n\nexport interface ServiceChannel {\n path: string;\n request: string;\n requestId: string;\n reply: string;\n replyId: string;\n epoch: string;\n}\n\n/** One authenticated gateway owns these channels. Handler effects are never retried. */\nexport const runOpcUaServices = async <Router extends AnyRouter>(options: {\n router: Router;\n channels: readonly ServiceChannel[];\n getSession: () => ClientSession;\n createContext: () => inferRouterContext<Router> | Promise<inferRouterContext<Router>>;\n signal: AbortSignal;\n onError: (error: unknown) => void;\n onReady?: (session: ClientSession) => void;\n}) => {\n if (!options.channels.length) return;\n const dispatch = createServiceHandler(options);\n const pending = new Map<\n string,\n { identity: string; controller: AbortController; done: Promise<void> }\n >();\n const invocations = new Set<Promise<Response>>();\n const writers = new Map<string, Promise<void>>();\n const handled = new Map<string, string>();\n const epochs = new Map<string, string>();\n let activeSession: ClientSession | undefined;\n let connectionError: string | undefined;\n let nodes = new Map<string, string>();\n const write = async (session: ClientSession, path: string, value: string) => {\n const nodeId = nodes.get(path);\n if (!nodeId) throw new Error(`Missing OPC UA service channel: ${path}`);\n const status = await session.write({\n nodeId,\n attributeId: AttributeIds.Value,\n value: { value: { dataType: DataType.String, value } },\n });\n if (!status.isGood()) throw new Error(`OPC UA service write failed: ${status.toString()}`);\n };\n const read = async (session: ClientSession, paths: readonly string[]) => {\n const requests = paths.map((path) => {\n const nodeId = nodes.get(path);\n if (!nodeId) throw new Error(`Missing OPC UA service channel: ${path}`);\n return { nodeId, attributeId: AttributeIds.Value };\n });\n const values = await session.read(requests, 0);\n if (values.length !== requests.length || values.some((value) => !value.statusCode.isGood()))\n throw new Error(\"OPC UA service read failed\");\n return values.map((value): unknown => value.value.value);\n };\n const reply = async (\n session: ClientSession,\n channel: ServiceChannel,\n identity: string,\n body: string,\n signal: AbortSignal,\n ) => {\n const previous = writers.get(channel.reply) ?? Promise.resolve();\n const writing = previous\n .catch(() => undefined)\n .then(async () => {\n if (signal.aborted || activeSession !== session) return;\n await write(session, channel.reply, body);\n // Commit after the entire payload write finishes. PLC readers ignore an uncommitted string.\n await write(session, channel.replyId, identity);\n });\n writers.set(channel.reply, writing);\n try {\n await writing;\n } finally {\n if (writers.get(channel.reply) === writing) writers.delete(channel.reply);\n }\n };\n try {\n while (!options.signal.aborted) {\n try {\n const session = options.getSession();\n if (session !== activeSession) {\n for (const work of pending.values()) work.controller.abort();\n nodes = await discoverVariables(\n session,\n options.channels.flatMap((channel) => [\n channel.request,\n channel.requestId,\n channel.reply,\n channel.replyId,\n channel.epoch,\n ]),\n );\n epochs.clear();\n for (const channel of options.channels) {\n await write(session, channel.replyId, \"\");\n const epoch = randomBytes(16).toString(\"hex\");\n await write(session, channel.epoch, epoch);\n epochs.set(channel.request, epoch);\n }\n activeSession = session;\n handled.clear();\n options.onReady?.(session);\n }\n // A commit is sampled on both sides of its payload to reject a concurrent PLC update.\n const before = await read(\n session,\n options.channels.map((channel) => channel.requestId),\n );\n const batch = await read(\n session,\n options.channels.flatMap((channel) => [channel.request, channel.epoch]),\n );\n const after = await read(\n session,\n options.channels.map((channel) => channel.requestId),\n );\n for (const [index, channel] of options.channels.entries()) {\n const identity = after[index];\n if (typeof identity !== \"string\") throw new Error(\"Invalid OPC UA service identity type\");\n const current = pending.get(channel.request);\n if (current && current.identity !== identity) current.controller.abort();\n const request = batch[index * 2],\n epochValue = batch[index * 2 + 1];\n if (epochValue === \"\") {\n const epoch = randomBytes(16).toString(\"hex\");\n await write(session, channel.epoch, epoch);\n epochs.set(channel.request, epoch);\n continue;\n }\n if (before[index] !== identity || !identity) continue;\n const epoch = epochs.get(channel.request);\n if (epochValue !== epoch) throw new Error(\"OPC UA service gateway ownership changed\");\n if (\n typeof request !== \"string\" ||\n !identity.startsWith(epoch + \":\") ||\n !request.startsWith(identity + \"|\")\n )\n continue;\n if (handled.get(channel.request) === identity) continue;\n handled.set(channel.request, identity);\n const controller = new AbortController();\n const signal = AbortSignal.any([options.signal, controller.signal]);\n const operation = (async () => {\n // Uncooperative cancelled handlers still occupy bounded capacity until they settle.\n if (invocations.size >= options.channels.length * 2) {\n await reply(session, channel, identity, `${identity}|1|{}`, signal);\n return;\n }\n const invocation = dispatch(\n new Request(\"http://tsplc.internal\" + channel.path, {\n method: \"POST\",\n body: request.slice(identity.length + 1),\n signal,\n }),\n );\n invocations.add(invocation);\n void invocation.then(\n () => invocations.delete(invocation),\n () => invocations.delete(invocation),\n );\n const timer = new AbortController();\n const response = await Promise.race([\n invocation,\n delay(5000, undefined, { signal: AbortSignal.any([signal, timer.signal]) }).then(\n () => {\n throw new Error(\"OPC UA service handler timed out\");\n },\n ),\n ]).finally(() => timer.abort());\n const body = `${identity}|${response.ok ? 0 : 1}|${response.ok ? await response.text() : \"{}\"}`;\n if (Buffer.byteLength(body, \"utf8\") > 2048)\n throw new Error(\"OPC UA service reply exceeds 2048 bytes\");\n await reply(session, channel, identity, body, signal);\n })()\n .catch((error) => {\n if (!signal.aborted) options.onError(error);\n })\n .finally(() => {\n if (pending.get(channel.request)?.controller === controller)\n pending.delete(channel.request);\n });\n pending.set(channel.request, { identity, controller, done: operation });\n }\n connectionError = undefined;\n } catch (error) {\n activeSession = undefined;\n for (const work of pending.values()) work.controller.abort();\n const message = error instanceof Error ? error.message : String(error);\n if (connectionError !== message) {\n connectionError = message;\n options.onError(error);\n }\n await delay(1000, undefined, { signal: options.signal });\n }\n await delay(20, undefined, { signal: options.signal });\n }\n } catch (error) {\n if (!options.signal.aborted) throw error;\n } finally {\n activeSession = undefined;\n for (const work of pending.values()) work.controller.abort();\n await Promise.all([...pending.values()].map((work) => work.done));\n await Promise.allSettled(writers.values());\n }\n};\n"
|
|
10
|
+
],
|
|
11
|
+
"mappings": "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",
|
|
12
|
+
"debugId": "757C86D2676275DF64756E2164756E21",
|
|
13
|
+
"names": []
|
|
14
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tsplc/server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Server side of tsPLC services: iec schemas for tRPC procedures the PLC calls, the PLC service handler, OPC UA gateway and HMI.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/livingforjesus/tsplc.git",
|
|
10
|
+
"directory": "packages/server"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"bun": "./src/index.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./opcua": {
|
|
19
|
+
"types": "./src/opcua.ts",
|
|
20
|
+
"bun": "./src/opcua.ts",
|
|
21
|
+
"default": "./dist/opcua.js"
|
|
22
|
+
},
|
|
23
|
+
"./hmi": {
|
|
24
|
+
"types": "./src/hmi.ts",
|
|
25
|
+
"bun": "./src/hmi.ts",
|
|
26
|
+
"default": "./dist/hmi.js"
|
|
27
|
+
},
|
|
28
|
+
"./serve": {
|
|
29
|
+
"types": "./src/serve.ts",
|
|
30
|
+
"bun": "./src/serve.ts",
|
|
31
|
+
"default": "./dist/serve.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"!**/*.test.ts"
|
|
38
|
+
],
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@tsplc/config": "0.1.0",
|
|
41
|
+
"@tsplc/opcua": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@trpc/server": "^11.0.0",
|
|
48
|
+
"zod": "^4.3.6"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { AnyRouter, AnyProcedure } from "@trpc/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/** Marks a tRPC procedure as callable by the PLC; the PLC calls it through `createServiceClient`. */
|
|
6
|
+
export interface PlcMeta {
|
|
7
|
+
plc?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type WireScalar =
|
|
11
|
+
| { kind: "boolean" }
|
|
12
|
+
| { kind: "integer"; iec: IntegerType; minimum: number; maximum: number }
|
|
13
|
+
| { kind: "string"; capacity: number }
|
|
14
|
+
| { kind: "enum"; members: Record<string, number> };
|
|
15
|
+
export type WireField = WireScalar | { kind: "array"; length: number; element: WireScalar };
|
|
16
|
+
export interface WireRecord {
|
|
17
|
+
fields: Record<string, WireField>;
|
|
18
|
+
}
|
|
19
|
+
export interface ServiceContract {
|
|
20
|
+
path: string;
|
|
21
|
+
kind: "query" | "mutation";
|
|
22
|
+
input: WireRecord;
|
|
23
|
+
output: WireRecord;
|
|
24
|
+
version: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const integerTypes = {
|
|
28
|
+
SINT: [-128, 127],
|
|
29
|
+
USINT: [0, 255],
|
|
30
|
+
BYTE: [0, 255],
|
|
31
|
+
INT: [-32768, 32767],
|
|
32
|
+
UINT: [0, 65535],
|
|
33
|
+
WORD: [0, 65535],
|
|
34
|
+
DINT: [-2147483648, 2147483647],
|
|
35
|
+
UDINT: [0, 4294967295],
|
|
36
|
+
DWORD: [0, 4294967295],
|
|
37
|
+
} as const;
|
|
38
|
+
export type IntegerType = keyof typeof integerTypes;
|
|
39
|
+
|
|
40
|
+
/** The type-only property that carries a wire value's PLC layout through tRPC's inference. */
|
|
41
|
+
export const wireLayout = "~plc";
|
|
42
|
+
/** A wire value whose type names its PLC layout, so the PLC compiler reads it without running the server. */
|
|
43
|
+
export type Wire<T, Layout> = T & { readonly [wireLayout]?: Layout };
|
|
44
|
+
type Schema<T> = z.ZodType<T, T>;
|
|
45
|
+
|
|
46
|
+
const integer =
|
|
47
|
+
<const Type extends IntegerType>(type: Type) =>
|
|
48
|
+
<
|
|
49
|
+
const Minimum extends number = (typeof integerTypes)[Type][0],
|
|
50
|
+
const Maximum extends number = (typeof integerTypes)[Type][1],
|
|
51
|
+
>(
|
|
52
|
+
bounds: { min?: Minimum; max?: Maximum } = {},
|
|
53
|
+
): Schema<Wire<number, { iec: Type; minimum: Minimum; maximum: Maximum }>> =>
|
|
54
|
+
z
|
|
55
|
+
.number()
|
|
56
|
+
.int()
|
|
57
|
+
.min(bounds.min ?? integerTypes[type][0])
|
|
58
|
+
.max(bounds.max ?? integerTypes[type][1])
|
|
59
|
+
.meta({ iec: type });
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Zod schemas for PLC wire values. Booleans are `z.boolean()` and enums are `z.enum()` of a
|
|
63
|
+
* numeric TypeScript enum the PLC also imports; records are `z.strictObject()` of these.
|
|
64
|
+
*/
|
|
65
|
+
export const iec = {
|
|
66
|
+
SINT: integer("SINT"),
|
|
67
|
+
USINT: integer("USINT"),
|
|
68
|
+
BYTE: integer("BYTE"),
|
|
69
|
+
INT: integer("INT"),
|
|
70
|
+
UINT: integer("UINT"),
|
|
71
|
+
WORD: integer("WORD"),
|
|
72
|
+
DINT: integer("DINT"),
|
|
73
|
+
UDINT: integer("UDINT"),
|
|
74
|
+
DWORD: integer("DWORD"),
|
|
75
|
+
/** A UTF-8 string of at most `capacity` bytes. */
|
|
76
|
+
STRING: <const N extends number>(capacity: N): Schema<Wire<string, { capacity: N }>> =>
|
|
77
|
+
z.string().max(capacity),
|
|
78
|
+
FixedArray: <T, const N extends number>(
|
|
79
|
+
element: Schema<T>,
|
|
80
|
+
length: N,
|
|
81
|
+
): Schema<Wire<T[], { length: N }>> => z.array(element).length(length),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const identifier = /^(?!__proto__$)[A-Za-z_][A-Za-z0-9_]*$/;
|
|
85
|
+
export const isIntegerType = (value: string): value is IntegerType =>
|
|
86
|
+
Object.hasOwn(integerTypes, value);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Checks a scalar's layout and rebuilds it in canonical key order, with enum members zero first,
|
|
90
|
+
* so the version digest does not depend on which side built it or on declaration order.
|
|
91
|
+
*/
|
|
92
|
+
const wireScalar = (field: WireScalar, label: string): WireScalar => {
|
|
93
|
+
switch (field.kind) {
|
|
94
|
+
case "boolean":
|
|
95
|
+
return { kind: "boolean" };
|
|
96
|
+
case "string":
|
|
97
|
+
if (!Number.isInteger(field.capacity) || field.capacity < 1 || field.capacity > 2048)
|
|
98
|
+
throw new Error(`${label}: string capacity must be 1..2048 bytes.`);
|
|
99
|
+
return { kind: "string", capacity: field.capacity };
|
|
100
|
+
case "integer": {
|
|
101
|
+
const [minimum, maximum] = integerTypes[field.iec];
|
|
102
|
+
if (!Number.isInteger(field.minimum) || !Number.isInteger(field.maximum))
|
|
103
|
+
throw new Error(`${label}: integer bounds must be integers.`);
|
|
104
|
+
if (field.minimum > field.maximum)
|
|
105
|
+
throw new Error(`${label}: integer bounds are contradictory.`);
|
|
106
|
+
if (field.minimum < minimum || field.maximum > maximum)
|
|
107
|
+
throw new Error(`${label}: integer bounds do not fit ${field.iec}.`);
|
|
108
|
+
return { kind: "integer", iec: field.iec, minimum: field.minimum, maximum: field.maximum };
|
|
109
|
+
}
|
|
110
|
+
case "enum": {
|
|
111
|
+
const values = Object.entries(field.members).sort((left, right) => {
|
|
112
|
+
if (left[1] === 0) return -1;
|
|
113
|
+
if (right[1] === 0) return 1;
|
|
114
|
+
return left[1] - right[1];
|
|
115
|
+
});
|
|
116
|
+
if (
|
|
117
|
+
!values.length ||
|
|
118
|
+
values.some(
|
|
119
|
+
([name, value]) =>
|
|
120
|
+
!identifier.test(name) ||
|
|
121
|
+
!Number.isInteger(value) ||
|
|
122
|
+
value < -2147483648 ||
|
|
123
|
+
value > 2147483647,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
throw new Error(`${label}: PLC enums require named DINT values.`);
|
|
127
|
+
if (new Set(values.map(([, value]) => value)).size !== values.length)
|
|
128
|
+
throw new Error(`${label}: PLC enum values must be distinct.`);
|
|
129
|
+
return { kind: "enum", members: Object.fromEntries(values) };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Checks a record against the native JSON codec's limits, in canonical field order. */
|
|
135
|
+
export const wireRecord = (
|
|
136
|
+
fields: Record<string, WireField>,
|
|
137
|
+
label: string,
|
|
138
|
+
output: boolean,
|
|
139
|
+
): WireRecord => {
|
|
140
|
+
const checked: Record<string, WireField> = {};
|
|
141
|
+
for (const key of Object.keys(fields).sort()) {
|
|
142
|
+
const field = fields[key]!;
|
|
143
|
+
if (!identifier.test(key)) throw new Error(`${label}.${key}: use an IEC field identifier.`);
|
|
144
|
+
if (field.kind !== "array") {
|
|
145
|
+
checked[key] = wireScalar(field, `${label}.${key}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (output)
|
|
149
|
+
throw new Error(
|
|
150
|
+
`${label}.${key}: the native HTTP decoder currently supports scalar response fields only.`,
|
|
151
|
+
);
|
|
152
|
+
if (!Number.isInteger(field.length) || field.length < 1 || field.length > 256)
|
|
153
|
+
throw new Error(`${label}.${key}: arrays require one fixed length from 1 to 256.`);
|
|
154
|
+
checked[key] = {
|
|
155
|
+
kind: "array",
|
|
156
|
+
length: field.length,
|
|
157
|
+
element: wireScalar(field.element, `${label}.${key}`),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (Object.keys(checked).length > 31 || (output && !Object.keys(checked).length))
|
|
161
|
+
throw new Error(`${label}: response records require 1..31 fields; requests allow 0..31.`);
|
|
162
|
+
return { fields: checked };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** One owner of the path digest, so the PLC compiler and the host agree without sharing a file. */
|
|
166
|
+
export const serviceContract = (
|
|
167
|
+
path: string,
|
|
168
|
+
kind: ServiceContract["kind"],
|
|
169
|
+
input: WireRecord,
|
|
170
|
+
output: WireRecord,
|
|
171
|
+
): ServiceContract => {
|
|
172
|
+
if (path.split(".").some((part) => !identifier.test(part)))
|
|
173
|
+
throw new Error(`${path}: PLC procedure paths require identifier segments.`);
|
|
174
|
+
const version = createHash("sha256")
|
|
175
|
+
.update(JSON.stringify({ abi: 2, path, kind, input, output }))
|
|
176
|
+
.digest("hex")
|
|
177
|
+
.slice(0, 24);
|
|
178
|
+
return { path, kind, input, output, version };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const schemaValue = (value: unknown): value is z.ZodType =>
|
|
182
|
+
typeof value === "object" && value !== null && "_zod" in value && "safeParse" in value;
|
|
183
|
+
const schema = (value: unknown, label: string): z.ZodType => {
|
|
184
|
+
if (!schemaValue(value))
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${label}: provide a bounded Zod input/output schema; TypeScript number/string inference has no IEC memory layout.`,
|
|
187
|
+
);
|
|
188
|
+
return value;
|
|
189
|
+
};
|
|
190
|
+
const jsonSchema = z.looseObject({
|
|
191
|
+
type: z.string().optional(),
|
|
192
|
+
maxLength: z.number().optional(),
|
|
193
|
+
minItems: z.number().optional(),
|
|
194
|
+
maxItems: z.number().optional(),
|
|
195
|
+
minimum: z.number().optional(),
|
|
196
|
+
maximum: z.number().optional(),
|
|
197
|
+
iec: z.string().optional(),
|
|
198
|
+
});
|
|
199
|
+
/** Schema metadata from the schema's own Zod instance; JSON Schema output varies by Zod version. */
|
|
200
|
+
const metadata = (value: z.ZodType) => jsonSchema.parse(value.meta() ?? {});
|
|
201
|
+
const refSchema = z.looseObject({
|
|
202
|
+
$ref: z.string().optional(),
|
|
203
|
+
$defs: z.record(z.string(), z.unknown()).optional(),
|
|
204
|
+
});
|
|
205
|
+
/** The JSON Schema of `value` itself, following the top-level `$ref` newer Zod emits for schemas with an id. */
|
|
206
|
+
const jsonFor = (value: z.ZodType, options: Parameters<typeof z.toJSONSchema>[1]) => {
|
|
207
|
+
const json = refSchema.parse(z.toJSONSchema(value, options));
|
|
208
|
+
const definition = json.$ref?.startsWith("#/$defs/")
|
|
209
|
+
? json.$defs?.[json.$ref.slice("#/$defs/".length)]
|
|
210
|
+
: json;
|
|
211
|
+
return jsonSchema.parse(definition);
|
|
212
|
+
};
|
|
213
|
+
const scalar = (value: z.ZodType, label: string): WireScalar => {
|
|
214
|
+
const definition = value._zod.def;
|
|
215
|
+
if (
|
|
216
|
+
["optional", "nullable", "union", "default", "prefault", "pipe", "catch"].includes(
|
|
217
|
+
definition.type,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
throw new Error(
|
|
221
|
+
`${label}: optional, nullable, transformed and defaulted wire fields are unsupported.`,
|
|
222
|
+
);
|
|
223
|
+
if (definition.type === "enum") {
|
|
224
|
+
const entries = z
|
|
225
|
+
.record(z.string(), z.union([z.number().int(), z.string()]))
|
|
226
|
+
.parse(Reflect.get(definition, "entries"));
|
|
227
|
+
const members: Record<string, number> = {};
|
|
228
|
+
// A TypeScript numeric enum also maps each value back to its name; those entries are skipped.
|
|
229
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
230
|
+
if (typeof value === "number") members[name] = value;
|
|
231
|
+
else if (identifier.test(name) && entries[value] !== name)
|
|
232
|
+
throw new Error(`${label}: PLC enums require only numeric members.`);
|
|
233
|
+
}
|
|
234
|
+
return { kind: "enum", members };
|
|
235
|
+
}
|
|
236
|
+
const json = jsonFor(value, { io: "input", unrepresentable: "throw" });
|
|
237
|
+
if (json.type === "boolean") return { kind: "boolean" };
|
|
238
|
+
if (json.type === "string" && json.maxLength !== undefined)
|
|
239
|
+
return { kind: "string", capacity: json.maxLength };
|
|
240
|
+
const type = metadata(value).iec;
|
|
241
|
+
if (
|
|
242
|
+
json.type === "integer" &&
|
|
243
|
+
type !== undefined &&
|
|
244
|
+
isIntegerType(type) &&
|
|
245
|
+
json.minimum !== undefined &&
|
|
246
|
+
json.maximum !== undefined
|
|
247
|
+
)
|
|
248
|
+
return { kind: "integer", iec: type, minimum: json.minimum, maximum: json.maximum };
|
|
249
|
+
throw new Error(
|
|
250
|
+
`${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.`,
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
const record = (declared: z.ZodType, label: string, output: boolean): WireRecord => {
|
|
254
|
+
// An input record may `.transform()` into the handler's shape; the wire keeps the record.
|
|
255
|
+
const piped = declared._zod.def;
|
|
256
|
+
const value =
|
|
257
|
+
!output && piped.type === "pipe" ? schema(Reflect.get(piped, "in"), label) : declared;
|
|
258
|
+
const def = value._zod.def;
|
|
259
|
+
if (def.type !== "object")
|
|
260
|
+
throw new Error(`${label}: HTTP services require a closed object schema.`);
|
|
261
|
+
const shape = z
|
|
262
|
+
.record(z.string(), z.custom<z.ZodType>(schemaValue))
|
|
263
|
+
.parse(Reflect.get(def, "shape"));
|
|
264
|
+
const fields: Record<string, WireField> = {};
|
|
265
|
+
for (const [key, item] of Object.entries(shape)) {
|
|
266
|
+
if (item._zod.def.type !== "array") {
|
|
267
|
+
fields[key] = scalar(item, `${label}.${key}`);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const bounds = jsonFor(item, { io: "input" });
|
|
271
|
+
if (bounds.maxItems === undefined || bounds.maxItems !== bounds.minItems)
|
|
272
|
+
throw new Error(`${label}.${key}: arrays require one fixed length from 1 to 256.`);
|
|
273
|
+
fields[key] = {
|
|
274
|
+
kind: "array",
|
|
275
|
+
length: bounds.maxItems,
|
|
276
|
+
element: scalar(
|
|
277
|
+
schema(Reflect.get(item._zod.def, "element"), `${label}.${key}`),
|
|
278
|
+
`${label}.${key}`,
|
|
279
|
+
),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return wireRecord(fields, label, output);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/** Reflection is isolated here because tRPC v11 keeps parsers on procedure._def. */
|
|
286
|
+
export const serviceContracts = (router: AnyRouter): ServiceContract[] => {
|
|
287
|
+
const contracts: ServiceContract[] = [];
|
|
288
|
+
const procedures = z.record(z.string(), z.unknown()).parse(router._def.procedures);
|
|
289
|
+
for (const [path, procedure] of Object.entries(procedures)) {
|
|
290
|
+
if (typeof procedure !== "function" || !("_def" in procedure))
|
|
291
|
+
throw new Error("Unsupported tRPC router metadata.");
|
|
292
|
+
const reflected = z
|
|
293
|
+
.custom<AnyProcedure>((value) => typeof value === "function" && "_def" in value)
|
|
294
|
+
.parse(procedure);
|
|
295
|
+
const def = reflected._def;
|
|
296
|
+
if (
|
|
297
|
+
typeof def.meta !== "object" ||
|
|
298
|
+
def.meta === null ||
|
|
299
|
+
!("plc" in def.meta) ||
|
|
300
|
+
def.meta.plc !== true
|
|
301
|
+
)
|
|
302
|
+
continue;
|
|
303
|
+
if (def.type !== "query" && def.type !== "mutation")
|
|
304
|
+
throw new Error(
|
|
305
|
+
`${path}: subscriptions use a different transport; expose a query or mutation to the PLC.`,
|
|
306
|
+
);
|
|
307
|
+
if (def.inputs.length > 1)
|
|
308
|
+
throw new Error(`${path}: merge the input into one bounded object schema.`);
|
|
309
|
+
const input = record(
|
|
310
|
+
def.inputs.length ? schema(def.inputs[0], `${path}.input`) : z.strictObject({}),
|
|
311
|
+
`${path}.Input`,
|
|
312
|
+
false,
|
|
313
|
+
);
|
|
314
|
+
const output = record(
|
|
315
|
+
schema(Reflect.get(def, "output"), `${path}.output`),
|
|
316
|
+
`${path}.Output`,
|
|
317
|
+
true,
|
|
318
|
+
);
|
|
319
|
+
contracts.push(serviceContract(path, def.type, input, output));
|
|
320
|
+
}
|
|
321
|
+
return contracts.sort((a, b) => a.path.localeCompare(b.path));
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export const servicePath = (contract: ServiceContract) =>
|
|
325
|
+
`/tsplc/services/${contract.version}/${contract.path}`;
|
|
326
|
+
|
|
327
|
+
export const validateWireRecord = (record: WireRecord, value: unknown): void => {
|
|
328
|
+
const scalarValue = (field: WireScalar, value: unknown): boolean => {
|
|
329
|
+
switch (field.kind) {
|
|
330
|
+
case "boolean":
|
|
331
|
+
return typeof value === "boolean";
|
|
332
|
+
case "integer":
|
|
333
|
+
return (
|
|
334
|
+
typeof value === "number" &&
|
|
335
|
+
Number.isInteger(value) &&
|
|
336
|
+
value >= field.minimum &&
|
|
337
|
+
value <= field.maximum
|
|
338
|
+
);
|
|
339
|
+
case "string":
|
|
340
|
+
return (
|
|
341
|
+
typeof value === "string" &&
|
|
342
|
+
!value.includes("\0") &&
|
|
343
|
+
new TextEncoder().encode(value).length <= field.capacity
|
|
344
|
+
);
|
|
345
|
+
case "enum":
|
|
346
|
+
return typeof value === "number" && Object.values(field.members).includes(value);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
350
|
+
throw new Error("Expected a closed wire record.");
|
|
351
|
+
const entries = Object.entries(value);
|
|
352
|
+
if (entries.length !== Object.keys(record.fields).length)
|
|
353
|
+
throw new Error("Wire record fields differ from the contract.");
|
|
354
|
+
for (const [name, field] of Object.entries(record.fields)) {
|
|
355
|
+
const item: unknown = Reflect.get(value, name);
|
|
356
|
+
const valid =
|
|
357
|
+
field.kind === "array"
|
|
358
|
+
? Array.isArray(item) &&
|
|
359
|
+
item.length === field.length &&
|
|
360
|
+
item.every((value) => scalarValue(field.element, value))
|
|
361
|
+
: scalarValue(field, item);
|
|
362
|
+
if (!valid) throw new Error(`Wire field ${name} violates its PLC representation.`);
|
|
363
|
+
}
|
|
364
|
+
if (new TextEncoder().encode(JSON.stringify(value)).length > 2048)
|
|
365
|
+
throw new Error("Wire record exceeds the 2048-byte native JSON limit.");
|
|
366
|
+
};
|