@tsplc/debug 0.2.16 → 0.2.18

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/errors.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** What to show for a caught value: an error's message, or anything else as text. */
2
+ export const errorMessage = (error: unknown) =>
3
+ error instanceof Error ? error.message : String(error);
package/src/hardware.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type TargetRead, type TargetSample, worstQuality } from "./adapter.ts";
2
- import type { DeviceState } from "./api.ts";
2
+ import type { DeviceState, Quality } from "./api.ts";
3
3
  import type { DebugDevice, DebugIo, DebugType } from "./manifest.ts";
4
4
 
5
5
  /** Driver state names that mean the connection isn't working. */
@@ -10,74 +10,102 @@ const untyped: DebugType = { kind: "scalar", iec: "ANY" };
10
10
 
11
11
  /** What a device's health is read from: the driver's own state, when the build names it. */
12
12
  export const healthReads = (device: DebugDevice): TargetRead[] => {
13
- const { state, states, diagnostic, message } = device.health ?? {};
14
- return [
15
- ...(state
16
- ? [
17
- {
18
- target: state,
19
- type: states
20
- ? { kind: "enum" as const, name: "state", members: states }
21
- : { kind: "scalar" as const, iec: "DINT" },
22
- },
23
- ]
24
- : []),
25
- ...(diagnostic ? [{ target: diagnostic, type: { kind: "scalar" as const, iec: "BOOL" } }] : []),
26
- ...(message ? [{ target: message, type: { kind: "scalar" as const, iec: "STRING" } }] : []),
27
- ];
13
+ const { state, states, diagnostic, message } = device.health ?? {};
14
+ return [
15
+ ...(state
16
+ ? [
17
+ {
18
+ target: state,
19
+ type: states
20
+ ? { kind: "enum" as const, name: "state", members: states }
21
+ : { kind: "scalar" as const, iec: "DINT" },
22
+ },
23
+ ]
24
+ : []),
25
+ ...(diagnostic
26
+ ? [{ target: diagnostic, type: { kind: "scalar" as const, iec: "BOOL" } }]
27
+ : []),
28
+ ...(message
29
+ ? [{ target: message, type: { kind: "scalar" as const, iec: "STRING" } }]
30
+ : []),
31
+ ];
28
32
  };
29
33
 
30
34
  /** Everything a device's page shows: its health, raw assembly bytes, bound channels and status symbols. */
31
35
  export const deviceReads = (
32
- device: DebugDevice,
33
- io: readonly DebugIo[],
34
- typeOf: (id: string) => DebugType,
36
+ device: DebugDevice,
37
+ io: readonly DebugIo[],
38
+ typeOf: (id: string) => DebugType,
35
39
  ): TargetRead[] => [
36
- ...healthReads(device),
37
- ...device.assemblies.flatMap(({ raw, bytes }) =>
38
- raw
39
- ? [
40
- {
41
- target: raw,
42
- type: { kind: "array" as const, element: "BYTE", length: bytes, lower: 0 },
43
- },
44
- ]
45
- : [],
46
- ),
47
- ...io
48
- .filter((binding) => binding.device === device.id)
49
- .map(({ target, type }) => ({ target, type: typeOf(type) })),
50
- ...Object.values(device.status ?? {}).map((target) => ({ target, type: untyped })),
40
+ ...healthReads(device),
41
+ ...device.assemblies.flatMap(({ raw, bytes }) =>
42
+ raw
43
+ ? [
44
+ {
45
+ target: raw,
46
+ type: {
47
+ kind: "array" as const,
48
+ element: "BYTE",
49
+ length: bytes,
50
+ lower: 0,
51
+ },
52
+ },
53
+ ]
54
+ : [],
55
+ ),
56
+ ...io
57
+ .filter((binding) => binding.device === device.id)
58
+ .map(({ target, type }) => ({ target, type: typeOf(type) })),
59
+ ...Object.values(device.status ?? {}).map((target) => ({
60
+ target,
61
+ type: untyped,
62
+ })),
51
63
  ];
52
64
 
