@tsplc/debug 0.2.17 → 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/telemetry.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { TelemetryBatch, TelemetryRecord, TelemetryReply } from "@tsplc/server/telemetry";
1
+ import type {
2
+ TelemetryBatch,
3
+ TelemetryRecord,
4
+ TelemetryReply,
5
+ } from "@tsplc/server/telemetry";
2
6
  import { recordedAt } from "@tsplc/server/telemetry-log";
3
7
  import type { Session } from "./api.ts";
4
8
  import type { DebugManifest } from "./manifest.ts";
@@ -8,11 +12,11 @@ const clockWindowMs = 60_000;
8
12
 
9
13
  /** A record with the batch it came in, numbered in its task's sequence, at its time on the server's clock. */
10
14
  export interface ReceivedRecord {
11
- build: string;
12
- task: string;
13
- sequence: number;
14
- at: string;
15
- record: TelemetryRecord;
15
+ build: string;
16
+ task: string;
17
+ sequence: number;
18
+ at: string;
19
+ record: TelemetryRecord;
16
20
  }
17
21
 
18
22
  /**
@@ -20,91 +24,101 @@ export interface ReceivedRecord {
20
24
  * once, keeps the tasks' scan rates, and estimates the PLC's clock against the server's.
21
25
  */
22
26
  export const createTelemetryFollower = (manifest: DebugManifest) => {
23
- const intervals = new Map(manifest.tasks.map(({ name, intervalMs }) => [name, intervalMs]));
24
- const tasks = new Map<string, Session["tasks"][number]>(
25
- manifest.tasks.map(({ name }) => [
26
- name,
27
- { name, scan: null, scansPerSecond: null, dropped: 0 },
28
- ]),
29
- );
30
- const rates = new Map<string, { scan: number; now: number }>();
31
- let estimates: { offsetMs: number; intervalMs: number; at: number }[] = [];
32
- let newest = 0;
33
- let build: string | null = null;
27
+ const intervals = new Map(
28
+ manifest.tasks.map(({ name, intervalMs }) => [name, intervalMs]),
29
+ );
30
+ const tasks = new Map<string, Session["tasks"][number]>(
31
+ manifest.tasks.map(({ name }) => [
32
+ name,
33
+ { name, scan: null, scansPerSecond: null, dropped: 0 },
34
+ ]),
35
+ );
36
+ const rates = new Map<string, { scan: number; now: number }>();
37
+ let estimates: { offsetMs: number; intervalMs: number; at: number }[] = [];
38
+ let newest = 0;
39
+ let build: string | null = null;
34
40
 
35
- /**
36
- * A batch's `now` was read before it was sent, so `now − receivedAt` falls short of the true
37
- * offset by its transit time: the largest recent estimate is the closest, and their spread,
38
- * plus a scan, bounds how far off it may be.
39
- */
40
- const clock = (): Session["clock"] => {
41
- if (!estimates.length) return null;
42
- const offsets = estimates.map(({ offsetMs }) => offsetMs);
43
- const best = estimates.reduce((best, estimate) =>
44
- estimate.offsetMs > best.offsetMs ? estimate : best,
45
- );
46
- return {
47
- offsetMs: best.offsetMs,
48
- uncertaintyMs: best.offsetMs - Math.min(...offsets) + best.intervalMs,
49
- };
50
- };
41
+ /**
42
+ * A batch's `now` was read before it was sent, so `now − receivedAt` falls short of the true
43
+ * offset by its transit time: the largest recent estimate is the closest, and their spread,
44
+ * plus a scan, bounds how far off it may be.
45
+ */
46
+ const clock = (): Session["clock"] => {
47
+ if (!estimates.length) {
48
+ return null;
49
+ }
50
+ const offsets = estimates.map(({ offsetMs }) => offsetMs);
51
+ const best = estimates.reduce((best, estimate) =>
52
+ estimate.offsetMs > best.offsetMs ? estimate : best,
53
+ );
54
+ return {
55
+ offsetMs: best.offsetMs,
56
+ uncertaintyMs: best.offsetMs - Math.min(...offsets) + best.intervalMs,
57
+ };
58
+ };
51
59
 
52
- const observe = (batch: TelemetryBatch, receivedAt: number) => {
53
- const rate = rates.get(batch.task);
54
- // A restarted PLC counts scans and time from zero again.
55
- const restarted = rate !== undefined && batch.now < rate.now;
56
- if (restarted) estimates = [];
57
- estimates = estimates.filter(({ at }) => receivedAt - at < clockWindowMs);
58
- estimates.push({
59
- offsetMs: batch.now - receivedAt,
60
- intervalMs: intervals.get(batch.task) ?? 0,
61
- at: receivedAt,
62
- });
63
- const stats = {
64
- name: batch.task,
65
- scansPerSecond: tasks.get(batch.task)?.scansPerSecond ?? null,
66
- scan: batch.scan,
67
- dropped: batch.dropped,
68
- };
69
- if (!rate || restarted) rates.set(batch.task, { scan: batch.scan, now: batch.now });
70
- else if (batch.now - rate.now >= 1000) {
71
- stats.scansPerSecond = ((batch.scan - rate.scan) * 1000) / (batch.now - rate.now);
72
- rates.set(batch.task, { scan: batch.scan, now: batch.now });
73
- }
74
- tasks.set(batch.task, stats);
75
- };
60
+ const observe = (batch: TelemetryBatch, receivedAt: number) => {
61
+ const rate = rates.get(batch.task);
62
+ // A restarted PLC counts scans and time from zero again.
63
+ const restarted = rate !== undefined && batch.now < rate.now;
64
+ if (restarted) {
65
+ estimates = [];
66
+ }
67
+ estimates = estimates.filter(({ at }) => receivedAt - at < clockWindowMs);
68
+ estimates.push({
69
+ offsetMs: batch.now - receivedAt,
70
+ intervalMs: intervals.get(batch.task) ?? 0,
71
+ at: receivedAt,
72
+ });
73
+ const stats = {
74
+ name: batch.task,
75
+ scansPerSecond: tasks.get(batch.task)?.scansPerSecond ?? null,
76
+ scan: batch.scan,
77
+ dropped: batch.dropped,
78
+ };
79
+ if (!rate || restarted) {
80
+ rates.set(batch.task, { scan: batch.scan, now: batch.now });
81
+ } else if (batch.now - rate.now >= 1000) {
82
+ stats.scansPerSecond =
83
+ ((batch.scan - rate.scan) * 1000) / (batch.now - rate.now);
84
+ rates.set(batch.task, { scan: batch.scan, now: batch.now });
85
+ }
86
+ tasks.set(batch.task, stats);
87
+ };
76
88
 
77
- return {
78
- /** Where the next pull starts. */
79
- since: () => newest,
80
- /** Takes a reply to `GET /tsplc/telemetry?since=<since()>`; a restarted server starts over. */
81
- accept: (reply: TelemetryReply): ReceivedRecord[] => {
82
- if (reply.latest < newest) {
83
- newest = 0;
84
- return [];
85
- }
86
- const received: ReceivedRecord[] = [];
87
- for (const batch of reply.batches) {
88
- if (batch.seq <= newest) continue;
89
- newest = batch.seq;
90
- build = batch.build;
91
- const receivedAt = new Date(batch.receivedAt);
92
- observe(batch, receivedAt.getTime());
93
- batch.records.forEach((record, index) =>
94
- received.push({
95
- build: batch.build,
96
- task: batch.task,
97
- sequence: batch.first + index,
98
- at: recordedAt(batch, record, receivedAt).toISOString(),
99
- record,
100
- }),
101
- );
102
- }
103
- return received;
104
- },
105
- /** The build the newest batch came from. */
106
- build: () => build,
107
- clock,
108
- tasks: () => [...tasks.values()].map((task) => ({ ...task })),
109
- };
89
+ return {
90
+ /** Where the next pull starts. */
91
+ since: () => newest,
92
+ /** Takes a reply to `GET /tsplc/telemetry?since=<since()>`; a restarted server starts over. */
93
+ accept: (reply: TelemetryReply): ReceivedRecord[] => {
94
+ if (reply.latest < newest) {
95
+ newest = 0;
96
+ return [];
97
+ }
98
+ const received: ReceivedRecord[] = [];
99
+ for (const batch of reply.batches) {
100
+ if (batch.seq <= newest) {
101
+ continue;
102
+ }
103
+ newest = batch.seq;
104
+ build = batch.build;
105
+ const receivedAt = new Date(batch.receivedAt);
106
+ observe(batch, receivedAt.getTime());
107
+ batch.records.forEach((record, index) =>
108
+ received.push({
109
+ build: batch.build,
110
+ task: batch.task,
111
+ sequence: batch.first + index,
112
+ at: recordedAt(batch, record, receivedAt).toISOString(),
113
+ record,
114
+ }),
115
+ );
116
+ }
117
+ return received;
118
+ },
119
+ /** The build the newest batch came from. */
120
+ build: () => build,
121
+ clock,
122
+ tasks: () => [...tasks.values()].map((task) => ({ ...task })),
123
+ };
110
124
  };
