@openbrf/display 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.
Files changed (69) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSE-EXCEPTION.md +28 -0
  3. package/README.md +109 -2
  4. package/dist/contract.d.ts +244 -0
  5. package/dist/contract.d.ts.map +1 -0
  6. package/dist/contract.js +54 -0
  7. package/dist/contract.js.map +1 -0
  8. package/dist/fetch-loop.d.ts +87 -0
  9. package/dist/fetch-loop.d.ts.map +1 -0
  10. package/dist/fetch-loop.js +351 -0
  11. package/dist/fetch-loop.js.map +1 -0
  12. package/dist/fit.d.ts +44 -0
  13. package/dist/fit.d.ts.map +1 -0
  14. package/dist/fit.js +257 -0
  15. package/dist/fit.js.map +1 -0
  16. package/dist/index.d.ts +23 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +18 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/parse.d.ts +39 -0
  21. package/dist/parse.d.ts.map +1 -0
  22. package/dist/parse.js +494 -0
  23. package/dist/parse.js.map +1 -0
  24. package/dist/renderer.d.ts +74 -0
  25. package/dist/renderer.d.ts.map +1 -0
  26. package/dist/renderer.js +473 -0
  27. package/dist/renderer.js.map +1 -0
  28. package/dist/rotation.d.ts +129 -0
  29. package/dist/rotation.d.ts.map +1 -0
  30. package/dist/rotation.js +330 -0
  31. package/dist/rotation.js.map +1 -0
  32. package/dist/slides.d.ts +36 -0
  33. package/dist/slides.d.ts.map +1 -0
  34. package/dist/slides.js +333 -0
  35. package/dist/slides.js.map +1 -0
  36. package/dist/styles.d.ts +7 -0
  37. package/dist/styles.d.ts.map +1 -0
  38. package/dist/styles.js +104 -0
  39. package/dist/styles.js.map +1 -0
  40. package/dist/text.d.ts +16 -0
  41. package/dist/text.d.ts.map +1 -0
  42. package/dist/text.js +62 -0
  43. package/dist/text.js.map +1 -0
  44. package/dist/time.d.ts +39 -0
  45. package/dist/time.d.ts.map +1 -0
  46. package/dist/time.js +94 -0
  47. package/dist/time.js.map +1 -0
  48. package/dist/translate.d.ts +18 -0
  49. package/dist/translate.d.ts.map +1 -0
  50. package/dist/translate.js +35 -0
  51. package/dist/translate.js.map +1 -0
  52. package/dist/words.d.ts +41 -0
  53. package/dist/words.d.ts.map +1 -0
  54. package/dist/words.js +73 -0
  55. package/dist/words.js.map +1 -0
  56. package/package.json +49 -3
  57. package/src/contract.ts +279 -0
  58. package/src/fetch-loop.ts +472 -0
  59. package/src/fit.ts +348 -0
  60. package/src/index.ts +77 -0
  61. package/src/parse.ts +605 -0
  62. package/src/renderer.ts +688 -0
  63. package/src/rotation.ts +508 -0
  64. package/src/slides.ts +583 -0
  65. package/src/styles.ts +110 -0
  66. package/src/text.ts +65 -0
  67. package/src/time.ts +147 -0
  68. package/src/translate.ts +56 -0
  69. package/src/words.ts +91 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * The fetch loop: the request for screen content, and what is done with each