65
+ /** A reading that is no longer good says nothing current, fault or not. */
66
+ const healthState = (quality: Quality, faulted: boolean) => {
67
+ if (quality !== "good") {
68
+ return "stale";
69
+ }
70
+ return faulted ? "fault" : "ok";
71
+ };
72
+
53
73
  const health = (
54
- device: DebugDevice,
55
- value: (target: string) => TargetSample | undefined,
74
+ device: DebugDevice,
75
+ value: (target: string) => TargetSample | undefined,
56
76
  ): DeviceState["health"] => {
57
- const { state, states, diagnostic, message } = device.health ?? {};
58
- if (!state && !diagnostic) return { state: "unknown", quality: "unavailable" };
59
- const stateSample = state ? value(state) : undefined;
60
- const diagnosticSample = diagnostic ? value(diagnostic) : undefined;
61
- const messageSample = message ? value(message) : undefined;
62
- const quality = worstQuality([
63
- ...(state ? [stateSample] : []),
64
- ...(diagnostic ? [diagnosticSample] : []),
65
- ]);
66
- const number = typeof stateSample?.value === "number" ? stateSample.value : undefined;
67
- // The login names enum members the manifest may not list.
68
- const name =
69
- Object.entries(states ?? {}).find(([, member]) => member === number)?.[0] ??
70
- (typeof stateSample?.value === "string" ? stateSample.value : undefined);
71
- const faulted =
72
- diagnosticSample?.value === true || (name !== undefined && faultyState.test(name));
73
- return {
74
- state: quality !== "good" ? "stale" : faulted ? "fault" : "ok",
75
- ...(number !== undefined && { value: number }),
76
- ...(name && { name }),
77
- ...(typeof diagnosticSample?.value === "boolean" && { diagnostic: diagnosticSample.value }),
78
- ...(typeof messageSample?.value === "string" && { message: messageSample.value }),
79
- quality,
80
- };
77
+ const { state, states, diagnostic, message } = device.health ?? {};
78
+ if (!state && !diagnostic) {
79
+ return { state: "unknown", quality: "unavailable" };
80
+ }
81
+ const stateSample = state ? value(state) : undefined;
82
+ const diagnosticSample = diagnostic ? value(diagnostic) : undefined;
83
+ const messageSample = message ? value(message) : undefined;
84
+ const quality = worstQuality([
85
+ ...(state ? [stateSample] : []),
86
+ ...(diagnostic ? [diagnosticSample] : []),
87
+ ]);
88
+ const number =
89
+ typeof stateSample?.value === "number" ? stateSample.value : undefined;
90
+ // The login names enum members the manifest may not list.
91
+ const name =
92
+ Object.entries(states ?? {}).find(([, member]) => member === number)?.[0] ??
93
+ (typeof stateSample?.value === "string" ? stateSample.value : undefined);
94
+ const faulted =
95
+ diagnosticSample?.value === true ||
96
+ (name !== undefined && faultyState.test(name));
97
+ return {
98
+ state: healthState(quality, faulted),
99
+ ...(number !== undefined && { value: number }),
100
+ ...(name && { name }),
101
+ ...(typeof diagnosticSample?.value === "boolean" && {
102
+ diagnostic: diagnosticSample.value,
103
+ }),
104
+ ...(typeof messageSample?.value === "string" && {
105
+ message: messageSample.value,
106
+ }),
107
+ quality,
108
+ };
81
109
  };
82
110
 
83
111
  /**
@@ -85,41 +113,44 @@ const health = (
85
113
  * is "unknown" unless the manifest names the driver's own state.
86
114
  */