package/src/timeline.ts CHANGED
@@ -1,9 +1,18 @@
1
- import type { AppRecord, ConnectionState, RequestTrace, TimelineEntry, TraceEvent } from "./api.ts";
1
+ import type {
2
+ AppRecord,
3
+ ConnectionState,
4
+ RequestTrace,
5
+ TimelineEntry,
6
+ TraceEvent,
7
+ } from "./api.ts";
2
8
 
3
- export type Entry<Kind extends TimelineEntry["kind"]> = Extract<TimelineEntry, { kind: Kind }>;
9
+ export type Entry<Kind extends TimelineEntry["kind"]> = Extract<
10
+ TimelineEntry,
11
+ { kind: Kind }
12
+ >;
4
13
  type Details<Kind extends TimelineEntry["kind"]> = Omit<
5
- Entry<Kind>,
6
- "at" | "approximate" | "source" | "kind"
14
+ Entry<Kind>,
15
+ "at" | "approximate" | "source" | "kind"
7
16
  >;
8
17
 
9
18
  /**
@@ -12,67 +21,93 @@ type Details<Kind extends TimelineEntry["kind"]> = Omit<
12
21
  * approximate. The agent's entries reach a debugger only when the agent hosts it, on the same clock.
13
22
  */
14
23
  export const timelineEntries = {
15
- app: (at: string, task: string, sequence: number, record: AppRecord): Entry<"app"> => ({
16
- at,
17
- approximate: true,
18
- source: "plc",
19
- kind: "app",
20
- task,
21
- sequence,
22
- record,
23
- }),
24
- trace: (at: string, trace: TraceEvent): Entry<"trace"> => ({
25
- at,
26
- approximate: true,
27
- source: "plc",
28
- kind: "trace",
29
- trace,
30
- }),
31
- request: (request: RequestTrace): Entry<"request"> => ({
32
- at: request.startedAt,
33
- approximate: true,
34
- source: "server",
35
- kind: "request",
36
- request,
37
- }),
38
- deploy: (at: string, details: Details<"deploy">): Entry<"deploy"> => ({
39
- at,
40
- approximate: false,
41
- source: "agent",
42
- kind: "deploy",
43
- ...details,
44
- }),
45
- audit: (at: string, details: Details<"audit">): Entry<"audit"> => ({
46
- at,
47
- approximate: false,
48
- source: "agent",
49
- kind: "audit",
50
- ...details,
51
- }),
52
- connection: (at: string, state: ConnectionState, error?: string): Entry<"connection"> => ({
53
- at,
54
- approximate: false,
55
- source: "debugger",
56
- kind: "connection",
57
- state,
58
- ...(error && { error }),
59
- }),
24
+ app: (
25
+ at: string,
26
+ task: string,
27
+ sequence: number,
28
+ record: AppRecord,
29
+ ): Entry<"app"> => ({
30
+ at,
31
+ approximate: true,
32
+ source: "plc",
33
+ kind: "app",
34
+ task,
35
+ sequence,
36
+ record,
37
+ }),
38
+ trace: (at: string, trace: TraceEvent): Entry<"trace"> => ({
39
+ at,
40
+ approximate: true,
41
+ source: "plc",
42
+ kind: "trace",
43
+ trace,
44
+ }),
45
+ request: (request: RequestTrace): Entry<"request"> => ({
46
+ at: request.startedAt,
47
+ approximate: true,
48
+ source: "server",
49
+ kind: "request",
50
+ request,
51
+ }),
52
+ deploy: (at: string, details: Details<"deploy">): Entry<"deploy"> => ({
53
+ at,
54
+ approximate: false,
55
+ source: "agent",
56
+ kind: "deploy",
57
+ ...details,
58
+ }),
59
+ audit: (at: string, details: Details<"audit">): Entry<"audit"> => ({
60
+ at,
61
+ approximate: false,
62
+ source: "agent",
63
+ kind: "audit",
64
+ ...details,
65
+ }),
66
+ connection: (
67
+ at: string,
68
+ state: ConnectionState,
69
+ error?: string,
70
+ ): Entry<"connection"> => ({
71
+ at,
72
+ approximate: false,
73
+ source: "debugger",
74
+ kind: "connection",
75
+ state,
76
+ ...(error && { error }),
77
+ }),
78
+ };
79
+
80
+ /**
81
+ * Adds `entry` to `entries`, which are oldest first. Sources arrive late and out of order, so it
82
+ * goes after the last entry not later than it.
83
+ */
84
+ export const insertInTimeOrder = (
85
+ entries: TimelineEntry[],
86
+ entry: TimelineEntry,
87
+ ) => {
88
+ for (let index = entries.length; index >= 0; index--) {
89
+ const before = entries[index - 1];
90
+ if (before === undefined || before.at <= entry.at) {
91
+ entries.splice(index, 0, entry);
92
+ return;
93
+ }
94
+ }
60
95
  };
