@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/src/serve.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import type { AnyRouter, inferRouterContext } from "@trpc/server";
4
+ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
+ import type { ClientSession, PlcLog } from "@tsplc/opcua";
6
+ import type { TLSOptions } from "bun";
7
+ import { z } from "zod";
8
+ import type { Hmi, HmiPlc } from "./hmi";
9
+ import { hmiPage } from "./hmi-page";
10
+ import { createServiceHandler } from "./index";
11
+ import { runOpcUaServices, type ServiceChannel } from "./opcua";
12
+
13
+ type RouteHandler = (request: Request) => Response | Promise<Response>;
14
+
15
+ export interface ServeOptions<Router extends AnyRouter> {
16
+ hostname: string;
17
+ port: number;
18
+ tls?: TLSOptions;
19
+ /** Required on every route except `/health`. Browser requests also need an allowed origin. */
20
+ auth: { bearerToken: string; allowedOrigins: readonly string[] };
21
+ plc: {
22
+ run: (signal: AbortSignal) => Promise<void>;
23
+ snapshot: () => { connected: boolean };
24
+ session: () => ClientSession;
25
+ };
26
+ /** Served at `/trpc/*`; its `plc` procedures also serve the PLC at `/tsplc/services/*`. */
27
+ router: Router;
28
+ /** Context for every call, after authentication. */
29
+ createContext: () => inferRouterContext<Router> | Promise<inferRouterContext<Router>>;
30
+ /** The generated OPC UA service channels; empty when the PLC calls over HTTP. */
31
+ channels: readonly ServiceChannel[];
32
+ /** Work repeated until shutdown. A failed run is logged and retried after its interval. */
33
+ jobs?: ReadonlyArray<{ name: string; intervalMs: number; run: () => Promise<void> }>;
34
+ /**
35
+ * An operator page at `/`, with `/hmi/state` and `/hmi/control`. These don't need the bearer
36
+ * token: only use it on a closed network. Controls still reject other sites' origins.
37
+ */
38
+ hmi?: { hmi: Hmi; plc: HmiPlc };
39
+ log: PlcLog;
40
+ }
41
+
42
+ const hmiControlRequest = z.strictObject({
43
+ index: z.number().int().nonnegative(),
44
+ value: z.number().optional(),
45
+ });
46
+
47
+ const json = (body: unknown, status = 200) =>
48
+ Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
49
+
50
+ const sleep = (ms: number, signal: AbortSignal) =>
51
+ delay(ms, undefined, { signal }).catch(() => undefined);
52
+
53
+ /**
54
+ * The PLC's host process: serves one tRPC router to applications and its PLC
55
+ * procedures to the PLC, keeps the PLC session and background jobs running, and
56
+ * stops all of them on SIGINT/SIGTERM or `stop()`.
57
+ */
58
+ export const serve = <Router extends AnyRouter>(options: ServeOptions<Router>) => {
59
+ const controller = new AbortController();
60
+ const { signal } = controller;
61
+ const expected = Buffer.from(`Bearer ${options.auth.bearerToken}`);
62
+ const protect =
63
+ (handler: RouteHandler): RouteHandler =>
64
+ async (request) => {
65
+ const actual = Buffer.from(request.headers.get("authorization") ?? "");
66
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
67
+ return json({ message: "Unauthorized" }, 401);
68
+ const origin = request.headers.get("origin");
69
+ if (origin !== null && !options.auth.allowedOrigins.includes(origin))
70
+ return json({ message: "Origin is not allowed" }, 403);
71
+ try {
72
+ return await handler(request);
73
+ } catch (error) {
74
+ options.log.warn(`Request failed: ${new URL(request.url).pathname}`, error);
75
+ return json({ message: "Internal server error" }, 500);
76
+ }
77
+ };
78
+ const routes: Record<string, RouteHandler> = {
79
+ "/health": () => json({ plcConnected: options.plc.snapshot().connected, running: true }),
80
+ "/trpc/*": protect((request) =>
81
+ fetchRequestHandler({
82
+ createContext: options.createContext,
83
+ endpoint: "/trpc",
84
+ req: request,
85
+ router: options.router,
86
+ }),
87
+ ),
88
+ "/tsplc/services/*": protect(
89
+ createServiceHandler({
90
+ router: options.router,
91
+ createContext: options.createContext,
92
+ onError: (error) => options.log.warn("PLC service call failed", error),
93
+ }),
94
+ ),
95
+ };
96
+ if (options.hmi) {
97
+ const { hmi, plc } = options.hmi;
98
+ routes["/"] = () =>
99
+ new Response(hmiPage(hmi.title), { headers: { "Content-Type": "text/html; charset=utf-8" } });
100
+ routes["/hmi/state"] = () => json(hmi.state(plc));
101
+ routes["/hmi/control"] = async (request) => {
102
+ const origin = request.headers.get("origin");
103
+ if (
104
+ request.method !== "POST" ||
105
+ (origin !== null &&
106
+ origin !== new URL(request.url).origin &&
107
+ !options.auth.allowedOrigins.includes(origin))
108
+ )
109
+ return json({ message: "Control request not allowed" }, 403);
110
+ const parsed = hmiControlRequest.safeParse(await request.json().catch(() => null));
111
+ if (!parsed.success) return json({ message: "Invalid control request" }, 400);
112
+ try {
113
+ await hmi.control(plc, parsed.data);
114
+ return json({ ok: true });
115
+ } catch (error) {
116
+ if (error instanceof RangeError) return json({ message: error.message }, 400);
117
+ options.log.warn("HMI control failed", error);
118
+ return json({ message: "The PLC did not accept this command" }, 503);
119
+ }
120
+ };
121
+ }
122
+ const server = Bun.serve({
123
+ ...(options.tls ? { tls: options.tls } : {}),
124
+ hostname: options.hostname,
125
+ port: options.port,
126
+ routes,
127
+ fetch: () => json({ message: "Not found" }, 404),
128
+ });
129
+ const stop = () => controller.abort();
130
+ process.once("SIGINT", stop);
131
+ process.once("SIGTERM", stop);
132
+ options.log.info(`Listening on ${server.url}`);
133
+
134
+ const closed = Promise.all([
135
+ options.plc.run(signal),
136
+ runOpcUaServices({
137
+ router: options.router,
138
+ createContext: options.createContext,
139
+ channels: options.channels,
140
+ getSession: options.plc.session,
141
+ onError: (error) => options.log.warn("PLC service gateway unavailable", error),
142
+ onReady: () => options.log.info("PLC service gateway ready"),
143
+ signal,
144
+ }),
145
+ ...(options.jobs ?? []).map(async (job) => {
146
+ while (!signal.aborted) {
147
+ try {
148
+ await job.run();
149
+ } catch (error) {
150
+ options.log.warn(`${job.name} failed`, error);
151
+ }
152
+ await sleep(job.intervalMs, signal);
153
+ }
154
+ }),
155
+ ]).finally(async () => {
156
+ controller.abort();
157
+ process.off("SIGINT", stop);
158
+ process.off("SIGTERM", stop);
159
+ await server.stop(true);
160
+ });
161
+
162
+ return {
163
+ url: server.url,
164
+ closed: closed.then(() => undefined),
165
+ stop: async () => {
166
+ stop();
167
+ await closed;
168
+ },
169
+ };
170
+ };