87
115
  export const deviceState = (
88
- device: DebugDevice,
89
- io: readonly DebugIo[],
90
- value: (target: string) => TargetSample | undefined,
116
+ device: DebugDevice,
117
+ io: readonly DebugIo[],
118
+ value: (target: string) => TargetSample | undefined,
91
119
  ): DeviceState => ({
92
- id: device.id,
93
- health: health(device, value),
94
- assemblies: device.assemblies.map(({ direction, address, raw }) => {
95
- const sample = raw ? value(raw) : undefined;
96
- return {
97
- direction,
98
- ...(address && { address }),
99
- bytes:
100
- Array.isArray(sample?.value) &&
101
- sample.value.every((byte): byte is number => typeof byte === "number")
102
- ? sample.value
103
- : null,
104
- quality: sample?.quality ?? "unavailable",
105
- };
106
- }),
107
- channels: io.flatMap((binding) =>
108
- binding.device === device.id && binding.channel
109
- ? [
110
- {
111
- id: binding.channel,
112
- path: binding.path,
113
- value: value(binding.target)?.value ?? null,
114
- quality: value(binding.target)?.quality ?? "unavailable",
115
- },
116
- ]
117
- : [],
118
- ),
119
- status: Object.fromEntries(
120
- Object.entries(device.status ?? {}).map(([label, target]) => [
121
- label,
122
- { value: value(target)?.value ?? null, quality: value(target)?.quality ?? "unavailable" },
123
- ]),
124
- ),
120
+ id: device.id,
121
+ health: health(device, value),
122
+ assemblies: device.assemblies.map(({ direction, address, raw }) => {
123
+ const sample = raw ? value(raw) : undefined;
124
+ return {
125
+ direction,
126
+ ...(address && { address }),
127
+ bytes:
128
+ Array.isArray(sample?.value) &&
129
+ sample.value.every((byte): byte is number => typeof byte === "number")
130
+ ? sample.value
131
+ : null,
132
+ quality: sample?.quality ?? "unavailable",
133
+ };
134
+ }),
135
+ channels: io.flatMap((binding) =>
136
+ binding.device === device.id && binding.channel
137
+ ? [
138
+ {
139
+ id: binding.channel,
140
+ path: binding.path,
141
+ value: value(binding.target)?.value ?? null,
142
+ quality: value(binding.target)?.quality ?? "unavailable",
143
+ },
144
+ ]
145
+ : [],
146
+ ),
147
+ status: Object.fromEntries(
148
+ Object.entries(device.status ?? {}).map(([label, target]) => [
149
+ label,
150
+ {
151
+ value: value(target)?.value ?? null,
152
+ quality: value(target)?.quality ?? "unavailable",
153
+ },
154
+ ]),
155
+ ),
125
156
  });
package/src/http.ts CHANGED
@@ -11,76 +11,95 @@ const heartbeatMs = 5000;
11
11
  const encoder = new TextEncoder();
12
12
 
13
13
  const json = (body: unknown, status = 200) =>
14
- Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
14
+ Response.json(body, { status, headers: { "Cache-Control": "no-store" } });
15
15
 
16
16
  const list = (value: string | null) =>
17
- (value ?? "")
18
- .split(",")
19
- .map((item) => item.trim())
20
- .filter(Boolean);
17
+ (value ?? "")
18
+ .split(",")
19
+ .map((item) => item.trim())
20
+ .filter(Boolean);
21
21
 
22
22
  export const loadManifest = async (file: string) =>
23
- debugManifest.parse(JSON.parse(await readFile(file, "utf8")));
23
+ debugManifest.parse(JSON.parse(await readFile(file, "utf8")));
24
24
 
25
25
  /** What a stream's page shows, from its query. */
26
26
  const interest = (search: URLSearchParams): Interest => {
27
- const jobs = list(search.get("jobs")).map(Number);
28
- if (!jobs.every(Number.isInteger)) throw new DebugError("jobs must be job ids", 400);
29
- return {
30
- watch: list(search.get("watch")),
31
- jobs,
32
- health: list(search.get("health")),
33
- devices: list(search.get("devices")),
34
- };
27
+ const jobs = list(search.get("jobs")).map(Number);
28
+ if (!jobs.every(Number.isInteger)) {
29
+ throw new DebugError("jobs must be job ids", 400);
30
+ }
31
+ return {
32
+ watch: list(search.get("watch")),
33
+ jobs,
34
+ health: list(search.get("health")),
35
+ devices: list(search.get("devices")),
36
+ };
35
37
  };
36
38
 
37
39
  /** Every stream event, plus samples of the watched paths and states of the shown jobs and devices. */