61
96
 
62
97
  /** Entries from every source in time order, keeping the newest `limit`. */
63
98
  export const createTimeline = (limit = 10_000) => {
64
- const entries: TimelineEntry[] = [];
65
- return {
66
- add: (entry: TimelineEntry) => {
67
- // Sources arrive late and out of order, so insert after the last entry not later than it.
68
- let index = entries.length;
69
- while (index > 0 && entry.at < (entries[index - 1]?.at ?? entry.at)) index--;
70
- entries.splice(index, 0, entry);
71
- if (entries.length > limit) entries.splice(0, entries.length - limit);
72
- },
73
- /** Entries after `at`, or all of them. */
74
- since: (at?: string) => (at ? entries.filter((entry) => entry.at > at) : [...entries]),
75
- };
99
+ const entries: TimelineEntry[] = [];
100
+ return {
101
+ add: (entry: TimelineEntry) => {
102
+ insertInTimeOrder(entries, entry);
103
+ if (entries.length > limit) {
104
+ entries.splice(0, entries.length - limit);
105
+ }
106
+ },
107
+ /** Entries after `at`, or all of them. */
108
+ since: (at?: string) =>
109
+ at ? entries.filter((entry) => entry.at > at) : [...entries],
110
+ };
76
111
  };
77
112
 
