@protoboardai/web-viewer 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,444 @@
1
+ /**
2
+ * A viewer tab's connection to the project's `protoboard dev` server (PB-943):
3
+ * load the session's state and bundle, follow its events, and recover.
4
+ *
5
+ * - A load that fails is tried again by itself (2 s, then up to 15 s apart)
6
+ * and at once on `retry()`; a loopback server that does not answer under
7
+ * one name (`127.0.0.1`) is tried under the other (`localhost`).
8
+ * - Rebuilds arrive on the server's event stream where the webview can
9
+ * open one. Where it cannot (VS Code 1.106 to at least 1.120: every
10
+ * attempt errors, though plain requests work; 1.130 and later can. This is
11
+ * what left "The dev server is unreachable; retrying…" on a drawn diagram
12
+ * in beta test 1, on 1.108.2), and while a stream is down
13
+ * (a laptop that slept, a network that changed: the browser does not
14
+ * reconnect a cut stream in a webview), the tab asks the server for its
15
+ * state every second instead, and loads a newer build or a restarted
16
+ * session when it sees one. A stream the browser gave up is opened again.
17
+ * - Only a server that does not answer the questions either is a lost
18
+ * connection: said after a few seconds, and cleared when it answers.
19
+ * - Everything it tried and every error goes to `log` (the extension's
20
+ * Protoboard log), with the URL.
21
+ *
22
+ * No DOM and no React here: the tests drive it with a fake fetch and a fake
23
+ * EventSource.
24
+ */
25
+ import type { DevEndpoint } from "./protocol.js";
26
+
27
+ /** What the tab says over the canvas. */
28
+ export interface ViewerStatus {
29
+ message: string;
30
+ busy?: boolean;
31
+ /**
32
+ * The tab cannot read from the dev server: shown in the middle of the tab,
33
+ * with what the dev server is and what to try (Retry, Reload Window, Show
34
+ * Log). Without it the message is a note (a rebuild in progress, a failed
35
+ * rebuild) at the bottom.
36
+ */
37
+ problem?: boolean;
38
+ }
39
+
40
+ /** What the dev server is, in one sentence, for the tab's message. */
41
+ export const DEV_SERVER_IS = "The dev server serves this project's model and files to the viewer and rebuilds them when the project changes.";
42
+
43
+ export interface EventSourceLike {
44
+ readyState: number;
45
+ onopen: ((e: unknown) => void) | null;
46
+ onerror: ((e: unknown) => void) | null;
47
+ addEventListener(type: string, listener: (e: { data: string }) => void): void;
48
+ close(): void;
49
+ }
50
+
51
+ export interface DevState {
52
+ session?: string;
53
+ version?: number;
54
+ failed?: boolean;
55
+ /** Why the server does not watch the project (B19): live reload is off. */
56
+ unwatched?: string;
57
+ message?: string;
58
+ views?: unknown[];
59
+ diagnostics?: unknown;
60
+ /** The parts' status by definition id (PB-932), said again with every rebuild. */
61
+ partStatus?: unknown;
62
+ }
63
+
64
+ export interface DevLinkDeps {
65
+ fetch: (url: string, init?: { cache?: "no-store"; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
66
+ eventSource: (url: string) => EventSourceLike;
67
+ /** The session's state and bundle, read from `base` (first load, a restart, a reconnect to a newer bundle). */
68
+ onLoad(load: { base: string; state: DevState; bundle: unknown }): void;
69
+ /** A rebuild's bundle (the `bundle` event, or a newer version found by asking: `reason` is then `poll`). */
70
+ onBundle(update: { base: string; bundle: unknown; event: { reason?: string; diagnostics?: unknown; failed?: boolean; message?: string; version?: number; partStatus?: unknown } }): void;
71
+ onStatus(status: ViewerStatus | null): void;
72
+ /** For the extension's log. */
73
+ log(level: "info" | "warn" | "error", message: string): void;
74
+ setTimeout?: (fn: () => void, ms: number) => unknown;
75
+ clearTimeout?: (handle: unknown) => void;
76
+ now?: () => number;
77
+ /** How long one request may take (default 10 s). */
78
+ requestTimeoutMs?: number;
79
+ /** How long the event stream may be down before the tab says so (default 4 s). */
80
+ graceMs?: number;
81
+ /** The first wait before a load is tried again (default 2 s; doubles up to 15 s). */
82
+ retryMs?: number;
83
+ }
84
+
85
+ /** The other loopback name of a URL (`127.0.0.1` ⇄ `localhost`), or undefined for any other host. */
86
+ export function otherLoopback(url: string): string | undefined {
87
+ try {
88
+ const u = new URL(url);
89
+ if (u.hostname === "127.0.0.1") u.hostname = "localhost";
90
+ else if (u.hostname === "localhost") u.hostname = "127.0.0.1";
91
+ else return undefined;
92
+ return u.href;
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ }
97
+
98
+ /** An error as one line: its name when that says more than the message (`TypeError: Failed to fetch`). */
99
+ export function describeError(e: unknown): string {
100
+ if (e instanceof Error) {
101
+ if (e.name === "AbortError") return "no answer in time";
102
+ // what a browser says for a refused, reset or blocked connection
103
+ if (e.name === "TypeError" && /failed to fetch|load failed|networkerror/i.test(e.message)) return `no connection (${e.message})`;
104
+ return e.name && e.name !== "Error" ? `${e.name}: ${e.message}` : e.message;
105
+ }
106
+ return String(e);
107
+ }
108
+
109
+ const MAX_RETRY_MS = 15000;
110
+ /** How often the tab asks the server while it has no event stream, and while the server does not answer. */
111
+ const POLL_MS = 1000;
112
+ const POLL_FAILING_MS = 3000;
113
+ /** How long after the browser gave a stream up another one is opened. */
114
+ const STREAM_RETRY_MS = 15000;
115
+
116
+ type BundleEvent = { reason?: string; diagnostics?: unknown; failed?: boolean; message?: string; version?: number; partStatus?: unknown; unwatched?: string };
117
+
118
+ /** The note a tab keeps at its foot while the stream is fine: a failed rebuild, or live reload off (B19). */
119
+ export function restingStatus(s: { failed?: boolean; message?: string; unwatched?: string }): ViewerStatus | null {
120
+ if (s.failed) return { message: s.message ?? "The last rebuild failed; the viewer shows the one before it." };
121
+ if (s.unwatched) return { message: s.unwatched };
122
+ return null;
123
+ }
124
+
125
+ export class DevLink {
126
+ private dev: DevEndpoint | null = null;
127
+ /** The URL the server answers at (the endpoint's, or its other loopback name). */
128
+ private base: string | undefined;
129
+ private events: EventSourceLike | undefined;
130
+ /** Counts connections: work started for an older one is dropped. */
131
+ private generation = 0;
132
+ private retryTimer: unknown;
133
+ /** While the event stream is down the tab asks the server instead (`poll`). */
134
+ private polling = false;
135
+ private pollTimer: unknown;
136
+ private pollFailedAt: number | undefined;
137
+ private saidBuilding = false;
138
+ /** Opens a stream again after the browser gave one up. */
139
+ private streamTimer: unknown;
140
+ /** A stream has delivered events in this page (tests). */
141
+ streamWorked = false;
142
+ private attempts = 0;
143
+ /** When the event stream went down (undefined: it is up). */
144
+ private lostAt: number | undefined;
145
+ /** The lost-connection message is on screen. */
146
+ private lostShown = false;
147
+ private version: number | undefined;
148
+ private everLoaded = false;
149
+ /** What the tab says when the stream is fine (a failed rebuild's note). */
150
+ private resting: ViewerStatus | null = null;
151
+
152
+ constructor(private readonly deps: DevLinkDeps) {}
153
+
154
+ private get later() {
155
+ return this.deps.setTimeout ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
156
+ }
157
+ private cancel(handle: unknown) {
158
+ if (handle === undefined) return;
159
+ (this.deps.clearTimeout ?? ((h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>)))(handle);
160
+ }
161
+ private now() {
162
+ return (this.deps.now ?? Date.now)();
163
+ }
164
+
165
+ /** The endpoint in use (tests). */
166
+ get endpoint(): DevEndpoint | null {
167
+ return this.dev;
168
+ }
169
+
170
+ /** The extension said where the server is (`null`: there is none, and `message` says why). */
171
+ set(next: DevEndpoint | null, message?: string, busy?: boolean) {
172
+ if (next && this.dev && next.url === this.dev.url && next.session === this.dev.session) return;
173
+ this.stop();
174
+ this.dev = next;
175
+ if (!next) {
176
+ this.deps.onStatus({ message: message ?? "The dev server is not running.", ...(busy ? { busy: true } : {}), problem: true });
177
+ return;
178
+ }
179
+ this.attempts = 0;
180
+ void this.load(next, ++this.generation);
181
+ }
182
+
183
+ /** Try now (the Retry button): load again from the endpoint the tab has. */
184
+ retry() {
185
+ if (!this.dev) return;
186
+ this.deps.log("info", `retry: loading from ${this.dev.url} again`);
187
+ this.stop();
188
+ this.attempts = 0;
189
+ void this.load(this.dev, ++this.generation);
190
+ }
191
+
192
+ dispose() {
193
+ this.stop();
194
+ this.dev = null;
195
+ this.generation++;
196
+ }
197
+
198
+ private stop() {
199
+ this.cancel(this.retryTimer);
200
+ this.cancel(this.streamTimer);
201
+ this.retryTimer = this.streamTimer = undefined;
202
+ this.stopPolling();
203
+ this.saidBuilding = false;
204
+ this.events?.close();
205
+ this.events = undefined;
206
+ this.lostAt = undefined;
207
+ this.lostShown = false;
208
+ }
209
+
210
+ private async get(url: string): Promise<unknown> {
211
+ const ctl = typeof AbortController === "undefined" ? undefined : new AbortController();
212
+ const timer = this.later(() => ctl?.abort(), this.deps.requestTimeoutMs ?? 10000);
213
+ try {
214
+ const res = await this.deps.fetch(url, { cache: "no-store", ...(ctl ? { signal: ctl.signal } : {}) });
215
+ if (!res.ok) throw new Error(`the dev server answered ${res.status}`);
216
+ return await res.json();
217
+ } finally {
218
+ this.cancel(timer);
219
+ }
220
+ }
221
+
222
+ /** The state and the bundle from one base URL. */
223
+ private async read(base: string): Promise<{ state: DevState; bundle: unknown }> {
224
+ const state = ((await this.get(new URL("__protoboard/state", base).href)) ?? {}) as DevState;
225
+ const bundle = await this.get(new URL("uhd-bundle.json", base).href);
226
+ return { state, bundle };
227
+ }
228
+
229
+ private async load(dev: DevEndpoint, generation: number) {
230
+ const current = () => generation === this.generation;
231
+ if (!this.everLoaded || this.attempts > 0) this.deps.onStatus({ message: this.attempts ? `Trying the dev server at ${new URL(dev.url).host} again…` : "Loading the project…", busy: true, ...(this.attempts ? { problem: true } : {}) });
232
+ const bases = [this.base && otherLoopback(this.base) === dev.url ? this.base : dev.url];
233
+ const other = otherLoopback(bases[0]);
234
+ if (other) bases.push(other);
235
+ const errors: string[] = [];
236
+ for (const base of bases) {
237
+ try {
238
+ const { state, bundle } = await this.read(base);
239
+ if (!current()) return;
240
+ if (base !== dev.url) this.deps.log("warn", `the dev server did not answer at ${dev.url} (${errors[0]}) but does at ${base}: reading from there`);
241
+ else if (this.attempts) this.deps.log("info", `the dev server at ${base} answers again (attempt ${this.attempts + 1})`);
242
+ else this.deps.log("info", `loaded the project from ${base} (session ${state.session ?? "?"}, version ${state.version ?? "?"})`);
243
+ this.base = base;
244
+ this.attempts = 0;
245
+ this.everLoaded = true;
246
+ this.version = typeof state.version === "number" ? state.version : undefined;
247
+ this.deps.onLoad({ base, state, bundle });
248
+ this.resting = restingStatus(state);
249
+ this.deps.onStatus(this.resting);
250
+ this.listen(dev, base, generation);
251
+ return;
252
+ } catch (e) {
253
+ if (!current()) return;
254
+ errors.push(describeError(e));
255
+ this.deps.log("error", `GET ${base} failed: ${describeError(e)}${(globalThis as { navigator?: { onLine?: boolean } }).navigator?.onLine === false ? " (the browser reports it is offline)" : ""}`);
256
+ }
257
+ }
258
+ this.attempts++;
259
+ const wait = Math.min((this.deps.retryMs ?? 2000) * 2 ** (this.attempts - 1), MAX_RETRY_MS);
260
+ this.deps.onStatus({ message: `The dev server at ${new URL(dev.url).host} is not answering: ${errors[0]}. Retrying…`, busy: true, problem: true });
261
+ this.retryTimer = this.later(() => {
262
+ this.retryTimer = undefined;
263
+ if (current()) void this.load(dev, generation);
264
+ }, wait);
265
+ }
266
+
267
+ private listen(dev: DevEndpoint, base: string, generation: number) {
268
+ const current = () => generation === this.generation;
269
+ const url = new URL("__protoboard/events", base).href;
270
+ const es = (this.events = this.deps.eventSource(url));
271
+ /** This stream delivered something: the browser can stream from the server at all. */
272
+ let opened = false;
273
+ let errors = 0;
274
+ const up = () => {
275
+ if (!current()) return;
276
+ opened = true;
277
+ errors = 0;
278
+ this.streamWorked = true;
279
+ if (this.polling) {
280
+ this.deps.log("info", `events from ${url} are back${this.lostAt !== undefined ? ` after ${((this.now() - this.lostAt) / 1000).toFixed(1)} s` : ""}`);
281
+ this.stopPolling();
282
+ }
283
+ this.lostAt = undefined;
284
+ if (this.lostShown) {
285
+ this.lostShown = false;
286
+ this.deps.onStatus(this.resting);
287
+ }
288
+ };
289
+ es.onopen = up;
290
+ es.addEventListener("hello", (e) => {
291
+ if (!current()) return;
292
+ up();
293
+ let hello: { session?: string; version?: number } = {};
294
+ try {
295
+ hello = JSON.parse(e.data);
296
+ } catch {
297
+ return;
298
+ }
299
+ // a restarted server (the extension follows with its new session), or a rebuild missed while the stream was down
300
+ if (hello.session && dev.session && hello.session !== dev.session) return this.reload(dev, `the dev server at ${base} restarted (session ${hello.session})`);
301
+ if (typeof hello.version === "number" && this.version !== undefined && hello.version !== this.version) return this.reload(dev, `the dev server at ${base} has a newer build (version ${hello.version}, shown ${this.version})`);
302
+ });
303
+ es.addEventListener("status", (e) => {
304
+ if (!current()) return;
305
+ const s = JSON.parse(e.data) as { state?: string; message?: string };
306
+ this.deps.onStatus(s.state === "error" ? { message: s.message ?? "error" } : s.state === "busy" ? { message: s.message ?? "building", busy: true } : this.resting);
307
+ });
308
+ // the server could not watch the project's files (B19): live reload is off, and the tab says so
309
+ es.addEventListener("watch", (e) => {
310
+ if (!current()) return;
311
+ let w: { unwatched?: string } = {};
312
+ try {
313
+ w = JSON.parse(e.data);
314
+ } catch {
315
+ return;
316
+ }
317
+ if (!w.unwatched || this.resting) return;
318
+ this.resting = { message: w.unwatched };
319
+ this.deps.onStatus(this.resting);
320
+ });
321
+ es.addEventListener("bundle", (e) => {
322
+ if (!current()) return;
323
+ const event = JSON.parse(e.data) as BundleEvent;
324
+ void this.rebuilt(base, event, generation);
325
+ });
326
+ es.onerror = () => {
327
+ if (!current() || this.events !== es) return;
328
+ errors++;
329
+ const gaveUp = es.readyState === 2;
330
+ if (!opened && !gaveUp && errors < 2) return; // one failed attempt: the browser tries again
331
+ if (this.lostAt === undefined) this.lostAt = this.now();
332
+ if (!this.polling) {
333
+ // the webviews of VS Code before about 1.130 never open an event stream to a loopback server (every attempt
334
+ // errors), and newer ones do not reconnect one that was cut: either way the tab follows the server by asking it
335
+ this.deps.log(
336
+ opened ? "warn" : "info",
337
+ opened
338
+ ? `events from ${url} stopped (${gaveUp ? "the browser will not reconnect" : "the browser is reconnecting"}); asking the server every second meanwhile`
339
+ : `this VS Code's webview cannot open an event stream to ${url} (${errors} attempt${errors === 1 ? "" : "s"} failed); following the server by asking it every second instead`,
340
+ );
341
+ this.startPolling(dev, base, generation);
342
+ }
343
+ if (!opened) {
344
+ // it never worked here: stop the browser's attempts (each one is an error in the console)
345
+ es.close();
346
+ if (this.events === es) this.events = undefined;
347
+ return;
348
+ }
349
+ // it worked and the browser gave it up: open another one in a while (the polling goes on until it is up)
350
+ if (gaveUp && this.streamTimer === undefined) {
351
+ this.streamTimer = this.later(() => {
352
+ this.streamTimer = undefined;
353
+ if (!current()) return;
354
+ es.close();
355
+ this.listen(dev, base, generation);
356
+ }, STREAM_RETRY_MS);
357
+ }
358
+ };
359
+ }
360
+
361
+ private reload(dev: DevEndpoint, why: string) {
362
+ this.deps.log("info", `${why}: loading the project again`);
363
+ this.stop();
364
+ void this.load(dev, ++this.generation);
365
+ }
366
+
367
+ /** A rebuild: read the bundle again. */
368
+ private async rebuilt(base: string, event: BundleEvent, generation: number) {
369
+ try {
370
+ const bundle = await this.get(new URL("uhd-bundle.json", base).href);
371
+ if (generation !== this.generation) return;
372
+ if (typeof event.version === "number") this.version = event.version;
373
+ this.deps.onBundle({ base, bundle, event });
374
+ this.resting = restingStatus(event);
375
+ this.deps.onStatus(this.resting);
376
+ } catch (err) {
377
+ if (generation !== this.generation) return;
378
+ this.deps.log("error", `GET ${new URL("uhd-bundle.json", base).href} after a rebuild failed: ${describeError(err)}`);
379
+ this.deps.onStatus({ message: `The rebuilt project could not be read from the dev server: ${describeError(err)}` });
380
+ }
381
+ }
382
+
383
+ private startPolling(dev: DevEndpoint, base: string, generation: number) {
384
+ this.polling = true;
385
+ this.pollFailedAt = undefined;
386
+ const tick = () => {
387
+ this.pollTimer = this.later(() => {
388
+ this.pollTimer = undefined;
389
+ if (generation !== this.generation || !this.polling) return;
390
+ void this.poll(dev, base, generation).then(() => {
391
+ if (generation === this.generation && this.polling) tick();
392
+ });
393
+ }, this.pollFailedAt === undefined ? POLL_MS : POLL_FAILING_MS);
394
+ };
395
+ tick();
396
+ }
397
+
398
+ private stopPolling() {
399
+ this.polling = false;
400
+ this.cancel(this.pollTimer);
401
+ this.pollTimer = undefined;
402
+ this.pollFailedAt = undefined;
403
+ }
404
+
405
+ /** Ask the server what it has: a new session or version is loaded, a build in progress is said. */
406
+ private async poll(dev: DevEndpoint, base: string, generation: number) {
407
+ let state: DevState & { building?: boolean };
408
+ try {
409
+ state = ((await this.get(new URL("__protoboard/state", base).href)) ?? {}) as DevState & { building?: boolean };
410
+ } catch (e) {
411
+ if (generation !== this.generation) return;
412
+ if (this.pollFailedAt === undefined) {
413
+ this.pollFailedAt = this.now();
414
+ this.deps.log("error", `GET ${new URL("__protoboard/state", base).href} failed: ${describeError(e)}`);
415
+ }
416
+ // a short gap says nothing
417
+ if (!this.lostShown && this.now() - this.pollFailedAt >= (this.deps.graceMs ?? 4000)) {
418
+ this.lostShown = true;
419
+ this.deps.onStatus({ message: `Lost the dev server at ${new URL(base).host}: ${describeError(e)}. Retrying…`, busy: true, problem: true });
420
+ }
421
+ return;
422
+ }
423
+ if (generation !== this.generation) return;
424
+ if (this.pollFailedAt !== undefined) {
425
+ this.deps.log("info", `the dev server at ${base} answers again after ${((this.now() - this.pollFailedAt) / 1000).toFixed(1)} s`);
426
+ this.pollFailedAt = undefined;
427
+ }
428
+ if (this.lostShown) {
429
+ this.lostShown = false;
430
+ this.deps.onStatus(this.resting);
431
+ }
432
+ if (state.session && dev.session && state.session !== dev.session) return this.reload(dev, `the dev server at ${base} restarted (session ${state.session})`);
433
+ if (typeof state.version === "number" && state.version !== this.version) {
434
+ await this.rebuilt(base, { version: state.version, reason: "poll", diagnostics: state.diagnostics, failed: state.failed, message: state.message, partStatus: state.partStatus }, generation);
435
+ this.saidBuilding = false;
436
+ } else if (state.building && !this.saidBuilding) {
437
+ this.saidBuilding = true;
438
+ this.deps.onStatus({ message: "rebuilding", busy: true });
439
+ } else if (!state.building && this.saidBuilding) {
440
+ this.saidBuilding = false;
441
+ this.deps.onStatus(this.resting);
442
+ }
443
+ }
444
+ }