3
+ * answer, as the table under "Pairing" in ADR 0034 says it.
4
+ *
5
+ * | Answer | What the loop does |
6
+ * | ------------------------------------------------------------- | -------------------------------------------------------------------------- |
7
+ * | 200 | Replaces the cache whole and shows the document. |
8
+ * | 304 | Shows the cached document, now confirmed. |
9
+ * | 401, `realm="device"` and `device-token-invalid` | Clears the cache, stops, and says so: the caller forgets the token. |
10
+ * | 404 | Clears the cache and says the screen is paused; asks again every minute. |
11
+ * | 429 | Keeps what is shown and asks again after `Retry-After`, within a minute. |
12
+ * | Anything else, a body that is not a document, a network error | Shows the cache for 24 hours since it was fetched, then says disconnected. |
13
+ *
14
+ * Where the content is cached, and what a revocation wipes besides it, is the
15
+ * caller's, because a shell holds more than the display page does.
16
+ */
17
+ import type { ScreenContent } from "./contract.ts";
18
+ import { SCREEN_CONTENT_LIMITS } from "./contract.ts";
19
+ import { parseScreenContent } from "./parse.ts";
20
+
21
+ /** What the loop keeps between fetches. */
22
+ export interface CachedScreenContent {
23
+ content: ScreenContent;
24
+ /** The strong ETag the document came with, or null. */
25
+ etag: string | null;
26
+ /** ISO 8601 instant of the last 200 or 304. */
27
+ fetchedAt: string;
28
+ }
29
+
30
+ /** Where the content is kept, such as `localStorage`. The caller's to choose. */
31
+ export interface ScreenContentCache {
32
+ read(): CachedScreenContent | null;
33
+ write(entry: CachedScreenContent): void;
34
+ clear(): void;
35
+ }
36
+
37
+ /** How fresh the content handed to `onContent` is. */
38
+ export interface FetchedStatus {
39
+ fetchedAt: Date;
40
+ /** True when the instance could not be reached and this is the last good content. */
41
+ stale: boolean;
42
+ }
43
+
44
+ export interface FetchLoopOptions {
45
+ /** The absolute content URL, query included. The token is never put in it. */
46
+ url: string;
47
+ /** The device token, read before each request; null when there is none. */
48
+ token: () => string | null;
49
+ cache: ScreenContentCache;
50
+ /** Content to show: fresh, or the last good content while the instance cannot be reached. */
51
+ onContent(content: ScreenContent, status: FetchedStatus): void;
52
+ /**
53
+ * The instance refused the token (401 with `realm="device"` and
54
+ * `device-token-invalid`), or there is no token. The loop has cleared the
55
+ * cache and stopped; the caller forgets whatever else it holds, the token
56
+ * first.
57
+ */
58
+ onRevoked(): void;
59
+ /**
60
+ * 404: the route is switched off. The loop has cleared the cache, and asks
61
+ * again every 60 seconds. Without it, a 404 is told as `onDisconnected(null)`.
62
+ */
63
+ onPaused?(): void;
64
+ /**
65
+ * Nothing to show: no content was ever fetched, or the last was fetched 24
66
+ * hours ago or more. The loop asks again every 60 seconds.
67
+ */
68
+ onDisconnected(lastFetchedAt: Date | null): void;
69
+ fetch?: typeof fetch;
70
+ now?: () => Date;
71
+ setTimeout?: (callback: () => void, ms: number) => unknown;
72
+ clearTimeout?: (handle: unknown) => void;
73
+ }
74
+
75
+ /** A running fetch loop. */
76
+ export interface FetchLoop {
77
+ /** Stops the loop; a request in flight is aborted and its answer ignored. */
78
+ stop(): void;
79
+ /** Fetches now, unless a request is already in flight. */
80
+ refreshNow(): void;
81
+ }
82
+
83
+ const SECOND = 1000;
84
+ /** How long the last good content may be shown, from when it was fetched. */
85
+ export const STALE_CONTENT_LIMIT_MS = 24 * 60 * 60 * SECOND;
86
+ /** How long one request may take before it counts as a network failure. */
87
+ const REQUEST_TIMEOUT_MS = 30 * SECOND;
88
+ const RETRY_SECONDS = 60;
89
+ /** A cache entry stamped this far in the future is a clock gone wrong, not fresh content. */
90
+ const CLOCK_SKEW_MS = 5 * 60 * SECOND;
91
+
92
+ /**
93
+ * Starts fetching screen content: at once, then `refreshSeconds` after each
94
+ * 200 or 304, held to 30 to 60, and 60 seconds after anything else.
95
+ *
96
+ * Each request is `GET url` with the token in `Authorization: Bearer`, the
97
+ * cached ETag in `If-None-Match`, no cookie (`credentials: "omit"`) and no
98
+ * HTTP cache (`cache: "no-store"`). Only one is ever in flight. A cache entry
99
+ * younger than 24 hours is shown at once, as stale, before the first answer.
100
+ */
101
+ export function startFetchLoop(options: FetchLoopOptions): FetchLoop {
102
+ const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
103
+ const now = (): number => (options.now ?? (() => new Date()))().getTime();
104
+ const schedule =
105
+ options.setTimeout ??
106
+ ((callback: () => void, ms: number): unknown => setTimeout(callback, ms));
107
+ const unschedule =
108
+ options.clearTimeout ??
109
+ ((handle: unknown): void =>
110
+ clearTimeout(handle as ReturnType<typeof setTimeout>));
111
+
112
+ let stopped = false;
113
+ let timer: unknown = null;
114
+ let inFlight: AbortController | null = null;
115
+ /** After a 304 with nothing cached, the next request asks unconditionally. */
116
+ let unconditional = false;
117
+ /** The last successful fetch, remembered after the cache is cleared. */
118
+ let lastFetchedAt: number | null = null;
119
+
120
+ /** The cache entry, when it is still a document this package reads. */
121
+ function readCache(): {
122
+ entry: CachedScreenContent;
123
+ fetchedAt: number;
124
+ } | null {
125
+ let entry: CachedScreenContent | null;
126
+ try {
127
+ entry = options.cache.read();
128
+ } catch {
129
+ return null;
130
+ }
131
+ if (entry === null) {
132
+ return null;
133
+ }
134
+ const fetchedAt = Date.parse(entry.fetchedAt);
135
+ try {
136
+ if (!Number.isFinite(fetchedAt)) {
137
+ throw new Error("fetchedAt is not an instant.");
138
+ }
139
+ const content = parseScreenContent(entry.content);
140
+ return {
141
+ entry: {
142
+ content,
143
+ etag: typeof entry.etag === "string" ? entry.etag : null,
144
+ fetchedAt: entry.fetchedAt,
145
+ },
146
+ fetchedAt,
147
+ };
148
+ } catch {
149
+ options.cache.clear();
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function fresh(fetchedAt: number): boolean {
155
+ const age = now() - fetchedAt;
156
+ return age > -CLOCK_SKEW_MS && age < STALE_CONTENT_LIMIT_MS;
157
+ }
158
+
159
+ function stop(): void {
160
+ stopped = true;
161
+ if (timer !== null) {
162
+ unschedule(timer);
163
+ timer = null;
164
+ }
165
+ inFlight?.abort();
166
+ inFlight = null;
167
+ }
168
+
169
+ function revoke(): void {
170
+ stop();
171
+ options.cache.clear();
172
+ options.onRevoked();
173
+ }
174
+
175
+ function next(seconds: number): void {
176
+ if (stopped) {
177
+ return;
178
+ }
179
+ if (timer !== null) {
180
+ unschedule(timer);
181
+ }
182
+ timer = schedule(() => {
183
+ timer = null;
184
+ void request();
185
+ }, seconds * SECOND);
186
+ }
187
+
188
+ function remember(fetchedAt: number): void {
189
+ lastFetchedAt = Math.max(lastFetchedAt ?? fetchedAt, fetchedAt);
190
+ }
191
+
192
+ /** Any answer but 200, 304, a revoking 401, 404 and 429. */
193
+ function unreachable(): void {
194
+ const cached = readCache();
195
+ if (cached !== null) {
196
+ remember(cached.fetchedAt);
197
+ }
198
+ if (cached !== null && fresh(cached.fetchedAt)) {
199
+ options.onContent(cached.entry.content, {
200
+ fetchedAt: new Date(cached.fetchedAt),
201
+ stale: true,
202
+ });
203
+ } else {
204
+ options.cache.clear();
205
+ options.onDisconnected(
206
+ lastFetchedAt === null ? null : new Date(lastFetchedAt),
207
+ );
208
+ }
209
+ next(RETRY_SECONDS);
210
+ }
211
+
212
+ function shown(content: ScreenContent, fetchedAt: number): void {
213
+ remember(fetchedAt);
214
+ options.onContent(content, {
215
+ fetchedAt: new Date(fetchedAt),
216
+ stale: false,
217
+ });
218
+ next(
219
+ Math.min(
220
+ SCREEN_CONTENT_LIMITS.refreshSecondsMax,
221
+ Math.max(
222
+ SCREEN_CONTENT_LIMITS.refreshSecondsMin,
223
+ content.refreshSeconds,
224
+ ),
225
+ ),
226
+ );
227
+ }
228
+
229
+ async function request(): Promise<void> {
230
+ if (stopped || inFlight !== null) {
231
+ return;
232
+ }
233
+ const token = options.token();
234
+ if (token === null || token === "") {
235
+ revoke();
236
+ return;
237
+ }
238
+ const cached = readCache();
239
+ const headers: Record<string, string> = {
240
+ Accept: "application/json",
241
+ Authorization: `Bearer ${token}`,
242
+ };
243
+ if (cached !== null && cached.entry.etag !== null && !unconditional) {
244
+ headers["If-None-Match"] = cached.entry.etag;
245
+ }
246
+ const controller = new AbortController();
247
+ inFlight = controller;
248
+ const timeout = schedule(() => controller.abort(), REQUEST_TIMEOUT_MS);
249
+ let answer: Answer;
250
+ try {
251
+ const response = await fetchImpl(options.url, {
252
+ method: "GET",
253
+ headers,
254
+ cache: "no-store",
255
+ credentials: "omit",
256
+ mode: "cors",
257
+ redirect: "error",
258
+ signal: controller.signal,
259
+ });
260
+ answer = await read(response, now());
261
+ } catch {
262
+ answer = { kind: "other" };
263
+ } finally {
264
+ unschedule(timeout);
265
+ }
266
+ if (stopped || inFlight !== controller) {
267
+ return;
268
+ }
269
+ inFlight = null;
270
+ handle(answer, cached);
271
+ }
272
+
273
+ function handle(
274
+ answer: Answer,
275
+ cached: { entry: CachedScreenContent; fetchedAt: number } | null,
276
+ ): void {
277
+ switch (answer.kind) {
278
+ case "content": {
279
+ const fetchedAt = now();
280
+ unconditional = false;
281
+ options.cache.write({
282
+ content: answer.content,
283
+ etag: answer.etag,
284
+ fetchedAt: new Date(fetchedAt).toISOString(),
285
+ });
286
+ shown(answer.content, fetchedAt);
287
+ return;
288
+ }
289
+ case "not-modified": {
290
+ if (cached === null) {
291
+ unconditional = true;
292
+ unreachable();
293
+ return;
294
+ }
295
+ const fetchedAt = now();
296
+ options.cache.write({
297
+ ...cached.entry,
298
+ fetchedAt: new Date(fetchedAt).toISOString(),
299
+ });
300
+ shown(cached.entry.content, fetchedAt);
301
+ return;
302
+ }
303
+ case "revoked":
304
+ revoke();
305
+ return;
306
+ case "paused":
307
+ options.cache.clear();
308
+ if (options.onPaused === undefined) {
309
+ options.onDisconnected(null);
310
+ } else {
311
+ options.onPaused();
312
+ }
313
+ next(RETRY_SECONDS);
314
+ return;
315
+ case "slow-down": {
316
+ const current = readCache();
317
+ if (current !== null && !fresh(current.fetchedAt)) {
318
+ // Slowing down does not stretch the 24 hours.
319
+ unreachable();
320
+ return;
321
+ }
322
+ next(answer.retryAfter);
323
+ return;
324
+ }
325
+ case "other":
326
+ unreachable();
327
+ return;
328
+ }
329
+ }
330
+
331
+ const cachedAtStart = readCache();
332
+ if (cachedAtStart !== null) {
333
+ remember(cachedAtStart.fetchedAt);
334
+ if (fresh(cachedAtStart.fetchedAt)) {
335
+ options.onContent(cachedAtStart.entry.content, {
336
+ fetchedAt: new Date(cachedAtStart.fetchedAt),
337
+ stale: true,
338
+ });
339
+ } else {
340
+ options.cache.clear();
341
+ }
342
+ }
343
+ void request();
344
+
345
+ return {
346
+ stop,
347
+ refreshNow() {
348
+ if (stopped || inFlight !== null) {
349
+ return;
350
+ }
351
+ if (timer !== null) {
352
+ unschedule(timer);
353
+ timer = null;
354
+ }
355
+ void request();
356
+ },
357
+ };
358
+ }
359
+
360
+ type Answer =
361
+ | { kind: "content"; content: ScreenContent; etag: string | null }
362
+ | { kind: "not-modified" }
363
+ | { kind: "revoked" }
364
+ | { kind: "paused" }
365
+ | { kind: "slow-down"; retryAfter: number }
366
+ | { kind: "other" };
367
+
368
+ async function read(response: Response, now: number): Promise<Answer> {
369
+ switch (response.status) {
370
+ case 200: {
371
+ const text = await response.text();
372
+ if (
373
+ new TextEncoder().encode(text).byteLength >
374
+ SCREEN_CONTENT_LIMITS.documentBytes
375
+ ) {
376
+ return { kind: "other" };
377
+ }
378
+ try {
379
+ return {
380
+ kind: "content",
381
+ content: parseScreenContent(JSON.parse(text)),
382
+ etag: response.headers.get("ETag"),
383
+ };
384
+ } catch {
385
+ return { kind: "other" };
386
+ }
387
+ }
388
+ case 304:
389
+ return { kind: "not-modified" };
390
+ case 401:
391
+ return (await revokes(response))
392
+ ? { kind: "revoked" }
393
+ : { kind: "other" };
394
+ case 404:
395
+ return { kind: "paused" };
396
+ case 429:
397
+ return {
398
+ kind: "slow-down",
399
+ retryAfter: retryAfter(response.headers.get("Retry-After"), now),
400
+ };
401
+ default:
402
+ return { kind: "other" };
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Whether a 401 is the instance refusing the token: the challenge names
408
+ * `realm="device"`, and the body's `reason` (or the challenge's `error`) is
409
+ * `device-token-invalid`. Any other 401, such as one from a proxy in between,
410
+ * is not a reason to forget the token.
411
+ */
412
+ async function revokes(response: Response): Promise<boolean> {
413
+ const challenge = parseChallenge(response.headers.get("WWW-Authenticate"));
414
+ if (challenge === null || challenge.get("realm") !== "device") {
415
+ return false;
416
+ }
417
+ if (challenge.get("error") === "device-token-invalid") {
418
+ return true;
419
+ }
420
+ try {
421
+ const body: unknown = await response.json();
422
+ return (
423
+ typeof body === "object" &&
424
+ body !== null &&
425
+ (body as { reason?: unknown }).reason === "device-token-invalid"
426
+ );
427
+ } catch {
428
+ return false;
429
+ }
430
+ }
431
+
432
+ /** The parameters of a `Bearer` challenge, or null when there is none. */
433
+ function parseChallenge(header: string | null): Map<string, string> | null {
434
+ if (header === null) {
435
+ return null;
436
+ }
437
+ const match = /^\s*Bearer(?:\s+(.*))?$/i.exec(header);
438
+ if (match === null) {
439
+ return null;
440
+ }
441
+ const parameters = new Map<string, string>();
442
+ const pattern = /([A-Za-z_][\w-]*)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/g;
443
+ for (const parameter of (match[1] ?? "").matchAll(pattern)) {
444
+ const name = parameter[1];
445
+ if (name !== undefined) {
446
+ parameters.set(
447
+ name.toLowerCase(),
448
+ parameter[2]?.replace(/\\(.)/g, "$1") ?? parameter[3] ?? "",
449
+ );
450
+ }
451
+ }
452
+ return parameters;
453
+ }
454
+
455
+ /** `Retry-After` in seconds, held to 1 to 60; 60 when absent or unreadable. */
456
+ function retryAfter(header: string | null, now: number): number {
457
+ if (header === null) {
458
+ return RETRY_SECONDS;
459
+ }
460
+ const value = header.trim();
461
+ let seconds: number;
462
+ if (/^\d+$/.test(value)) {
463
+ seconds = Number(value);
464
+ } else {
465
+ const date = Date.parse(value);
466
+ if (!Number.isFinite(date)) {
467
+ return RETRY_SECONDS;
468
+ }
469
+ seconds = Math.ceil((date - now) / SECOND);
470
+ }
471
+ return Math.min(RETRY_SECONDS, Math.max(1, seconds));
472
+ }