78
113
  export type Timeline = ReturnType<typeof createTimeline>;
package/src/trace.ts CHANGED
@@ -10,36 +10,46 @@ export type TraceRecord = Extract<TelemetryRecord, { kind: "trace" }>;
10
10
  * records of the manifest's own build may be decoded: another build's site ids mean other code.
11
11
  */
12
12
  export const createTraceDecoder = (manifest: DebugManifest, paths: Paths) => {
13
- const sites = new Map(manifest.events.map((site) => [site.id, site]));
14
- const jobs = new Map(manifest.jobs.map((job) => [job.id, job]));
15
- const jobSites = new Map(
16
- manifest.events.flatMap(({ id, job: jobId, phase }) => {
17
- const job = jobId === undefined ? undefined : jobs.get(jobId);
18
- return job && phase ? [[id, { job, started: phase === "started" }] as const] : [];
19
- }),
20
- );
13
+ const sites = new Map(manifest.events.map((site) => [site.id, site]));
14
+ const jobs = new Map(manifest.jobs.map((job) => [job.id, job]));
15
+ const jobSites = new Map(
16
+ manifest.events.flatMap(({ id, job: jobId, phase }) => {
17
+ const job = jobId === undefined ? undefined : jobs.get(jobId);
18
+ return job && phase
19
+ ? [[id, { job, started: phase === "started" }] as const]
20
+ : [];
21
+ }),
22
+ );
21
23
 
22
- return {
23
- decode: (record: TraceRecord, task: string, sequence: number): TraceEvent | undefined => {
24
- const site = sites.get(record.site);
25
- if (!site) return undefined;
26
- return {
27
- sequence,
28
- task,
29
- scan: record.scan,
30
- plcTime: record.time,
31
- site: site.id,
32
- value: paths.decode(site.type, record.value),
33
- operands: site.operands.map(({ text, path, stable, type }, index) => ({
34
- text,
35
- ...(path && { path }),
36
- stable,
37
- value: paths.decode(type, record.operands[index]),
38
- })),
39
- };
40
- },
41
- /** The job a record starts or finishes, if its site is a job's. */
42
- job: (record: TraceRecord): { job: DebugJob; started: boolean } | undefined =>
43
- jobSites.get(record.site),
44
- };
24
+ return {
25
+ decode: (
26
+ record: TraceRecord,
27
+ task: string,
28
+ sequence: number,
29
+ ): TraceEvent | undefined => {
30
+ const site = sites.get(record.site);
31
+ if (!site) {
32
+ return undefined;
33
+ }
34
+ return {
35
+ sequence,
36
+ task,
37
+ scan: record.scan,
38
+ plcTime: record.time,
39
+ site: site.id,
40
+ value: paths.decode(site.type, record.value),
41
+ operands: site.operands.map(({ text, path, stable, type }, index) => ({
42
+ text,
43
+ ...(path && { path }),
44
+ stable,
45
+ value: paths.decode(type, record.operands[index]),
46
+ })),
47
+ };
48
+ },
49
+ /** The job a record starts or finishes, if its site is a job's. */
50
+ job: (
51
+ record: TraceRecord,
52
+ ): { job: DebugJob; started: boolean } | undefined =>
53
+ jobSites.get(record.site),
54
+ };
45
55
  };