38
40
  const stream = (debug: Debugger, { watch, ...shown }: Interest) => {
39
- // Refuse a watch that matches too much before the stream starts.
40
- debug.paths.expand(watch);
41
- let stop = () => {};
42
- return new Response(
43
- new ReadableStream<Uint8Array>({
44
- start: (controller) => {
45
- let open = true;
46
- const write = (text: string) => {
47
- if (open) controller.enqueue(encoder.encode(text));
48
- };
49
- const send = ({ event, data }: StreamMessage) =>
50
- write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
51
- send({ event: "session", data: debug.session() });
52
- const latest = new Map<string, { sample: Sample; sentAt: number }>();
53
- const sendSample = (sample: Sample) => {
54
- latest.set(sample.path, { sample, sentAt: Date.now() });
55
- send({ event: "sample", data: sample });
56
- };
57
- const unsubscribe = debug.subscribe(send);
58
- const unwatch = debug.watch(watch, sendSample);
59
- const unshow = debug.show(shown);
60
- const heartbeat = setInterval(() => write(": keepalive\n\n"), heartbeatMs);
61
- // Over OPC UA, samples come only on change. While the session holds, an unchanged value
62
- // is still current, so it is sent again each interval; clients mark samples stale by their age.
63
- const { intervalMs } = debug.session().reading;
64
- const refresh = setInterval(() => {
65
- if (debug.session().connection.state !== "connected") return;
66
- const now = Date.now();
67
- for (const { sample, sentAt } of latest.values())
68
- if (now - sentAt >= intervalMs)
69
- sendSample({ ...sample, receivedAt: new Date(now).toISOString() });
70
- }, intervalMs);
71
- stop = () => {
72
- open = false;
73
- clearInterval(heartbeat);
74
- clearInterval(refresh);
75
- unsubscribe();
76
- unwatch();
77
- unshow();
78
- };
79
- },
80
- cancel: () => stop(),
81
- }),
82
- { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store" } },
83
- );
41
+ // Refuse a watch that matches too much before the stream starts.
42
+ debug.paths.expand(watch);
43
+ let stop = () => {};
44
+ return new Response(
45
+ new ReadableStream<Uint8Array>({
46
+ start: (controller) => {
47
+ let open = true;
48
+ const write = (text: string) => {
49
+ if (open) {
50
+ controller.enqueue(encoder.encode(text));
51
+ }
52
+ };
53
+ const send = ({ event, data }: StreamMessage) =>
54
+ write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
55
+ send({ event: "session", data: debug.session() });
56
+ const latest = new Map<string, { sample: Sample; sentAt: number }>();
57
+ const sendSample = (sample: Sample) => {
58
+ latest.set(sample.path, { sample, sentAt: Date.now() });
59
+ send({ event: "sample", data: sample });
60
+ };
61
+ const unsubscribe = debug.subscribe(send);
62
+ const unwatch = debug.watch(watch, sendSample);
63
+ const unshow = debug.show(shown);
64
+ const heartbeat = setInterval(
65
+ () => write(": keepalive\n\n"),
66
+ heartbeatMs,
67
+ );
68
+ // Over OPC UA, samples come only on change. While the session holds, an unchanged value
69
+ // is still current, so it is sent again each interval; clients mark samples stale by their age.
70
+ const { intervalMs } = debug.session().reading;
71
+ const refresh = setInterval(() => {
72
+ if (debug.session().connection.state !== "connected") {
73
+ return;
74
+ }
75
+ const now = Date.now();
76
+ for (const { sample, sentAt } of latest.values()) {
77
+ if (now - sentAt >= intervalMs) {
78
+ sendSample({
79
+ ...sample,
80
+ receivedAt: new Date(now).toISOString(),
81
+ });
82
+ }
83
+ }
84
+ }, intervalMs);
85
+ stop = () => {
86
+ open = false;
87
+ clearInterval(heartbeat);
88
+ clearInterval(refresh);
89
+ unsubscribe();
90
+ unwatch();
91
+ unshow();
92
+ };
93
+ },
94
+ cancel: () => stop(),
95
+ }),
96
+ {
97
+ headers: {
98
+ "Content-Type": "text/event-stream",
99
+ "Cache-Control": "no-store",
100
+ },
101
+ },
102
+ );
84
103
  };
85
104
 
86
105
  /**
@@ -88,68 +107,80 @@ const stream = (debug: Debugger, { watch, ...shown }: Interest) => {
88
107
  * the host mounts them. There is no route that writes to the controller; hosts authenticate.
89
108
  */
90
109
  export const createDebugHandler =
91
- (debug: Debugger, log: (message: string) => void) =>
92
- async (request: Request, route: string): Promise<Response> => {
93
- const { searchParams } = new URL(request.url);
94
- const key = `${request.method} ${route}`;
95
- try {
96
- switch (key) {
97
- case "GET /":
98
- return new Response((await loadUi()).html, {
99
- headers: { "Content-Type": "text/html; charset=utf-8" },
100
- });
101
- case "GET /ui.js":
102
- return new Response((await loadUi()).script, {
103
- headers: { "Content-Type": "text/javascript; charset=utf-8" },
104
- });
105
- case "GET /api/session":
106
- return json(debug.session());
107
- case "GET /api/manifest":
108
- return json(debug.manifest);
109
- case "GET /api/stream":
110
- return stream(debug, interest(searchParams));
111
- case "GET /api/values":
112
- return json(await debug.values(list(searchParams.get("paths"))));
113
- case "GET /api/jobs":
114
- return json(debug.jobs());
115
- case "GET /api/hardware":
116
- return json(debug.hardware());
117
- case "GET /api/timeline":
118
- return json(debug.timeline(searchParams.get("since") ?? undefined));
119
- case "GET /api/watch":
120
- return json(debug.watchGroups());
121
- case "PUT /api/watch":
122
- return json(await debug.saveWatchGroups(watchGroupsSchema.parse(await request.json())));
123
- }
124
- return json({ error: `No route ${key}` }, 404);
125
- } catch (error) {
126
- if (error instanceof DebugError) return json({ error: error.message }, error.status);
127
- // RangeError: a watch matches too many values; SyntaxError: a body isn't JSON.
128
- if (error instanceof RangeError || error instanceof SyntaxError)
129
- return json({ error: error.message }, 400);
130
- if (error instanceof z.ZodError) return json({ error: z.prettifyError(error) }, 400);
131
- log(`${key} failed: ${error instanceof Error ? error.stack : String(error)}`);
132
- return json({ error: "The debugger failed; see its log" }, 500);
133
- }
134
- };
110
+ (debug: Debugger, log: (message: string) => void) =>
111
+ async (request: Request, route: string): Promise<Response> => {
112
+ const { searchParams } = new URL(request.url);
113
+ const key = `${request.method} ${route}`;
114
+ try {
115
+ switch (key) {
116
+ case "GET /":
117
+ return new Response((await loadUi()).html, {
118
+ headers: { "Content-Type": "text/html; charset=utf-8" },
119
+ });
120
+ case "GET /ui.js":
121
+ return new Response((await loadUi()).script, {
122
+ headers: { "Content-Type": "text/javascript; charset=utf-8" },
123
+ });
124
+ case "GET /api/session":
125
+ return json(debug.session());
126
+ case "GET /api/manifest":
127
+ return json(debug.manifest);
128
+ case "GET /api/stream":
129
+ return stream(debug, interest(searchParams));
130
+ case "GET /api/values":
131
+ return json(await debug.values(list(searchParams.get("paths"))));
132
+ case "GET /api/jobs":
133
+ return json(debug.jobs());
134
+ case "GET /api/hardware":
135
+ return json(debug.hardware());
136
+ case "GET /api/timeline":
137
+ return json(debug.timeline(searchParams.get("since") ?? undefined));
138
+ case "GET /api/watch":
139
+ return json(debug.watchGroups());
140
+ case "PUT /api/watch":
141
+ return json(
142
+ await debug.saveWatchGroups(
143
+ watchGroupsSchema.parse(await request.json()),
144
+ ),
145
+ );
146
+ }
147
+ return json({ error: `No route ${key}` }, 404);
148
+ } catch (error) {
149
+ if (error instanceof DebugError) {
150
+ return json({ error: error.message }, error.status);
151
+ }
152
+ // RangeError: a watch matches too many values; SyntaxError: a body isn't JSON.
153
+ if (error instanceof RangeError || error instanceof SyntaxError) {
154
+ return json({ error: error.message }, 400);
155
+ }
156
+ if (error instanceof z.ZodError) {
157
+ return json({ error: z.prettifyError(error) }, 400);
158
+ }
159
+ log(
160
+ `${key} failed: ${error instanceof Error ? error.stack : String(error)}`,
161
+ );
162
+ return json({ error: "The debugger failed; see its log" }, 500);
163
+ }
164
+ };
135
165
 
136
166
  /**
137
167
  * Serves the debugger on 127.0.0.1 without a token. Requests that name another host are refused,
138
168
  * so no website can reach it by rebinding a domain name to 127.0.0.1.
139
169
  */
140
170
  export const serveDebugger = (
141
- debug: Debugger,
142
- options: { port: number; log: (message: string) => void },
171
+ debug: Debugger,
172
+ options: { port: number; log: (message: string) => void },
143
173
  ) => {
144
- const handle = createDebugHandler(debug, options.log);
145
- return Bun.serve({
146
- hostname: "127.0.0.1",
147
- port: options.port,
148
- fetch: (request) => {
149
- const host = request.headers.get("host")?.replace(/:\d+$/, "");
150
- if (host !== "127.0.0.1" && host !== "localhost")
151
- return json({ error: "Use http://127.0.0.1 or http://localhost" }, 403);
152
- return handle(request, new URL(request.url).pathname);
153
- },
154
- });
174
+ const handle = createDebugHandler(debug, options.log);
175
+ return Bun.serve({
176
+ hostname: "127.0.0.1",
177
+ port: options.port,
178
+ fetch: (request) => {
179
+ const host = request.headers.get("host")?.replace(/:\d+$/, "");
180
+ if (host !== "127.0.0.1" && host !== "localhost") {
181
+ return json({ error: "Use http://127.0.0.1 or http://localhost" }, 403);
182
+ }
183
+ return handle(request, new URL(request.url).pathname);
184
+ },
185
+ });
155
186
  };
package/src/index.ts CHANGED
@@ -1,8 +1,38 @@
1
- export type * from "./api.ts";
2
- export type { Connection, TargetAdapter, TargetRead, TargetSample } from "./adapter.ts";
3
- export { createDebugger, DebugError, type Debugger, type DebuggerOptions } from "./debugger.ts";
1
+ export type {
2
+ ApiResponses,
3
+ AppRecord,
4
+ ConnectionState,
5
+ DeviceState,
6
+ Interest,
7
+ JobState,
8
+ Quality,
9
+ RequestTrace,
10
+ Sample,
11
+ Session,
12
+ StreamEvents,
13
+ TimelineEntry,
14
+ TraceEvent,
15
+ WatchGroups,
16
+ } from "./api.ts";
17
+ export type {
18
+ Connection,
19
+ TargetAdapter,
20
+ TargetRead,
21
+ TargetSample,
22
+ } from "./adapter.ts";
23
+ export {
24
+ createDebugger,
25
+ DebugError,
26
+ type Debugger,
27
+ type DebuggerOptions,
28
+ } from "./debugger.ts";
4
29
  export { createDebugHandler, loadManifest, serveDebugger } from "./http.ts";
5
30
  export { createLoginAdapter, LoginUnavailable, noLogin } from "./login.ts";
6
31
  export { createOpcUaAdapter, opcUaConnection } from "./opcua.ts";
7
- export { createPaths, type PathNode, type Paths, type ResolvedPath } from "./paths.ts";
32
+ export {
33
+ createPaths,
34
+ type PathNode,
35
+ type Paths,
36
+ type ResolvedPath,
37
+ } from "./paths.ts";
8
38
  export { timelineEntries } from "./timeline.ts";