package/src/ui/bits.ts CHANGED
@@ -1,41 +1,57 @@
1
1
  import type { DebugChannel, DebugIo } from "../manifest.ts";
2
2
 
3
3
  /** An unsigned little-endian field of an assembly, as EtherNet/IP lays them out; null past 32 bits or the end. */
4
- export const readBits = (bytes: number[], offsetBits: number, bits: number): number | null => {
5
- if (bits > 32 || offsetBits + bits > bytes.length * 8) return null;
6
- let value = 0;
7
- for (let bit = 0; bit < bits; bit++) {
8
- const at = offsetBits + bit;
9
- if ((bytes[at >> 3]! >> (at & 7)) & 1) value += 2 ** bit;
10
- }
11
- return value;
4
+ export const readBits = (
5
+ bytes: number[],
6
+ offsetBits: number,
7
+ bits: number,
8
+ ): number | null => {
9
+ if (bits > 32 || offsetBits + bits > bytes.length * 8) {
10
+ return null;
11
+ }
12
+ let value = 0;
13
+ for (let bit = 0; bit < bits; bit++) {
14
+ const at = offsetBits + bit;
15
+ if ((bytes[at >> 3]! >> (at & 7)) & 1) {
16
+ value += 2 ** bit;
17
+ }
18
+ }
19
+ return value;
12
20
  };
13
21
 
14
22
  interface MappedBit {
15
- channel: DebugChannel;
16
- paths: string[];
23
+ channel: DebugChannel;
24
+ paths: string[];
17
25
  }
18
26
 
19
27
  /** What each bit of one assembly carries: the channel covering it and the TypeScript paths mapped to that channel. */
20
28
  export const mapBits = (
21
- device: string,
22
- direction: "input" | "output",
23
- bytes: number,
24
- channels: DebugChannel[],
25
- io: DebugIo[],
29
+ device: string,
30
+ direction: "input" | "output",
31
+ bytes: number,
32
+ channels: DebugChannel[],
33
+ io: DebugIo[],
26
34
  ): (MappedBit | undefined)[] => {
27
- const bits: (MappedBit | undefined)[] = Array.from({ length: bytes * 8 }, () => undefined);
28
- for (const channel of channels) {
29
- if (channel.direction !== direction) continue;
30
- const paths = io
31
- .filter((entry) => entry.device === device && entry.channel === channel.id)
32
- .map((entry) => entry.path);
33
- for (
34
- let bit = channel.offsetBits;
35
- bit < channel.offsetBits + channel.bits && bit < bits.length;
36
- bit++
37
- )
38
- bits[bit] = { channel, paths };
39
- }
40
- return bits;
35
+ const bits: (MappedBit | undefined)[] = Array.from(
36
+ { length: bytes * 8 },
37
+ () => undefined,
38
+ );
39
+ for (const channel of channels) {
40
+ if (channel.direction !== direction) {
41
+ continue;
42
+ }
43
+ const paths = io
44
+ .filter(
45
+ (entry) => entry.device === device && entry.channel === channel.id,
46
+ )
47
+ .map((entry) => entry.path);
48
+ for (
49
+ let bit = channel.offsetBits;
50
+ bit < channel.offsetBits + channel.bits && bit < bits.length;
51
+ bit++
52
+ ) {
53
+ bits[bit] = { channel, paths };
54
+ }
55
+ }
56
+ return bits;
41
57
  };
package/src/ui/client.ts CHANGED
@@ -8,41 +8,48 @@ const token = new URLSearchParams(location.search).get("token");
8
8
 
9
9
  type Method = "GET" | "PUT";
10
10
  type RouteOf<M extends Method> = {
11
- [K in keyof ApiResponses]: K extends `${M} /api/${infer Route}` ? Route : never;
11
+ [K in keyof ApiResponses]: K extends `${M} /api/${infer Route}`
12
+ ? Route
13
+ : never;
12
14
  }[keyof ApiResponses];
13
15
  type ResponseOf<M extends Method, R extends string> = ApiResponses[Extract<
14
- keyof ApiResponses,
15
- `${M} /api/${R}`
16
+ keyof ApiResponses,
17
+ `${M} /api/${R}`
16
18
  >];
17
19
 
18
20
  export const apiUrl = (route: string, params: Record<string, string> = {}) => {
19
- const search = new URLSearchParams(params);
20
- if (token) search.set("token", token);
21
- const query = search.toString();
22
- return `./api/${route}${query ? `?${query}` : ""}`;
21
+ const search = new URLSearchParams(params);
22
+ if (token) {
23
+ search.set("token", token);
24
+ }
25
+ const query = search.toString();
26
+ return `./api/${route}${query ? `?${query}` : ""}`;
23
27
  };
24
28
 
25
29
  const request = async <M extends Method, R extends RouteOf<M>>(
26
- method: M,
27
- route: R,
28
- params?: Record<string, string>,
29
- body?: unknown,
30
+ method: M,
31
+ route: R,
32
+ params?: Record<string, string>,
33
+ body?: unknown,
30
34
  ): Promise<ResponseOf<M, R>> => {
31
- const response = await fetch(apiUrl(route, params), {
32
- method,
33
- headers: body === undefined ? undefined : { "Content-Type": "application/json" },
34
- body: body === undefined ? undefined : JSON.stringify(body),
35
- });
36
- if (!response.ok) {
37
- const detail = response.headers.get("Content-Type")?.includes("json")
38
- ? (await response.json()).error
39
- : await response.text();
40
- throw new Error(`${method} ${route}: ${response.status} ${detail}`);
41
- }
42
- return response.json();
35
+ const response = await fetch(apiUrl(route, params), {
36
+ method,
37
+ headers:
38
+ body === undefined ? undefined : { "Content-Type": "application/json" },
39
+ body: body === undefined ? undefined : JSON.stringify(body),
40
+ });
41
+ if (!response.ok) {
42
+ const detail = response.headers.get("Content-Type")?.includes("json")
43
+ ? (await response.json()).error
44
+ : await response.text();
45
+ throw new Error(`${method} ${route}: ${response.status} ${detail}`);
46
+ }
47
+ return response.json();
43
48
  };
44
49
 
45
- export const getJson = <R extends RouteOf<"GET">>(route: R, params?: Record<string, string>) =>
46
- request("GET", route, params);
50
+ export const getJson = <R extends RouteOf<"GET">>(
51
+ route: R,
52
+ params?: Record<string, string>,
53
+ ) => request("GET", route, params);
47
54
  export const putJson = <R extends RouteOf<"PUT">>(route: R, body: unknown) =>
48
- request("PUT", route, undefined, body);
55
+ request("PUT", route, undefined, body);