anthodev/lapin-futepublic Fork
⑂ ffd859a
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: establish Alloy app foundation (#10)Unverified

Cédric Anthony committed 2026-08-30T17:23:37+02:00 Browse files
ffd859a parent: 4c1dead
added .gitignore +11 -0
new file mode 100644
@@ -0,0 +1,11 @@
1+node_modules/
2+packages/watch/build/
3+packages/watch/src/pkjs/
4+docs/
5+*.pbw
6+.DS_Store
7+
8+graft/
9+
10+AGENTS.md
11+.agents/
new file mode 100644
@@ -0,0 +1,11 @@
1+node_modules/
2+packages/watch/build/
3+packages/watch/src/pkjs/
4+docs/
5+*.pbw
6+.DS_Store
7+
8+graft/
9+
10+AGENTS.md
11+.agents/
added .ignore +5 -0
new file mode 100644
@@ -0,0 +1,5 @@
1+# graft's cards are gitignored but should stay greppable: ripgrep reads
2+# .ignore before .gitignore, so this re-admits the tree to search only.
3+!graft/
4+graft/.cache/
5+graft/.graph/
new file mode 100644
@@ -0,0 +1,5 @@
1+# graft's cards are gitignored but should stay greppable: ripgrep reads
2+# .ignore before .gitignore, so this re-admits the tree to search only.
3+!graft/
4+graft/.cache/
5+graft/.graph/
added .node-version +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+24.18.0
new file mode 100644
@@ -0,0 +1 @@
1+24.18.0
added README.md +130 -0
new file mode 100644
@@ -0,0 +1,130 @@
1+<h1 align="center">Lapin Futé</h1>
2+
3+<p align="center">
4+ <strong>Île-de-France departures on Pebble, without background traffic.</strong>
5+ <br>
6+ One Alloy watchapp, one phone companion, and a narrow PRIM credential boundary.
7+</p>
8+
9+<p align="center">
10+ <a href="#about">About</a>
11+ ·
12+ <a href="#architecture">Architecture</a>
13+ ·
14+ <a href="#build-from-source">Build</a>
15+ ·
16+ <a href="#quality-gates">Quality gates</a>
17+</p>
18+
19+<p align="center">
20+ <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
21+ <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript">
22+ <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24">
23+ <img src="https://img.shields.io/badge/status-foundation-F59E0B" alt="Foundation status">
24+</p>
25+
26+> [!IMPORTANT]
27+> Lapin Futé is a foundation implementation for [issue #2](https://github.com/Anthodev/lapin-fute/issues/2). Host-side contracts and the recorded fixture are present. Emulator, Pebble Time 2, XS-memory, and battery evidence remain external release gates because the Pebble CLI is not installed on the current development host.
28+
29+## About
30+
31+Lapin Futé shows IDFM/PRIM departures on a Pebble watch. The watch runs one [Alloy](https://developer.repebble.com/guides/js-apps/) embedded-JavaScript application with a Piu interface. It targets `emery` and `gabbro`; there is no native watchapp fallback.
32+
33+```text
34+Minimal Piu fixture view
35+ │ symbolic AppMessage aliases
36+ ▼
37+PebbleKit JS companion ── 60 s public cache ──► HTTPS backend ──► PRIM
38+ │
39+ └── phone-local favorites + personal key
40+```
41+
42+**The watch never receives the personal PRIM key and never starts network or background work.**
43+
44+## Highlights
45+
46+- **One watch runtime.** Alloy/XS owns protocol staging, localization, and the minimal recorded-fixture renderer. The only C file is the standard `mdbl.c` VM bootstrap.
47+- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` explicitly maps those aliases to the frozen IDs `0..24`.
48+- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched sequence cannot replace the last complete state.
49+- **Foundation round trip.** The watch sends one `APP_OPEN` fixture request after its first complete configuration. The companion returns the recorded result without XHR.
50+- **Responsive fixture layout.** Geometry comes from screen dimensions and roundness. Emery shows up to three recorded departure rows; Gabbro shows up to two.
51+- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, configuration storage, and the exact personal-key boundary.
52+
53+## Architecture
54+
55+| Component | Owns | Must not |
56+|---|---|---|
57+| Backend | Exact-host PRIM relay, response validation, normalization, non-secret shared cache | persist credentials or user preferences |
58+| Configuration page | Favorite editing and masked key replacement/removal | receive the stored key when reopening |
59+| PebbleKit JS | One atomic phone-local record, HTTPS requests, cache, watch synchronization, active-watch language | parse watch layout or expose the key over AppMessage |
60+| Alloy watchapp | Fixture request, protocol staging, minimal bilingual Piu rendering | implement favorite browsing or production consultation behavior; use networking, WakeUp, Worker, timeline, touch polling, or secret storage |
61+
62+The embedded modules are `main`, `controller`, `model`, `protocol`, `message-queue`, `localization`, `ui`, and `contracts`. Every module appears in `packages/watch/src/embeddedjs/manifest.json`.
63+
64+## Targets and requirements
65+
66+| Layer | Pinned requirement |
67+|---|---|
68+| Watch targets | `emery` primary and physical Pebble Time 2; `gabbro` emulator |
69+| Firmware | 4.32 or newer |
70+| Pebble SDK | 4.33.1 |
71+| Pebble CLI | `pebble-tool` 5.0.40 |
72+| Host runtime | Node.js 24.18.0 |
73+| Watch UI | Piu, built-in Pebble fonts, `touchCount=0`, no media |
74+
75+See [platforms](docs/platforms.md) and [toolchain](docs/toolchain.md) for the exact proof matrix and commands.
76+
77+
78+## Build from source
79+
80+Install the pinned prerequisites in [docs/toolchain.md](docs/toolchain.md). No repository script installs or upgrades a toolchain.
81+
82+```sh
83+npm test
84+npm run measure:radio
85+npm run build
86+```
87+
88+`npm run build` runs the host contracts, measures the recorded radio fixture, generates the PebbleKit JS tree, builds the single Alloy package, then verifies the generated alias-to-ID map. Optional hosted endpoints are explicit, credential-free HTTPS build inputs:
89+
90+```sh
91+LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \
92+LAPIN_FUTE_BACKEND_URL=https://example.org/api/departures \
93+npm run build
94+```
95+
96+Personal PRIM keys are never build inputs.
97+
98+## Privacy
99+
100+The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, HTTPS `Authorization`, transient backend memory, then the exact PRIM `apikey` header. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence.
101+
102+Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request.
103+
104+## Quality gates
105+
106+| Gate | Budget |
107+|---|---|
108+| Alloy app binary | At most 120 KiB on each target |
109+| Loaded image and RAM | At most 56 KiB each |
110+| XS arenas | At least 25% free at the worst measured point |
111+| Settled XS drift | At most 1 KiB over the final ten of 50 cycles |
112+| Two-departure refresh | At most 7 dictionaries and 2048 encoded bytes |
113+| Watch request | At most 192 encoded bytes |
114+| Phone-to-watch dictionary | At most 768 encoded bytes |
115+| Watch unsent queue | Four messages, at most once, one write per `onWritable` |
116+
117+The physical 24-hour A/B battery gate remains unchanged. See [performance](docs/performance.md) and the truthful [evidence ledger](docs/evidence.md).
118+
119+## Documentation
120+
121+- [Contracts](docs/contracts.md)
122+- [Toolchain](docs/toolchain.md)
123+- [Platforms](docs/platforms.md)
124+- [Performance and battery](docs/performance.md)
125+- [Localization](docs/localization.md)
126+- [Evidence ledger](docs/evidence.md)
127+
128+## Contributing
129+
130+Keep the frozen v1 contracts, symbolic runtime payloads, event-only policy, and secret boundary intact. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests and evidence-ledger updates.
new file mode 100644
@@ -0,0 +1,130 @@
1+<h1 align="center">Lapin Futé</h1>
2+
3+<p align="center">
4+ <strong>Île-de-France departures on Pebble, without background traffic.</strong>
5+ <br>
6+ One Alloy watchapp, one phone companion, and a narrow PRIM credential boundary.
7+</p>
8+
9+<p align="center">
10+ <a href="#about">About</a>
11+ ·
12+ <a href="#architecture">Architecture</a>
13+ ·
14+ <a href="#build-from-source">Build</a>
15+ ·
16+ <a href="#quality-gates">Quality gates</a>
17+</p>
18+
19+<p align="center">
20+ <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
21+ <img src="https://img.shields.io/badge/Alloy-embedded%20JavaScript-20232A" alt="Alloy embedded JavaScript">
22+ <img src="https://img.shields.io/badge/Node.js-24-339933?logo=nodedotjs&logoColor=white" alt="Node.js 24">
23+ <img src="https://img.shields.io/badge/status-foundation-F59E0B" alt="Foundation status">
24+</p>
25+
26+> [!IMPORTANT]
27+> Lapin Futé is a foundation implementation for [issue #2](https://github.com/Anthodev/lapin-fute/issues/2). Host-side contracts and the recorded fixture are present. Emulator, Pebble Time 2, XS-memory, and battery evidence remain external release gates because the Pebble CLI is not installed on the current development host.
28+
29+## About
30+
31+Lapin Futé shows IDFM/PRIM departures on a Pebble watch. The watch runs one [Alloy](https://developer.repebble.com/guides/js-apps/) embedded-JavaScript application with a Piu interface. It targets `emery` and `gabbro`; there is no native watchapp fallback.
32+
33+```text
34+Minimal Piu fixture view
35+ │ symbolic AppMessage aliases
36+ ▼
37+PebbleKit JS companion ── 60 s public cache ──► HTTPS backend ──► PRIM
38+ │
39+ └── phone-local favorites + personal key
40+```
41+
42+**The watch never receives the personal PRIM key and never starts network or background work.**
43+
44+## Highlights
45+
46+- **One watch runtime.** Alloy/XS owns protocol staging, localization, and the minimal recorded-fixture renderer. The only C file is the standard `mdbl.c` VM bootstrap.
47+- **Frozen wire IDs.** Runtime code uses `SCHEMA_VERSION`, `REQUEST_ID`, and the other symbolic aliases. `packages/watch/package.json` explicitly maps those aliases to the frozen IDs `0..24`.
48+- **Atomic updates.** Configuration and result sequences stay private until their matching commit. A malformed, late, incomplete, or mismatched sequence cannot replace the last complete state.
49+- **Foundation round trip.** The watch sends one `APP_OPEN` fixture request after its first complete configuration. The companion returns the recorded result without XHR.
50+- **Responsive fixture layout.** Geometry comes from screen dimensions and roundness. Emery shows up to three recorded departure rows; Gabbro shows up to two.
51+- **Phone-owned data access.** PebbleKit JS retains ACK-serialized sending, the 60-second cache, configuration storage, and the exact personal-key boundary.
52+
53+## Architecture
54+
55+| Component | Owns | Must not |
56+|---|---|---|
57+| Backend | Exact-host PRIM relay, response validation, normalization, non-secret shared cache | persist credentials or user preferences |
58+| Configuration page | Favorite editing and masked key replacement/removal | receive the stored key when reopening |
59+| PebbleKit JS | One atomic phone-local record, HTTPS requests, cache, watch synchronization, active-watch language | parse watch layout or expose the key over AppMessage |
60+| Alloy watchapp | Fixture request, protocol staging, minimal bilingual Piu rendering | implement favorite browsing or production consultation behavior; use networking, WakeUp, Worker, timeline, touch polling, or secret storage |
61+
62+The embedded modules are `main`, `controller`, `model`, `protocol`, `message-queue`, `localization`, `ui`, and `contracts`. Every module appears in `packages/watch/src/embeddedjs/manifest.json`.
63+
64+## Targets and requirements
65+
66+| Layer | Pinned requirement |
67+|---|---|
68+| Watch targets | `emery` primary and physical Pebble Time 2; `gabbro` emulator |
69+| Firmware | 4.32 or newer |
70+| Pebble SDK | 4.33.1 |
71+| Pebble CLI | `pebble-tool` 5.0.40 |
72+| Host runtime | Node.js 24.18.0 |
73+| Watch UI | Piu, built-in Pebble fonts, `touchCount=0`, no media |
74+
75+See [platforms](docs/platforms.md) and [toolchain](docs/toolchain.md) for the exact proof matrix and commands.
76+
77+
78+## Build from source
79+
80+Install the pinned prerequisites in [docs/toolchain.md](docs/toolchain.md). No repository script installs or upgrades a toolchain.
81+
82+```sh
83+npm test
84+npm run measure:radio
85+npm run build
86+```
87+
88+`npm run build` runs the host contracts, measures the recorded radio fixture, generates the PebbleKit JS tree, builds the single Alloy package, then verifies the generated alias-to-ID map. Optional hosted endpoints are explicit, credential-free HTTPS build inputs:
89+
90+```sh
91+LAPIN_FUTE_CONFIG_URL=https://example.org/config/ \
92+LAPIN_FUTE_BACKEND_URL=https://example.org/api/departures \
93+npm run build
94+```
95+
96+Personal PRIM keys are never build inputs.
97+
98+## Privacy
99+
100+The personal key follows one path: masked configuration control, changed-value close fragment, phone-local `localStorage`, HTTPS `Authorization`, transient backend memory, then the exact PRIM `apikey` header. It does not enter watch messages, favorites, results, caches, URLs, logs, metrics, traces, errors, or public evidence.
101+
102+Lapin Futé has no telemetry. The watch has no network module and starts no periodic or background request.
103+
104+## Quality gates
105+
106+| Gate | Budget |
107+|---|---|
108+| Alloy app binary | At most 120 KiB on each target |
109+| Loaded image and RAM | At most 56 KiB each |
110+| XS arenas | At least 25% free at the worst measured point |
111+| Settled XS drift | At most 1 KiB over the final ten of 50 cycles |
112+| Two-departure refresh | At most 7 dictionaries and 2048 encoded bytes |
113+| Watch request | At most 192 encoded bytes |
114+| Phone-to-watch dictionary | At most 768 encoded bytes |
115+| Watch unsent queue | Four messages, at most once, one write per `onWritable` |
116+
117+The physical 24-hour A/B battery gate remains unchanged. See [performance](docs/performance.md) and the truthful [evidence ledger](docs/evidence.md).
118+
119+## Documentation
120+
121+- [Contracts](docs/contracts.md)
122+- [Toolchain](docs/toolchain.md)
123+- [Platforms](docs/platforms.md)
124+- [Performance and battery](docs/performance.md)
125+- [Localization](docs/localization.md)
126+- [Evidence ledger](docs/evidence.md)
127+
128+## Contributing
129+
130+Keep the frozen v1 contracts, symbolic runtime payloads, event-only policy, and secret boundary intact. Changes to message IDs, targets, toolchain pins, budgets, or ownership boundaries need matching contract tests and evidence-ledger updates.
added fixtures/departures/foundation.json +34 -0
new file mode 100644
@@ -0,0 +1,34 @@
1+{
2+ "favorite": {
3+ "schemaVersion": 1,
4+ "id": "foundation-home",
5+ "serviceId": "opaque:foundation:service:1",
6+ "displayName": "Commute home",
7+ "stopLabel": "Châtelet",
8+ "lineLabel": "Métro 1",
9+ "destinationLabel": "La Défense",
10+ "sortOrder": 0
11+ },
12+ "result": {
13+ "schemaVersion": 1,
14+ "requestId": "foundation-request",
15+ "favoriteId": "foundation-home",
16+ "fetchedAt": 1788000000,
17+ "sourceUpdatedAt": 1787999990,
18+ "freshness": "REALTIME",
19+ "departures": [
20+ {
21+ "expectedAt": 1788000120,
22+ "aimedAt": 1788000100,
23+ "minutes": 2,
24+ "status": "DELAYED",
25+ "nextIntervalMinutes": 4
26+ },
27+ {
28+ "expectedAt": 1788000360,
29+ "minutes": 6,
30+ "status": "ON_TIME"
31+ }
32+ ]
33+ }
34+}
new file mode 100644
@@ -0,0 +1,34 @@
1+{
2+ "favorite": {
3+ "schemaVersion": 1,
4+ "id": "foundation-home",
5+ "serviceId": "opaque:foundation:service:1",
6+ "displayName": "Commute home",
7+ "stopLabel": "Châtelet",
8+ "lineLabel": "Métro 1",
9+ "destinationLabel": "La Défense",
10+ "sortOrder": 0
11+ },
12+ "result": {
13+ "schemaVersion": 1,
14+ "requestId": "foundation-request",
15+ "favoriteId": "foundation-home",
16+ "fetchedAt": 1788000000,
17+ "sourceUpdatedAt": 1787999990,
18+ "freshness": "REALTIME",
19+ "departures": [
20+ {
21+ "expectedAt": 1788000120,
22+ "aimedAt": 1788000100,
23+ "minutes": 2,
24+ "status": "DELAYED",
25+ "nextIntervalMinutes": 4
26+ },
27+ {
28+ "expectedAt": 1788000360,
29+ "minutes": 6,
30+ "status": "ON_TIME"
31+ }
32+ ]
33+ }
34+}
added package.json +20 -0
new file mode 100644
@@ -0,0 +1,20 @@
1+{
2+ "name": "lapin-fute",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "workspaces": [
7+ "packages/*"
8+ ],
9+ "engines": {
10+ "node": "24.18.0"
11+ },
12+ "scripts": {
13+ "build": "node scripts/build.mjs",
14+ "measure:radio": "node scripts/measure-radio.mjs",
15+ "test": "node scripts/test.mjs",
16+ "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
17+ "test:watch": "node --test packages/watch/test/*.test.js",
18+ "verify:watch-build": "node scripts/verify-watch-build.mjs"
19+ }
20+}
new file mode 100644
@@ -0,0 +1,20 @@
1+{
2+ "name": "lapin-fute",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "workspaces": [
7+ "packages/*"
8+ ],
9+ "engines": {
10+ "node": "24.18.0"
11+ },
12+ "scripts": {
13+ "build": "node scripts/build.mjs",
14+ "measure:radio": "node scripts/measure-radio.mjs",
15+ "test": "node scripts/test.mjs",
16+ "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
17+ "test:watch": "node --test packages/watch/test/*.test.js",
18+ "verify:watch-build": "node scripts/verify-watch-build.mjs"
19+ }
20+}
added packages/backend/package.json +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+{
2+ "name": "@lapin-fute/backend",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "exports": "./src/index.ts",
7+ "scripts": {
8+ "test": "node --test test/backend.test.ts"
9+ }
10+}
new file mode 100644
@@ -0,0 +1,10 @@
1+{
2+ "name": "@lapin-fute/backend",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "exports": "./src/index.ts",
7+ "scripts": {
8+ "test": "node --test test/backend.test.ts"
9+ }
10+}
added packages/backend/src/index.ts +708 -0
new file mode 100644
@@ -0,0 +1,708 @@
1+import {
2+ CACHE_FRESH_SECONDS,
3+ LIMITS,
4+ PRIM_ORIGIN,
5+ SCHEMA_VERSION,
6+ isDepartureResult,
7+ isErrorResult,
8+ isPersonalApiKey,
9+ utf8Bytes,
10+ type Departure,
11+ type DepartureResult,
12+ type ErrorCode,
13+ type ErrorResult,
14+} from "../../contracts/src/index.ts";
15+
16+export const REDACTED_SECRET = "[REDACTED]" as const;
17+
18+export const BACKEND_ERROR_CODE = {
19+ API_KEY_REQUIRED: "API_KEY_REQUIRED",
20+ API_KEY_INVALID: "API_KEY_INVALID",
21+ INVALID_PRIM_URL: "INVALID_PRIM_URL",
22+ PRIM_ORIGIN_REJECTED: "PRIM_ORIGIN_REJECTED",
23+ PRIM_CREDENTIAL_IN_URL: "PRIM_CREDENTIAL_IN_URL",
24+ PRIM_TIMEOUT: "PRIM_TIMEOUT",
25+ PRIM_REDIRECT_REJECTED: "PRIM_REDIRECT_REJECTED",
26+ PRIM_RESPONSE_TOO_LARGE: "PRIM_RESPONSE_TOO_LARGE",
27+ PRIM_INVALID_RESPONSE: "PRIM_INVALID_RESPONSE",
28+ PRIM_REQUEST_FAILED: "PRIM_REQUEST_FAILED",
29+} as const;
30+
31+export type BackendErrorCode = (typeof BACKEND_ERROR_CODE)[keyof typeof BACKEND_ERROR_CODE];
32+
33+const ERROR_MESSAGES: Readonly<Record<BackendErrorCode, string>> = {
34+ API_KEY_REQUIRED: "Authorization header is required",
35+ API_KEY_INVALID: "Authorization header must contain one valid Bearer credential",
36+ INVALID_PRIM_URL: "PRIM target URL is invalid",
37+ PRIM_ORIGIN_REJECTED: "PRIM target origin is not allowed",
38+ PRIM_CREDENTIAL_IN_URL: "PRIM credentials are forbidden in URLs",
39+ PRIM_TIMEOUT: `PRIM request timed out after ${LIMITS.httpTimeoutMs} ms`,
40+ PRIM_REDIRECT_REJECTED: "PRIM redirects are not allowed",
41+ PRIM_RESPONSE_TOO_LARGE: `PRIM response exceeded ${LIMITS.httpResponseBytes} bytes`,
42+ PRIM_INVALID_RESPONSE: "PRIM returned an invalid response",
43+ PRIM_REQUEST_FAILED: "PRIM request failed",
44+};
45+
46+export class BackendBoundaryError extends Error {
47+ readonly code: BackendErrorCode;
48+
49+ constructor(code: BackendErrorCode) {
50+ super(ERROR_MESSAGES[code]);
51+ this.name = "BackendBoundaryError";
52+ this.code = code;
53+ }
54+}
55+
56+const BEARER_PREFIX = "Bearer ";
57+
58+export function parseBearerAuthorization(value: unknown): string {
59+ if (value === undefined || value === null || value === "") {
60+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.API_KEY_REQUIRED);
61+ }
62+
63+ if (
64+ typeof value !== "string"
65+ || !value.startsWith(BEARER_PREFIX)
66+ || value.length === BEARER_PREFIX.length
67+ || value.charCodeAt(BEARER_PREFIX.length) === 0x20
68+ ) {
69+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.API_KEY_INVALID);
70+ }
71+
72+ const apiKey = value.slice(BEARER_PREFIX.length);
73+ if (!isPersonalApiKey(apiKey)) {
74+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.API_KEY_INVALID);
75+ }
76+
77+ return apiKey;
78+}
79+
80+function addEncodedVariants(variants: Set<string>, secret: string): void {
81+ try {
82+ const encoded = encodeURIComponent(secret);
83+ const lowerEscapes = encoded.replace(/%[0-9A-F]{2}/gu, (escape) => escape.toLowerCase());
84+ variants.add(encoded);
85+ variants.add(lowerEscapes);
86+ variants.add(encoded.replaceAll("%20", "+"));
87+ variants.add(lowerEscapes.replaceAll("%20", "+").replaceAll("%20".toLowerCase(), "+"));
88+ } catch {
89+ // encodeURIComponent rejects lone surrogates. The literal and JSON forms still get redacted.
90+ }
91+
92+ const json = JSON.stringify(secret);
93+ if (json !== undefined) {
94+ variants.add(json.slice(1, -1));
95+ }
96+}
97+
98+export function redactSecrets(value: string, secrets: readonly string[]): string {
99+ const variants = new Set<string>();
100+ for (const secret of secrets) {
101+ if (secret.length === 0) continue;
102+ variants.add(secret);
103+ addEncodedVariants(variants, secret);
104+ }
105+
106+ const ordered = [...variants].filter(Boolean).sort((left, right) => right.length - left.length);
107+ let redacted = value;
108+ for (const secret of ordered) {
109+ redacted = redacted.split(secret).join(REDACTED_SECRET);
110+ }
111+ return redacted;
112+}
113+
114+export function redactError(error: unknown, secrets: readonly string[]): string {
115+ let message = "Unknown PRIM failure";
116+ if (error instanceof Error) {
117+ message = error.message || error.name || message;
118+ } else if (typeof error === "string" && error.length > 0) {
119+ message = error;
120+ }
121+ return redactSecrets(message, secrets);
122+}
123+
124+export type RelayLogger = (message: string) => void;
125+
126+export function createRedactingLogger(
127+ logger: RelayLogger | undefined,
128+ secrets: readonly string[],
129+): RelayLogger {
130+ if (logger === undefined) return () => undefined;
131+
132+ return (message) => {
133+ try {
134+ logger(redactSecrets(message, secrets));
135+ } catch {
136+ // Logging must neither expose a credential nor alter relay behavior.
137+ }
138+ };
139+}
140+
141+export interface PrimFetchInit {
142+ readonly method: "GET";
143+ readonly headers: Readonly<{ apikey: string }>;
144+ readonly redirect: "manual";
145+ readonly credentials: "omit";
146+ readonly cache: "no-store";
147+ readonly signal: AbortSignal;
148+}
149+
150+export interface PrimFetchResponse {
151+ readonly status: number;
152+ readonly redirected?: boolean;
153+ readonly type?: string;
154+ readonly body: ReadableStream<Uint8Array> | null;
155+}
156+
157+export type PrimFetch = (target: URL, init: PrimFetchInit) => Promise<PrimFetchResponse>;
158+
159+export interface RelayTimer {
160+ set(callback: () => void, delayMs: number): unknown;
161+ clear(handle: unknown): void;
162+}
163+
164+export interface PrimRelayDependencies {
165+ readonly fetch: PrimFetch;
166+ readonly logger?: RelayLogger;
167+ readonly timer?: RelayTimer;
168+}
169+
170+export interface PrimRelayRequest {
171+ readonly target: string | URL;
172+ readonly authorization: unknown;
173+}
174+
175+export interface PrimRelayResponse {
176+ readonly status: number;
177+ readonly bytes: Uint8Array;
178+}
179+
180+const SYSTEM_TIMER: RelayTimer = {
181+ set(callback, delayMs) {
182+ return setTimeout(callback, delayMs);
183+ },
184+ clear(handle) {
185+ clearTimeout(handle as number);
186+ },
187+};
188+
189+const MAX_URL_DECODE_PASSES = 4;
190+const PERCENT_ENCODED_BYTES = /(?:%[0-9A-Fa-f]{2})+/gu;
191+const URL_COMPONENT_DECODER = new TextDecoder();
192+
193+function decodePercentBytes(value: string): string {
194+ const bytes = new Uint8Array(value.length / 3);
195+ for (let index = 0; index < bytes.length; index += 1) {
196+ const offset = index * 3;
197+ bytes[index] = Number.parseInt(value.slice(offset + 1, offset + 3), 16);
198+ }
199+ return URL_COMPONENT_DECODER.decode(bytes);
200+}
201+
202+function decodePercentLayer(value: string): string {
203+ return value.replace(PERCENT_ENCODED_BYTES, decodePercentBytes);
204+}
205+
206+function decodedUrlComponent(value: string): string {
207+ let candidate = value;
208+ for (let pass = 0; pass < MAX_URL_DECODE_PASSES; pass += 1) {
209+ const next = decodePercentLayer(candidate);
210+ if (next === candidate) break;
211+ candidate = next;
212+ }
213+ return candidate;
214+}
215+
216+function containsDecodedSubstring(value: string, apiKey: string): boolean {
217+ let candidate = value;
218+ for (let pass = 0; pass <= MAX_URL_DECODE_PASSES; pass += 1) {
219+ if (candidate.includes(apiKey)) return true;
220+ if (pass === MAX_URL_DECODE_PASSES) break;
221+ const next = decodePercentLayer(candidate);
222+ if (next === candidate) break;
223+ candidate = next;
224+ }
225+ return false;
226+}
227+
228+function containsCredential(target: URL, apiKey: string): boolean {
229+ if (target.username.length > 0 || target.password.length > 0) return true;
230+
231+ for (const component of [target.href, target.pathname, target.search, target.hash]) {
232+ if (containsDecodedSubstring(component, apiKey)) return true;
233+ }
234+
235+ for (const [name, value] of target.searchParams) {
236+ const normalizedName = decodedUrlComponent(name).toLowerCase();
237+ if (normalizedName === "apikey" || normalizedName === "authorization") return true;
238+ if (containsDecodedSubstring(name, apiKey) || containsDecodedSubstring(value, apiKey)) {
239+ return true;
240+ }
241+ }
242+
243+ return false;
244+}
245+
246+function parsePrimTarget(value: string | URL, apiKey: string): URL {
247+ let target: URL;
248+ try {
249+ target = new URL(value instanceof URL ? value.href : value);
250+ } catch {
251+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.INVALID_PRIM_URL);
252+ }
253+
254+ if (target.origin !== PRIM_ORIGIN) {
255+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_ORIGIN_REJECTED);
256+ }
257+ if (containsCredential(target, apiKey)) {
258+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_CREDENTIAL_IN_URL);
259+ }
260+
261+ return target;
262+}
263+
264+function isRedirect(response: PrimFetchResponse): boolean {
265+ return response.redirected === true
266+ || response.type === "opaqueredirect"
267+ || (Number.isInteger(response.status) && response.status >= 300 && response.status < 400);
268+}
269+
270+async function readBoundedBody(
271+ body: ReadableStream<Uint8Array> | null,
272+ controller: AbortController,
273+): Promise<Uint8Array> {
274+ if (body === null) return new Uint8Array(0);
275+ if (typeof body.getReader !== "function") {
276+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
277+ }
278+
279+ const reader = body.getReader();
280+ const chunks: Uint8Array[] = [];
281+ let byteLength = 0;
282+
283+ try {
284+ while (true) {
285+ const item = await reader.read();
286+ if (item.done) break;
287+ if (!(item.value instanceof Uint8Array)) {
288+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
289+ }
290+ if (item.value.byteLength > LIMITS.httpResponseBytes - byteLength) {
291+ controller.abort();
292+ try {
293+ void reader.cancel().catch(() => undefined);
294+ } catch {
295+ // The size error remains authoritative even if cancellation fails.
296+ }
297+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_RESPONSE_TOO_LARGE);
298+ }
299+ if (item.value.byteLength === 0) continue;
300+ byteLength += item.value.byteLength;
301+ chunks.push(item.value);
302+ }
303+ } finally {
304+ try {
305+ reader.releaseLock();
306+ } catch {
307+ // A failed release cannot expose data and must not replace the relay result.
308+ }
309+ }
310+
311+ if (chunks.length === 0) return new Uint8Array(0);
312+ if (chunks.length === 1) {
313+ const onlyChunk = chunks[0];
314+ if (onlyChunk !== undefined) return onlyChunk;
315+ }
316+
317+ const bytes = new Uint8Array(byteLength);
318+ let offset = 0;
319+ for (const chunk of chunks) {
320+ bytes.set(chunk, offset);
321+ offset += chunk.byteLength;
322+ }
323+ return bytes;
324+}
325+
326+async function performRelay(
327+ target: URL,
328+ apiKey: string,
329+ fetch: PrimFetch,
330+ controller: AbortController,
331+): Promise<PrimRelayResponse> {
332+ const response = await fetch(target, {
333+ method: "GET",
334+ headers: Object.freeze({ apikey: apiKey }),
335+ redirect: "manual",
336+ credentials: "omit",
337+ cache: "no-store",
338+ signal: controller.signal,
339+ });
340+
341+ if (response === null || typeof response !== "object") {
342+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
343+ }
344+ if (isRedirect(response)) {
345+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_REDIRECT_REJECTED);
346+ }
347+ if (!Number.isInteger(response.status) || response.status < 100 || response.status > 599) {
348+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
349+ }
350+
351+ return {
352+ status: response.status,
353+ bytes: await readBoundedBody(response.body, controller),
354+ };
355+}
356+
357+export async function relayPrimRequest(
358+ request: PrimRelayRequest,
359+ dependencies: PrimRelayDependencies,
360+): Promise<PrimRelayResponse> {
361+ const apiKey = parseBearerAuthorization(request.authorization);
362+ const target = parsePrimTarget(request.target, apiKey);
363+ const controller = new AbortController();
364+ const timer = dependencies.timer ?? SYSTEM_TIMER;
365+ const log = createRedactingLogger(dependencies.logger, [apiKey]);
366+
367+ let timedOut = false;
368+ let timerHandle: unknown;
369+ let timerScheduled = false;
370+ const timeout = new Promise<never>((_, reject) => {
371+ timerHandle = timer.set(() => {
372+ timedOut = true;
373+ controller.abort();
374+ reject(new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_TIMEOUT));
375+ }, LIMITS.httpTimeoutMs);
376+ timerScheduled = true;
377+ });
378+
379+ try {
380+ return await Promise.race([
381+ performRelay(target, apiKey, dependencies.fetch, controller),
382+ timeout,
383+ ]);
384+ } catch (failure) {
385+ const error = failure instanceof BackendBoundaryError
386+ ? failure
387+ : new BackendBoundaryError(
388+ timedOut ? BACKEND_ERROR_CODE.PRIM_TIMEOUT : BACKEND_ERROR_CODE.PRIM_REQUEST_FAILED,
389+ );
390+ log(`PRIM relay failed (${error.code})`);
391+ throw error;
392+ } finally {
393+ if (timerScheduled) {
394+ try {
395+ timer.clear(timerHandle);
396+ } catch {
397+ // Timer cleanup cannot replace a completed relay result.
398+ }
399+ }
400+ }
401+}
402+
403+export interface DepartureRequestBody {
404+ readonly schemaVersion: typeof SCHEMA_VERSION;
405+ readonly requestId: string;
406+ readonly favoriteId: string;
407+ readonly serviceId: string;
408+}
409+
410+export interface DepartureHandlerRequest {
411+ readonly authorization: unknown;
412+ readonly body: unknown;
413+}
414+
415+export type PublicDepartureData = Omit<DepartureResult, "requestId" | "favoriteId">;
416+export type PublicDepartureError = Omit<ErrorResult, "requestId" | "favoriteId">;
417+export type DepartureResolverResult = PublicDepartureData | PublicDepartureError;
418+
419+export interface DepartureResolverRequest {
420+ readonly serviceId: string;
421+ readonly apiKey: string;
422+}
423+
424+export type DepartureResolver = (
425+ request: DepartureResolverRequest,
426+) => Promise<DepartureResolverResult>;
427+
428+export interface DepartureHandlerDependencies {
429+ readonly resolve: DepartureResolver;
430+ readonly nowMilliseconds?: () => number;
431+}
432+
433+export type DepartureHandlerResponse = DepartureResult | ErrorResult;
434+
435+interface DepartureBinding {
436+ readonly requestId: string;
437+ readonly favoriteId?: string;
438+}
439+
440+interface CachedDeparture {
441+ readonly storedAtMilliseconds: number;
442+ readonly data: PublicDepartureData;
443+}
444+
445+const DEPARTURE_REQUEST_KEYS = ["schemaVersion", "requestId", "favoriteId", "serviceId"] as const;
446+const PUBLIC_DEPARTURE_KEYS = [
447+ "schemaVersion",
448+ "fetchedAt",
449+ "sourceUpdatedAt",
450+ "freshness",
451+ "departures",
452+] as const;
453+const PUBLIC_ERROR_KEYS = [
454+ "schemaVersion",
455+ "code",
456+ "occurredAt",
457+ "retryAfterSeconds",
458+] as const;
459+const INVALID_REQUEST_ID = "invalid-request";
460+const UINT32_MAX = 0xffff_ffff;
461+const DEPARTURE_CACHE_FRESH_MS = CACHE_FRESH_SECONDS * 1_000;
462+const DEPARTURE_CACHE = new Map<string, CachedDeparture>();
463+
464+function record(value: unknown): value is Record<string, unknown> {
465+ return typeof value === "object" && value !== null && !Array.isArray(value);
466+}
467+
468+function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
469+ return Object.keys(value).every((key) => allowed.includes(key));
470+}
471+
472+function boundedId(value: unknown): value is string {
473+ return typeof value === "string"
474+ && utf8Bytes(value) >= 1
475+ && utf8Bytes(value) <= LIMITS.idUtf8Bytes;
476+}
477+
478+function departureBinding(value: unknown): DepartureBinding {
479+ if (!record(value)) return { requestId: INVALID_REQUEST_ID };
480+
481+ try {
482+ const requestId = boundedId(value.requestId) ? value.requestId : INVALID_REQUEST_ID;
483+ const favoriteId = boundedId(value.favoriteId) ? value.favoriteId : undefined;
484+ return favoriteId === undefined ? { requestId } : { requestId, favoriteId };
485+ } catch {
486+ return { requestId: INVALID_REQUEST_ID };
487+ }
488+}
489+
490+function isDepartureRequestBody(value: unknown): value is DepartureRequestBody {
491+ if (!record(value)) return false;
492+
493+ try {
494+ return hasOnlyKeys(value, DEPARTURE_REQUEST_KEYS)
495+ && value.schemaVersion === SCHEMA_VERSION
496+ && boundedId(value.requestId)
497+ && boundedId(value.favoriteId)
498+ && boundedId(value.serviceId);
499+ } catch {
500+ return false;
501+ }
502+}
503+
504+function isPublicDepartureData(value: unknown): value is PublicDepartureData {
505+ if (!record(value)) return false;
506+
507+ try {
508+ return hasOnlyKeys(value, PUBLIC_DEPARTURE_KEYS)
509+ && isDepartureResult({
510+ ...value,
511+ requestId: INVALID_REQUEST_ID,
512+ favoriteId: INVALID_REQUEST_ID,
513+ });
514+ } catch {
515+ return false;
516+ }
517+}
518+
519+function isPublicDepartureError(value: unknown): value is PublicDepartureError {
520+ if (!record(value)) return false;
521+
522+ try {
523+ return hasOnlyKeys(value, PUBLIC_ERROR_KEYS)
524+ && isErrorResult({ ...value, requestId: INVALID_REQUEST_ID });
525+ } catch {
526+ return false;
527+ }
528+}
529+
530+function nowMilliseconds(dependencies: DepartureHandlerDependencies): number {
531+ const fallback = Date.now();
532+ try {
533+ const value = dependencies.nowMilliseconds?.() ?? fallback;
534+ return Number.isFinite(value) && value >= 0 ? value : fallback;
535+ } catch {
536+ return fallback;
537+ }
538+}
539+
540+function occurredAt(milliseconds: number): number {
541+ return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000));
542+}
543+
544+function stableError(
545+ binding: DepartureBinding,
546+ code: ErrorCode,
547+ milliseconds: number,
548+): ErrorResult {
549+ const result: ErrorResult = {
550+ schemaVersion: SCHEMA_VERSION,
551+ requestId: binding.requestId,
552+ code,
553+ occurredAt: occurredAt(milliseconds),
554+ };
555+ if (binding.favoriteId !== undefined) result.favoriteId = binding.favoriteId;
556+ return result;
557+}
558+
559+function copyDeparture(value: Departure): Departure {
560+ const copy: Departure = {
561+ expectedAt: value.expectedAt,
562+ minutes: value.minutes,
563+ status: value.status,
564+ };
565+ if (value.aimedAt !== undefined) copy.aimedAt = value.aimedAt;
566+ if (value.nextIntervalMinutes !== undefined) {
567+ copy.nextIntervalMinutes = value.nextIntervalMinutes;
568+ }
569+ return copy;
570+}
571+
572+function normalizePublicDeparture(value: PublicDepartureData): PublicDepartureData {
573+ const normalized: PublicDepartureData = {
574+ schemaVersion: SCHEMA_VERSION,
575+ fetchedAt: value.fetchedAt,
576+ freshness: value.freshness,
577+ departures: value.departures.map(copyDeparture),
578+ };
579+ if (value.sourceUpdatedAt !== undefined) {
580+ normalized.sourceUpdatedAt = value.sourceUpdatedAt;
581+ }
582+ return normalized;
583+}
584+
585+function bindDeparture(
586+ data: PublicDepartureData,
587+ request: DepartureRequestBody,
588+): DepartureResult {
589+ const result: DepartureResult = {
590+ schemaVersion: SCHEMA_VERSION,
591+ requestId: request.requestId,
592+ favoriteId: request.favoriteId,
593+ fetchedAt: data.fetchedAt,
594+ freshness: data.freshness,
595+ departures: data.departures.map(copyDeparture),
596+ };
597+ if (data.sourceUpdatedAt !== undefined) result.sourceUpdatedAt = data.sourceUpdatedAt;
598+ return result;
599+}
600+
601+function bindPublicError(
602+ error: PublicDepartureError,
603+ request: DepartureRequestBody,
604+): ErrorResult {
605+ const result: ErrorResult = {
606+ schemaVersion: SCHEMA_VERSION,
607+ requestId: request.requestId,
608+ favoriteId: request.favoriteId,
609+ code: error.code,
610+ occurredAt: error.occurredAt,
611+ };
612+ if (error.retryAfterSeconds !== undefined) {
613+ result.retryAfterSeconds = error.retryAfterSeconds;
614+ }
615+ return result;
616+}
617+
618+function cachedDeparture(
619+ serviceId: string,
620+ milliseconds: number,
621+): PublicDepartureData | undefined {
622+ const cached = DEPARTURE_CACHE.get(serviceId);
623+ if (cached === undefined) return undefined;
624+
625+ const age = milliseconds - cached.storedAtMilliseconds;
626+ if (age < 0 || age >= DEPARTURE_CACHE_FRESH_MS) {
627+ DEPARTURE_CACHE.delete(serviceId);
628+ return undefined;
629+ }
630+ return cached.data;
631+}
632+
633+export async function handleDepartureRequest(
634+ request: DepartureHandlerRequest,
635+ dependencies: DepartureHandlerDependencies,
636+): Promise<DepartureHandlerResponse> {
637+ let authorization: unknown;
638+ let bodyValue: unknown;
639+ try {
640+ authorization = request.authorization;
641+ bodyValue = request.body;
642+ } catch {
643+ authorization = undefined;
644+ bodyValue = undefined;
645+ }
646+
647+ const binding = departureBinding(bodyValue);
648+ let apiKey: string;
649+ try {
650+ apiKey = parseBearerAuthorization(authorization);
651+ } catch (failure) {
652+ const code: ErrorCode = failure instanceof BackendBoundaryError
653+ && failure.code === BACKEND_ERROR_CODE.API_KEY_REQUIRED
654+ ? "API_KEY_REQUIRED"
655+ : "API_KEY_INVALID";
656+ return stableError(binding, code, nowMilliseconds(dependencies));
657+ }
658+
659+ if (!isDepartureRequestBody(bodyValue)) {
660+ apiKey = "";
661+ return stableError(binding, "INVALID_SERVICE", nowMilliseconds(dependencies));
662+ }
663+
664+ const requestBody = bodyValue;
665+ const currentMilliseconds = nowMilliseconds(dependencies);
666+ const cached = cachedDeparture(requestBody.serviceId, currentMilliseconds);
667+ if (cached !== undefined) {
668+ apiKey = "";
669+ const result = bindDeparture(cached, requestBody);
670+ return isDepartureResult(result)
671+ ? result
672+ : stableError(binding, "INVALID_RESPONSE", currentMilliseconds);
673+ }
674+
675+ let resolved: unknown;
676+ try {
677+ resolved = await dependencies.resolve({
678+ serviceId: requestBody.serviceId,
679+ apiKey,
680+ });
681+ } catch {
682+ return stableError(binding, "SOURCE_UNAVAILABLE", nowMilliseconds(dependencies));
683+ } finally {
684+ apiKey = "";
685+ }
686+
687+ if (isPublicDepartureData(resolved)) {
688+ const normalized = normalizePublicDeparture(resolved);
689+ const storedAtMilliseconds = nowMilliseconds(dependencies);
690+ DEPARTURE_CACHE.set(requestBody.serviceId, {
691+ storedAtMilliseconds,
692+ data: normalized,
693+ });
694+ const result = bindDeparture(normalized, requestBody);
695+ return isDepartureResult(result)
696+ ? result
697+ : stableError(binding, "INVALID_RESPONSE", storedAtMilliseconds);
698+ }
699+
700+ if (isPublicDepartureError(resolved)) {
701+ const error = bindPublicError(resolved, requestBody);
702+ return isErrorResult(error)
703+ ? error
704+ : stableError(binding, "INVALID_RESPONSE", nowMilliseconds(dependencies));
705+ }
706+
707+ return stableError(binding, "INVALID_RESPONSE", nowMilliseconds(dependencies));
708+}
new file mode 100644
@@ -0,0 +1,708 @@
1+import {
2+ CACHE_FRESH_SECONDS,
3+ LIMITS,
4+ PRIM_ORIGIN,
5+ SCHEMA_VERSION,
6+ isDepartureResult,
7+ isErrorResult,
8+ isPersonalApiKey,
9+ utf8Bytes,
10+ type Departure,
11+ type DepartureResult,
12+ type ErrorCode,
13+ type ErrorResult,
14+} from "../../contracts/src/index.ts";
15+
16+export const REDACTED_SECRET = "[REDACTED]" as const;
17+
18+export const BACKEND_ERROR_CODE = {
19+ API_KEY_REQUIRED: "API_KEY_REQUIRED",
20+ API_KEY_INVALID: "API_KEY_INVALID",
21+ INVALID_PRIM_URL: "INVALID_PRIM_URL",
22+ PRIM_ORIGIN_REJECTED: "PRIM_ORIGIN_REJECTED",
23+ PRIM_CREDENTIAL_IN_URL: "PRIM_CREDENTIAL_IN_URL",
24+ PRIM_TIMEOUT: "PRIM_TIMEOUT",
25+ PRIM_REDIRECT_REJECTED: "PRIM_REDIRECT_REJECTED",
26+ PRIM_RESPONSE_TOO_LARGE: "PRIM_RESPONSE_TOO_LARGE",
27+ PRIM_INVALID_RESPONSE: "PRIM_INVALID_RESPONSE",
28+ PRIM_REQUEST_FAILED: "PRIM_REQUEST_FAILED",
29+} as const;
30+
31+export type BackendErrorCode = (typeof BACKEND_ERROR_CODE)[keyof typeof BACKEND_ERROR_CODE];
32+
33+const ERROR_MESSAGES: Readonly<Record<BackendErrorCode, string>> = {
34+ API_KEY_REQUIRED: "Authorization header is required",
35+ API_KEY_INVALID: "Authorization header must contain one valid Bearer credential",
36+ INVALID_PRIM_URL: "PRIM target URL is invalid",
37+ PRIM_ORIGIN_REJECTED: "PRIM target origin is not allowed",
38+ PRIM_CREDENTIAL_IN_URL: "PRIM credentials are forbidden in URLs",
39+ PRIM_TIMEOUT: `PRIM request timed out after ${LIMITS.httpTimeoutMs} ms`,
40+ PRIM_REDIRECT_REJECTED: "PRIM redirects are not allowed",
41+ PRIM_RESPONSE_TOO_LARGE: `PRIM response exceeded ${LIMITS.httpResponseBytes} bytes`,
42+ PRIM_INVALID_RESPONSE: "PRIM returned an invalid response",
43+ PRIM_REQUEST_FAILED: "PRIM request failed",
44+};
45+
46+export class BackendBoundaryError extends Error {
47+ readonly code: BackendErrorCode;
48+
49+ constructor(code: BackendErrorCode) {
50+ super(ERROR_MESSAGES[code]);
51+ this.name = "BackendBoundaryError";
52+ this.code = code;
53+ }
54+}
55+
56+const BEARER_PREFIX = "Bearer ";
57+
58+export function parseBearerAuthorization(value: unknown): string {
59+ if (value === undefined || value === null || value === "") {
60+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.API_KEY_REQUIRED);
61+ }
62+
63+ if (
64+ typeof value !== "string"
65+ || !value.startsWith(BEARER_PREFIX)
66+ || value.length === BEARER_PREFIX.length
67+ || value.charCodeAt(BEARER_PREFIX.length) === 0x20
68+ ) {
69+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.API_KEY_INVALID);
70+ }
71+
72+ const apiKey = value.slice(BEARER_PREFIX.length);
73+ if (!isPersonalApiKey(apiKey)) {
74+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.API_KEY_INVALID);
75+ }
76+
77+ return apiKey;
78+}
79+
80+function addEncodedVariants(variants: Set<string>, secret: string): void {
81+ try {
82+ const encoded = encodeURIComponent(secret);
83+ const lowerEscapes = encoded.replace(/%[0-9A-F]{2}/gu, (escape) => escape.toLowerCase());
84+ variants.add(encoded);
85+ variants.add(lowerEscapes);
86+ variants.add(encoded.replaceAll("%20", "+"));
87+ variants.add(lowerEscapes.replaceAll("%20", "+").replaceAll("%20".toLowerCase(), "+"));
88+ } catch {
89+ // encodeURIComponent rejects lone surrogates. The literal and JSON forms still get redacted.
90+ }
91+
92+ const json = JSON.stringify(secret);
93+ if (json !== undefined) {
94+ variants.add(json.slice(1, -1));
95+ }
96+}
97+
98+export function redactSecrets(value: string, secrets: readonly string[]): string {
99+ const variants = new Set<string>();
100+ for (const secret of secrets) {
101+ if (secret.length === 0) continue;
102+ variants.add(secret);
103+ addEncodedVariants(variants, secret);
104+ }
105+
106+ const ordered = [...variants].filter(Boolean).sort((left, right) => right.length - left.length);
107+ let redacted = value;
108+ for (const secret of ordered) {
109+ redacted = redacted.split(secret).join(REDACTED_SECRET);
110+ }
111+ return redacted;
112+}
113+
114+export function redactError(error: unknown, secrets: readonly string[]): string {
115+ let message = "Unknown PRIM failure";
116+ if (error instanceof Error) {
117+ message = error.message || error.name || message;
118+ } else if (typeof error === "string" && error.length > 0) {
119+ message = error;
120+ }
121+ return redactSecrets(message, secrets);
122+}
123+
124+export type RelayLogger = (message: string) => void;
125+
126+export function createRedactingLogger(
127+ logger: RelayLogger | undefined,
128+ secrets: readonly string[],
129+): RelayLogger {
130+ if (logger === undefined) return () => undefined;
131+
132+ return (message) => {
133+ try {
134+ logger(redactSecrets(message, secrets));
135+ } catch {
136+ // Logging must neither expose a credential nor alter relay behavior.
137+ }
138+ };
139+}
140+
141+export interface PrimFetchInit {
142+ readonly method: "GET";
143+ readonly headers: Readonly<{ apikey: string }>;
144+ readonly redirect: "manual";
145+ readonly credentials: "omit";
146+ readonly cache: "no-store";
147+ readonly signal: AbortSignal;
148+}
149+
150+export interface PrimFetchResponse {
151+ readonly status: number;
152+ readonly redirected?: boolean;
153+ readonly type?: string;
154+ readonly body: ReadableStream<Uint8Array> | null;
155+}
156+
157+export type PrimFetch = (target: URL, init: PrimFetchInit) => Promise<PrimFetchResponse>;
158+
159+export interface RelayTimer {
160+ set(callback: () => void, delayMs: number): unknown;
161+ clear(handle: unknown): void;
162+}
163+
164+export interface PrimRelayDependencies {
165+ readonly fetch: PrimFetch;
166+ readonly logger?: RelayLogger;
167+ readonly timer?: RelayTimer;
168+}
169+
170+export interface PrimRelayRequest {
171+ readonly target: string | URL;
172+ readonly authorization: unknown;
173+}
174+
175+export interface PrimRelayResponse {
176+ readonly status: number;
177+ readonly bytes: Uint8Array;
178+}
179+
180+const SYSTEM_TIMER: RelayTimer = {
181+ set(callback, delayMs) {
182+ return setTimeout(callback, delayMs);
183+ },
184+ clear(handle) {
185+ clearTimeout(handle as number);
186+ },
187+};
188+
189+const MAX_URL_DECODE_PASSES = 4;
190+const PERCENT_ENCODED_BYTES = /(?:%[0-9A-Fa-f]{2})+/gu;
191+const URL_COMPONENT_DECODER = new TextDecoder();
192+
193+function decodePercentBytes(value: string): string {
194+ const bytes = new Uint8Array(value.length / 3);
195+ for (let index = 0; index < bytes.length; index += 1) {
196+ const offset = index * 3;
197+ bytes[index] = Number.parseInt(value.slice(offset + 1, offset + 3), 16);
198+ }
199+ return URL_COMPONENT_DECODER.decode(bytes);
200+}
201+
202+function decodePercentLayer(value: string): string {
203+ return value.replace(PERCENT_ENCODED_BYTES, decodePercentBytes);
204+}
205+
206+function decodedUrlComponent(value: string): string {
207+ let candidate = value;
208+ for (let pass = 0; pass < MAX_URL_DECODE_PASSES; pass += 1) {
209+ const next = decodePercentLayer(candidate);
210+ if (next === candidate) break;
211+ candidate = next;
212+ }
213+ return candidate;
214+}
215+
216+function containsDecodedSubstring(value: string, apiKey: string): boolean {
217+ let candidate = value;
218+ for (let pass = 0; pass <= MAX_URL_DECODE_PASSES; pass += 1) {
219+ if (candidate.includes(apiKey)) return true;
220+ if (pass === MAX_URL_DECODE_PASSES) break;
221+ const next = decodePercentLayer(candidate);
222+ if (next === candidate) break;
223+ candidate = next;
224+ }
225+ return false;
226+}
227+
228+function containsCredential(target: URL, apiKey: string): boolean {
229+ if (target.username.length > 0 || target.password.length > 0) return true;
230+
231+ for (const component of [target.href, target.pathname, target.search, target.hash]) {
232+ if (containsDecodedSubstring(component, apiKey)) return true;
233+ }
234+
235+ for (const [name, value] of target.searchParams) {
236+ const normalizedName = decodedUrlComponent(name).toLowerCase();
237+ if (normalizedName === "apikey" || normalizedName === "authorization") return true;
238+ if (containsDecodedSubstring(name, apiKey) || containsDecodedSubstring(value, apiKey)) {
239+ return true;
240+ }
241+ }
242+
243+ return false;
244+}
245+
246+function parsePrimTarget(value: string | URL, apiKey: string): URL {
247+ let target: URL;
248+ try {
249+ target = new URL(value instanceof URL ? value.href : value);
250+ } catch {
251+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.INVALID_PRIM_URL);
252+ }
253+
254+ if (target.origin !== PRIM_ORIGIN) {
255+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_ORIGIN_REJECTED);
256+ }
257+ if (containsCredential(target, apiKey)) {
258+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_CREDENTIAL_IN_URL);
259+ }
260+
261+ return target;
262+}
263+
264+function isRedirect(response: PrimFetchResponse): boolean {
265+ return response.redirected === true
266+ || response.type === "opaqueredirect"
267+ || (Number.isInteger(response.status) && response.status >= 300 && response.status < 400);
268+}
269+
270+async function readBoundedBody(
271+ body: ReadableStream<Uint8Array> | null,
272+ controller: AbortController,
273+): Promise<Uint8Array> {
274+ if (body === null) return new Uint8Array(0);
275+ if (typeof body.getReader !== "function") {
276+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
277+ }
278+
279+ const reader = body.getReader();
280+ const chunks: Uint8Array[] = [];
281+ let byteLength = 0;
282+
283+ try {
284+ while (true) {
285+ const item = await reader.read();
286+ if (item.done) break;
287+ if (!(item.value instanceof Uint8Array)) {
288+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
289+ }
290+ if (item.value.byteLength > LIMITS.httpResponseBytes - byteLength) {
291+ controller.abort();
292+ try {
293+ void reader.cancel().catch(() => undefined);
294+ } catch {
295+ // The size error remains authoritative even if cancellation fails.
296+ }
297+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_RESPONSE_TOO_LARGE);
298+ }
299+ if (item.value.byteLength === 0) continue;
300+ byteLength += item.value.byteLength;
301+ chunks.push(item.value);
302+ }
303+ } finally {
304+ try {
305+ reader.releaseLock();
306+ } catch {
307+ // A failed release cannot expose data and must not replace the relay result.
308+ }
309+ }
310+
311+ if (chunks.length === 0) return new Uint8Array(0);
312+ if (chunks.length === 1) {
313+ const onlyChunk = chunks[0];
314+ if (onlyChunk !== undefined) return onlyChunk;
315+ }
316+
317+ const bytes = new Uint8Array(byteLength);
318+ let offset = 0;
319+ for (const chunk of chunks) {
320+ bytes.set(chunk, offset);
321+ offset += chunk.byteLength;
322+ }
323+ return bytes;
324+}
325+
326+async function performRelay(
327+ target: URL,
328+ apiKey: string,
329+ fetch: PrimFetch,
330+ controller: AbortController,
331+): Promise<PrimRelayResponse> {
332+ const response = await fetch(target, {
333+ method: "GET",
334+ headers: Object.freeze({ apikey: apiKey }),
335+ redirect: "manual",
336+ credentials: "omit",
337+ cache: "no-store",
338+ signal: controller.signal,
339+ });
340+
341+ if (response === null || typeof response !== "object") {
342+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
343+ }
344+ if (isRedirect(response)) {
345+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_REDIRECT_REJECTED);
346+ }
347+ if (!Number.isInteger(response.status) || response.status < 100 || response.status > 599) {
348+ throw new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_INVALID_RESPONSE);
349+ }
350+
351+ return {
352+ status: response.status,
353+ bytes: await readBoundedBody(response.body, controller),
354+ };
355+}
356+
357+export async function relayPrimRequest(
358+ request: PrimRelayRequest,
359+ dependencies: PrimRelayDependencies,
360+): Promise<PrimRelayResponse> {
361+ const apiKey = parseBearerAuthorization(request.authorization);
362+ const target = parsePrimTarget(request.target, apiKey);
363+ const controller = new AbortController();
364+ const timer = dependencies.timer ?? SYSTEM_TIMER;
365+ const log = createRedactingLogger(dependencies.logger, [apiKey]);
366+
367+ let timedOut = false;
368+ let timerHandle: unknown;
369+ let timerScheduled = false;
370+ const timeout = new Promise<never>((_, reject) => {
371+ timerHandle = timer.set(() => {
372+ timedOut = true;
373+ controller.abort();
374+ reject(new BackendBoundaryError(BACKEND_ERROR_CODE.PRIM_TIMEOUT));
375+ }, LIMITS.httpTimeoutMs);
376+ timerScheduled = true;
377+ });
378+
379+ try {
380+ return await Promise.race([
381+ performRelay(target, apiKey, dependencies.fetch, controller),
382+ timeout,
383+ ]);
384+ } catch (failure) {
385+ const error = failure instanceof BackendBoundaryError
386+ ? failure
387+ : new BackendBoundaryError(
388+ timedOut ? BACKEND_ERROR_CODE.PRIM_TIMEOUT : BACKEND_ERROR_CODE.PRIM_REQUEST_FAILED,
389+ );
390+ log(`PRIM relay failed (${error.code})`);
391+ throw error;
392+ } finally {
393+ if (timerScheduled) {
394+ try {
395+ timer.clear(timerHandle);
396+ } catch {
397+ // Timer cleanup cannot replace a completed relay result.
398+ }
399+ }
400+ }
401+}
402+
403+export interface DepartureRequestBody {
404+ readonly schemaVersion: typeof SCHEMA_VERSION;
405+ readonly requestId: string;
406+ readonly favoriteId: string;
407+ readonly serviceId: string;
408+}
409+
410+export interface DepartureHandlerRequest {
411+ readonly authorization: unknown;
412+ readonly body: unknown;
413+}
414+
415+export type PublicDepartureData = Omit<DepartureResult, "requestId" | "favoriteId">;
416+export type PublicDepartureError = Omit<ErrorResult, "requestId" | "favoriteId">;
417+export type DepartureResolverResult = PublicDepartureData | PublicDepartureError;
418+
419+export interface DepartureResolverRequest {
420+ readonly serviceId: string;
421+ readonly apiKey: string;
422+}
423+
424+export type DepartureResolver = (
425+ request: DepartureResolverRequest,
426+) => Promise<DepartureResolverResult>;
427+
428+export interface DepartureHandlerDependencies {
429+ readonly resolve: DepartureResolver;
430+ readonly nowMilliseconds?: () => number;
431+}
432+
433+export type DepartureHandlerResponse = DepartureResult | ErrorResult;
434+
435+interface DepartureBinding {
436+ readonly requestId: string;
437+ readonly favoriteId?: string;
438+}
439+
440+interface CachedDeparture {
441+ readonly storedAtMilliseconds: number;
442+ readonly data: PublicDepartureData;
443+}
444+
445+const DEPARTURE_REQUEST_KEYS = ["schemaVersion", "requestId", "favoriteId", "serviceId"] as const;
446+const PUBLIC_DEPARTURE_KEYS = [
447+ "schemaVersion",
448+ "fetchedAt",
449+ "sourceUpdatedAt",
450+ "freshness",
451+ "departures",
452+] as const;
453+const PUBLIC_ERROR_KEYS = [
454+ "schemaVersion",
455+ "code",
456+ "occurredAt",
457+ "retryAfterSeconds",
458+] as const;
459+const INVALID_REQUEST_ID = "invalid-request";
460+const UINT32_MAX = 0xffff_ffff;
461+const DEPARTURE_CACHE_FRESH_MS = CACHE_FRESH_SECONDS * 1_000;
462+const DEPARTURE_CACHE = new Map<string, CachedDeparture>();
463+
464+function record(value: unknown): value is Record<string, unknown> {
465+ return typeof value === "object" && value !== null && !Array.isArray(value);
466+}
467+
468+function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
469+ return Object.keys(value).every((key) => allowed.includes(key));
470+}
471+
472+function boundedId(value: unknown): value is string {
473+ return typeof value === "string"
474+ && utf8Bytes(value) >= 1
475+ && utf8Bytes(value) <= LIMITS.idUtf8Bytes;
476+}
477+
478+function departureBinding(value: unknown): DepartureBinding {
479+ if (!record(value)) return { requestId: INVALID_REQUEST_ID };
480+
481+ try {
482+ const requestId = boundedId(value.requestId) ? value.requestId : INVALID_REQUEST_ID;
483+ const favoriteId = boundedId(value.favoriteId) ? value.favoriteId : undefined;
484+ return favoriteId === undefined ? { requestId } : { requestId, favoriteId };
485+ } catch {
486+ return { requestId: INVALID_REQUEST_ID };
487+ }
488+}
489+
490+function isDepartureRequestBody(value: unknown): value is DepartureRequestBody {
491+ if (!record(value)) return false;
492+
493+ try {
494+ return hasOnlyKeys(value, DEPARTURE_REQUEST_KEYS)
495+ && value.schemaVersion === SCHEMA_VERSION
496+ && boundedId(value.requestId)
497+ && boundedId(value.favoriteId)
498+ && boundedId(value.serviceId);
499+ } catch {
500+ return false;
501+ }
502+}
503+
504+function isPublicDepartureData(value: unknown): value is PublicDepartureData {
505+ if (!record(value)) return false;
506+
507+ try {
508+ return hasOnlyKeys(value, PUBLIC_DEPARTURE_KEYS)
509+ && isDepartureResult({
510+ ...value,
511+ requestId: INVALID_REQUEST_ID,
512+ favoriteId: INVALID_REQUEST_ID,
513+ });
514+ } catch {
515+ return false;
516+ }
517+}
518+
519+function isPublicDepartureError(value: unknown): value is PublicDepartureError {
520+ if (!record(value)) return false;
521+
522+ try {
523+ return hasOnlyKeys(value, PUBLIC_ERROR_KEYS)
524+ && isErrorResult({ ...value, requestId: INVALID_REQUEST_ID });
525+ } catch {
526+ return false;
527+ }
528+}
529+
530+function nowMilliseconds(dependencies: DepartureHandlerDependencies): number {
531+ const fallback = Date.now();
532+ try {
533+ const value = dependencies.nowMilliseconds?.() ?? fallback;
534+ return Number.isFinite(value) && value >= 0 ? value : fallback;
535+ } catch {
536+ return fallback;
537+ }
538+}
539+
540+function occurredAt(milliseconds: number): number {
541+ return Math.min(UINT32_MAX, Math.floor(milliseconds / 1_000));
542+}
543+
544+function stableError(
545+ binding: DepartureBinding,
546+ code: ErrorCode,
547+ milliseconds: number,
548+): ErrorResult {
549+ const result: ErrorResult = {
550+ schemaVersion: SCHEMA_VERSION,
551+ requestId: binding.requestId,
552+ code,
553+ occurredAt: occurredAt(milliseconds),
554+ };
555+ if (binding.favoriteId !== undefined) result.favoriteId = binding.favoriteId;
556+ return result;
557+}
558+
559+function copyDeparture(value: Departure): Departure {
560+ const copy: Departure = {
561+ expectedAt: value.expectedAt,
562+ minutes: value.minutes,
563+ status: value.status,
564+ };
565+ if (value.aimedAt !== undefined) copy.aimedAt = value.aimedAt;
566+ if (value.nextIntervalMinutes !== undefined) {
567+ copy.nextIntervalMinutes = value.nextIntervalMinutes;
568+ }
569+ return copy;
570+}
571+
572+function normalizePublicDeparture(value: PublicDepartureData): PublicDepartureData {
573+ const normalized: PublicDepartureData = {
574+ schemaVersion: SCHEMA_VERSION,
575+ fetchedAt: value.fetchedAt,
576+ freshness: value.freshness,
577+ departures: value.departures.map(copyDeparture),
578+ };
579+ if (value.sourceUpdatedAt !== undefined) {
580+ normalized.sourceUpdatedAt = value.sourceUpdatedAt;
581+ }
582+ return normalized;
583+}
584+
585+function bindDeparture(
586+ data: PublicDepartureData,
587+ request: DepartureRequestBody,
588+): DepartureResult {
589+ const result: DepartureResult = {
590+ schemaVersion: SCHEMA_VERSION,
591+ requestId: request.requestId,
592+ favoriteId: request.favoriteId,
593+ fetchedAt: data.fetchedAt,
594+ freshness: data.freshness,
595+ departures: data.departures.map(copyDeparture),
596+ };
597+ if (data.sourceUpdatedAt !== undefined) result.sourceUpdatedAt = data.sourceUpdatedAt;
598+ return result;
599+}
600+
601+function bindPublicError(
602+ error: PublicDepartureError,
603+ request: DepartureRequestBody,
604+): ErrorResult {
605+ const result: ErrorResult = {
606+ schemaVersion: SCHEMA_VERSION,
607+ requestId: request.requestId,
608+ favoriteId: request.favoriteId,
609+ code: error.code,
610+ occurredAt: error.occurredAt,
611+ };
612+ if (error.retryAfterSeconds !== undefined) {
613+ result.retryAfterSeconds = error.retryAfterSeconds;
614+ }
615+ return result;
616+}
617+
618+function cachedDeparture(
619+ serviceId: string,
620+ milliseconds: number,
621+): PublicDepartureData | undefined {
622+ const cached = DEPARTURE_CACHE.get(serviceId);
623+ if (cached === undefined) return undefined;
624+
625+ const age = milliseconds - cached.storedAtMilliseconds;
626+ if (age < 0 || age >= DEPARTURE_CACHE_FRESH_MS) {
627+ DEPARTURE_CACHE.delete(serviceId);
628+ return undefined;
629+ }
630+ return cached.data;
631+}
632+
633+export async function handleDepartureRequest(
634+ request: DepartureHandlerRequest,
635+ dependencies: DepartureHandlerDependencies,
636+): Promise<DepartureHandlerResponse> {
637+ let authorization: unknown;
638+ let bodyValue: unknown;
639+ try {
640+ authorization = request.authorization;
641+ bodyValue = request.body;
642+ } catch {
643+ authorization = undefined;
644+ bodyValue = undefined;
645+ }
646+
647+ const binding = departureBinding(bodyValue);
648+ let apiKey: string;
649+ try {
650+ apiKey = parseBearerAuthorization(authorization);
651+ } catch (failure) {
652+ const code: ErrorCode = failure instanceof BackendBoundaryError
653+ && failure.code === BACKEND_ERROR_CODE.API_KEY_REQUIRED
654+ ? "API_KEY_REQUIRED"
655+ : "API_KEY_INVALID";
656+ return stableError(binding, code, nowMilliseconds(dependencies));
657+ }
658+
659+ if (!isDepartureRequestBody(bodyValue)) {
660+ apiKey = "";
661+ return stableError(binding, "INVALID_SERVICE", nowMilliseconds(dependencies));
662+ }
663+
664+ const requestBody = bodyValue;
665+ const currentMilliseconds = nowMilliseconds(dependencies);
666+ const cached = cachedDeparture(requestBody.serviceId, currentMilliseconds);
667+ if (cached !== undefined) {
668+ apiKey = "";
669+ const result = bindDeparture(cached, requestBody);
670+ return isDepartureResult(result)
671+ ? result
672+ : stableError(binding, "INVALID_RESPONSE", currentMilliseconds);
673+ }
674+
675+ let resolved: unknown;
676+ try {
677+ resolved = await dependencies.resolve({
678+ serviceId: requestBody.serviceId,
679+ apiKey,
680+ });
681+ } catch {
682+ return stableError(binding, "SOURCE_UNAVAILABLE", nowMilliseconds(dependencies));
683+ } finally {
684+ apiKey = "";
685+ }
686+
687+ if (isPublicDepartureData(resolved)) {
688+ const normalized = normalizePublicDeparture(resolved);
689+ const storedAtMilliseconds = nowMilliseconds(dependencies);
690+ DEPARTURE_CACHE.set(requestBody.serviceId, {
691+ storedAtMilliseconds,
692+ data: normalized,
693+ });
694+ const result = bindDeparture(normalized, requestBody);
695+ return isDepartureResult(result)
696+ ? result
697+ : stableError(binding, "INVALID_RESPONSE", storedAtMilliseconds);
698+ }
699+
700+ if (isPublicDepartureError(resolved)) {
701+ const error = bindPublicError(resolved, requestBody);
702+ return isErrorResult(error)
703+ ? error
704+ : stableError(binding, "INVALID_RESPONSE", nowMilliseconds(dependencies));
705+ }
706+
707+ return stableError(binding, "INVALID_RESPONSE", nowMilliseconds(dependencies));
708+}
added packages/backend/test/backend.test.ts +516 -0
new file mode 100644
@@ -0,0 +1,516 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ CACHE_FRESH_SECONDS,
5+ LIMITS,
6+ PRIM_ORIGIN,
7+ SCHEMA_VERSION,
8+ isDepartureResult,
9+ isErrorResult,
10+} from "../../contracts/src/index.ts";
11+import {
12+ BACKEND_ERROR_CODE,
13+ REDACTED_SECRET,
14+ BackendBoundaryError,
15+ createRedactingLogger,
16+ handleDepartureRequest,
17+ parseBearerAuthorization,
18+ redactError,
19+ redactSecrets,
20+ relayPrimRequest,
21+ type BackendErrorCode,
22+ type DepartureRequestBody,
23+ type DepartureResolverRequest,
24+ type PrimFetch,
25+ type PrimFetchInit,
26+ type PrimFetchResponse,
27+ type PublicDepartureData,
28+ type RelayTimer,
29+} from "../src/index.ts";
30+
31+const encoder = new TextEncoder();
32+const TEST_KEY = "test-only-personal-key";
33+const AUTHORIZATION = `Bearer ${TEST_KEY}`;
34+
35+function boundaryCode(code: BackendErrorCode): (error: unknown) => boolean {
36+ return (error) => {
37+ assert.ok(error instanceof BackendBoundaryError);
38+ assert.equal(error.code, code);
39+ return true;
40+ };
41+}
42+
43+function streamedResponse(chunks: readonly Uint8Array[], status = 200): PrimFetchResponse {
44+ return {
45+ status,
46+ redirected: false,
47+ type: "basic",
48+ body: new ReadableStream<Uint8Array>({
49+ start(controller) {
50+ for (const chunk of chunks) controller.enqueue(chunk);
51+ controller.close();
52+ },
53+ }),
54+ };
55+}
56+
57+function fullyPercentEncoded(value: string): string {
58+ return [...encoder.encode(value)]
59+ .map((byte) => `%${byte.toString(16).padStart(2, "0")}`)
60+ .join("");
61+}
62+
63+function departureBody(
64+ requestId: string,
65+ favoriteId: string,
66+ serviceId: string,
67+): DepartureRequestBody {
68+ return { schemaVersion: SCHEMA_VERSION, requestId, favoriteId, serviceId };
69+}
70+
71+function publicDeparture(fetchedAt = 1_788_000_000): PublicDepartureData {
72+ return {
73+ schemaVersion: SCHEMA_VERSION,
74+ fetchedAt,
75+ sourceUpdatedAt: fetchedAt - 10,
76+ freshness: "REALTIME",
77+ departures: [{
78+ expectedAt: fetchedAt + 120,
79+ aimedAt: fetchedAt + 100,
80+ minutes: 2,
81+ status: "DELAYED",
82+ nextIntervalMinutes: 4,
83+ }],
84+ };
85+}
86+
87+test("Authorization parsing requires exact Bearer casing, one separator, and canonical UTF-8 bounds", () => {
88+ assert.equal(parseBearerAuthorization(AUTHORIZATION), TEST_KEY);
89+
90+ for (const value of [
91+ undefined,
92+ null,
93+ "",
94+ ]) {
95+ assert.throws(
96+ () => parseBearerAuthorization(value),
97+ boundaryCode(BACKEND_ERROR_CODE.API_KEY_REQUIRED),
98+ );
99+ }
100+
101+ for (const value of [
102+ TEST_KEY,
103+ `bearer ${TEST_KEY}`,
104+ `BEARER ${TEST_KEY}`,
105+ `Bearer\t${TEST_KEY}`,
106+ `Bearer ${TEST_KEY}`,
107+ "Bearer ",
108+ `Bearer ${TEST_KEY}\rignored`,
109+ `Bearer ${TEST_KEY}\nignored`,
110+ ]) {
111+ assert.throws(
112+ () => parseBearerAuthorization(value),
113+ boundaryCode(BACKEND_ERROR_CODE.API_KEY_INVALID),
114+ );
115+ }
116+
117+ const exactUtf8Limit = "é".repeat(LIMITS.apiKeyUtf8Bytes / 2);
118+ assert.equal(parseBearerAuthorization(`Bearer ${exactUtf8Limit}`), exactUtf8Limit);
119+ assert.throws(
120+ () => parseBearerAuthorization(`Bearer ${exactUtf8Limit}x`),
121+ boundaryCode(BACKEND_ERROR_CODE.API_KEY_INVALID),
122+ );
123+});
124+
125+test("relay sends a GET to the canonical origin with the key only in apikey", async () => {
126+ let capturedTarget: URL | undefined;
127+ let capturedInit: PrimFetchInit | undefined;
128+ const fetch: PrimFetch = async (target, init) => {
129+ capturedTarget = target;
130+ capturedInit = init;
131+ return streamedResponse([
132+ Uint8Array.of(1, 2),
133+ Uint8Array.of(3, 4),
134+ ], 206);
135+ };
136+
137+ const response = await relayPrimRequest({
138+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring?MonitoringRef=opaque-service-id`,
139+ authorization: AUTHORIZATION,
140+ }, { fetch });
141+
142+ assert.equal(capturedTarget?.origin, PRIM_ORIGIN);
143+ assert.equal(capturedTarget?.href.includes(TEST_KEY), false);
144+ assert.deepEqual(capturedInit?.headers, { apikey: TEST_KEY });
145+ assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), ["apikey"]);
146+ assert.equal(capturedInit?.method, "GET");
147+ assert.equal(capturedInit?.redirect, "manual");
148+ assert.equal(capturedInit?.credentials, "omit");
149+ assert.equal(capturedInit?.cache, "no-store");
150+ assert.ok(capturedInit?.signal instanceof AbortSignal);
151+ assert.equal(response.status, 206);
152+ assert.deepEqual([...response.bytes], [1, 2, 3, 4]);
153+});
154+
155+test("relay rejects non-canonical origins and credential substrings at every URL encoding layer", async () => {
156+ let fetchCalls = 0;
157+ const fetch: PrimFetch = async () => {
158+ fetchCalls += 1;
159+ throw new Error("network must remain unused");
160+ };
161+
162+ for (const target of [
163+ "http://prim.iledefrance-mobilites.fr/path",
164+ "https://prim.iledefrance-mobilites.fr.evil.example/path",
165+ "https://evil.example/path",
166+ ]) {
167+ await assert.rejects(
168+ relayPrimRequest({ target, authorization: AUTHORIZATION }, { fetch }),
169+ boundaryCode(BACKEND_ERROR_CODE.PRIM_ORIGIN_REJECTED),
170+ );
171+ }
172+
173+ const encodedKey = fullyPercentEncoded(TEST_KEY);
174+ const doubleEncodedKey = encodeURIComponent(encodedKey);
175+ for (const target of [
176+ `https://${TEST_KEY}@prim.iledefrance-mobilites.fr/path`,
177+ `${PRIM_ORIGIN}/prefix-${TEST_KEY}-suffix`,
178+ `${PRIM_ORIGIN}/prefix-${encodedKey}-suffix`,
179+ `${PRIM_ORIGIN}/prefix-%ff${encodedKey}-suffix`,
180+ `${PRIM_ORIGIN}/path?value=prefix-${encodedKey}-suffix`,
181+ `${PRIM_ORIGIN}/path?prefix-${doubleEncodedKey}-suffix=public`,
182+ `${PRIM_ORIGIN}/path#prefix-${TEST_KEY}-suffix`,
183+ `${PRIM_ORIGIN}/path#prefix-${doubleEncodedKey}-suffix`,
184+ `${PRIM_ORIGIN}/path?apikey=public`,
185+ `${PRIM_ORIGIN}/path?%2561pikey=public`,
186+ ]) {
187+ await assert.rejects(
188+ relayPrimRequest({ target, authorization: AUTHORIZATION }, { fetch }),
189+ boundaryCode(BACKEND_ERROR_CODE.PRIM_CREDENTIAL_IN_URL),
190+ );
191+ }
192+
193+ assert.equal(fetchCalls, 0);
194+});
195+
196+test("relay enforces the canonical timeout across the injected fetch", async () => {
197+ let scheduledDelay: number | undefined;
198+ let fireTimeout: (() => void) | undefined;
199+ let capturedSignal: AbortSignal | undefined;
200+ let cleared = 0;
201+ const handle = {};
202+ const timer: RelayTimer = {
203+ set(callback, delayMs) {
204+ fireTimeout = callback;
205+ scheduledDelay = delayMs;
206+ return handle;
207+ },
208+ clear(receivedHandle) {
209+ assert.equal(receivedHandle, handle);
210+ cleared += 1;
211+ },
212+ };
213+ const fetch: PrimFetch = (_target, init) => {
214+ capturedSignal = init.signal;
215+ return new Promise<never>(() => undefined);
216+ };
217+
218+ const pending = relayPrimRequest({
219+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
220+ authorization: AUTHORIZATION,
221+ }, { fetch, timer });
222+
223+ assert.equal(scheduledDelay, LIMITS.httpTimeoutMs);
224+ assert.ok(fireTimeout);
225+ fireTimeout();
226+ await assert.rejects(pending, boundaryCode(BACKEND_ERROR_CODE.PRIM_TIMEOUT));
227+ assert.equal(capturedSignal?.aborted, true);
228+ assert.equal(cleared, 1);
229+});
230+
231+test("relay accepts the byte limit and aborts while streaming the first excess byte", async () => {
232+ const atLimit = new Uint8Array(LIMITS.httpResponseBytes);
233+ const accepted = await relayPrimRequest({
234+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
235+ authorization: AUTHORIZATION,
236+ }, {
237+ fetch: async () => streamedResponse([atLimit]),
238+ });
239+ assert.equal(accepted.bytes.byteLength, LIMITS.httpResponseBytes);
240+
241+ let cancelled = false;
242+ let capturedSignal: AbortSignal | undefined;
243+ const oversizedBody = new ReadableStream<Uint8Array>({
244+ start(controller) {
245+ controller.enqueue(new Uint8Array(LIMITS.httpResponseBytes));
246+ controller.enqueue(Uint8Array.of(1));
247+ },
248+ cancel() {
249+ cancelled = true;
250+ },
251+ });
252+ const fetch: PrimFetch = async (_target, init) => {
253+ capturedSignal = init.signal;
254+ return { status: 200, redirected: false, type: "basic", body: oversizedBody };
255+ };
256+
257+ await assert.rejects(
258+ relayPrimRequest({
259+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
260+ authorization: AUTHORIZATION,
261+ }, { fetch }),
262+ boundaryCode(BACKEND_ERROR_CODE.PRIM_RESPONSE_TOO_LARGE),
263+ );
264+ assert.equal(capturedSignal?.aborted, true);
265+ assert.equal(cancelled, true);
266+});
267+
268+test("relay requests manual redirects and rejects every redirect response", async () => {
269+ let redirectMode: PrimFetchInit["redirect"] | undefined;
270+ const fetch: PrimFetch = async (_target, init) => {
271+ redirectMode = init.redirect;
272+ return streamedResponse([], 302);
273+ };
274+
275+ await assert.rejects(
276+ relayPrimRequest({
277+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
278+ authorization: AUTHORIZATION,
279+ }, { fetch }),
280+ boundaryCode(BACKEND_ERROR_CODE.PRIM_REDIRECT_REJECTED),
281+ );
282+ assert.equal(redirectMode, "manual");
283+});
284+
285+test("redaction helpers scrub secrets while relay logs only a stable failure code", async () => {
286+ const apiKey = "fake/key + value";
287+ const encodedKey = encodeURIComponent(apiKey);
288+ const unsafe = `upstream echoed ${apiKey} and ${encodedKey}`;
289+ assert.equal(redactSecrets(unsafe, [apiKey]).includes(apiKey), false);
290+ assert.equal(redactSecrets(unsafe, [apiKey]).includes(encodedKey), false);
291+ assert.equal(redactError(new Error(unsafe), [apiKey]).includes(apiKey), false);
292+
293+ const helperLogs: string[] = [];
294+ createRedactingLogger((message) => helperLogs.push(message), [apiKey])(unsafe);
295+ assert.equal(helperLogs.length, 1);
296+ assert.equal(helperLogs[0]?.includes(apiKey), false);
297+ assert.equal(helperLogs[0]?.includes(encodedKey), false);
298+ assert.equal(helperLogs[0]?.includes(REDACTED_SECRET), true);
299+
300+ const relayLogs: string[] = [];
301+ const fetch: PrimFetch = async () => {
302+ throw new Error(unsafe);
303+ };
304+ let caught: unknown;
305+ try {
306+ await relayPrimRequest({
307+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
308+ authorization: `Bearer ${apiKey}`,
309+ }, {
310+ fetch,
311+ logger(message) {
312+ relayLogs.push(message);
313+ },
314+ });
315+ } catch (error) {
316+ caught = error;
317+ }
318+
319+ assert.ok(caught instanceof BackendBoundaryError);
320+ assert.equal(caught.code, BACKEND_ERROR_CODE.PRIM_REQUEST_FAILED);
321+ assert.equal(String(caught).includes(apiKey), false);
322+ assert.equal(String(caught).includes(encodedKey), false);
323+ assert.deepEqual(relayLogs, ["PRIM relay failed (PRIM_REQUEST_FAILED)"]);
324+ assert.equal(relayLogs[0]?.includes("upstream echoed"), false);
325+ assert.deepEqual([...encoder.encode(apiKey)].length <= LIMITS.apiKeyUtf8Bytes, true);
326+});
327+
328+test("departure handler validates authorization and the exact companion POST body before lookup", async () => {
329+ const now = 1_788_000_123_000;
330+ const body = departureBody("route-request", "route-favorite", "route-service");
331+ const resolverInputs: DepartureResolverRequest[] = [];
332+ const dependencies = {
333+ nowMilliseconds: () => now,
334+ resolve: async (input: DepartureResolverRequest) => {
335+ resolverInputs.push(input);
336+ return publicDeparture();
337+ },
338+ };
339+
340+ const missingKey = await handleDepartureRequest({
341+ authorization: undefined,
342+ body,
343+ }, dependencies);
344+ assert.equal(isErrorResult(missingKey), true);
345+ assert.deepEqual(missingKey, {
346+ schemaVersion: SCHEMA_VERSION,
347+ requestId: body.requestId,
348+ favoriteId: body.favoriteId,
349+ code: "API_KEY_REQUIRED",
350+ occurredAt: Math.floor(now / 1_000),
351+ });
352+
353+ const invalidKey = await handleDepartureRequest({
354+ authorization: `bearer ${TEST_KEY}`,
355+ body,
356+ }, dependencies);
357+ assert.equal(isErrorResult(invalidKey), true);
358+ assert.equal("code" in invalidKey ? invalidKey.code : undefined, "API_KEY_INVALID");
359+
360+ for (const invalidBody of [
361+ { ...body, schemaVersion: SCHEMA_VERSION + 1 },
362+ { ...body, serviceId: "" },
363+ { ...body, requestId: "" },
364+ { ...body, unexpected: true },
365+ null,
366+ ]) {
367+ const result = await handleDepartureRequest({
368+ authorization: AUTHORIZATION,
369+ body: invalidBody,
370+ }, dependencies);
371+ assert.equal(isErrorResult(result), true);
372+ assert.equal("code" in result ? result.code : undefined, "INVALID_SERVICE");
373+ }
374+ assert.equal(resolverInputs.length, 0);
375+
376+ const result = await handleDepartureRequest({
377+ authorization: AUTHORIZATION,
378+ body,
379+ }, dependencies);
380+ assert.equal(isDepartureResult(result), true);
381+ assert.equal(result.requestId, body.requestId);
382+ assert.equal(result.favoriteId, body.favoriteId);
383+ assert.deepEqual(resolverInputs, [{ serviceId: body.serviceId, apiKey: TEST_KEY }]);
384+ assert.deepEqual(Object.keys(resolverInputs[0] ?? {}).sort(), ["apiKey", "serviceId"]);
385+ assert.equal(JSON.stringify(result).includes(TEST_KEY), false);
386+});
387+
388+test("departure cache is process-shared, public, service-only, and expires at exactly 60 seconds", async () => {
389+ const baseMilliseconds = 1_788_001_000_000;
390+ const secondKey = "second-test-only-personal-key";
391+ const serviceId = "shared-cache-service";
392+ let now = baseMilliseconds;
393+ let firstResolverCalls = 0;
394+ let secondResolverCalls = 0;
395+
396+ const first = await handleDepartureRequest({
397+ authorization: AUTHORIZATION,
398+ body: departureBody("cache-request-1", "cache-favorite-1", serviceId),
399+ }, {
400+ nowMilliseconds: () => now,
401+ resolve: async (input) => {
402+ firstResolverCalls += 1;
403+ assert.deepEqual(Object.keys(input).sort(), ["apiKey", "serviceId"]);
404+ return publicDeparture(1_788_001_000);
405+ },
406+ });
407+ assert.equal(isDepartureResult(first), true);
408+ if (!isDepartureResult(first)) assert.fail("expected a departure result");
409+ first.departures[0]!.minutes = 999;
410+
411+ now = baseMilliseconds + CACHE_FRESH_SECONDS * 1_000 - 1;
412+ const cached = await handleDepartureRequest({
413+ authorization: `Bearer ${secondKey}`,
414+ body: departureBody("cache-request-2", "cache-favorite-2", serviceId),
415+ }, {
416+ nowMilliseconds: () => now,
417+ resolve: async () => {
418+ secondResolverCalls += 1;
419+ return publicDeparture(1_788_001_060);
420+ },
421+ });
422+ assert.equal(isDepartureResult(cached), true);
423+ if (!isDepartureResult(cached)) assert.fail("expected a cached departure result");
424+ assert.equal(cached.requestId, "cache-request-2");
425+ assert.equal(cached.favoriteId, "cache-favorite-2");
426+ assert.equal(cached.fetchedAt, 1_788_001_000);
427+ assert.equal(cached.departures[0]?.minutes, 2);
428+ assert.equal(firstResolverCalls, 1);
429+ assert.equal(secondResolverCalls, 0);
430+ assert.equal(JSON.stringify(cached).includes(TEST_KEY), false);
431+ assert.equal(JSON.stringify(cached).includes(secondKey), false);
432+
433+ now = baseMilliseconds + CACHE_FRESH_SECONDS * 1_000;
434+ const expired = await handleDepartureRequest({
435+ authorization: `Bearer ${secondKey}`,
436+ body: departureBody("cache-request-3", "cache-favorite-3", serviceId),
437+ }, {
438+ nowMilliseconds: () => now,
439+ resolve: async () => {
440+ secondResolverCalls += 1;
441+ return publicDeparture(1_788_001_060);
442+ },
443+ });
444+ assert.equal(isDepartureResult(expired), true);
445+ assert.equal("fetchedAt" in expired ? expired.fetchedAt : undefined, 1_788_001_060);
446+ assert.equal(secondResolverCalls, 1);
447+});
448+
449+test("departure handler rebinds stable resolver errors and never caches or exposes failures", async () => {
450+ const now = 1_788_002_000_000;
451+ const body = departureBody("error-request-1", "error-favorite-1", "error-cache-service");
452+ let resolverCalls = 0;
453+ const dependencies = {
454+ nowMilliseconds: () => now,
455+ resolve: async () => {
456+ resolverCalls += 1;
457+ if (resolverCalls === 1) {
458+ return {
459+ schemaVersion: SCHEMA_VERSION,
460+ code: "RATE_LIMITED" as const,
461+ occurredAt: 1_788_001_999,
462+ retryAfterSeconds: 30,
463+ };
464+ }
465+ return publicDeparture(1_788_002_000);
466+ },
467+ };
468+
469+ const rateLimited = await handleDepartureRequest({
470+ authorization: AUTHORIZATION,
471+ body,
472+ }, dependencies);
473+ assert.equal(isErrorResult(rateLimited), true);
474+ assert.deepEqual(rateLimited, {
475+ schemaVersion: SCHEMA_VERSION,
476+ requestId: body.requestId,
477+ favoriteId: body.favoriteId,
478+ code: "RATE_LIMITED",
479+ occurredAt: 1_788_001_999,
480+ retryAfterSeconds: 30,
481+ });
482+
483+ const afterError = await handleDepartureRequest({
484+ authorization: AUTHORIZATION,
485+ body: departureBody("error-request-2", "error-favorite-2", body.serviceId),
486+ }, dependencies);
487+ assert.equal(isDepartureResult(afterError), true);
488+ assert.equal(resolverCalls, 2);
489+
490+ const thrown = await handleDepartureRequest({
491+ authorization: AUTHORIZATION,
492+ body: departureBody("throw-request", "throw-favorite", "throw-service"),
493+ }, {
494+ nowMilliseconds: () => now,
495+ resolve: async () => {
496+ throw new Error(`arbitrary upstream failure containing ${TEST_KEY}`);
497+ },
498+ });
499+ assert.equal(isErrorResult(thrown), true);
500+ assert.equal("code" in thrown ? thrown.code : undefined, "SOURCE_UNAVAILABLE");
501+ assert.equal(JSON.stringify(thrown).includes(TEST_KEY), false);
502+ assert.equal(JSON.stringify(thrown).includes("arbitrary upstream"), false);
503+
504+ const malformed = await handleDepartureRequest({
505+ authorization: AUTHORIZATION,
506+ body: departureBody("malformed-request", "malformed-favorite", "malformed-service"),
507+ }, {
508+ nowMilliseconds: () => now,
509+ resolve: async () => ({
510+ ...publicDeparture(),
511+ requestId: "resolver-must-not-bind-callers",
512+ }) as unknown as PublicDepartureData,
513+ });
514+ assert.equal(isErrorResult(malformed), true);
515+ assert.equal("code" in malformed ? malformed.code : undefined, "INVALID_RESPONSE");
516+});
new file mode 100644
@@ -0,0 +1,516 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ CACHE_FRESH_SECONDS,
5+ LIMITS,
6+ PRIM_ORIGIN,
7+ SCHEMA_VERSION,
8+ isDepartureResult,
9+ isErrorResult,
10+} from "../../contracts/src/index.ts";
11+import {
12+ BACKEND_ERROR_CODE,
13+ REDACTED_SECRET,
14+ BackendBoundaryError,
15+ createRedactingLogger,
16+ handleDepartureRequest,
17+ parseBearerAuthorization,
18+ redactError,
19+ redactSecrets,
20+ relayPrimRequest,
21+ type BackendErrorCode,
22+ type DepartureRequestBody,
23+ type DepartureResolverRequest,
24+ type PrimFetch,
25+ type PrimFetchInit,
26+ type PrimFetchResponse,
27+ type PublicDepartureData,
28+ type RelayTimer,
29+} from "../src/index.ts";
30+
31+const encoder = new TextEncoder();
32+const TEST_KEY = "test-only-personal-key";
33+const AUTHORIZATION = `Bearer ${TEST_KEY}`;
34+
35+function boundaryCode(code: BackendErrorCode): (error: unknown) => boolean {
36+ return (error) => {
37+ assert.ok(error instanceof BackendBoundaryError);
38+ assert.equal(error.code, code);
39+ return true;
40+ };
41+}
42+
43+function streamedResponse(chunks: readonly Uint8Array[], status = 200): PrimFetchResponse {
44+ return {
45+ status,
46+ redirected: false,
47+ type: "basic",
48+ body: new ReadableStream<Uint8Array>({
49+ start(controller) {
50+ for (const chunk of chunks) controller.enqueue(chunk);
51+ controller.close();
52+ },
53+ }),
54+ };
55+}
56+
57+function fullyPercentEncoded(value: string): string {
58+ return [...encoder.encode(value)]
59+ .map((byte) => `%${byte.toString(16).padStart(2, "0")}`)
60+ .join("");
61+}
62+
63+function departureBody(
64+ requestId: string,
65+ favoriteId: string,
66+ serviceId: string,
67+): DepartureRequestBody {
68+ return { schemaVersion: SCHEMA_VERSION, requestId, favoriteId, serviceId };
69+}
70+
71+function publicDeparture(fetchedAt = 1_788_000_000): PublicDepartureData {
72+ return {
73+ schemaVersion: SCHEMA_VERSION,
74+ fetchedAt,
75+ sourceUpdatedAt: fetchedAt - 10,
76+ freshness: "REALTIME",
77+ departures: [{
78+ expectedAt: fetchedAt + 120,
79+ aimedAt: fetchedAt + 100,
80+ minutes: 2,
81+ status: "DELAYED",
82+ nextIntervalMinutes: 4,
83+ }],
84+ };
85+}
86+
87+test("Authorization parsing requires exact Bearer casing, one separator, and canonical UTF-8 bounds", () => {
88+ assert.equal(parseBearerAuthorization(AUTHORIZATION), TEST_KEY);
89+
90+ for (const value of [
91+ undefined,
92+ null,
93+ "",
94+ ]) {
95+ assert.throws(
96+ () => parseBearerAuthorization(value),
97+ boundaryCode(BACKEND_ERROR_CODE.API_KEY_REQUIRED),
98+ );
99+ }
100+
101+ for (const value of [
102+ TEST_KEY,
103+ `bearer ${TEST_KEY}`,
104+ `BEARER ${TEST_KEY}`,
105+ `Bearer\t${TEST_KEY}`,
106+ `Bearer ${TEST_KEY}`,
107+ "Bearer ",
108+ `Bearer ${TEST_KEY}\rignored`,
109+ `Bearer ${TEST_KEY}\nignored`,
110+ ]) {
111+ assert.throws(
112+ () => parseBearerAuthorization(value),
113+ boundaryCode(BACKEND_ERROR_CODE.API_KEY_INVALID),
114+ );
115+ }
116+
117+ const exactUtf8Limit = "é".repeat(LIMITS.apiKeyUtf8Bytes / 2);
118+ assert.equal(parseBearerAuthorization(`Bearer ${exactUtf8Limit}`), exactUtf8Limit);
119+ assert.throws(
120+ () => parseBearerAuthorization(`Bearer ${exactUtf8Limit}x`),
121+ boundaryCode(BACKEND_ERROR_CODE.API_KEY_INVALID),
122+ );
123+});
124+
125+test("relay sends a GET to the canonical origin with the key only in apikey", async () => {
126+ let capturedTarget: URL | undefined;
127+ let capturedInit: PrimFetchInit | undefined;
128+ const fetch: PrimFetch = async (target, init) => {
129+ capturedTarget = target;
130+ capturedInit = init;
131+ return streamedResponse([
132+ Uint8Array.of(1, 2),
133+ Uint8Array.of(3, 4),
134+ ], 206);
135+ };
136+
137+ const response = await relayPrimRequest({
138+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring?MonitoringRef=opaque-service-id`,
139+ authorization: AUTHORIZATION,
140+ }, { fetch });
141+
142+ assert.equal(capturedTarget?.origin, PRIM_ORIGIN);
143+ assert.equal(capturedTarget?.href.includes(TEST_KEY), false);
144+ assert.deepEqual(capturedInit?.headers, { apikey: TEST_KEY });
145+ assert.deepEqual(Object.keys(capturedInit?.headers ?? {}), ["apikey"]);
146+ assert.equal(capturedInit?.method, "GET");
147+ assert.equal(capturedInit?.redirect, "manual");
148+ assert.equal(capturedInit?.credentials, "omit");
149+ assert.equal(capturedInit?.cache, "no-store");
150+ assert.ok(capturedInit?.signal instanceof AbortSignal);
151+ assert.equal(response.status, 206);
152+ assert.deepEqual([...response.bytes], [1, 2, 3, 4]);
153+});
154+
155+test("relay rejects non-canonical origins and credential substrings at every URL encoding layer", async () => {
156+ let fetchCalls = 0;
157+ const fetch: PrimFetch = async () => {
158+ fetchCalls += 1;
159+ throw new Error("network must remain unused");
160+ };
161+
162+ for (const target of [
163+ "http://prim.iledefrance-mobilites.fr/path",
164+ "https://prim.iledefrance-mobilites.fr.evil.example/path",
165+ "https://evil.example/path",
166+ ]) {
167+ await assert.rejects(
168+ relayPrimRequest({ target, authorization: AUTHORIZATION }, { fetch }),
169+ boundaryCode(BACKEND_ERROR_CODE.PRIM_ORIGIN_REJECTED),
170+ );
171+ }
172+
173+ const encodedKey = fullyPercentEncoded(TEST_KEY);
174+ const doubleEncodedKey = encodeURIComponent(encodedKey);
175+ for (const target of [
176+ `https://${TEST_KEY}@prim.iledefrance-mobilites.fr/path`,
177+ `${PRIM_ORIGIN}/prefix-${TEST_KEY}-suffix`,
178+ `${PRIM_ORIGIN}/prefix-${encodedKey}-suffix`,
179+ `${PRIM_ORIGIN}/prefix-%ff${encodedKey}-suffix`,
180+ `${PRIM_ORIGIN}/path?value=prefix-${encodedKey}-suffix`,
181+ `${PRIM_ORIGIN}/path?prefix-${doubleEncodedKey}-suffix=public`,
182+ `${PRIM_ORIGIN}/path#prefix-${TEST_KEY}-suffix`,
183+ `${PRIM_ORIGIN}/path#prefix-${doubleEncodedKey}-suffix`,
184+ `${PRIM_ORIGIN}/path?apikey=public`,
185+ `${PRIM_ORIGIN}/path?%2561pikey=public`,
186+ ]) {
187+ await assert.rejects(
188+ relayPrimRequest({ target, authorization: AUTHORIZATION }, { fetch }),
189+ boundaryCode(BACKEND_ERROR_CODE.PRIM_CREDENTIAL_IN_URL),
190+ );
191+ }
192+
193+ assert.equal(fetchCalls, 0);
194+});
195+
196+test("relay enforces the canonical timeout across the injected fetch", async () => {
197+ let scheduledDelay: number | undefined;
198+ let fireTimeout: (() => void) | undefined;
199+ let capturedSignal: AbortSignal | undefined;
200+ let cleared = 0;
201+ const handle = {};
202+ const timer: RelayTimer = {
203+ set(callback, delayMs) {
204+ fireTimeout = callback;
205+ scheduledDelay = delayMs;
206+ return handle;
207+ },
208+ clear(receivedHandle) {
209+ assert.equal(receivedHandle, handle);
210+ cleared += 1;
211+ },
212+ };
213+ const fetch: PrimFetch = (_target, init) => {
214+ capturedSignal = init.signal;
215+ return new Promise<never>(() => undefined);
216+ };
217+
218+ const pending = relayPrimRequest({
219+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
220+ authorization: AUTHORIZATION,
221+ }, { fetch, timer });
222+
223+ assert.equal(scheduledDelay, LIMITS.httpTimeoutMs);
224+ assert.ok(fireTimeout);
225+ fireTimeout();
226+ await assert.rejects(pending, boundaryCode(BACKEND_ERROR_CODE.PRIM_TIMEOUT));
227+ assert.equal(capturedSignal?.aborted, true);
228+ assert.equal(cleared, 1);
229+});
230+
231+test("relay accepts the byte limit and aborts while streaming the first excess byte", async () => {
232+ const atLimit = new Uint8Array(LIMITS.httpResponseBytes);
233+ const accepted = await relayPrimRequest({
234+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
235+ authorization: AUTHORIZATION,
236+ }, {
237+ fetch: async () => streamedResponse([atLimit]),
238+ });
239+ assert.equal(accepted.bytes.byteLength, LIMITS.httpResponseBytes);
240+
241+ let cancelled = false;
242+ let capturedSignal: AbortSignal | undefined;
243+ const oversizedBody = new ReadableStream<Uint8Array>({
244+ start(controller) {
245+ controller.enqueue(new Uint8Array(LIMITS.httpResponseBytes));
246+ controller.enqueue(Uint8Array.of(1));
247+ },
248+ cancel() {
249+ cancelled = true;
250+ },
251+ });
252+ const fetch: PrimFetch = async (_target, init) => {
253+ capturedSignal = init.signal;
254+ return { status: 200, redirected: false, type: "basic", body: oversizedBody };
255+ };
256+
257+ await assert.rejects(
258+ relayPrimRequest({
259+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
260+ authorization: AUTHORIZATION,
261+ }, { fetch }),
262+ boundaryCode(BACKEND_ERROR_CODE.PRIM_RESPONSE_TOO_LARGE),
263+ );
264+ assert.equal(capturedSignal?.aborted, true);
265+ assert.equal(cancelled, true);
266+});
267+
268+test("relay requests manual redirects and rejects every redirect response", async () => {
269+ let redirectMode: PrimFetchInit["redirect"] | undefined;
270+ const fetch: PrimFetch = async (_target, init) => {
271+ redirectMode = init.redirect;
272+ return streamedResponse([], 302);
273+ };
274+
275+ await assert.rejects(
276+ relayPrimRequest({
277+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
278+ authorization: AUTHORIZATION,
279+ }, { fetch }),
280+ boundaryCode(BACKEND_ERROR_CODE.PRIM_REDIRECT_REJECTED),
281+ );
282+ assert.equal(redirectMode, "manual");
283+});
284+
285+test("redaction helpers scrub secrets while relay logs only a stable failure code", async () => {
286+ const apiKey = "fake/key + value";
287+ const encodedKey = encodeURIComponent(apiKey);
288+ const unsafe = `upstream echoed ${apiKey} and ${encodedKey}`;
289+ assert.equal(redactSecrets(unsafe, [apiKey]).includes(apiKey), false);
290+ assert.equal(redactSecrets(unsafe, [apiKey]).includes(encodedKey), false);
291+ assert.equal(redactError(new Error(unsafe), [apiKey]).includes(apiKey), false);
292+
293+ const helperLogs: string[] = [];
294+ createRedactingLogger((message) => helperLogs.push(message), [apiKey])(unsafe);
295+ assert.equal(helperLogs.length, 1);
296+ assert.equal(helperLogs[0]?.includes(apiKey), false);
297+ assert.equal(helperLogs[0]?.includes(encodedKey), false);
298+ assert.equal(helperLogs[0]?.includes(REDACTED_SECRET), true);
299+
300+ const relayLogs: string[] = [];
301+ const fetch: PrimFetch = async () => {
302+ throw new Error(unsafe);
303+ };
304+ let caught: unknown;
305+ try {
306+ await relayPrimRequest({
307+ target: `${PRIM_ORIGIN}/marketplace/stop-monitoring`,
308+ authorization: `Bearer ${apiKey}`,
309+ }, {
310+ fetch,
311+ logger(message) {
312+ relayLogs.push(message);
313+ },
314+ });
315+ } catch (error) {
316+ caught = error;
317+ }
318+
319+ assert.ok(caught instanceof BackendBoundaryError);
320+ assert.equal(caught.code, BACKEND_ERROR_CODE.PRIM_REQUEST_FAILED);
321+ assert.equal(String(caught).includes(apiKey), false);
322+ assert.equal(String(caught).includes(encodedKey), false);
323+ assert.deepEqual(relayLogs, ["PRIM relay failed (PRIM_REQUEST_FAILED)"]);
324+ assert.equal(relayLogs[0]?.includes("upstream echoed"), false);
325+ assert.deepEqual([...encoder.encode(apiKey)].length <= LIMITS.apiKeyUtf8Bytes, true);
326+});
327+
328+test("departure handler validates authorization and the exact companion POST body before lookup", async () => {
329+ const now = 1_788_000_123_000;
330+ const body = departureBody("route-request", "route-favorite", "route-service");
331+ const resolverInputs: DepartureResolverRequest[] = [];
332+ const dependencies = {
333+ nowMilliseconds: () => now,
334+ resolve: async (input: DepartureResolverRequest) => {
335+ resolverInputs.push(input);
336+ return publicDeparture();
337+ },
338+ };
339+
340+ const missingKey = await handleDepartureRequest({
341+ authorization: undefined,
342+ body,
343+ }, dependencies);
344+ assert.equal(isErrorResult(missingKey), true);
345+ assert.deepEqual(missingKey, {
346+ schemaVersion: SCHEMA_VERSION,
347+ requestId: body.requestId,
348+ favoriteId: body.favoriteId,
349+ code: "API_KEY_REQUIRED",
350+ occurredAt: Math.floor(now / 1_000),
351+ });
352+
353+ const invalidKey = await handleDepartureRequest({
354+ authorization: `bearer ${TEST_KEY}`,
355+ body,
356+ }, dependencies);
357+ assert.equal(isErrorResult(invalidKey), true);
358+ assert.equal("code" in invalidKey ? invalidKey.code : undefined, "API_KEY_INVALID");
359+
360+ for (const invalidBody of [
361+ { ...body, schemaVersion: SCHEMA_VERSION + 1 },
362+ { ...body, serviceId: "" },
363+ { ...body, requestId: "" },
364+ { ...body, unexpected: true },
365+ null,
366+ ]) {
367+ const result = await handleDepartureRequest({
368+ authorization: AUTHORIZATION,
369+ body: invalidBody,
370+ }, dependencies);
371+ assert.equal(isErrorResult(result), true);
372+ assert.equal("code" in result ? result.code : undefined, "INVALID_SERVICE");
373+ }
374+ assert.equal(resolverInputs.length, 0);
375+
376+ const result = await handleDepartureRequest({
377+ authorization: AUTHORIZATION,
378+ body,
379+ }, dependencies);
380+ assert.equal(isDepartureResult(result), true);
381+ assert.equal(result.requestId, body.requestId);
382+ assert.equal(result.favoriteId, body.favoriteId);
383+ assert.deepEqual(resolverInputs, [{ serviceId: body.serviceId, apiKey: TEST_KEY }]);
384+ assert.deepEqual(Object.keys(resolverInputs[0] ?? {}).sort(), ["apiKey", "serviceId"]);
385+ assert.equal(JSON.stringify(result).includes(TEST_KEY), false);
386+});
387+
388+test("departure cache is process-shared, public, service-only, and expires at exactly 60 seconds", async () => {
389+ const baseMilliseconds = 1_788_001_000_000;
390+ const secondKey = "second-test-only-personal-key";
391+ const serviceId = "shared-cache-service";
392+ let now = baseMilliseconds;
393+ let firstResolverCalls = 0;
394+ let secondResolverCalls = 0;
395+
396+ const first = await handleDepartureRequest({
397+ authorization: AUTHORIZATION,
398+ body: departureBody("cache-request-1", "cache-favorite-1", serviceId),
399+ }, {
400+ nowMilliseconds: () => now,
401+ resolve: async (input) => {
402+ firstResolverCalls += 1;
403+ assert.deepEqual(Object.keys(input).sort(), ["apiKey", "serviceId"]);
404+ return publicDeparture(1_788_001_000);
405+ },
406+ });
407+ assert.equal(isDepartureResult(first), true);
408+ if (!isDepartureResult(first)) assert.fail("expected a departure result");
409+ first.departures[0]!.minutes = 999;
410+
411+ now = baseMilliseconds + CACHE_FRESH_SECONDS * 1_000 - 1;
412+ const cached = await handleDepartureRequest({
413+ authorization: `Bearer ${secondKey}`,
414+ body: departureBody("cache-request-2", "cache-favorite-2", serviceId),
415+ }, {
416+ nowMilliseconds: () => now,
417+ resolve: async () => {
418+ secondResolverCalls += 1;
419+ return publicDeparture(1_788_001_060);
420+ },
421+ });
422+ assert.equal(isDepartureResult(cached), true);
423+ if (!isDepartureResult(cached)) assert.fail("expected a cached departure result");
424+ assert.equal(cached.requestId, "cache-request-2");
425+ assert.equal(cached.favoriteId, "cache-favorite-2");
426+ assert.equal(cached.fetchedAt, 1_788_001_000);
427+ assert.equal(cached.departures[0]?.minutes, 2);
428+ assert.equal(firstResolverCalls, 1);
429+ assert.equal(secondResolverCalls, 0);
430+ assert.equal(JSON.stringify(cached).includes(TEST_KEY), false);
431+ assert.equal(JSON.stringify(cached).includes(secondKey), false);
432+
433+ now = baseMilliseconds + CACHE_FRESH_SECONDS * 1_000;
434+ const expired = await handleDepartureRequest({
435+ authorization: `Bearer ${secondKey}`,
436+ body: departureBody("cache-request-3", "cache-favorite-3", serviceId),
437+ }, {
438+ nowMilliseconds: () => now,
439+ resolve: async () => {
440+ secondResolverCalls += 1;
441+ return publicDeparture(1_788_001_060);
442+ },
443+ });
444+ assert.equal(isDepartureResult(expired), true);
445+ assert.equal("fetchedAt" in expired ? expired.fetchedAt : undefined, 1_788_001_060);
446+ assert.equal(secondResolverCalls, 1);
447+});
448+
449+test("departure handler rebinds stable resolver errors and never caches or exposes failures", async () => {
450+ const now = 1_788_002_000_000;
451+ const body = departureBody("error-request-1", "error-favorite-1", "error-cache-service");
452+ let resolverCalls = 0;
453+ const dependencies = {
454+ nowMilliseconds: () => now,
455+ resolve: async () => {
456+ resolverCalls += 1;
457+ if (resolverCalls === 1) {
458+ return {
459+ schemaVersion: SCHEMA_VERSION,
460+ code: "RATE_LIMITED" as const,
461+ occurredAt: 1_788_001_999,
462+ retryAfterSeconds: 30,
463+ };
464+ }
465+ return publicDeparture(1_788_002_000);
466+ },
467+ };
468+
469+ const rateLimited = await handleDepartureRequest({
470+ authorization: AUTHORIZATION,
471+ body,
472+ }, dependencies);
473+ assert.equal(isErrorResult(rateLimited), true);
474+ assert.deepEqual(rateLimited, {
475+ schemaVersion: SCHEMA_VERSION,
476+ requestId: body.requestId,
477+ favoriteId: body.favoriteId,
478+ code: "RATE_LIMITED",
479+ occurredAt: 1_788_001_999,
480+ retryAfterSeconds: 30,
481+ });
482+
483+ const afterError = await handleDepartureRequest({
484+ authorization: AUTHORIZATION,
485+ body: departureBody("error-request-2", "error-favorite-2", body.serviceId),
486+ }, dependencies);
487+ assert.equal(isDepartureResult(afterError), true);
488+ assert.equal(resolverCalls, 2);
489+
490+ const thrown = await handleDepartureRequest({
491+ authorization: AUTHORIZATION,
492+ body: departureBody("throw-request", "throw-favorite", "throw-service"),
493+ }, {
494+ nowMilliseconds: () => now,
495+ resolve: async () => {
496+ throw new Error(`arbitrary upstream failure containing ${TEST_KEY}`);
497+ },
498+ });
499+ assert.equal(isErrorResult(thrown), true);
500+ assert.equal("code" in thrown ? thrown.code : undefined, "SOURCE_UNAVAILABLE");
501+ assert.equal(JSON.stringify(thrown).includes(TEST_KEY), false);
502+ assert.equal(JSON.stringify(thrown).includes("arbitrary upstream"), false);
503+
504+ const malformed = await handleDepartureRequest({
505+ authorization: AUTHORIZATION,
506+ body: departureBody("malformed-request", "malformed-favorite", "malformed-service"),
507+ }, {
508+ nowMilliseconds: () => now,
509+ resolve: async () => ({
510+ ...publicDeparture(),
511+ requestId: "resolver-must-not-bind-callers",
512+ }) as unknown as PublicDepartureData,
513+ });
514+ assert.equal(isErrorResult(malformed), true);
515+ assert.equal("code" in malformed ? malformed.code : undefined, "INVALID_RESPONSE");
516+});
added packages/companion/package.json +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+{
2+ "name": "@lapin-fute/companion",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "commonjs",
6+ "main": "src/index.js",
7+ "scripts": {
8+ "test": "node --test test/*.test.js"
9+ }
10+}
new file mode 100644
@@ -0,0 +1,10 @@
1+{
2+ "name": "@lapin-fute/companion",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "commonjs",
6+ "main": "src/index.js",
7+ "scripts": {
8+ "test": "node --test test/*.test.js"
9+ }
10+}
added packages/companion/src/codec.js +155 -0
new file mode 100644
@@ -0,0 +1,155 @@
1+"use strict";
2+
3+var contracts = require("./contracts");
4+var T = contracts.MESSAGE_TYPE;
5+
6+function base(messageType) {
7+ return {
8+ SCHEMA_VERSION: contracts.SCHEMA_VERSION,
9+ MESSAGE_TYPE: messageType
10+ };
11+}
12+
13+function requireId(value, name) {
14+ if (!contracts.boundedString(value, contracts.LIMITS.idUtf8Bytes)) {
15+ throw new TypeError(name + " must be a bounded identifier");
16+ }
17+}
18+
19+function encodeRequest(request) {
20+ var message;
21+ requireId(request && request.requestId, "requestId");
22+ requireId(request && request.favoriteId, "favoriteId");
23+ if (Object.keys(contracts.REQUEST_TRIGGER).every(function (name) {
24+ return contracts.REQUEST_TRIGGER[name] !== request.trigger;
25+ })) {
26+ throw new TypeError("trigger is not supported");
27+ }
28+ message = base(T.REQUEST);
29+ message.REQUEST_ID = request.requestId;
30+ message.FAVORITE_ID = request.favoriteId;
31+ message.REQUEST_TRIGGER = request.trigger;
32+ return message;
33+}
34+
35+function decodeRequest(message) {
36+ var trigger;
37+ if (!contracts.isObject(message)
38+ || !contracts.hasOnlyKeys(message, [
39+ "SCHEMA_VERSION",
40+ "MESSAGE_TYPE",
41+ "REQUEST_ID",
42+ "FAVORITE_ID",
43+ "REQUEST_TRIGGER"
44+ ])
45+ || message.SCHEMA_VERSION !== contracts.SCHEMA_VERSION
46+ || message.MESSAGE_TYPE !== T.REQUEST) return null;
47+ trigger = message.REQUEST_TRIGGER;
48+ if (!contracts.boundedString(message.REQUEST_ID, contracts.LIMITS.idUtf8Bytes)
49+ || !contracts.boundedString(message.FAVORITE_ID, contracts.LIMITS.idUtf8Bytes)
50+ || Object.keys(contracts.REQUEST_TRIGGER).every(function (name) {
51+ return contracts.REQUEST_TRIGGER[name] !== trigger;
52+ })) return null;
53+ return {
54+ requestId: message.REQUEST_ID,
55+ favoriteId: message.FAVORITE_ID,
56+ trigger: trigger
57+ };
58+}
59+
60+function encodeConfiguration(sequenceId, favorites, keyStatus, language) {
61+ var messages = [];
62+ var begin;
63+ requireId(sequenceId, "sequenceId");
64+ if (!contracts.isFavoriteList(favorites)) throw new TypeError("favorites do not match the contract");
65+ if (Object.keys(contracts.KEY_STATUS).every(function (name) {
66+ return contracts.KEY_STATUS[name] !== keyStatus;
67+ })) throw new TypeError("keyStatus is not supported");
68+ if (language !== contracts.WIRE_LANGUAGE.EN && language !== contracts.WIRE_LANGUAGE.FR) {
69+ throw new TypeError("language must be en or fr");
70+ }
71+
72+ begin = base(T.CONFIG_BEGIN);
73+ begin.REQUEST_ID = sequenceId;
74+ begin.ITEM_COUNT = favorites.length;
75+ begin.KEY_STATUS = keyStatus;
76+ begin.DISPLAY_NAME = language;
77+ messages.push(begin);
78+
79+ favorites.forEach(function (favorite, index) {
80+ var message = base(T.FAVORITE);
81+ message.REQUEST_ID = sequenceId;
82+ message.ITEM_INDEX = index;
83+ message.FAVORITE_ID = favorite.id;
84+ message.SERVICE_ID = favorite.serviceId;
85+ if (typeof favorite.displayName !== "undefined") message.DISPLAY_NAME = favorite.displayName;
86+ message.STOP_LABEL = favorite.stopLabel;
87+ message.LINE_LABEL = favorite.lineLabel;
88+ message.DESTINATION_LABEL = favorite.destinationLabel;
89+ message.SORT_ORDER = favorite.sortOrder;
90+ messages.push(message);
91+ });
92+
93+ begin = base(T.CONFIG_COMMIT);
94+ begin.REQUEST_ID = sequenceId;
95+ messages.push(begin);
96+ return messages;
97+}
98+
99+function encodeResult(result) {
100+ var messages = [];
101+ var message;
102+ if (!contracts.isDepartureResult(result)) throw new TypeError("result does not match the contract");
103+
104+ message = base(T.RESULT_BEGIN);
105+ message.REQUEST_ID = result.requestId;
106+ message.FAVORITE_ID = result.favoriteId;
107+ message.ITEM_COUNT = result.departures.length;
108+ message.FETCHED_AT = result.fetchedAt;
109+ if (typeof result.sourceUpdatedAt !== "undefined") message.SOURCE_UPDATED_AT = result.sourceUpdatedAt;
110+ message.FRESHNESS = contracts.FRESHNESS.indexOf(result.freshness);
111+ messages.push(message);
112+
113+ result.departures.forEach(function (departure, index) {
114+ var item = base(T.DEPARTURE);
115+ item.REQUEST_ID = result.requestId;
116+ item.FAVORITE_ID = result.favoriteId;
117+ item.ITEM_INDEX = index;
118+ item.EXPECTED_AT = departure.expectedAt;
119+ if (typeof departure.aimedAt !== "undefined") item.AIMED_AT = departure.aimedAt;
120+ item.MINUTES = departure.minutes;
121+ item.DEPARTURE_STATUS = contracts.DEPARTURE_STATUS.indexOf(departure.status);
122+ if (typeof departure.nextIntervalMinutes !== "undefined") {
123+ item.NEXT_INTERVAL_MINUTES = departure.nextIntervalMinutes;
124+ }
125+ messages.push(item);
126+ });
127+
128+ message = base(T.RESULT_COMMIT);
129+ message.REQUEST_ID = result.requestId;
130+ message.FAVORITE_ID = result.favoriteId;
131+ messages.push(message);
132+ return messages;
133+}
134+
135+function encodeError(error) {
136+ var message;
137+ if (!contracts.isErrorResult(error)) throw new TypeError("error does not match the contract");
138+ message = base(T.ERROR);
139+ message.REQUEST_ID = error.requestId;
140+ if (typeof error.favoriteId !== "undefined") message.FAVORITE_ID = error.favoriteId;
141+ message.ERROR_CODE = contracts.ERROR_CODE.indexOf(error.code);
142+ message.OCCURRED_AT = error.occurredAt;
143+ if (typeof error.retryAfterSeconds !== "undefined") {
144+ message.RETRY_AFTER_SECONDS = error.retryAfterSeconds;
145+ }
146+ return message;
147+}
148+
149+module.exports = {
150+ encodeRequest: encodeRequest,
151+ decodeRequest: decodeRequest,
152+ encodeConfiguration: encodeConfiguration,
153+ encodeResult: encodeResult,
154+ encodeError: encodeError
155+};
new file mode 100644
@@ -0,0 +1,155 @@
1+"use strict";
2+
3+var contracts = require("./contracts");
4+var T = contracts.MESSAGE_TYPE;
5+
6+function base(messageType) {
7+ return {
8+ SCHEMA_VERSION: contracts.SCHEMA_VERSION,
9+ MESSAGE_TYPE: messageType
10+ };
11+}
12+
13+function requireId(value, name) {
14+ if (!contracts.boundedString(value, contracts.LIMITS.idUtf8Bytes)) {
15+ throw new TypeError(name + " must be a bounded identifier");
16+ }
17+}
18+
19+function encodeRequest(request) {
20+ var message;
21+ requireId(request && request.requestId, "requestId");
22+ requireId(request && request.favoriteId, "favoriteId");
23+ if (Object.keys(contracts.REQUEST_TRIGGER).every(function (name) {
24+ return contracts.REQUEST_TRIGGER[name] !== request.trigger;
25+ })) {
26+ throw new TypeError("trigger is not supported");
27+ }
28+ message = base(T.REQUEST);
29+ message.REQUEST_ID = request.requestId;
30+ message.FAVORITE_ID = request.favoriteId;
31+ message.REQUEST_TRIGGER = request.trigger;
32+ return message;
33+}
34+
35+function decodeRequest(message) {
36+ var trigger;
37+ if (!contracts.isObject(message)
38+ || !contracts.hasOnlyKeys(message, [
39+ "SCHEMA_VERSION",
40+ "MESSAGE_TYPE",
41+ "REQUEST_ID",
42+ "FAVORITE_ID",
43+ "REQUEST_TRIGGER"
44+ ])
45+ || message.SCHEMA_VERSION !== contracts.SCHEMA_VERSION
46+ || message.MESSAGE_TYPE !== T.REQUEST) return null;
47+ trigger = message.REQUEST_TRIGGER;
48+ if (!contracts.boundedString(message.REQUEST_ID, contracts.LIMITS.idUtf8Bytes)
49+ || !contracts.boundedString(message.FAVORITE_ID, contracts.LIMITS.idUtf8Bytes)
50+ || Object.keys(contracts.REQUEST_TRIGGER).every(function (name) {
51+ return contracts.REQUEST_TRIGGER[name] !== trigger;
52+ })) return null;
53+ return {
54+ requestId: message.REQUEST_ID,
55+ favoriteId: message.FAVORITE_ID,
56+ trigger: trigger
57+ };
58+}
59+
60+function encodeConfiguration(sequenceId, favorites, keyStatus, language) {
61+ var messages = [];
62+ var begin;
63+ requireId(sequenceId, "sequenceId");
64+ if (!contracts.isFavoriteList(favorites)) throw new TypeError("favorites do not match the contract");
65+ if (Object.keys(contracts.KEY_STATUS).every(function (name) {
66+ return contracts.KEY_STATUS[name] !== keyStatus;
67+ })) throw new TypeError("keyStatus is not supported");
68+ if (language !== contracts.WIRE_LANGUAGE.EN && language !== contracts.WIRE_LANGUAGE.FR) {
69+ throw new TypeError("language must be en or fr");
70+ }
71+
72+ begin = base(T.CONFIG_BEGIN);
73+ begin.REQUEST_ID = sequenceId;
74+ begin.ITEM_COUNT = favorites.length;
75+ begin.KEY_STATUS = keyStatus;
76+ begin.DISPLAY_NAME = language;
77+ messages.push(begin);
78+
79+ favorites.forEach(function (favorite, index) {
80+ var message = base(T.FAVORITE);
81+ message.REQUEST_ID = sequenceId;
82+ message.ITEM_INDEX = index;
83+ message.FAVORITE_ID = favorite.id;
84+ message.SERVICE_ID = favorite.serviceId;
85+ if (typeof favorite.displayName !== "undefined") message.DISPLAY_NAME = favorite.displayName;
86+ message.STOP_LABEL = favorite.stopLabel;
87+ message.LINE_LABEL = favorite.lineLabel;
88+ message.DESTINATION_LABEL = favorite.destinationLabel;
89+ message.SORT_ORDER = favorite.sortOrder;
90+ messages.push(message);
91+ });
92+
93+ begin = base(T.CONFIG_COMMIT);
94+ begin.REQUEST_ID = sequenceId;
95+ messages.push(begin);
96+ return messages;
97+}
98+
99+function encodeResult(result) {
100+ var messages = [];
101+ var message;
102+ if (!contracts.isDepartureResult(result)) throw new TypeError("result does not match the contract");
103+
104+ message = base(T.RESULT_BEGIN);
105+ message.REQUEST_ID = result.requestId;
106+ message.FAVORITE_ID = result.favoriteId;
107+ message.ITEM_COUNT = result.departures.length;
108+ message.FETCHED_AT = result.fetchedAt;
109+ if (typeof result.sourceUpdatedAt !== "undefined") message.SOURCE_UPDATED_AT = result.sourceUpdatedAt;
110+ message.FRESHNESS = contracts.FRESHNESS.indexOf(result.freshness);
111+ messages.push(message);
112+
113+ result.departures.forEach(function (departure, index) {
114+ var item = base(T.DEPARTURE);
115+ item.REQUEST_ID = result.requestId;
116+ item.FAVORITE_ID = result.favoriteId;
117+ item.ITEM_INDEX = index;
118+ item.EXPECTED_AT = departure.expectedAt;
119+ if (typeof departure.aimedAt !== "undefined") item.AIMED_AT = departure.aimedAt;
120+ item.MINUTES = departure.minutes;
121+ item.DEPARTURE_STATUS = contracts.DEPARTURE_STATUS.indexOf(departure.status);
122+ if (typeof departure.nextIntervalMinutes !== "undefined") {
123+ item.NEXT_INTERVAL_MINUTES = departure.nextIntervalMinutes;
124+ }
125+ messages.push(item);
126+ });
127+
128+ message = base(T.RESULT_COMMIT);
129+ message.REQUEST_ID = result.requestId;
130+ message.FAVORITE_ID = result.favoriteId;
131+ messages.push(message);
132+ return messages;
133+}
134+
135+function encodeError(error) {
136+ var message;
137+ if (!contracts.isErrorResult(error)) throw new TypeError("error does not match the contract");
138+ message = base(T.ERROR);
139+ message.REQUEST_ID = error.requestId;
140+ if (typeof error.favoriteId !== "undefined") message.FAVORITE_ID = error.favoriteId;
141+ message.ERROR_CODE = contracts.ERROR_CODE.indexOf(error.code);
142+ message.OCCURRED_AT = error.occurredAt;
143+ if (typeof error.retryAfterSeconds !== "undefined") {
144+ message.RETRY_AFTER_SECONDS = error.retryAfterSeconds;
145+ }
146+ return message;
147+}
148+
149+module.exports = {
150+ encodeRequest: encodeRequest,
151+ decodeRequest: decodeRequest,
152+ encodeConfiguration: encodeConfiguration,
153+ encodeResult: encodeResult,
154+ encodeError: encodeError
155+};
added packages/companion/src/configuration.js +185 -0
new file mode 100644
@@ -0,0 +1,185 @@
1+"use strict";
2+
3+var contracts = require("./contracts");
4+var STORAGE_KEY = "lapin-fute.configuration.v1";
5+var RECORD_KEYS = ["schemaVersion", "favorites", "apiKey"];
6+var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate"];
7+var MAX_CLOSE_RESPONSE_LENGTH = 32768;
8+var FAVORITE_STRING_KEYS = [
9+ "id",
10+ "serviceId",
11+ "displayName",
12+ "stopLabel",
13+ "lineLabel",
14+ "destinationLabel"
15+];
16+
17+function emptyConfiguration() {
18+ return { schemaVersion: contracts.SCHEMA_VERSION, favorites: [], apiKey: null };
19+}
20+function isFavoriteSecretFree(favorite, firstSecret, secondSecret) {
21+ var field;
22+ var value;
23+ for (field = 0; field < FAVORITE_STRING_KEYS.length; field += 1) {
24+ value = favorite[FAVORITE_STRING_KEYS[field]];
25+ if (typeof value !== "string") continue;
26+ if (typeof firstSecret === "string" && firstSecret.length > 0 && value.indexOf(firstSecret) !== -1) return false;
27+ if (typeof secondSecret === "string" && secondSecret.length > 0 && value.indexOf(secondSecret) !== -1) return false;
28+ }
29+ return true;
30+}
31+
32+function areFavoritesSecretFree(favorites, firstSecret, secondSecret) {
33+ var index;
34+ for (index = 0; index < favorites.length; index += 1) {
35+ if (!isFavoriteSecretFree(favorites[index], firstSecret, secondSecret)) return false;
36+ }
37+ return true;
38+}
39+
40+function normalizeLanguage(value) {
41+ if (typeof value !== "string") return contracts.WIRE_LANGUAGE.EN;
42+ return value.toLowerCase().split(/[-_]/)[0] === contracts.WIRE_LANGUAGE.FR
43+ ? contracts.WIRE_LANGUAGE.FR
44+ : contracts.WIRE_LANGUAGE.EN;
45+}
46+
47+function activeWatchLanguage(Pebble) {
48+ var info;
49+ try {
50+ if (!Pebble || typeof Pebble.getActiveWatchInfo !== "function") {
51+ return contracts.WIRE_LANGUAGE.EN;
52+ }
53+ info = Pebble.getActiveWatchInfo();
54+ if (!contracts.isObject(info)) return contracts.WIRE_LANGUAGE.EN;
55+ return normalizeLanguage(info.language);
56+ } catch (ignored) {
57+ return contracts.WIRE_LANGUAGE.EN;
58+ }
59+}
60+
61+
62+function isStoredConfiguration(value) {
63+ return contracts.isObject(value)
64+ && contracts.hasOnlyKeys(value, RECORD_KEYS)
65+ && value.schemaVersion === contracts.SCHEMA_VERSION
66+ && contracts.isFavoriteList(value.favorites)
67+ && (value.apiKey === null || contracts.isPersonalApiKey(value.apiKey))
68+ && areFavoritesSecretFree(value.favorites, value.apiKey, null);
69+}
70+
71+function isConfigurationUpdate(value) {
72+ return contracts.isObject(value)
73+ && contracts.hasOnlyKeys(value, UPDATE_KEYS)
74+ && value.schemaVersion === contracts.SCHEMA_VERSION
75+ && contracts.isFavoriteList(value.favorites)
76+ && contracts.isApiKeyUpdate(value.apiKeyUpdate);
77+}
78+
79+function copyConfiguration(configuration) {
80+ return {
81+ schemaVersion: contracts.SCHEMA_VERSION,
82+ favorites: configuration.favorites.map(contracts.copyFavorite),
83+ apiKey: configuration.apiKey
84+ };
85+}
86+
87+function loadConfiguration(storage) {
88+ var parsed;
89+ var serialized;
90+ try {
91+ serialized = storage.getItem(STORAGE_KEY);
92+ if (typeof serialized !== "string" || serialized.length === 0) return emptyConfiguration();
93+ parsed = JSON.parse(serialized);
94+ } catch (ignored) {
95+ return emptyConfiguration();
96+ }
97+ return isStoredConfiguration(parsed) ? copyConfiguration(parsed) : emptyConfiguration();
98+}
99+
100+function saveConfiguration(storage, configuration) {
101+ if (!isStoredConfiguration(configuration)) return false;
102+ try {
103+ storage.setItem(STORAGE_KEY, JSON.stringify(configuration));
104+ return true;
105+ } catch (ignored) {
106+ return false;
107+ }
108+}
109+
110+function applyConfigurationUpdate(current, update) {
111+ var apiKey;
112+ var replacementKey = null;
113+ if (!isStoredConfiguration(current) || !isConfigurationUpdate(update)) return null;
114+ if (update.apiKeyUpdate.action === "REPLACE") replacementKey = update.apiKeyUpdate.value;
115+ if (!areFavoritesSecretFree(update.favorites, current.apiKey, replacementKey)) return null;
116+ apiKey = current.apiKey;
117+ if (replacementKey !== null) apiKey = replacementKey;
118+ if (update.apiKeyUpdate.action === "REMOVE") apiKey = null;
119+ return {
120+ schemaVersion: contracts.SCHEMA_VERSION,
121+ favorites: update.favorites.map(contracts.copyFavorite),
122+ apiKey: apiKey
123+ };
124+}
125+
126+function parseCloseFragment(response) {
127+ var fragment;
128+ var hashIndex;
129+ var parsed;
130+ if (typeof response !== "string"
131+ || response.length === 0
132+ || response.length > MAX_CLOSE_RESPONSE_LENGTH
133+ || response === "CANCELLED") return null;
134+ hashIndex = response.indexOf("#");
135+ fragment = hashIndex === -1 ? response : response.slice(hashIndex + 1);
136+ if (fragment.charAt(0) === "#") fragment = fragment.slice(1);
137+ if (fragment.length === 0 || fragment === "CANCELLED") return null;
138+ try {
139+ fragment = decodeURIComponent(fragment);
140+ parsed = JSON.parse(fragment);
141+ } catch (ignored) {
142+ return null;
143+ }
144+ if (!isConfigurationUpdate(parsed)) return null;
145+ return {
146+ schemaVersion: contracts.SCHEMA_VERSION,
147+ favorites: parsed.favorites.map(contracts.copyFavorite),
148+ apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE"
149+ ? { schemaVersion: contracts.SCHEMA_VERSION, action: "REPLACE", value: parsed.apiKeyUpdate.value }
150+ : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action }
151+ };
152+}
153+
154+function configurationPageState(configuration, language) {
155+ return {
156+ hasKey: configuration.apiKey !== null,
157+ favorites: configuration.favorites.map(contracts.copyFavorite),
158+ language: typeof language === "string" && language.length > 0 ? language : "en"
159+ };
160+}
161+
162+function configurationUrl(baseUrl, configuration, language) {
163+ var withoutFragment;
164+ if (typeof baseUrl !== "string" || baseUrl.length === 0 || !isStoredConfiguration(configuration)) return null;
165+ withoutFragment = baseUrl.split("#")[0];
166+ return withoutFragment + "#" + encodeURIComponent(JSON.stringify(configurationPageState(configuration, language)));
167+}
168+
169+module.exports = {
170+ STORAGE_KEY: STORAGE_KEY,
171+ MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH,
172+ emptyConfiguration: emptyConfiguration,
173+ isStoredConfiguration: isStoredConfiguration,
174+ isConfigurationUpdate: isConfigurationUpdate,
175+ isFavoriteSecretFree: isFavoriteSecretFree,
176+ areFavoritesSecretFree: areFavoritesSecretFree,
177+ normalizeLanguage: normalizeLanguage,
178+ activeWatchLanguage: activeWatchLanguage,
179+ loadConfiguration: loadConfiguration,
180+ saveConfiguration: saveConfiguration,
181+ applyConfigurationUpdate: applyConfigurationUpdate,
182+ parseCloseFragment: parseCloseFragment,
183+ configurationPageState: configurationPageState,
184+ configurationUrl: configurationUrl
185+};
new file mode 100644
@@ -0,0 +1,185 @@
1+"use strict";
2+
3+var contracts = require("./contracts");
4+var STORAGE_KEY = "lapin-fute.configuration.v1";
5+var RECORD_KEYS = ["schemaVersion", "favorites", "apiKey"];
6+var UPDATE_KEYS = ["schemaVersion", "favorites", "apiKeyUpdate"];
7+var MAX_CLOSE_RESPONSE_LENGTH = 32768;
8+var FAVORITE_STRING_KEYS = [
9+ "id",
10+ "serviceId",
11+ "displayName",
12+ "stopLabel",
13+ "lineLabel",
14+ "destinationLabel"
15+];
16+
17+function emptyConfiguration() {
18+ return { schemaVersion: contracts.SCHEMA_VERSION, favorites: [], apiKey: null };
19+}
20+function isFavoriteSecretFree(favorite, firstSecret, secondSecret) {
21+ var field;
22+ var value;
23+ for (field = 0; field < FAVORITE_STRING_KEYS.length; field += 1) {
24+ value = favorite[FAVORITE_STRING_KEYS[field]];
25+ if (typeof value !== "string") continue;
26+ if (typeof firstSecret === "string" && firstSecret.length > 0 && value.indexOf(firstSecret) !== -1) return false;
27+ if (typeof secondSecret === "string" && secondSecret.length > 0 && value.indexOf(secondSecret) !== -1) return false;
28+ }
29+ return true;
30+}
31+
32+function areFavoritesSecretFree(favorites, firstSecret, secondSecret) {
33+ var index;
34+ for (index = 0; index < favorites.length; index += 1) {
35+ if (!isFavoriteSecretFree(favorites[index], firstSecret, secondSecret)) return false;
36+ }
37+ return true;
38+}
39+
40+function normalizeLanguage(value) {
41+ if (typeof value !== "string") return contracts.WIRE_LANGUAGE.EN;
42+ return value.toLowerCase().split(/[-_]/)[0] === contracts.WIRE_LANGUAGE.FR
43+ ? contracts.WIRE_LANGUAGE.FR
44+ : contracts.WIRE_LANGUAGE.EN;
45+}
46+
47+function activeWatchLanguage(Pebble) {
48+ var info;
49+ try {
50+ if (!Pebble || typeof Pebble.getActiveWatchInfo !== "function") {
51+ return contracts.WIRE_LANGUAGE.EN;
52+ }
53+ info = Pebble.getActiveWatchInfo();
54+ if (!contracts.isObject(info)) return contracts.WIRE_LANGUAGE.EN;
55+ return normalizeLanguage(info.language);
56+ } catch (ignored) {
57+ return contracts.WIRE_LANGUAGE.EN;
58+ }
59+}
60+
61+
62+function isStoredConfiguration(value) {
63+ return contracts.isObject(value)
64+ && contracts.hasOnlyKeys(value, RECORD_KEYS)
65+ && value.schemaVersion === contracts.SCHEMA_VERSION
66+ && contracts.isFavoriteList(value.favorites)
67+ && (value.apiKey === null || contracts.isPersonalApiKey(value.apiKey))
68+ && areFavoritesSecretFree(value.favorites, value.apiKey, null);
69+}
70+
71+function isConfigurationUpdate(value) {
72+ return contracts.isObject(value)
73+ && contracts.hasOnlyKeys(value, UPDATE_KEYS)
74+ && value.schemaVersion === contracts.SCHEMA_VERSION
75+ && contracts.isFavoriteList(value.favorites)
76+ && contracts.isApiKeyUpdate(value.apiKeyUpdate);
77+}
78+
79+function copyConfiguration(configuration) {
80+ return {
81+ schemaVersion: contracts.SCHEMA_VERSION,
82+ favorites: configuration.favorites.map(contracts.copyFavorite),
83+ apiKey: configuration.apiKey
84+ };
85+}
86+
87+function loadConfiguration(storage) {
88+ var parsed;
89+ var serialized;
90+ try {
91+ serialized = storage.getItem(STORAGE_KEY);
92+ if (typeof serialized !== "string" || serialized.length === 0) return emptyConfiguration();
93+ parsed = JSON.parse(serialized);
94+ } catch (ignored) {
95+ return emptyConfiguration();
96+ }
97+ return isStoredConfiguration(parsed) ? copyConfiguration(parsed) : emptyConfiguration();
98+}
99+
100+function saveConfiguration(storage, configuration) {
101+ if (!isStoredConfiguration(configuration)) return false;
102+ try {
103+ storage.setItem(STORAGE_KEY, JSON.stringify(configuration));
104+ return true;
105+ } catch (ignored) {
106+ return false;
107+ }
108+}
109+
110+function applyConfigurationUpdate(current, update) {
111+ var apiKey;
112+ var replacementKey = null;
113+ if (!isStoredConfiguration(current) || !isConfigurationUpdate(update)) return null;
114+ if (update.apiKeyUpdate.action === "REPLACE") replacementKey = update.apiKeyUpdate.value;
115+ if (!areFavoritesSecretFree(update.favorites, current.apiKey, replacementKey)) return null;
116+ apiKey = current.apiKey;
117+ if (replacementKey !== null) apiKey = replacementKey;
118+ if (update.apiKeyUpdate.action === "REMOVE") apiKey = null;
119+ return {
120+ schemaVersion: contracts.SCHEMA_VERSION,
121+ favorites: update.favorites.map(contracts.copyFavorite),
122+ apiKey: apiKey
123+ };
124+}
125+
126+function parseCloseFragment(response) {
127+ var fragment;
128+ var hashIndex;
129+ var parsed;
130+ if (typeof response !== "string"
131+ || response.length === 0
132+ || response.length > MAX_CLOSE_RESPONSE_LENGTH
133+ || response === "CANCELLED") return null;
134+ hashIndex = response.indexOf("#");
135+ fragment = hashIndex === -1 ? response : response.slice(hashIndex + 1);
136+ if (fragment.charAt(0) === "#") fragment = fragment.slice(1);
137+ if (fragment.length === 0 || fragment === "CANCELLED") return null;
138+ try {
139+ fragment = decodeURIComponent(fragment);
140+ parsed = JSON.parse(fragment);
141+ } catch (ignored) {
142+ return null;
143+ }
144+ if (!isConfigurationUpdate(parsed)) return null;
145+ return {
146+ schemaVersion: contracts.SCHEMA_VERSION,
147+ favorites: parsed.favorites.map(contracts.copyFavorite),
148+ apiKeyUpdate: parsed.apiKeyUpdate.action === "REPLACE"
149+ ? { schemaVersion: contracts.SCHEMA_VERSION, action: "REPLACE", value: parsed.apiKeyUpdate.value }
150+ : { schemaVersion: contracts.SCHEMA_VERSION, action: parsed.apiKeyUpdate.action }
151+ };
152+}
153+
154+function configurationPageState(configuration, language) {
155+ return {
156+ hasKey: configuration.apiKey !== null,
157+ favorites: configuration.favorites.map(contracts.copyFavorite),
158+ language: typeof language === "string" && language.length > 0 ? language : "en"
159+ };
160+}
161+
162+function configurationUrl(baseUrl, configuration, language) {
163+ var withoutFragment;
164+ if (typeof baseUrl !== "string" || baseUrl.length === 0 || !isStoredConfiguration(configuration)) return null;
165+ withoutFragment = baseUrl.split("#")[0];
166+ return withoutFragment + "#" + encodeURIComponent(JSON.stringify(configurationPageState(configuration, language)));
167+}
168+
169+module.exports = {
170+ STORAGE_KEY: STORAGE_KEY,
171+ MAX_CLOSE_RESPONSE_LENGTH: MAX_CLOSE_RESPONSE_LENGTH,
172+ emptyConfiguration: emptyConfiguration,
173+ isStoredConfiguration: isStoredConfiguration,
174+ isConfigurationUpdate: isConfigurationUpdate,
175+ isFavoriteSecretFree: isFavoriteSecretFree,
176+ areFavoritesSecretFree: areFavoritesSecretFree,
177+ normalizeLanguage: normalizeLanguage,
178+ activeWatchLanguage: activeWatchLanguage,
179+ loadConfiguration: loadConfiguration,
180+ saveConfiguration: saveConfiguration,
181+ applyConfigurationUpdate: applyConfigurationUpdate,
182+ parseCloseFragment: parseCloseFragment,
183+ configurationPageState: configurationPageState,
184+ configurationUrl: configurationUrl
185+};
added packages/companion/src/contracts.js +322 -0
new file mode 100644
@@ -0,0 +1,322 @@
1+"use strict";
2+
3+var SCHEMA_VERSION = 1;
4+var LIMITS = Object.freeze({
5+ apiKeyUtf8Bytes: 512,
6+ idUtf8Bytes: 64,
7+ labelUtf8Bytes: 96,
8+ favorites: 8,
9+ departures: 4,
10+ httpTimeoutMs: 8000,
11+ httpResponseBytes: 262144
12+});
13+var CACHE_FRESH_SECONDS = 60;
14+var FAVORITE_SETTLE_MS = 500;
15+var FRESHNESS = Object.freeze(["REALTIME", "SCHEDULED", "MIXED", "STALE"]);
16+var DEPARTURE_STATUS = Object.freeze(["ON_TIME", "DELAYED", "CANCELLED", "UNKNOWN"]);
17+var ERROR_CODE = Object.freeze([
18+ "API_KEY_REQUIRED",
19+ "API_KEY_INVALID",
20+ "INVALID_SERVICE",
21+ "SOURCE_UNAVAILABLE",
22+ "RATE_LIMITED",
23+ "INVALID_RESPONSE"
24+]);
25+var API_KEY_ACTION = Object.freeze(["KEEP", "REPLACE", "REMOVE"]);
26+var KEY_STATUS = Object.freeze({ MISSING: 0, CONFIGURED: 1, INVALID: 2 });
27+var REQUEST_TRIGGER = Object.freeze({ APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 });
28+var MESSAGE_TYPE = Object.freeze({
29+ REQUEST: 1,
30+ CONFIG_BEGIN: 2,
31+ FAVORITE: 3,
32+ CONFIG_COMMIT: 4,
33+ RESULT_BEGIN: 5,
34+ DEPARTURE: 6,
35+ RESULT_COMMIT: 7,
36+ ERROR: 8
37+});
38+var APP_MESSAGE_KEY_ORDER = Object.freeze([
39+ "SCHEMA_VERSION",
40+ "MESSAGE_TYPE",
41+ "REQUEST_ID",
42+ "FAVORITE_ID",
43+ "SERVICE_ID",
44+ "DISPLAY_NAME",
45+ "STOP_LABEL",
46+ "LINE_LABEL",
47+ "DESTINATION_LABEL",
48+ "SORT_ORDER",
49+ "KEY_STATUS",
50+ "ITEM_COUNT",
51+ "ITEM_INDEX",
52+ "FETCHED_AT",
53+ "SOURCE_UPDATED_AT",
54+ "FRESHNESS",
55+ "EXPECTED_AT",
56+ "AIMED_AT",
57+ "MINUTES",
58+ "DEPARTURE_STATUS",
59+ "NEXT_INTERVAL_MINUTES",
60+ "ERROR_CODE",
61+ "OCCURRED_AT",
62+ "RETRY_AFTER_SECONDS",
63+ "REQUEST_TRIGGER"
64+]);
65+
66+var APP_MESSAGE_KEY = Object.freeze({
67+ SCHEMA_VERSION: 0,
68+ MESSAGE_TYPE: 1,
69+ REQUEST_ID: 2,
70+ FAVORITE_ID: 3,
71+ SERVICE_ID: 4,
72+ DISPLAY_NAME: 5,
73+ STOP_LABEL: 6,
74+ LINE_LABEL: 7,
75+ DESTINATION_LABEL: 8,
76+ SORT_ORDER: 9,
77+ KEY_STATUS: 10,
78+ ITEM_COUNT: 11,
79+ ITEM_INDEX: 12,
80+ FETCHED_AT: 13,
81+ SOURCE_UPDATED_AT: 14,
82+ FRESHNESS: 15,
83+ EXPECTED_AT: 16,
84+ AIMED_AT: 17,
85+ MINUTES: 18,
86+ DEPARTURE_STATUS: 19,
87+ NEXT_INTERVAL_MINUTES: 20,
88+ ERROR_CODE: 21,
89+ OCCURRED_AT: 22,
90+ RETRY_AFTER_SECONDS: 23,
91+ REQUEST_TRIGGER: 24
92+});
93+
94+var UINT32_MAX = 4294967295;
95+var FAVORITE_KEYS = [
96+ "schemaVersion",
97+ "id",
98+ "serviceId",
99+ "displayName",
100+ "stopLabel",
101+ "lineLabel",
102+ "destinationLabel",
103+ "sortOrder"
104+];
105+var DEPARTURE_KEYS = ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"];
106+var RESULT_KEYS = [
107+ "schemaVersion",
108+ "requestId",
109+ "favoriteId",
110+ "fetchedAt",
111+ "sourceUpdatedAt",
112+ "freshness",
113+ "departures"
114+];
115+var ERROR_KEYS = [
116+ "schemaVersion",
117+ "requestId",
118+ "favoriteId",
119+ "code",
120+ "occurredAt",
121+ "retryAfterSeconds"
122+];
123+var KEY_UPDATE_KEYS = ["schemaVersion", "action", "value"];
124+var WIRE_LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });
125+
126+function isObject(value) {
127+ return typeof value === "object" && value !== null && !Array.isArray(value);
128+}
129+
130+function hasOnlyKeys(value, allowed) {
131+ return Object.keys(value).every(function (key) {
132+ return allowed.indexOf(key) !== -1;
133+ });
134+}
135+
136+function utf8Bytes(value) {
137+ var total = 0;
138+ var index;
139+ var code;
140+ var next;
141+ for (index = 0; index < value.length; index += 1) {
142+ code = value.charCodeAt(index);
143+ if (code <= 127) {
144+ total += 1;
145+ } else if (code <= 2047) {
146+ total += 2;
147+ } else if (code >= 55296 && code <= 56319) {
148+ next = value.charCodeAt(index + 1);
149+ if (next >= 56320 && next <= 57343) {
150+ total += 4;
151+ index += 1;
152+ } else {
153+ total += 3;
154+ }
155+ } else {
156+ total += 3;
157+ }
158+ }
159+ return total;
160+}
161+
162+function boundedString(value, maximum) {
163+ var size;
164+ if (typeof value !== "string") return false;
165+ size = utf8Bytes(value);
166+ return size >= 1 && size <= maximum;
167+}
168+
169+function uint32(value) {
170+ return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 && value <= UINT32_MAX;
171+}
172+
173+function optionalUint32(value) {
174+ return typeof value === "undefined" || uint32(value);
175+}
176+
177+function isFavorite(value) {
178+ return isObject(value)
179+ && hasOnlyKeys(value, FAVORITE_KEYS)
180+ && value.schemaVersion === SCHEMA_VERSION
181+ && boundedString(value.id, LIMITS.idUtf8Bytes)
182+ && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
183+ && (typeof value.displayName === "undefined" || boundedString(value.displayName, LIMITS.labelUtf8Bytes))
184+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
185+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
186+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
187+ && typeof value.sortOrder === "number"
188+ && Math.floor(value.sortOrder) === value.sortOrder
189+ && value.sortOrder >= 0
190+ && value.sortOrder < LIMITS.favorites;
191+}
192+
193+function isFavoriteList(value) {
194+ var seen = Object.create(null);
195+ if (!Array.isArray(value) || value.length > LIMITS.favorites) return false;
196+ return value.every(function (favorite) {
197+ if (!isFavorite(favorite) || seen[favorite.id]) return false;
198+ seen[favorite.id] = true;
199+ return true;
200+ });
201+}
202+
203+function isDeparture(value) {
204+ return isObject(value)
205+ && hasOnlyKeys(value, DEPARTURE_KEYS)
206+ && uint32(value.expectedAt)
207+ && optionalUint32(value.aimedAt)
208+ && typeof value.minutes === "number"
209+ && Math.floor(value.minutes) === value.minutes
210+ && value.minutes >= -1440
211+ && value.minutes <= 1440
212+ && DEPARTURE_STATUS.indexOf(value.status) !== -1
213+ && (typeof value.nextIntervalMinutes === "undefined"
214+ || (typeof value.nextIntervalMinutes === "number"
215+ && Math.floor(value.nextIntervalMinutes) === value.nextIntervalMinutes
216+ && value.nextIntervalMinutes >= 0
217+ && value.nextIntervalMinutes <= 1440));
218+}
219+
220+function isDepartureResult(value) {
221+ return isObject(value)
222+ && hasOnlyKeys(value, RESULT_KEYS)
223+ && value.schemaVersion === SCHEMA_VERSION
224+ && boundedString(value.requestId, LIMITS.idUtf8Bytes)
225+ && boundedString(value.favoriteId, LIMITS.idUtf8Bytes)
226+ && uint32(value.fetchedAt)
227+ && optionalUint32(value.sourceUpdatedAt)
228+ && FRESHNESS.indexOf(value.freshness) !== -1
229+ && Array.isArray(value.departures)
230+ && value.departures.length <= LIMITS.departures
231+ && value.departures.every(isDeparture);
232+}
233+
234+function isErrorResult(value) {
235+ return isObject(value)
236+ && hasOnlyKeys(value, ERROR_KEYS)
237+ && value.schemaVersion === SCHEMA_VERSION
238+ && boundedString(value.requestId, LIMITS.idUtf8Bytes)
239+ && (typeof value.favoriteId === "undefined" || boundedString(value.favoriteId, LIMITS.idUtf8Bytes))
240+ && ERROR_CODE.indexOf(value.code) !== -1
241+ && uint32(value.occurredAt)
242+ && optionalUint32(value.retryAfterSeconds);
243+}
244+
245+function isPersonalApiKey(value) {
246+ return boundedString(value, LIMITS.apiKeyUtf8Bytes) && value.indexOf("\r") === -1 && value.indexOf("\n") === -1;
247+}
248+
249+function isApiKeyUpdate(value) {
250+ if (!isObject(value) || !hasOnlyKeys(value, KEY_UPDATE_KEYS) || value.schemaVersion !== SCHEMA_VERSION) return false;
251+ if (value.action === "REPLACE") return isPersonalApiKey(value.value);
252+ return (value.action === "KEEP" || value.action === "REMOVE") && typeof value.value === "undefined";
253+}
254+
255+function copyFavorite(favorite) {
256+ var copy = {
257+ schemaVersion: SCHEMA_VERSION,
258+ id: favorite.id,
259+ serviceId: favorite.serviceId,
260+ stopLabel: favorite.stopLabel,
261+ lineLabel: favorite.lineLabel,
262+ destinationLabel: favorite.destinationLabel,
263+ sortOrder: favorite.sortOrder
264+ };
265+ if (typeof favorite.displayName !== "undefined") copy.displayName = favorite.displayName;
266+ return copy;
267+}
268+
269+function copyDeparture(departure) {
270+ var copy = {
271+ expectedAt: departure.expectedAt,
272+ minutes: departure.minutes,
273+ status: departure.status
274+ };
275+ if (typeof departure.aimedAt !== "undefined") copy.aimedAt = departure.aimedAt;
276+ if (typeof departure.nextIntervalMinutes !== "undefined") copy.nextIntervalMinutes = departure.nextIntervalMinutes;
277+ return copy;
278+}
279+
280+function copyDepartureResult(result, requestId) {
281+ var copy = {
282+ schemaVersion: SCHEMA_VERSION,
283+ requestId: typeof requestId === "string" ? requestId : result.requestId,
284+ favoriteId: result.favoriteId,
285+ fetchedAt: result.fetchedAt,
286+ freshness: result.freshness,
287+ departures: result.departures.map(copyDeparture)
288+ };
289+ if (typeof result.sourceUpdatedAt !== "undefined") copy.sourceUpdatedAt = result.sourceUpdatedAt;
290+ return copy;
291+}
292+
293+module.exports = {
294+ SCHEMA_VERSION: SCHEMA_VERSION,
295+ LIMITS: LIMITS,
296+ CACHE_FRESH_SECONDS: CACHE_FRESH_SECONDS,
297+ FAVORITE_SETTLE_MS: FAVORITE_SETTLE_MS,
298+ FRESHNESS: FRESHNESS,
299+ DEPARTURE_STATUS: DEPARTURE_STATUS,
300+ ERROR_CODE: ERROR_CODE,
301+ API_KEY_ACTION: API_KEY_ACTION,
302+ KEY_STATUS: KEY_STATUS,
303+ REQUEST_TRIGGER: REQUEST_TRIGGER,
304+ MESSAGE_TYPE: MESSAGE_TYPE,
305+ APP_MESSAGE_KEY_ORDER: APP_MESSAGE_KEY_ORDER,
306+ APP_MESSAGE_KEY: APP_MESSAGE_KEY,
307+ WIRE_LANGUAGE: WIRE_LANGUAGE,
308+ utf8Bytes: utf8Bytes,
309+ uint32: uint32,
310+ isObject: isObject,
311+ hasOnlyKeys: hasOnlyKeys,
312+ boundedString: boundedString,
313+ isFavorite: isFavorite,
314+ isFavoriteList: isFavoriteList,
315+ isDeparture: isDeparture,
316+ isDepartureResult: isDepartureResult,
317+ isErrorResult: isErrorResult,
318+ isPersonalApiKey: isPersonalApiKey,
319+ isApiKeyUpdate: isApiKeyUpdate,
320+ copyFavorite: copyFavorite,
321+ copyDepartureResult: copyDepartureResult
322+};
new file mode 100644
@@ -0,0 +1,322 @@
1+"use strict";
2+
3+var SCHEMA_VERSION = 1;
4+var LIMITS = Object.freeze({
5+ apiKeyUtf8Bytes: 512,
6+ idUtf8Bytes: 64,
7+ labelUtf8Bytes: 96,
8+ favorites: 8,
9+ departures: 4,
10+ httpTimeoutMs: 8000,
11+ httpResponseBytes: 262144
12+});
13+var CACHE_FRESH_SECONDS = 60;
14+var FAVORITE_SETTLE_MS = 500;
15+var FRESHNESS = Object.freeze(["REALTIME", "SCHEDULED", "MIXED", "STALE"]);
16+var DEPARTURE_STATUS = Object.freeze(["ON_TIME", "DELAYED", "CANCELLED", "UNKNOWN"]);
17+var ERROR_CODE = Object.freeze([
18+ "API_KEY_REQUIRED",
19+ "API_KEY_INVALID",
20+ "INVALID_SERVICE",
21+ "SOURCE_UNAVAILABLE",
22+ "RATE_LIMITED",
23+ "INVALID_RESPONSE"
24+]);
25+var API_KEY_ACTION = Object.freeze(["KEEP", "REPLACE", "REMOVE"]);
26+var KEY_STATUS = Object.freeze({ MISSING: 0, CONFIGURED: 1, INVALID: 2 });
27+var REQUEST_TRIGGER = Object.freeze({ APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 });
28+var MESSAGE_TYPE = Object.freeze({
29+ REQUEST: 1,
30+ CONFIG_BEGIN: 2,
31+ FAVORITE: 3,
32+ CONFIG_COMMIT: 4,
33+ RESULT_BEGIN: 5,
34+ DEPARTURE: 6,
35+ RESULT_COMMIT: 7,
36+ ERROR: 8
37+});
38+var APP_MESSAGE_KEY_ORDER = Object.freeze([
39+ "SCHEMA_VERSION",
40+ "MESSAGE_TYPE",
41+ "REQUEST_ID",
42+ "FAVORITE_ID",
43+ "SERVICE_ID",
44+ "DISPLAY_NAME",
45+ "STOP_LABEL",
46+ "LINE_LABEL",
47+ "DESTINATION_LABEL",
48+ "SORT_ORDER",
49+ "KEY_STATUS",
50+ "ITEM_COUNT",
51+ "ITEM_INDEX",
52+ "FETCHED_AT",
53+ "SOURCE_UPDATED_AT",
54+ "FRESHNESS",
55+ "EXPECTED_AT",
56+ "AIMED_AT",
57+ "MINUTES",
58+ "DEPARTURE_STATUS",
59+ "NEXT_INTERVAL_MINUTES",
60+ "ERROR_CODE",
61+ "OCCURRED_AT",
62+ "RETRY_AFTER_SECONDS",
63+ "REQUEST_TRIGGER"
64+]);
65+
66+var APP_MESSAGE_KEY = Object.freeze({
67+ SCHEMA_VERSION: 0,
68+ MESSAGE_TYPE: 1,
69+ REQUEST_ID: 2,
70+ FAVORITE_ID: 3,
71+ SERVICE_ID: 4,
72+ DISPLAY_NAME: 5,
73+ STOP_LABEL: 6,
74+ LINE_LABEL: 7,
75+ DESTINATION_LABEL: 8,
76+ SORT_ORDER: 9,
77+ KEY_STATUS: 10,
78+ ITEM_COUNT: 11,
79+ ITEM_INDEX: 12,
80+ FETCHED_AT: 13,
81+ SOURCE_UPDATED_AT: 14,
82+ FRESHNESS: 15,
83+ EXPECTED_AT: 16,
84+ AIMED_AT: 17,
85+ MINUTES: 18,
86+ DEPARTURE_STATUS: 19,
87+ NEXT_INTERVAL_MINUTES: 20,
88+ ERROR_CODE: 21,
89+ OCCURRED_AT: 22,
90+ RETRY_AFTER_SECONDS: 23,
91+ REQUEST_TRIGGER: 24
92+});
93+
94+var UINT32_MAX = 4294967295;
95+var FAVORITE_KEYS = [
96+ "schemaVersion",
97+ "id",
98+ "serviceId",
99+ "displayName",
100+ "stopLabel",
101+ "lineLabel",
102+ "destinationLabel",
103+ "sortOrder"
104+];
105+var DEPARTURE_KEYS = ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"];
106+var RESULT_KEYS = [
107+ "schemaVersion",
108+ "requestId",
109+ "favoriteId",
110+ "fetchedAt",
111+ "sourceUpdatedAt",
112+ "freshness",
113+ "departures"
114+];
115+var ERROR_KEYS = [
116+ "schemaVersion",
117+ "requestId",
118+ "favoriteId",
119+ "code",
120+ "occurredAt",
121+ "retryAfterSeconds"
122+];
123+var KEY_UPDATE_KEYS = ["schemaVersion", "action", "value"];
124+var WIRE_LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });
125+
126+function isObject(value) {
127+ return typeof value === "object" && value !== null && !Array.isArray(value);
128+}
129+
130+function hasOnlyKeys(value, allowed) {
131+ return Object.keys(value).every(function (key) {
132+ return allowed.indexOf(key) !== -1;
133+ });
134+}
135+
136+function utf8Bytes(value) {
137+ var total = 0;
138+ var index;
139+ var code;
140+ var next;
141+ for (index = 0; index < value.length; index += 1) {
142+ code = value.charCodeAt(index);
143+ if (code <= 127) {
144+ total += 1;
145+ } else if (code <= 2047) {
146+ total += 2;
147+ } else if (code >= 55296 && code <= 56319) {
148+ next = value.charCodeAt(index + 1);
149+ if (next >= 56320 && next <= 57343) {
150+ total += 4;
151+ index += 1;
152+ } else {
153+ total += 3;
154+ }
155+ } else {
156+ total += 3;
157+ }
158+ }
159+ return total;
160+}
161+
162+function boundedString(value, maximum) {
163+ var size;
164+ if (typeof value !== "string") return false;
165+ size = utf8Bytes(value);
166+ return size >= 1 && size <= maximum;
167+}
168+
169+function uint32(value) {
170+ return typeof value === "number" && isFinite(value) && Math.floor(value) === value && value >= 0 && value <= UINT32_MAX;
171+}
172+
173+function optionalUint32(value) {
174+ return typeof value === "undefined" || uint32(value);
175+}
176+
177+function isFavorite(value) {
178+ return isObject(value)
179+ && hasOnlyKeys(value, FAVORITE_KEYS)
180+ && value.schemaVersion === SCHEMA_VERSION
181+ && boundedString(value.id, LIMITS.idUtf8Bytes)
182+ && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
183+ && (typeof value.displayName === "undefined" || boundedString(value.displayName, LIMITS.labelUtf8Bytes))
184+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
185+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
186+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
187+ && typeof value.sortOrder === "number"
188+ && Math.floor(value.sortOrder) === value.sortOrder
189+ && value.sortOrder >= 0
190+ && value.sortOrder < LIMITS.favorites;
191+}
192+
193+function isFavoriteList(value) {
194+ var seen = Object.create(null);
195+ if (!Array.isArray(value) || value.length > LIMITS.favorites) return false;
196+ return value.every(function (favorite) {
197+ if (!isFavorite(favorite) || seen[favorite.id]) return false;
198+ seen[favorite.id] = true;
199+ return true;
200+ });
201+}
202+
203+function isDeparture(value) {
204+ return isObject(value)
205+ && hasOnlyKeys(value, DEPARTURE_KEYS)
206+ && uint32(value.expectedAt)
207+ && optionalUint32(value.aimedAt)
208+ && typeof value.minutes === "number"
209+ && Math.floor(value.minutes) === value.minutes
210+ && value.minutes >= -1440
211+ && value.minutes <= 1440
212+ && DEPARTURE_STATUS.indexOf(value.status) !== -1
213+ && (typeof value.nextIntervalMinutes === "undefined"
214+ || (typeof value.nextIntervalMinutes === "number"
215+ && Math.floor(value.nextIntervalMinutes) === value.nextIntervalMinutes
216+ && value.nextIntervalMinutes >= 0
217+ && value.nextIntervalMinutes <= 1440));
218+}
219+
220+function isDepartureResult(value) {
221+ return isObject(value)
222+ && hasOnlyKeys(value, RESULT_KEYS)
223+ && value.schemaVersion === SCHEMA_VERSION
224+ && boundedString(value.requestId, LIMITS.idUtf8Bytes)
225+ && boundedString(value.favoriteId, LIMITS.idUtf8Bytes)
226+ && uint32(value.fetchedAt)
227+ && optionalUint32(value.sourceUpdatedAt)
228+ && FRESHNESS.indexOf(value.freshness) !== -1
229+ && Array.isArray(value.departures)
230+ && value.departures.length <= LIMITS.departures
231+ && value.departures.every(isDeparture);
232+}
233+
234+function isErrorResult(value) {
235+ return isObject(value)
236+ && hasOnlyKeys(value, ERROR_KEYS)
237+ && value.schemaVersion === SCHEMA_VERSION
238+ && boundedString(value.requestId, LIMITS.idUtf8Bytes)
239+ && (typeof value.favoriteId === "undefined" || boundedString(value.favoriteId, LIMITS.idUtf8Bytes))
240+ && ERROR_CODE.indexOf(value.code) !== -1
241+ && uint32(value.occurredAt)
242+ && optionalUint32(value.retryAfterSeconds);
243+}
244+
245+function isPersonalApiKey(value) {
246+ return boundedString(value, LIMITS.apiKeyUtf8Bytes) && value.indexOf("\r") === -1 && value.indexOf("\n") === -1;
247+}
248+
249+function isApiKeyUpdate(value) {
250+ if (!isObject(value) || !hasOnlyKeys(value, KEY_UPDATE_KEYS) || value.schemaVersion !== SCHEMA_VERSION) return false;
251+ if (value.action === "REPLACE") return isPersonalApiKey(value.value);
252+ return (value.action === "KEEP" || value.action === "REMOVE") && typeof value.value === "undefined";
253+}
254+
255+function copyFavorite(favorite) {
256+ var copy = {
257+ schemaVersion: SCHEMA_VERSION,
258+ id: favorite.id,
259+ serviceId: favorite.serviceId,
260+ stopLabel: favorite.stopLabel,
261+ lineLabel: favorite.lineLabel,
262+ destinationLabel: favorite.destinationLabel,
263+ sortOrder: favorite.sortOrder
264+ };
265+ if (typeof favorite.displayName !== "undefined") copy.displayName = favorite.displayName;
266+ return copy;
267+}
268+
269+function copyDeparture(departure) {
270+ var copy = {
271+ expectedAt: departure.expectedAt,
272+ minutes: departure.minutes,
273+ status: departure.status
274+ };
275+ if (typeof departure.aimedAt !== "undefined") copy.aimedAt = departure.aimedAt;
276+ if (typeof departure.nextIntervalMinutes !== "undefined") copy.nextIntervalMinutes = departure.nextIntervalMinutes;
277+ return copy;
278+}
279+
280+function copyDepartureResult(result, requestId) {
281+ var copy = {
282+ schemaVersion: SCHEMA_VERSION,
283+ requestId: typeof requestId === "string" ? requestId : result.requestId,
284+ favoriteId: result.favoriteId,
285+ fetchedAt: result.fetchedAt,
286+ freshness: result.freshness,
287+ departures: result.departures.map(copyDeparture)
288+ };
289+ if (typeof result.sourceUpdatedAt !== "undefined") copy.sourceUpdatedAt = result.sourceUpdatedAt;
290+ return copy;
291+}
292+
293+module.exports = {
294+ SCHEMA_VERSION: SCHEMA_VERSION,
295+ LIMITS: LIMITS,
296+ CACHE_FRESH_SECONDS: CACHE_FRESH_SECONDS,
297+ FAVORITE_SETTLE_MS: FAVORITE_SETTLE_MS,
298+ FRESHNESS: FRESHNESS,
299+ DEPARTURE_STATUS: DEPARTURE_STATUS,
300+ ERROR_CODE: ERROR_CODE,
301+ API_KEY_ACTION: API_KEY_ACTION,
302+ KEY_STATUS: KEY_STATUS,
303+ REQUEST_TRIGGER: REQUEST_TRIGGER,
304+ MESSAGE_TYPE: MESSAGE_TYPE,
305+ APP_MESSAGE_KEY_ORDER: APP_MESSAGE_KEY_ORDER,
306+ APP_MESSAGE_KEY: APP_MESSAGE_KEY,
307+ WIRE_LANGUAGE: WIRE_LANGUAGE,
308+ utf8Bytes: utf8Bytes,
309+ uint32: uint32,
310+ isObject: isObject,
311+ hasOnlyKeys: hasOnlyKeys,
312+ boundedString: boundedString,
313+ isFavorite: isFavorite,
314+ isFavoriteList: isFavoriteList,
315+ isDeparture: isDeparture,
316+ isDepartureResult: isDepartureResult,
317+ isErrorResult: isErrorResult,
318+ isPersonalApiKey: isPersonalApiKey,
319+ isApiKeyUpdate: isApiKeyUpdate,
320+ copyFavorite: copyFavorite,
321+ copyDepartureResult: copyDepartureResult
322+};
added packages/companion/src/index.js +412 -0
new file mode 100644
@@ -0,0 +1,412 @@
1+"use strict";
2+
3+var contracts = require("./contracts");
4+var codec = require("./codec");
5+var configuration = require("./configuration");
6+var MessageQueue = require("./message-queue");
7+var hasOwn = Object.prototype.hasOwnProperty;
8+var APP_MESSAGE_ALIAS_BY_NUMERIC_KEY = Object.create(null);
9+
10+contracts.APP_MESSAGE_KEY_ORDER.forEach(function (alias) {
11+ APP_MESSAGE_ALIAS_BY_NUMERIC_KEY[String(contracts.APP_MESSAGE_KEY[alias])] = alias;
12+});
13+
14+function requiredAdapter(condition, message) {
15+ if (!condition) throw new TypeError(message);
16+}
17+
18+function normalizeIncomingPayload(payload) {
19+ var keys;
20+ var normalized = {};
21+ var aliasCount = 0;
22+ var index;
23+ var key;
24+ var alias;
25+ if (!contracts.isObject(payload)) return null;
26+ keys = Object.keys(payload);
27+ for (index = 0; index < keys.length; index += 1) {
28+ key = keys[index];
29+ if (hasOwn.call(contracts.APP_MESSAGE_KEY, key)) {
30+ normalized[key] = payload[key];
31+ aliasCount += 1;
32+ continue;
33+ }
34+ alias = APP_MESSAGE_ALIAS_BY_NUMERIC_KEY[key];
35+ if (typeof alias === "undefined"
36+ || !hasOwn.call(payload, alias)
37+ || payload[key] !== payload[alias]) return null;
38+ }
39+ return aliasCount > 0 ? normalized : null;
40+}
41+
42+function isFixture(value) {
43+ return contracts.isObject(value)
44+ && contracts.hasOnlyKeys(value, ["favorite", "result"])
45+ && contracts.isFavorite(value.favorite)
46+ && contracts.isDepartureResult(value.result)
47+ && value.favorite.id === value.result.favoriteId;
48+}
49+
50+function Companion(options) {
51+ options = options || {};
52+ requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");
53+ requiredAdapter(options.storage && typeof options.storage.getItem === "function" && typeof options.storage.setItem === "function", "storage adapter is required");
54+ requiredAdapter(typeof options.XHR === "function", "XHR adapter is required");
55+ requiredAdapter(options.clock
56+ && typeof options.clock.now === "function", "clock adapter is required");
57+ requiredAdapter(typeof options.defer === "function", "defer adapter is required");
58+ if (typeof options.fixture !== "undefined" && !isFixture(options.fixture)) {
59+ throw new TypeError("fixture does not match the frozen contracts");
60+ }
61+
62+ this._Pebble = options.Pebble;
63+ this._storage = options.storage;
64+ this._XHR = options.XHR;
65+ this._clock = options.clock;
66+ this._backendUrl = typeof options.backendUrl === "string" ? options.backendUrl : "";
67+ this._configurationUrl = typeof options.configurationUrl === "string" ? options.configurationUrl : "";
68+ this._fixture = options.fixture || null;
69+ this._fixtureEnabled = false;
70+ this._configuration = configuration.loadConfiguration(this._storage);
71+ this._watchFavorites = [];
72+ this._keyStatus = contracts.KEY_STATUS.MISSING;
73+ this._cache = Object.create(null);
74+ this._requestGenerations = Object.create(null);
75+ this._inFlightRequests = Object.create(null);
76+ this._requestGeneration = 0;
77+ this._sequence = 0;
78+ this._started = false;
79+ this._queue = new MessageQueue(this._Pebble, options.defer);
80+ this._handlers = null;
81+ this._refreshWatchState();
82+}
83+
84+Companion.prototype.start = function () {
85+ var self = this;
86+ if (this._started) return this;
87+ this._handlers = {
88+ ready: function () { self._onReady(); },
89+ showConfiguration: function () { self._onShowConfiguration(); },
90+ webviewclosed: function (event) { self._onWebviewClosed(event); },
91+ appmessage: function (event) { self._onAppMessage(event); }
92+ };
93+ Object.keys(this._handlers).forEach(function (eventName) {
94+ self._Pebble.addEventListener(eventName, self._handlers[eventName]);
95+ });
96+ this._started = true;
97+ return this;
98+};
99+
100+Companion.prototype.stop = function () {
101+ var self = this;
102+ var inFlightRequests;
103+ if (!this._started) return;
104+ if (typeof this._Pebble.removeEventListener === "function") {
105+ Object.keys(this._handlers).forEach(function (eventName) {
106+ self._Pebble.removeEventListener(eventName, self._handlers[eventName]);
107+ });
108+ }
109+ this._started = false;
110+ this._requestGenerations = Object.create(null);
111+ inFlightRequests = this._inFlightRequests;
112+ this._inFlightRequests = Object.create(null);
113+ this._queue.clear();
114+ Object.keys(inFlightRequests).forEach(function (generation) {
115+ var xhr = inFlightRequests[generation];
116+ if (!xhr || typeof xhr.abort !== "function") return;
117+ try {
118+ xhr.abort();
119+ } catch (ignored) {
120+ // Generation invalidation remains authoritative if abort is unsupported or races.
121+ }
122+ });
123+};
124+
125+Companion.prototype._refreshWatchState = function () {
126+ var configured = this._configuration.favorites.map(contracts.copyFavorite);
127+ this._fixtureEnabled = this._fixture !== null && configured.length === 0;
128+ this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;
129+ this._keyStatus = this._fixtureEnabled || this._configuration.apiKey !== null
130+ ? contracts.KEY_STATUS.CONFIGURED
131+ : contracts.KEY_STATUS.MISSING;
132+};
133+
134+Companion.prototype._nextSequenceId = function () {
135+ this._sequence += 1;
136+ if (this._sequence > 1679615) this._sequence = 1;
137+ return "config-" + Math.floor(this._clock.now()).toString(36) + "-" + this._sequence.toString(36);
138+};
139+
140+Companion.prototype._sendConfiguration = function () {
141+ if (!configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.apiKey, null)) return;
142+ this._queue.enqueue(codec.encodeConfiguration(
143+ this._nextSequenceId(),
144+ this._watchFavorites,
145+ this._keyStatus,
146+ configuration.activeWatchLanguage(this._Pebble)
147+ ));
148+};
149+
150+Companion.prototype._onReady = function () {
151+ this._configuration = configuration.loadConfiguration(this._storage);
152+ this._refreshWatchState();
153+ this._sendConfiguration();
154+};
155+
156+Companion.prototype._onShowConfiguration = function () {
157+ var language = configuration.activeWatchLanguage(this._Pebble);
158+ var url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
159+ if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);
160+};
161+
162+Companion.prototype._onWebviewClosed = function (event) {
163+ var update = configuration.parseCloseFragment(event && event.response);
164+ var next;
165+ if (update === null) return;
166+ next = configuration.applyConfigurationUpdate(this._configuration, update);
167+ if (next === null || !configuration.saveConfiguration(this._storage, next)) return;
168+ this._configuration = next;
169+ this._cache = Object.create(null);
170+ this._requestGenerations = Object.create(null);
171+ this._refreshWatchState();
172+ this._sendConfiguration();
173+};
174+
175+Companion.prototype._onAppMessage = function (event) {
176+ var request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
177+ if (request === null || request.trigger !== contracts.REQUEST_TRIGGER.APP_OPEN) return;
178+ if (this._findFavorite(request.favoriteId) === null) {
179+ this._sendError(request, "INVALID_SERVICE");
180+ return;
181+ }
182+ this._dispatchRequest(request);
183+};
184+
185+Companion.prototype._findFavorite = function (favoriteId) {
186+ var index;
187+ for (index = 0; index < this._watchFavorites.length; index += 1) {
188+ if (this._watchFavorites[index].id === favoriteId) return this._watchFavorites[index];
189+ }
190+ return null;
191+};
192+
193+Companion.prototype._cacheResult = function (result) {
194+ this._cache[result.favoriteId] = {
195+ storedAt: this._clock.now(),
196+ result: contracts.copyDepartureResult(result)
197+ };
198+};
199+
200+Companion.prototype._cachedResult = function (request) {
201+ var cached = this._cache[request.favoriteId];
202+ var age;
203+ if (!cached) return null;
204+ age = this._clock.now() - cached.storedAt;
205+ if (age < 0 || age >= contracts.CACHE_FRESH_SECONDS * 1000) return null;
206+ return contracts.copyDepartureResult(cached.result, request.requestId);
207+};
208+Companion.prototype._beginRequest = function (favoriteId) {
209+ this._requestGeneration += 1;
210+ this._requestGenerations[favoriteId] = this._requestGeneration;
211+ return this._requestGeneration;
212+};
213+
214+Companion.prototype._isLatestRequest = function (favoriteId, generation) {
215+ return this._requestGenerations[favoriteId] === generation;
216+};
217+
218+
219+Companion.prototype._dispatchRequest = function (request) {
220+ var generation = this._beginRequest(request.favoriteId);
221+ var cached = this._cachedResult(request);
222+ var fixtureResult;
223+ var favorite;
224+ if (cached !== null) {
225+ this._queue.enqueue(codec.encodeResult(cached));
226+ return;
227+ }
228+ if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {
229+ fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);
230+ this._cacheResult(fixtureResult);
231+ this._queue.enqueue(codec.encodeResult(fixtureResult));
232+ return;
233+ }
234+ if (this._configuration.apiKey === null) {
235+ this._sendError(request, "API_KEY_REQUIRED");
236+ return;
237+ }
238+ if (this._keyStatus === contracts.KEY_STATUS.INVALID) {
239+ this._sendError(request, "API_KEY_INVALID");
240+ return;
241+ }
242+ favorite = this._findFavorite(request.favoriteId);
243+ if (favorite === null) {
244+ this._sendError(request, "INVALID_SERVICE");
245+ return;
246+ }
247+ this._fetch(request, favorite, generation);
248+};
249+
250+Companion.prototype._fetch = function (request, favorite, generation) {
251+ var self = this;
252+ var xhr;
253+ var settled = false;
254+ var key = this._configuration.apiKey;
255+ if (!configuration.isFavoriteSecretFree(favorite, key, null)) return;
256+ if (this._backendUrl.indexOf("https://") !== 0) {
257+ this._sendError(request, "SOURCE_UNAVAILABLE");
258+ return;
259+ }
260+
261+ function once(callback) {
262+ return function () {
263+ if (settled) return;
264+ settled = true;
265+ if (self._inFlightRequests[generation] === xhr) {
266+ delete self._inFlightRequests[generation];
267+ }
268+ if (!self._isLatestRequest(request.favoriteId, generation)) return;
269+ callback();
270+ };
271+ }
272+
273+ try {
274+ xhr = new this._XHR();
275+ xhr.open("POST", this._backendUrl, true);
276+ xhr.timeout = contracts.LIMITS.httpTimeoutMs;
277+ xhr.setRequestHeader("Authorization", "Bearer " + key);
278+ xhr.setRequestHeader("Content-Type", "application/json");
279+ xhr.setRequestHeader("Accept", "application/json");
280+ xhr.onload = once(function () { self._handleResponse(request, xhr); });
281+ xhr.onerror = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
282+ xhr.ontimeout = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
283+ xhr.onabort = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
284+ if (!this._isLatestRequest(request.favoriteId, generation)) {
285+ key = null;
286+ return;
287+ }
288+ this._inFlightRequests[generation] = xhr;
289+ xhr.send(JSON.stringify({
290+ schemaVersion: contracts.SCHEMA_VERSION,
291+ requestId: request.requestId,
292+ favoriteId: request.favoriteId,
293+ serviceId: favorite.serviceId
294+ }));
295+ key = null;
296+ } catch (ignored) {
297+ key = null;
298+ if (this._inFlightRequests[generation] === xhr) {
299+ delete this._inFlightRequests[generation];
300+ }
301+ if (!settled) {
302+ settled = true;
303+ if (this._isLatestRequest(request.favoriteId, generation)) {
304+ this._sendError(request, "SOURCE_UNAVAILABLE");
305+ }
306+ }
307+ }
308+};
309+
310+Companion.prototype._responseJson = function (xhr) {
311+ var text = xhr.responseText;
312+ if (typeof text !== "string" || text.length === 0 || text.length > contracts.LIMITS.httpResponseBytes) return null;
313+ if (contracts.utf8Bytes(text) > contracts.LIMITS.httpResponseBytes) return null;
314+ try {
315+ return JSON.parse(text);
316+ } catch (ignored) {
317+ return null;
318+ }
319+};
320+
321+Companion.prototype._handleResponse = function (request, xhr) {
322+ var body = this._responseJson(xhr);
323+ var status = Number(xhr.status);
324+ var error;
325+ if (status >= 200 && status < 300) {
326+ if (!contracts.isDepartureResult(body)
327+ || body.requestId !== request.requestId
328+ || body.favoriteId !== request.favoriteId) {
329+ this._sendError(request, "INVALID_RESPONSE");
330+ return;
331+ }
332+ this._cacheResult(body);
333+ this._queue.enqueue(codec.encodeResult(body));
334+ return;
335+ }
336+
337+ if (body !== null && contracts.isErrorResult(body)) {
338+ if (body.requestId !== request.requestId
339+ || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {
340+ this._sendError(request, "INVALID_RESPONSE");
341+ return;
342+ }
343+ error = {
344+ schemaVersion: contracts.SCHEMA_VERSION,
345+ requestId: request.requestId,
346+ favoriteId: request.favoriteId,
347+ code: body.code,
348+ occurredAt: body.occurredAt
349+ };
350+ if (typeof body.retryAfterSeconds !== "undefined") error.retryAfterSeconds = body.retryAfterSeconds;
351+ this._sendErrorResult(error);
352+ return;
353+ }
354+
355+ this._sendError(request, this._httpErrorCode(status), this._retryAfter(xhr));
356+};
357+
358+Companion.prototype._httpErrorCode = function (status) {
359+ if (status === 401 || status === 403) return "API_KEY_INVALID";
360+ if (status === 400 || status === 404 || status === 422) return "INVALID_SERVICE";
361+ if (status === 429) return "RATE_LIMITED";
362+ if (status === 0 || status >= 500) return "SOURCE_UNAVAILABLE";
363+ return "INVALID_RESPONSE";
364+};
365+
366+Companion.prototype._retryAfter = function (xhr) {
367+ var raw;
368+ var value;
369+ if (typeof xhr.getResponseHeader !== "function") return undefined;
370+ try {
371+ raw = xhr.getResponseHeader("Retry-After");
372+ } catch (ignored) {
373+ return undefined;
374+ }
375+ if (typeof raw !== "string" || !/^[0-9]+$/.test(raw)) return undefined;
376+ value = Number(raw);
377+ return contracts.uint32(value) ? value : undefined;
378+};
379+
380+Companion.prototype._sendError = function (request, code, retryAfterSeconds) {
381+ var error = {
382+ schemaVersion: contracts.SCHEMA_VERSION,
383+ requestId: request.requestId,
384+ favoriteId: request.favoriteId,
385+ code: code,
386+ occurredAt: Math.floor(this._clock.now() / 1000)
387+ };
388+ if (typeof retryAfterSeconds !== "undefined") error.retryAfterSeconds = retryAfterSeconds;
389+ this._sendErrorResult(error);
390+};
391+
392+Companion.prototype._sendErrorResult = function (error) {
393+ this._queue.enqueue([codec.encodeError(error)]);
394+ if (error.code === "API_KEY_INVALID") {
395+ this._keyStatus = contracts.KEY_STATUS.INVALID;
396+ this._cache = Object.create(null);
397+ this._sendConfiguration();
398+ }
399+};
400+
401+function createCompanion(options) {
402+ return new Companion(options).start();
403+}
404+
405+module.exports = {
406+ Companion: Companion,
407+ createCompanion: createCompanion,
408+ contracts: contracts,
409+ codec: codec,
410+ configuration: configuration,
411+ MessageQueue: MessageQueue
412+};
new file mode 100644
@@ -0,0 +1,412 @@
1+"use strict";
2+
3+var contracts = require("./contracts");
4+var codec = require("./codec");
5+var configuration = require("./configuration");
6+var MessageQueue = require("./message-queue");
7+var hasOwn = Object.prototype.hasOwnProperty;
8+var APP_MESSAGE_ALIAS_BY_NUMERIC_KEY = Object.create(null);
9+
10+contracts.APP_MESSAGE_KEY_ORDER.forEach(function (alias) {
11+ APP_MESSAGE_ALIAS_BY_NUMERIC_KEY[String(contracts.APP_MESSAGE_KEY[alias])] = alias;
12+});
13+
14+function requiredAdapter(condition, message) {
15+ if (!condition) throw new TypeError(message);
16+}
17+
18+function normalizeIncomingPayload(payload) {
19+ var keys;
20+ var normalized = {};
21+ var aliasCount = 0;
22+ var index;
23+ var key;
24+ var alias;
25+ if (!contracts.isObject(payload)) return null;
26+ keys = Object.keys(payload);
27+ for (index = 0; index < keys.length; index += 1) {
28+ key = keys[index];
29+ if (hasOwn.call(contracts.APP_MESSAGE_KEY, key)) {
30+ normalized[key] = payload[key];
31+ aliasCount += 1;
32+ continue;
33+ }
34+ alias = APP_MESSAGE_ALIAS_BY_NUMERIC_KEY[key];
35+ if (typeof alias === "undefined"
36+ || !hasOwn.call(payload, alias)
37+ || payload[key] !== payload[alias]) return null;
38+ }
39+ return aliasCount > 0 ? normalized : null;
40+}
41+
42+function isFixture(value) {
43+ return contracts.isObject(value)
44+ && contracts.hasOnlyKeys(value, ["favorite", "result"])
45+ && contracts.isFavorite(value.favorite)
46+ && contracts.isDepartureResult(value.result)
47+ && value.favorite.id === value.result.favoriteId;
48+}
49+
50+function Companion(options) {
51+ options = options || {};
52+ requiredAdapter(options.Pebble && typeof options.Pebble.addEventListener === "function", "Pebble adapter is required");
53+ requiredAdapter(options.storage && typeof options.storage.getItem === "function" && typeof options.storage.setItem === "function", "storage adapter is required");
54+ requiredAdapter(typeof options.XHR === "function", "XHR adapter is required");
55+ requiredAdapter(options.clock
56+ && typeof options.clock.now === "function", "clock adapter is required");
57+ requiredAdapter(typeof options.defer === "function", "defer adapter is required");
58+ if (typeof options.fixture !== "undefined" && !isFixture(options.fixture)) {
59+ throw new TypeError("fixture does not match the frozen contracts");
60+ }
61+
62+ this._Pebble = options.Pebble;
63+ this._storage = options.storage;
64+ this._XHR = options.XHR;
65+ this._clock = options.clock;
66+ this._backendUrl = typeof options.backendUrl === "string" ? options.backendUrl : "";
67+ this._configurationUrl = typeof options.configurationUrl === "string" ? options.configurationUrl : "";
68+ this._fixture = options.fixture || null;
69+ this._fixtureEnabled = false;
70+ this._configuration = configuration.loadConfiguration(this._storage);
71+ this._watchFavorites = [];
72+ this._keyStatus = contracts.KEY_STATUS.MISSING;
73+ this._cache = Object.create(null);
74+ this._requestGenerations = Object.create(null);
75+ this._inFlightRequests = Object.create(null);
76+ this._requestGeneration = 0;
77+ this._sequence = 0;
78+ this._started = false;
79+ this._queue = new MessageQueue(this._Pebble, options.defer);
80+ this._handlers = null;
81+ this._refreshWatchState();
82+}
83+
84+Companion.prototype.start = function () {
85+ var self = this;
86+ if (this._started) return this;
87+ this._handlers = {
88+ ready: function () { self._onReady(); },
89+ showConfiguration: function () { self._onShowConfiguration(); },
90+ webviewclosed: function (event) { self._onWebviewClosed(event); },
91+ appmessage: function (event) { self._onAppMessage(event); }
92+ };
93+ Object.keys(this._handlers).forEach(function (eventName) {
94+ self._Pebble.addEventListener(eventName, self._handlers[eventName]);
95+ });
96+ this._started = true;
97+ return this;
98+};
99+
100+Companion.prototype.stop = function () {
101+ var self = this;
102+ var inFlightRequests;
103+ if (!this._started) return;
104+ if (typeof this._Pebble.removeEventListener === "function") {
105+ Object.keys(this._handlers).forEach(function (eventName) {
106+ self._Pebble.removeEventListener(eventName, self._handlers[eventName]);
107+ });
108+ }
109+ this._started = false;
110+ this._requestGenerations = Object.create(null);
111+ inFlightRequests = this._inFlightRequests;
112+ this._inFlightRequests = Object.create(null);
113+ this._queue.clear();
114+ Object.keys(inFlightRequests).forEach(function (generation) {
115+ var xhr = inFlightRequests[generation];
116+ if (!xhr || typeof xhr.abort !== "function") return;
117+ try {
118+ xhr.abort();
119+ } catch (ignored) {
120+ // Generation invalidation remains authoritative if abort is unsupported or races.
121+ }
122+ });
123+};
124+
125+Companion.prototype._refreshWatchState = function () {
126+ var configured = this._configuration.favorites.map(contracts.copyFavorite);
127+ this._fixtureEnabled = this._fixture !== null && configured.length === 0;
128+ this._watchFavorites = this._fixtureEnabled ? [contracts.copyFavorite(this._fixture.favorite)] : configured;
129+ this._keyStatus = this._fixtureEnabled || this._configuration.apiKey !== null
130+ ? contracts.KEY_STATUS.CONFIGURED
131+ : contracts.KEY_STATUS.MISSING;
132+};
133+
134+Companion.prototype._nextSequenceId = function () {
135+ this._sequence += 1;
136+ if (this._sequence > 1679615) this._sequence = 1;
137+ return "config-" + Math.floor(this._clock.now()).toString(36) + "-" + this._sequence.toString(36);
138+};
139+
140+Companion.prototype._sendConfiguration = function () {
141+ if (!configuration.areFavoritesSecretFree(this._watchFavorites, this._configuration.apiKey, null)) return;
142+ this._queue.enqueue(codec.encodeConfiguration(
143+ this._nextSequenceId(),
144+ this._watchFavorites,
145+ this._keyStatus,
146+ configuration.activeWatchLanguage(this._Pebble)
147+ ));
148+};
149+
150+Companion.prototype._onReady = function () {
151+ this._configuration = configuration.loadConfiguration(this._storage);
152+ this._refreshWatchState();
153+ this._sendConfiguration();
154+};
155+
156+Companion.prototype._onShowConfiguration = function () {
157+ var language = configuration.activeWatchLanguage(this._Pebble);
158+ var url = configuration.configurationUrl(this._configurationUrl, this._configuration, language);
159+ if (url !== null && typeof this._Pebble.openURL === "function") this._Pebble.openURL(url);
160+};
161+
162+Companion.prototype._onWebviewClosed = function (event) {
163+ var update = configuration.parseCloseFragment(event && event.response);
164+ var next;
165+ if (update === null) return;
166+ next = configuration.applyConfigurationUpdate(this._configuration, update);
167+ if (next === null || !configuration.saveConfiguration(this._storage, next)) return;
168+ this._configuration = next;
169+ this._cache = Object.create(null);
170+ this._requestGenerations = Object.create(null);
171+ this._refreshWatchState();
172+ this._sendConfiguration();
173+};
174+
175+Companion.prototype._onAppMessage = function (event) {
176+ var request = codec.decodeRequest(normalizeIncomingPayload(event && event.payload));
177+ if (request === null || request.trigger !== contracts.REQUEST_TRIGGER.APP_OPEN) return;
178+ if (this._findFavorite(request.favoriteId) === null) {
179+ this._sendError(request, "INVALID_SERVICE");
180+ return;
181+ }
182+ this._dispatchRequest(request);
183+};
184+
185+Companion.prototype._findFavorite = function (favoriteId) {
186+ var index;
187+ for (index = 0; index < this._watchFavorites.length; index += 1) {
188+ if (this._watchFavorites[index].id === favoriteId) return this._watchFavorites[index];
189+ }
190+ return null;
191+};
192+
193+Companion.prototype._cacheResult = function (result) {
194+ this._cache[result.favoriteId] = {
195+ storedAt: this._clock.now(),
196+ result: contracts.copyDepartureResult(result)
197+ };
198+};
199+
200+Companion.prototype._cachedResult = function (request) {
201+ var cached = this._cache[request.favoriteId];
202+ var age;
203+ if (!cached) return null;
204+ age = this._clock.now() - cached.storedAt;
205+ if (age < 0 || age >= contracts.CACHE_FRESH_SECONDS * 1000) return null;
206+ return contracts.copyDepartureResult(cached.result, request.requestId);
207+};
208+Companion.prototype._beginRequest = function (favoriteId) {
209+ this._requestGeneration += 1;
210+ this._requestGenerations[favoriteId] = this._requestGeneration;
211+ return this._requestGeneration;
212+};
213+
214+Companion.prototype._isLatestRequest = function (favoriteId, generation) {
215+ return this._requestGenerations[favoriteId] === generation;
216+};
217+
218+
219+Companion.prototype._dispatchRequest = function (request) {
220+ var generation = this._beginRequest(request.favoriteId);
221+ var cached = this._cachedResult(request);
222+ var fixtureResult;
223+ var favorite;
224+ if (cached !== null) {
225+ this._queue.enqueue(codec.encodeResult(cached));
226+ return;
227+ }
228+ if (this._fixtureEnabled && this._fixture.favorite.id === request.favoriteId) {
229+ fixtureResult = contracts.copyDepartureResult(this._fixture.result, request.requestId);
230+ this._cacheResult(fixtureResult);
231+ this._queue.enqueue(codec.encodeResult(fixtureResult));
232+ return;
233+ }
234+ if (this._configuration.apiKey === null) {
235+ this._sendError(request, "API_KEY_REQUIRED");
236+ return;
237+ }
238+ if (this._keyStatus === contracts.KEY_STATUS.INVALID) {
239+ this._sendError(request, "API_KEY_INVALID");
240+ return;
241+ }
242+ favorite = this._findFavorite(request.favoriteId);
243+ if (favorite === null) {
244+ this._sendError(request, "INVALID_SERVICE");
245+ return;
246+ }
247+ this._fetch(request, favorite, generation);
248+};
249+
250+Companion.prototype._fetch = function (request, favorite, generation) {
251+ var self = this;
252+ var xhr;
253+ var settled = false;
254+ var key = this._configuration.apiKey;
255+ if (!configuration.isFavoriteSecretFree(favorite, key, null)) return;
256+ if (this._backendUrl.indexOf("https://") !== 0) {
257+ this._sendError(request, "SOURCE_UNAVAILABLE");
258+ return;
259+ }
260+
261+ function once(callback) {
262+ return function () {
263+ if (settled) return;
264+ settled = true;
265+ if (self._inFlightRequests[generation] === xhr) {
266+ delete self._inFlightRequests[generation];
267+ }
268+ if (!self._isLatestRequest(request.favoriteId, generation)) return;
269+ callback();
270+ };
271+ }
272+
273+ try {
274+ xhr = new this._XHR();
275+ xhr.open("POST", this._backendUrl, true);
276+ xhr.timeout = contracts.LIMITS.httpTimeoutMs;
277+ xhr.setRequestHeader("Authorization", "Bearer " + key);
278+ xhr.setRequestHeader("Content-Type", "application/json");
279+ xhr.setRequestHeader("Accept", "application/json");
280+ xhr.onload = once(function () { self._handleResponse(request, xhr); });
281+ xhr.onerror = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
282+ xhr.ontimeout = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
283+ xhr.onabort = once(function () { self._sendError(request, "SOURCE_UNAVAILABLE"); });
284+ if (!this._isLatestRequest(request.favoriteId, generation)) {
285+ key = null;
286+ return;
287+ }
288+ this._inFlightRequests[generation] = xhr;
289+ xhr.send(JSON.stringify({
290+ schemaVersion: contracts.SCHEMA_VERSION,
291+ requestId: request.requestId,
292+ favoriteId: request.favoriteId,
293+ serviceId: favorite.serviceId
294+ }));
295+ key = null;
296+ } catch (ignored) {
297+ key = null;
298+ if (this._inFlightRequests[generation] === xhr) {
299+ delete this._inFlightRequests[generation];
300+ }
301+ if (!settled) {
302+ settled = true;
303+ if (this._isLatestRequest(request.favoriteId, generation)) {
304+ this._sendError(request, "SOURCE_UNAVAILABLE");
305+ }
306+ }
307+ }
308+};
309+
310+Companion.prototype._responseJson = function (xhr) {
311+ var text = xhr.responseText;
312+ if (typeof text !== "string" || text.length === 0 || text.length > contracts.LIMITS.httpResponseBytes) return null;
313+ if (contracts.utf8Bytes(text) > contracts.LIMITS.httpResponseBytes) return null;
314+ try {
315+ return JSON.parse(text);
316+ } catch (ignored) {
317+ return null;
318+ }
319+};
320+
321+Companion.prototype._handleResponse = function (request, xhr) {
322+ var body = this._responseJson(xhr);
323+ var status = Number(xhr.status);
324+ var error;
325+ if (status >= 200 && status < 300) {
326+ if (!contracts.isDepartureResult(body)
327+ || body.requestId !== request.requestId
328+ || body.favoriteId !== request.favoriteId) {
329+ this._sendError(request, "INVALID_RESPONSE");
330+ return;
331+ }
332+ this._cacheResult(body);
333+ this._queue.enqueue(codec.encodeResult(body));
334+ return;
335+ }
336+
337+ if (body !== null && contracts.isErrorResult(body)) {
338+ if (body.requestId !== request.requestId
339+ || (typeof body.favoriteId !== "undefined" && body.favoriteId !== request.favoriteId)) {
340+ this._sendError(request, "INVALID_RESPONSE");
341+ return;
342+ }
343+ error = {
344+ schemaVersion: contracts.SCHEMA_VERSION,
345+ requestId: request.requestId,
346+ favoriteId: request.favoriteId,
347+ code: body.code,
348+ occurredAt: body.occurredAt
349+ };
350+ if (typeof body.retryAfterSeconds !== "undefined") error.retryAfterSeconds = body.retryAfterSeconds;
351+ this._sendErrorResult(error);
352+ return;
353+ }
354+
355+ this._sendError(request, this._httpErrorCode(status), this._retryAfter(xhr));
356+};
357+
358+Companion.prototype._httpErrorCode = function (status) {
359+ if (status === 401 || status === 403) return "API_KEY_INVALID";
360+ if (status === 400 || status === 404 || status === 422) return "INVALID_SERVICE";
361+ if (status === 429) return "RATE_LIMITED";
362+ if (status === 0 || status >= 500) return "SOURCE_UNAVAILABLE";
363+ return "INVALID_RESPONSE";
364+};
365+
366+Companion.prototype._retryAfter = function (xhr) {
367+ var raw;
368+ var value;
369+ if (typeof xhr.getResponseHeader !== "function") return undefined;
370+ try {
371+ raw = xhr.getResponseHeader("Retry-After");
372+ } catch (ignored) {
373+ return undefined;
374+ }
375+ if (typeof raw !== "string" || !/^[0-9]+$/.test(raw)) return undefined;
376+ value = Number(raw);
377+ return contracts.uint32(value) ? value : undefined;
378+};
379+
380+Companion.prototype._sendError = function (request, code, retryAfterSeconds) {
381+ var error = {
382+ schemaVersion: contracts.SCHEMA_VERSION,
383+ requestId: request.requestId,
384+ favoriteId: request.favoriteId,
385+ code: code,
386+ occurredAt: Math.floor(this._clock.now() / 1000)
387+ };
388+ if (typeof retryAfterSeconds !== "undefined") error.retryAfterSeconds = retryAfterSeconds;
389+ this._sendErrorResult(error);
390+};
391+
392+Companion.prototype._sendErrorResult = function (error) {
393+ this._queue.enqueue([codec.encodeError(error)]);
394+ if (error.code === "API_KEY_INVALID") {
395+ this._keyStatus = contracts.KEY_STATUS.INVALID;
396+ this._cache = Object.create(null);
397+ this._sendConfiguration();
398+ }
399+};
400+
401+function createCompanion(options) {
402+ return new Companion(options).start();
403+}
404+
405+module.exports = {
406+ Companion: Companion,
407+ createCompanion: createCompanion,
408+ contracts: contracts,
409+ codec: codec,
410+ configuration: configuration,
411+ MessageQueue: MessageQueue
412+};
added packages/companion/src/message-queue.js +98 -0
new file mode 100644
@@ -0,0 +1,98 @@
1+"use strict";
2+
3+function MessageQueue(Pebble, defer, onFailure) {
4+ if (!Pebble || typeof Pebble.sendAppMessage !== "function") {
5+ throw new TypeError("Pebble.sendAppMessage is required");
6+ }
7+ if (typeof defer !== "function") throw new TypeError("defer adapter is required");
8+ this._Pebble = Pebble;
9+ this._defer = defer;
10+ this._onFailure = typeof onFailure === "function" ? onFailure : function () {};
11+ this._batches = [];
12+ this._active = null;
13+ this._sending = false;
14+ this._deferredGeneration = null;
15+ this._generation = 0;
16+}
17+
18+MessageQueue.prototype.enqueue = function (messages) {
19+ if (!Array.isArray(messages) || messages.length === 0) {
20+ throw new TypeError("A non-empty message sequence is required");
21+ }
22+ this._batches.push(messages.slice());
23+ this._advance();
24+};
25+
26+MessageQueue.prototype.clear = function () {
27+ this._generation += 1;
28+ this._batches = [];
29+ this._active = null;
30+ this._deferredGeneration = null;
31+};
32+
33+MessageQueue.prototype.isSending = function () {
34+ return this._sending;
35+};
36+
37+MessageQueue.prototype._scheduleAdvance = function (generation) {
38+ var self = this;
39+ if (this._deferredGeneration !== null) return;
40+ this._deferredGeneration = generation;
41+ try {
42+ this._defer(function () {
43+ if (self._deferredGeneration !== generation) return;
44+ self._deferredGeneration = null;
45+ if (generation !== self._generation) return;
46+ self._advance();
47+ });
48+ } catch (ignored) {
49+ this._deferredGeneration = null;
50+ this.clear();
51+ this._onFailure("APP_MESSAGE_FAILED");
52+ }
53+};
54+
55+MessageQueue.prototype._advance = function () {
56+ var self = this;
57+ var message;
58+ var generation;
59+ if (this._sending || this._deferredGeneration !== null) return;
60+ if (!this._active || this._active.length === 0) this._active = this._batches.shift() || null;
61+ if (!this._active) return;
62+
63+ message = this._active[0];
64+ this._sending = true;
65+ generation = this._generation;
66+ try {
67+ this._Pebble.sendAppMessage(message, function () {
68+ self._sending = false;
69+ if (generation !== self._generation) {
70+ if (self._active !== null || self._batches.length > 0) {
71+ self._scheduleAdvance(self._generation);
72+ }
73+ return;
74+ }
75+ self._active.shift();
76+ if (self._active.length === 0) self._active = null;
77+ if (self._active !== null || self._batches.length > 0) {
78+ self._scheduleAdvance(generation);
79+ }
80+ }, function () {
81+ self._sending = false;
82+ if (generation !== self._generation) {
83+ if (self._active !== null || self._batches.length > 0) {
84+ self._scheduleAdvance(self._generation);
85+ }
86+ return;
87+ }
88+ self.clear();
89+ self._onFailure("APP_MESSAGE_FAILED");
90+ });
91+ } catch (ignored) {
92+ this._sending = false;
93+ this.clear();
94+ this._onFailure("APP_MESSAGE_FAILED");
95+ }
96+};
97+
98+module.exports = MessageQueue;
new file mode 100644
@@ -0,0 +1,98 @@
1+"use strict";
2+
3+function MessageQueue(Pebble, defer, onFailure) {
4+ if (!Pebble || typeof Pebble.sendAppMessage !== "function") {
5+ throw new TypeError("Pebble.sendAppMessage is required");
6+ }
7+ if (typeof defer !== "function") throw new TypeError("defer adapter is required");
8+ this._Pebble = Pebble;
9+ this._defer = defer;
10+ this._onFailure = typeof onFailure === "function" ? onFailure : function () {};
11+ this._batches = [];
12+ this._active = null;
13+ this._sending = false;
14+ this._deferredGeneration = null;
15+ this._generation = 0;
16+}
17+
18+MessageQueue.prototype.enqueue = function (messages) {
19+ if (!Array.isArray(messages) || messages.length === 0) {
20+ throw new TypeError("A non-empty message sequence is required");
21+ }
22+ this._batches.push(messages.slice());
23+ this._advance();
24+};
25+
26+MessageQueue.prototype.clear = function () {
27+ this._generation += 1;
28+ this._batches = [];
29+ this._active = null;
30+ this._deferredGeneration = null;
31+};
32+
33+MessageQueue.prototype.isSending = function () {
34+ return this._sending;
35+};
36+
37+MessageQueue.prototype._scheduleAdvance = function (generation) {
38+ var self = this;
39+ if (this._deferredGeneration !== null) return;
40+ this._deferredGeneration = generation;
41+ try {
42+ this._defer(function () {
43+ if (self._deferredGeneration !== generation) return;
44+ self._deferredGeneration = null;
45+ if (generation !== self._generation) return;
46+ self._advance();
47+ });
48+ } catch (ignored) {
49+ this._deferredGeneration = null;
50+ this.clear();
51+ this._onFailure("APP_MESSAGE_FAILED");
52+ }
53+};
54+
55+MessageQueue.prototype._advance = function () {
56+ var self = this;
57+ var message;
58+ var generation;
59+ if (this._sending || this._deferredGeneration !== null) return;
60+ if (!this._active || this._active.length === 0) this._active = this._batches.shift() || null;
61+ if (!this._active) return;
62+
63+ message = this._active[0];
64+ this._sending = true;
65+ generation = this._generation;
66+ try {
67+ this._Pebble.sendAppMessage(message, function () {
68+ self._sending = false;
69+ if (generation !== self._generation) {
70+ if (self._active !== null || self._batches.length > 0) {
71+ self._scheduleAdvance(self._generation);
72+ }
73+ return;
74+ }
75+ self._active.shift();
76+ if (self._active.length === 0) self._active = null;
77+ if (self._active !== null || self._batches.length > 0) {
78+ self._scheduleAdvance(generation);
79+ }
80+ }, function () {
81+ self._sending = false;
82+ if (generation !== self._generation) {
83+ if (self._active !== null || self._batches.length > 0) {
84+ self._scheduleAdvance(self._generation);
85+ }
86+ return;
87+ }
88+ self.clear();
89+ self._onFailure("APP_MESSAGE_FAILED");
90+ });
91+ } catch (ignored) {
92+ this._sending = false;
93+ this.clear();
94+ this._onFailure("APP_MESSAGE_FAILED");
95+ }
96+};
97+
98+module.exports = MessageQueue;
added packages/companion/test/companion.test.js +503 -0
new file mode 100644
@@ -0,0 +1,503 @@
1+"use strict";
2+
3+var test = require("node:test");
4+var assert = require("node:assert/strict");
5+var vm = require("node:vm");
6+var companion = require("../src");
7+var fixture = require("../../../fixtures/departures/foundation.json");
8+var fakes = require("./fakes");
9+
10+var contracts = companion.contracts;
11+var codec = companion.codec;
12+var configuration = companion.configuration;
13+var T = contracts.MESSAGE_TYPE;
14+var TEST_KEY = "test-personal-prim-key";
15+
16+function harness(options) {
17+ var Pebble = new fakes.FakePebble(true);
18+ var storage = new fakes.FakeStorage();
19+ var clock = new fakes.FakeClock();
20+ var xhr = fakes.createXHRFactory();
21+ var defer;
22+ var instance;
23+ options = options || {};
24+ defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true);
25+ instance = companion.createCompanion({
26+ Pebble: Pebble,
27+ storage: storage,
28+ XHR: xhr.XHR,
29+ clock: clock,
30+ defer: defer,
31+ backendUrl: "https://backend.example.test/departures",
32+ configurationUrl: "https://config.example.test/index.html",
33+ fixture: options.fixture
34+ });
35+ return {
36+ Pebble: Pebble,
37+ storage: storage,
38+ clock: clock,
39+ defer: defer,
40+ xhr: xhr,
41+ companion: instance
42+ };
43+}
44+
45+function configurationPayload(action, key) {
46+ var update = {
47+ schemaVersion: contracts.SCHEMA_VERSION,
48+ favorites: [fixture.favorite],
49+ apiKeyUpdate: { schemaVersion: contracts.SCHEMA_VERSION, action: action }
50+ };
51+ if (action === "REPLACE") update.apiKeyUpdate.value = key;
52+ return update;
53+}
54+
55+function closeWith(target, payload) {
56+ target.Pebble.emit("webviewclosed", {
57+ response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(payload))
58+ });
59+}
60+
61+function sendRequest(target, requestId) {
62+ target.Pebble.emit("appmessage", {
63+ payload: codec.encodeRequest({
64+ requestId: requestId,
65+ favoriteId: fixture.favorite.id,
66+ trigger: contracts.REQUEST_TRIGGER.APP_OPEN
67+ })
68+ });
69+}
70+
71+function dualRequestPayload(requestId) {
72+ return {
73+ "0": contracts.SCHEMA_VERSION,
74+ "1": T.REQUEST,
75+ "2": requestId,
76+ "3": fixture.favorite.id,
77+ "24": contracts.REQUEST_TRIGGER.APP_OPEN,
78+ SCHEMA_VERSION: contracts.SCHEMA_VERSION,
79+ MESSAGE_TYPE: T.REQUEST,
80+ REQUEST_ID: requestId,
81+ FAVORITE_ID: fixture.favorite.id,
82+ REQUEST_TRIGGER: contracts.REQUEST_TRIGGER.APP_OPEN
83+ };
84+}
85+
86+function messagesOfType(target, type) {
87+ return target.Pebble.sent.filter(function (message) {
88+ return message.MESSAGE_TYPE === type;
89+ });
90+}
91+
92+function responseFor(requestId) {
93+ var result = contracts.copyDepartureResult(fixture.result, requestId);
94+ return result;
95+}
96+
97+test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () {
98+ var target = harness();
99+ var stored;
100+ var sentText;
101+ var pageState;
102+
103+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
104+ assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
105+ assert.equal(target.storage.writes.length, 1);
106+ stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
107+ assert.equal(stored.apiKey, TEST_KEY);
108+ assert.deepEqual(stored.favorites, [fixture.favorite]);
109+
110+ sentText = JSON.stringify(target.Pebble.sent);
111+ assert.equal(sentText.includes(TEST_KEY), false);
112+ target.Pebble.emit("showConfiguration");
113+ assert.equal(target.Pebble.openedUrls[0].includes(TEST_KEY), false);
114+ pageState = JSON.parse(decodeURIComponent(target.Pebble.openedUrls[0].split("#")[1]));
115+ assert.deepEqual(pageState, {
116+ hasKey: true,
117+ favorites: [fixture.favorite],
118+ language: "en"
119+ });
120+
121+ sendRequest(target, "secret-boundary");
122+ assert.equal(target.xhr.instances.length, 1);
123+ assert.equal(target.xhr.instances[0].headers.Authorization, "Bearer " + TEST_KEY);
124+ assert.equal(target.xhr.instances[0].url.includes(TEST_KEY), false);
125+ assert.equal(target.xhr.instances[0].body.includes(TEST_KEY), false);
126+ target.xhr.instances[0].respond(200, responseFor("secret-boundary"));
127+ assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false);
128+});
129+test("configuration opening uses the active watch language and safely falls back to English", function () {
130+ var target = harness();
131+ var state;
132+
133+ target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; };
134+ target.Pebble.emit("showConfiguration");
135+ state = JSON.parse(decodeURIComponent(target.Pebble.openedUrls[0].split("#")[1]));
136+ assert.equal(state.language, "fr");
137+
138+ target.Pebble.getActiveWatchInfo = function () { throw new Error("unavailable"); };
139+ target.Pebble.emit("showConfiguration");
140+ state = JSON.parse(decodeURIComponent(target.Pebble.openedUrls[1].split("#")[1]));
141+ assert.equal(state.language, "en");
142+});
143+
144+
145+test("KEEP, REPLACE, and REMOVE apply to one atomic phone-local record", function () {
146+ var target = harness();
147+ var stored;
148+
149+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
150+ closeWith(target, configurationPayload("KEEP"));
151+ stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
152+ assert.equal(stored.apiKey, TEST_KEY);
153+ assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
154+
155+ closeWith(target, configurationPayload("REMOVE"));
156+ stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
157+ assert.equal(stored.apiKey, null);
158+ assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
159+ assert.equal(target.storage.writes.length, 3);
160+});
161+test("untrusted webview responses are bounded and cannot persist a key inside favorites", function () {
162+ var target = harness();
163+ var replaceLeak = configurationPayload("REPLACE", TEST_KEY);
164+ var keepLeak;
165+ var stored;
166+
167+ target.Pebble.emit("webviewclosed", {
168+ response: "pebblejs://close#" + "x".repeat(configuration.MAX_CLOSE_RESPONSE_LENGTH)
169+ });
170+ assert.equal(target.storage.writes.length, 0);
171+
172+ replaceLeak.favorites = [
173+ Object.assign({}, fixture.favorite, { serviceId: "opaque:" + TEST_KEY })
174+ ];
175+ closeWith(target, replaceLeak);
176+ assert.equal(target.storage.writes.length, 0);
177+
178+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
179+ assert.equal(target.storage.writes.length, 1);
180+ keepLeak = configurationPayload("KEEP");
181+ keepLeak.favorites = [
182+ Object.assign({}, fixture.favorite, { stopLabel: "Stop " + TEST_KEY })
183+ ];
184+ closeWith(target, keepLeak);
185+ assert.equal(target.storage.writes.length, 1);
186+ stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
187+ assert.equal(JSON.stringify(stored.favorites).includes(TEST_KEY), false);
188+ assert.equal(stored.apiKey, TEST_KEY);
189+});
190+
191+
192+test("configuration advances one event-loop turn at a time after immediate ACKs", function () {
193+ var defer = fakes.createDefer(false);
194+ var target = harness({ fixture: fixture, defer: defer });
195+
196+ target.Pebble.emit("ready");
197+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
198+ T.CONFIG_BEGIN
199+ ]);
200+ assert.equal(defer.pending.length, 1);
201+
202+ defer.runNext();
203+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
204+ T.CONFIG_BEGIN,
205+ T.FAVORITE
206+ ]);
207+ assert.equal(defer.pending.length, 1);
208+
209+ defer.runNext();
210+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
211+ T.CONFIG_BEGIN,
212+ T.FAVORITE,
213+ T.CONFIG_COMMIT
214+ ]);
215+ assert.equal(defer.pending.length, 0);
216+});
217+
218+test("stop invalidates a deferred configuration advance", function () {
219+ var defer = fakes.createDefer(false);
220+ var target = harness({ fixture: fixture, defer: defer });
221+
222+ target.Pebble.emit("ready");
223+ target.companion.stop();
224+ defer.runNext();
225+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
226+ T.CONFIG_BEGIN
227+ ]);
228+});
229+
230+test("stop aborts and invalidates a late request callback across restart", function () {
231+ var target = harness();
232+ var staleRequest;
233+ var before;
234+ var resultBegins;
235+
236+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
237+ sendRequest(target, "before-stop");
238+ staleRequest = target.xhr.instances[0];
239+ staleRequest.abort = function () { this.aborted = true; };
240+
241+ target.companion.stop();
242+ assert.equal(staleRequest.aborted, true);
243+ target.companion.start();
244+
245+ before = target.Pebble.sent.length;
246+ staleRequest.respond(200, responseFor("before-stop"));
247+ assert.equal(target.Pebble.sent.length, before);
248+
249+ sendRequest(target, "after-restart");
250+ assert.equal(target.xhr.instances.length, 2);
251+ target.xhr.instances[1].respond(200, responseFor("after-restart"));
252+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
253+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "after-restart");
254+});
255+
256+test("ready sends full config and APP_OPEN returns the fixture without XHR or product timers", function () {
257+ var target = harness({ fixture: fixture });
258+ var before;
259+ var resultBegins;
260+
261+ target.Pebble.emit("ready");
262+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
263+ T.CONFIG_BEGIN,
264+ T.FAVORITE,
265+ T.CONFIG_COMMIT
266+ ]);
267+ assert.equal(target.Pebble.sent[0].DISPLAY_NAME, "en");
268+ assert.equal(target.Pebble.sent[0].KEY_STATUS, contracts.KEY_STATUS.CONFIGURED);
269+ assert.equal(target.Pebble.sent.every(function (message) {
270+ return Object.keys(message).every(function (key) { return !/^[0-9]+$/.test(key); });
271+ }), true);
272+
273+ before = target.Pebble.sent.length;
274+ sendRequest(target, "app-open");
275+ assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
276+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
277+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "app-open");
278+ assert.equal(target.xhr.instances.length, 0);
279+ assert.equal(target.clock.timers.length, 0);
280+});
281+
282+test("pypkjs dual request properties are canonicalized only at the event boundary", function () {
283+ var target = harness({ fixture: fixture });
284+ var payload = dualRequestPayload("dual-app-open");
285+ var before;
286+ var resultBegins;
287+
288+ target.Pebble.emit("ready");
289+ before = target.Pebble.sent.length;
290+ assert.equal(codec.decodeRequest(payload), null);
291+ target.Pebble.emit("appmessage", { payload: payload });
292+
293+ assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
294+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
295+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "dual-app-open");
296+ assert.equal(target.xhr.instances.length, 0);
297+ assert.equal(target.clock.timers.length, 0);
298+});
299+
300+test("incoming event normalization rejects numeric-only, unknown, conflicting, extra, and internal properties", function () {
301+ var target = harness({ fixture: fixture });
302+ var numericOnly = dualRequestPayload("numeric-only");
303+ var unknown = dualRequestPayload("unknown");
304+ var conflicting = dualRequestPayload("conflicting");
305+ var extra = dualRequestPayload("extra");
306+ var cases;
307+
308+ delete numericOnly.SCHEMA_VERSION;
309+ delete numericOnly.MESSAGE_TYPE;
310+ delete numericOnly.REQUEST_ID;
311+ delete numericOnly.FAVORITE_ID;
312+ delete numericOnly.REQUEST_TRIGGER;
313+ unknown["25"] = 0;
314+ conflicting["24"] = contracts.REQUEST_TRIGGER.FAVORITE_SELECTION;
315+ extra["4"] = fixture.favorite.serviceId;
316+ extra.SERVICE_ID = fixture.favorite.serviceId;
317+ cases = [
318+ { name: "numeric-only", payload: numericOnly },
319+ { name: "unknown numeric", payload: unknown },
320+ { name: "conflicting duplicate", payload: conflicting },
321+ { name: "extra canonical key", payload: extra },
322+ { name: "internal pypkjs event", payload: { "15025": 1 } }
323+ ];
324+
325+ target.Pebble.emit("ready");
326+ cases.forEach(function (testCase) {
327+ var before = target.Pebble.sent.length;
328+ target.Pebble.emit("appmessage", { payload: testCase.payload });
329+ assert.equal(target.Pebble.sent.length, before, testCase.name);
330+ });
331+ assert.equal(target.xhr.instances.length, 0);
332+ assert.equal(target.clock.timers.length, 0);
333+});
334+
335+test("fresh results are rebound from cache for 60 seconds without a network timer", function () {
336+ var target = harness();
337+ var before;
338+ var resultBegins;
339+
340+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
341+ sendRequest(target, "network-first");
342+ assert.equal(target.xhr.instances.length, 1);
343+ target.xhr.instances[0].respond(200, responseFor("network-first"));
344+
345+ target.clock.advance(59999);
346+ before = target.Pebble.sent.length;
347+ sendRequest(target, "cache-hit");
348+ assert.equal(target.xhr.instances.length, 1);
349+ assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
350+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
351+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "cache-hit");
352+
353+ target.clock.advance(1);
354+ sendRequest(target, "cache-expired");
355+ assert.equal(target.xhr.instances.length, 2);
356+ assert.equal(target.clock.delays.length, 0);
357+});
358+test("a late older response cannot overwrite or emit after the newest request for a favorite", function () {
359+ var target = harness();
360+ var older = responseFor("older");
361+ var newer = responseFor("newer");
362+ var beforeOlder;
363+ var resultBegins;
364+
365+ older.fetchedAt += 1;
366+ newer.fetchedAt += 2;
367+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
368+ sendRequest(target, "older");
369+ sendRequest(target, "newer");
370+ assert.equal(target.xhr.instances.length, 2);
371+
372+ target.xhr.instances[1].respond(200, newer);
373+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
374+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "newer");
375+ assert.equal(resultBegins[resultBegins.length - 1].FETCHED_AT, newer.fetchedAt);
376+
377+ beforeOlder = target.Pebble.sent.length;
378+ target.xhr.instances[0].respond(200, older);
379+ assert.equal(target.Pebble.sent.length, beforeOlder);
380+
381+ sendRequest(target, "cache-after-race");
382+ assert.equal(target.xhr.instances.length, 2);
383+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
384+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "cache-after-race");
385+ assert.equal(resultBegins[resultBegins.length - 1].FETCHED_AT, newer.fetchedAt);
386+});
387+
388+
389+test("HTTP failures map to stable error codes, keyStatus only, and never retry", function () {
390+ var cases = [
391+ { status: 401, expected: "API_KEY_INVALID" },
392+ { status: 404, expected: "INVALID_SERVICE" },
393+ { status: 429, expected: "RATE_LIMITED", headers: { "Retry-After": "17" }, retry: 17 },
394+ { status: 503, expected: "SOURCE_UNAVAILABLE" }
395+ ];
396+
397+ cases.forEach(function (item, index) {
398+ var target = harness();
399+ var errors;
400+ var configBegins;
401+ var responseStart;
402+ var lastError;
403+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
404+ sendRequest(target, "error-" + index);
405+ responseStart = target.Pebble.sent.length;
406+ target.xhr.instances[0].respond(item.status, "", item.headers);
407+
408+ errors = messagesOfType(target, T.ERROR);
409+ lastError = errors[errors.length - 1];
410+ assert.equal(lastError.ERROR_CODE, contracts.ERROR_CODE.indexOf(item.expected));
411+ assert.equal(lastError.REQUEST_ID, "error-" + index);
412+ assert.equal(lastError.FAVORITE_ID, fixture.favorite.id);
413+ assert.equal(lastError.RETRY_AFTER_SECONDS, item.retry);
414+ assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false);
415+ assert.equal(target.xhr.instances.length, 1);
416+ target.clock.advance(3600000);
417+ assert.equal(target.xhr.instances.length, 1);
418+
419+ if (item.expected === "API_KEY_INVALID") {
420+ configBegins = messagesOfType(target, T.CONFIG_BEGIN);
421+ assert.equal(configBegins[configBegins.length - 1].KEY_STATUS, contracts.KEY_STATUS.INVALID);
422+ assert.deepEqual(
423+ target.Pebble.sent.slice(responseStart).map(function (message) { return message.MESSAGE_TYPE; }),
424+ [T.ERROR, T.CONFIG_BEGIN, T.FAVORITE, T.CONFIG_COMMIT]
425+ );
426+ sendRequest(target, "known-invalid");
427+ assert.equal(target.xhr.instances.length, 1);
428+ }
429+ });
430+});
431+
432+test("network failure emits one sanitized error and schedules no retry", function () {
433+ var target = harness();
434+ var errors;
435+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
436+ sendRequest(target, "network-error");
437+ target.xhr.instances[0].networkError();
438+ errors = messagesOfType(target, T.ERROR);
439+ assert.equal(errors.length, 1);
440+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("SOURCE_UNAVAILABLE"));
441+ assert.equal(target.xhr.instances.length, 1);
442+ assert.equal(target.clock.timers.length, 0);
443+ assert.equal(JSON.stringify(errors).includes(TEST_KEY), false);
444+});
445+test("build bootstrap validates URLs and injects only the zero-delay ACK defer", async function () {
446+ var build = await import("../../../scripts/build.mjs");
447+ var configured;
448+ var captured = null;
449+ var scheduled = [];
450+ var deferred = false;
451+ var source;
452+
453+ assert.deepEqual(build.readBuildConfiguration(Object.create(null)), {
454+ backendUrl: "",
455+ configurationUrl: ""
456+ });
457+ assert.throws(function () {
458+ build.readBuildConfiguration({ LAPIN_FUTE_BACKEND_URL: "http://backend.example.test" });
459+ }, /absolute HTTPS URL/);
460+ assert.throws(function () {
461+ build.readBuildConfiguration({ LAPIN_FUTE_CONFIG_URL: "/configuration" });
462+ }, /absolute HTTPS URL/);
463+ assert.throws(function () {
464+ build.readBuildConfiguration({ LAPIN_FUTE_CONFIG_URL: "" });
465+ }, /absolute HTTPS URL/);
466+
467+ configured = build.readBuildConfiguration({
468+ LAPIN_FUTE_BACKEND_URL: "https://backend.example.test/departures",
469+ LAPIN_FUTE_CONFIG_URL: "https://config.example.test/index.html"
470+ });
471+ source = build.createBootstrap(fixture, configured);
472+ vm.runInNewContext(source, {
473+ Pebble: {},
474+ localStorage: {},
475+ XMLHttpRequest: function () {},
476+ Date: Date,
477+ setTimeout: function (callback, delay) {
478+ scheduled.push({ callback: callback, delay: delay });
479+ },
480+ require: function (specifier) {
481+ assert.equal(specifier, "./companion");
482+ return {
483+ createCompanion: function (options) {
484+ captured = options;
485+ }
486+ };
487+ }
488+ });
489+
490+ assert.notEqual(captured, null);
491+ assert.deepEqual(Object.keys(captured.clock), ["now"]);
492+ captured.defer(function () { deferred = true; });
493+ assert.equal(deferred, false);
494+ assert.equal(scheduled.length, 1);
495+ assert.equal(scheduled[0].delay, 0);
496+ scheduled[0].callback();
497+ assert.equal(deferred, true);
498+ assert.equal(captured.backendUrl, configured.backendUrl);
499+ assert.equal(captured.configurationUrl, configured.configurationUrl);
500+ assert.equal(JSON.stringify(captured.fixture), JSON.stringify(fixture));
501+ assert.equal(source.includes("LAPIN_FUTE_"), false);
502+ assert.equal(source.includes(TEST_KEY), false);
503+});
new file mode 100644
@@ -0,0 +1,503 @@
1+"use strict";
2+
3+var test = require("node:test");
4+var assert = require("node:assert/strict");
5+var vm = require("node:vm");
6+var companion = require("../src");
7+var fixture = require("../../../fixtures/departures/foundation.json");
8+var fakes = require("./fakes");
9+
10+var contracts = companion.contracts;
11+var codec = companion.codec;
12+var configuration = companion.configuration;
13+var T = contracts.MESSAGE_TYPE;
14+var TEST_KEY = "test-personal-prim-key";
15+
16+function harness(options) {
17+ var Pebble = new fakes.FakePebble(true);
18+ var storage = new fakes.FakeStorage();
19+ var clock = new fakes.FakeClock();
20+ var xhr = fakes.createXHRFactory();
21+ var defer;
22+ var instance;
23+ options = options || {};
24+ defer = typeof options.defer === "function" ? options.defer : fakes.createDefer(true);
25+ instance = companion.createCompanion({
26+ Pebble: Pebble,
27+ storage: storage,
28+ XHR: xhr.XHR,
29+ clock: clock,
30+ defer: defer,
31+ backendUrl: "https://backend.example.test/departures",
32+ configurationUrl: "https://config.example.test/index.html",
33+ fixture: options.fixture
34+ });
35+ return {
36+ Pebble: Pebble,
37+ storage: storage,
38+ clock: clock,
39+ defer: defer,
40+ xhr: xhr,
41+ companion: instance
42+ };
43+}
44+
45+function configurationPayload(action, key) {
46+ var update = {
47+ schemaVersion: contracts.SCHEMA_VERSION,
48+ favorites: [fixture.favorite],
49+ apiKeyUpdate: { schemaVersion: contracts.SCHEMA_VERSION, action: action }
50+ };
51+ if (action === "REPLACE") update.apiKeyUpdate.value = key;
52+ return update;
53+}
54+
55+function closeWith(target, payload) {
56+ target.Pebble.emit("webviewclosed", {
57+ response: "pebblejs://close#" + encodeURIComponent(JSON.stringify(payload))
58+ });
59+}
60+
61+function sendRequest(target, requestId) {
62+ target.Pebble.emit("appmessage", {
63+ payload: codec.encodeRequest({
64+ requestId: requestId,
65+ favoriteId: fixture.favorite.id,
66+ trigger: contracts.REQUEST_TRIGGER.APP_OPEN
67+ })
68+ });
69+}
70+
71+function dualRequestPayload(requestId) {
72+ return {
73+ "0": contracts.SCHEMA_VERSION,
74+ "1": T.REQUEST,
75+ "2": requestId,
76+ "3": fixture.favorite.id,
77+ "24": contracts.REQUEST_TRIGGER.APP_OPEN,
78+ SCHEMA_VERSION: contracts.SCHEMA_VERSION,
79+ MESSAGE_TYPE: T.REQUEST,
80+ REQUEST_ID: requestId,
81+ FAVORITE_ID: fixture.favorite.id,
82+ REQUEST_TRIGGER: contracts.REQUEST_TRIGGER.APP_OPEN
83+ };
84+}
85+
86+function messagesOfType(target, type) {
87+ return target.Pebble.sent.filter(function (message) {
88+ return message.MESSAGE_TYPE === type;
89+ });
90+}
91+
92+function responseFor(requestId) {
93+ var result = contracts.copyDepartureResult(fixture.result, requestId);
94+ return result;
95+}
96+
97+test("close fragment atomically stores one favorites-and-key record and exposes the key only to Authorization", function () {
98+ var target = harness();
99+ var stored;
100+ var sentText;
101+ var pageState;
102+
103+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
104+ assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
105+ assert.equal(target.storage.writes.length, 1);
106+ stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
107+ assert.equal(stored.apiKey, TEST_KEY);
108+ assert.deepEqual(stored.favorites, [fixture.favorite]);
109+
110+ sentText = JSON.stringify(target.Pebble.sent);
111+ assert.equal(sentText.includes(TEST_KEY), false);
112+ target.Pebble.emit("showConfiguration");
113+ assert.equal(target.Pebble.openedUrls[0].includes(TEST_KEY), false);
114+ pageState = JSON.parse(decodeURIComponent(target.Pebble.openedUrls[0].split("#")[1]));
115+ assert.deepEqual(pageState, {
116+ hasKey: true,
117+ favorites: [fixture.favorite],
118+ language: "en"
119+ });
120+
121+ sendRequest(target, "secret-boundary");
122+ assert.equal(target.xhr.instances.length, 1);
123+ assert.equal(target.xhr.instances[0].headers.Authorization, "Bearer " + TEST_KEY);
124+ assert.equal(target.xhr.instances[0].url.includes(TEST_KEY), false);
125+ assert.equal(target.xhr.instances[0].body.includes(TEST_KEY), false);
126+ target.xhr.instances[0].respond(200, responseFor("secret-boundary"));
127+ assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false);
128+});
129+test("configuration opening uses the active watch language and safely falls back to English", function () {
130+ var target = harness();
131+ var state;
132+
133+ target.Pebble.getActiveWatchInfo = function () { return { language: "fr_FR" }; };
134+ target.Pebble.emit("showConfiguration");
135+ state = JSON.parse(decodeURIComponent(target.Pebble.openedUrls[0].split("#")[1]));
136+ assert.equal(state.language, "fr");
137+
138+ target.Pebble.getActiveWatchInfo = function () { throw new Error("unavailable"); };
139+ target.Pebble.emit("showConfiguration");
140+ state = JSON.parse(decodeURIComponent(target.Pebble.openedUrls[1].split("#")[1]));
141+ assert.equal(state.language, "en");
142+});
143+
144+
145+test("KEEP, REPLACE, and REMOVE apply to one atomic phone-local record", function () {
146+ var target = harness();
147+ var stored;
148+
149+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
150+ closeWith(target, configurationPayload("KEEP"));
151+ stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
152+ assert.equal(stored.apiKey, TEST_KEY);
153+ assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
154+
155+ closeWith(target, configurationPayload("REMOVE"));
156+ stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
157+ assert.equal(stored.apiKey, null);
158+ assert.deepEqual(target.storage.keys(), [configuration.STORAGE_KEY]);
159+ assert.equal(target.storage.writes.length, 3);
160+});
161+test("untrusted webview responses are bounded and cannot persist a key inside favorites", function () {
162+ var target = harness();
163+ var replaceLeak = configurationPayload("REPLACE", TEST_KEY);
164+ var keepLeak;
165+ var stored;
166+
167+ target.Pebble.emit("webviewclosed", {
168+ response: "pebblejs://close#" + "x".repeat(configuration.MAX_CLOSE_RESPONSE_LENGTH)
169+ });
170+ assert.equal(target.storage.writes.length, 0);
171+
172+ replaceLeak.favorites = [
173+ Object.assign({}, fixture.favorite, { serviceId: "opaque:" + TEST_KEY })
174+ ];
175+ closeWith(target, replaceLeak);
176+ assert.equal(target.storage.writes.length, 0);
177+
178+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
179+ assert.equal(target.storage.writes.length, 1);
180+ keepLeak = configurationPayload("KEEP");
181+ keepLeak.favorites = [
182+ Object.assign({}, fixture.favorite, { stopLabel: "Stop " + TEST_KEY })
183+ ];
184+ closeWith(target, keepLeak);
185+ assert.equal(target.storage.writes.length, 1);
186+ stored = JSON.parse(target.storage.getItem(configuration.STORAGE_KEY));
187+ assert.equal(JSON.stringify(stored.favorites).includes(TEST_KEY), false);
188+ assert.equal(stored.apiKey, TEST_KEY);
189+});
190+
191+
192+test("configuration advances one event-loop turn at a time after immediate ACKs", function () {
193+ var defer = fakes.createDefer(false);
194+ var target = harness({ fixture: fixture, defer: defer });
195+
196+ target.Pebble.emit("ready");
197+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
198+ T.CONFIG_BEGIN
199+ ]);
200+ assert.equal(defer.pending.length, 1);
201+
202+ defer.runNext();
203+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
204+ T.CONFIG_BEGIN,
205+ T.FAVORITE
206+ ]);
207+ assert.equal(defer.pending.length, 1);
208+
209+ defer.runNext();
210+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
211+ T.CONFIG_BEGIN,
212+ T.FAVORITE,
213+ T.CONFIG_COMMIT
214+ ]);
215+ assert.equal(defer.pending.length, 0);
216+});
217+
218+test("stop invalidates a deferred configuration advance", function () {
219+ var defer = fakes.createDefer(false);
220+ var target = harness({ fixture: fixture, defer: defer });
221+
222+ target.Pebble.emit("ready");
223+ target.companion.stop();
224+ defer.runNext();
225+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
226+ T.CONFIG_BEGIN
227+ ]);
228+});
229+
230+test("stop aborts and invalidates a late request callback across restart", function () {
231+ var target = harness();
232+ var staleRequest;
233+ var before;
234+ var resultBegins;
235+
236+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
237+ sendRequest(target, "before-stop");
238+ staleRequest = target.xhr.instances[0];
239+ staleRequest.abort = function () { this.aborted = true; };
240+
241+ target.companion.stop();
242+ assert.equal(staleRequest.aborted, true);
243+ target.companion.start();
244+
245+ before = target.Pebble.sent.length;
246+ staleRequest.respond(200, responseFor("before-stop"));
247+ assert.equal(target.Pebble.sent.length, before);
248+
249+ sendRequest(target, "after-restart");
250+ assert.equal(target.xhr.instances.length, 2);
251+ target.xhr.instances[1].respond(200, responseFor("after-restart"));
252+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
253+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "after-restart");
254+});
255+
256+test("ready sends full config and APP_OPEN returns the fixture without XHR or product timers", function () {
257+ var target = harness({ fixture: fixture });
258+ var before;
259+ var resultBegins;
260+
261+ target.Pebble.emit("ready");
262+ assert.deepEqual(target.Pebble.sent.map(function (message) { return message.MESSAGE_TYPE; }), [
263+ T.CONFIG_BEGIN,
264+ T.FAVORITE,
265+ T.CONFIG_COMMIT
266+ ]);
267+ assert.equal(target.Pebble.sent[0].DISPLAY_NAME, "en");
268+ assert.equal(target.Pebble.sent[0].KEY_STATUS, contracts.KEY_STATUS.CONFIGURED);
269+ assert.equal(target.Pebble.sent.every(function (message) {
270+ return Object.keys(message).every(function (key) { return !/^[0-9]+$/.test(key); });
271+ }), true);
272+
273+ before = target.Pebble.sent.length;
274+ sendRequest(target, "app-open");
275+ assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
276+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
277+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "app-open");
278+ assert.equal(target.xhr.instances.length, 0);
279+ assert.equal(target.clock.timers.length, 0);
280+});
281+
282+test("pypkjs dual request properties are canonicalized only at the event boundary", function () {
283+ var target = harness({ fixture: fixture });
284+ var payload = dualRequestPayload("dual-app-open");
285+ var before;
286+ var resultBegins;
287+
288+ target.Pebble.emit("ready");
289+ before = target.Pebble.sent.length;
290+ assert.equal(codec.decodeRequest(payload), null);
291+ target.Pebble.emit("appmessage", { payload: payload });
292+
293+ assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
294+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
295+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "dual-app-open");
296+ assert.equal(target.xhr.instances.length, 0);
297+ assert.equal(target.clock.timers.length, 0);
298+});
299+
300+test("incoming event normalization rejects numeric-only, unknown, conflicting, extra, and internal properties", function () {
301+ var target = harness({ fixture: fixture });
302+ var numericOnly = dualRequestPayload("numeric-only");
303+ var unknown = dualRequestPayload("unknown");
304+ var conflicting = dualRequestPayload("conflicting");
305+ var extra = dualRequestPayload("extra");
306+ var cases;
307+
308+ delete numericOnly.SCHEMA_VERSION;
309+ delete numericOnly.MESSAGE_TYPE;
310+ delete numericOnly.REQUEST_ID;
311+ delete numericOnly.FAVORITE_ID;
312+ delete numericOnly.REQUEST_TRIGGER;
313+ unknown["25"] = 0;
314+ conflicting["24"] = contracts.REQUEST_TRIGGER.FAVORITE_SELECTION;
315+ extra["4"] = fixture.favorite.serviceId;
316+ extra.SERVICE_ID = fixture.favorite.serviceId;
317+ cases = [
318+ { name: "numeric-only", payload: numericOnly },
319+ { name: "unknown numeric", payload: unknown },
320+ { name: "conflicting duplicate", payload: conflicting },
321+ { name: "extra canonical key", payload: extra },
322+ { name: "internal pypkjs event", payload: { "15025": 1 } }
323+ ];
324+
325+ target.Pebble.emit("ready");
326+ cases.forEach(function (testCase) {
327+ var before = target.Pebble.sent.length;
328+ target.Pebble.emit("appmessage", { payload: testCase.payload });
329+ assert.equal(target.Pebble.sent.length, before, testCase.name);
330+ });
331+ assert.equal(target.xhr.instances.length, 0);
332+ assert.equal(target.clock.timers.length, 0);
333+});
334+
335+test("fresh results are rebound from cache for 60 seconds without a network timer", function () {
336+ var target = harness();
337+ var before;
338+ var resultBegins;
339+
340+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
341+ sendRequest(target, "network-first");
342+ assert.equal(target.xhr.instances.length, 1);
343+ target.xhr.instances[0].respond(200, responseFor("network-first"));
344+
345+ target.clock.advance(59999);
346+ before = target.Pebble.sent.length;
347+ sendRequest(target, "cache-hit");
348+ assert.equal(target.xhr.instances.length, 1);
349+ assert.equal(target.Pebble.sent.length, before + fixture.result.departures.length + 2);
350+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
351+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "cache-hit");
352+
353+ target.clock.advance(1);
354+ sendRequest(target, "cache-expired");
355+ assert.equal(target.xhr.instances.length, 2);
356+ assert.equal(target.clock.delays.length, 0);
357+});
358+test("a late older response cannot overwrite or emit after the newest request for a favorite", function () {
359+ var target = harness();
360+ var older = responseFor("older");
361+ var newer = responseFor("newer");
362+ var beforeOlder;
363+ var resultBegins;
364+
365+ older.fetchedAt += 1;
366+ newer.fetchedAt += 2;
367+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
368+ sendRequest(target, "older");
369+ sendRequest(target, "newer");
370+ assert.equal(target.xhr.instances.length, 2);
371+
372+ target.xhr.instances[1].respond(200, newer);
373+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
374+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "newer");
375+ assert.equal(resultBegins[resultBegins.length - 1].FETCHED_AT, newer.fetchedAt);
376+
377+ beforeOlder = target.Pebble.sent.length;
378+ target.xhr.instances[0].respond(200, older);
379+ assert.equal(target.Pebble.sent.length, beforeOlder);
380+
381+ sendRequest(target, "cache-after-race");
382+ assert.equal(target.xhr.instances.length, 2);
383+ resultBegins = messagesOfType(target, T.RESULT_BEGIN);
384+ assert.equal(resultBegins[resultBegins.length - 1].REQUEST_ID, "cache-after-race");
385+ assert.equal(resultBegins[resultBegins.length - 1].FETCHED_AT, newer.fetchedAt);
386+});
387+
388+
389+test("HTTP failures map to stable error codes, keyStatus only, and never retry", function () {
390+ var cases = [
391+ { status: 401, expected: "API_KEY_INVALID" },
392+ { status: 404, expected: "INVALID_SERVICE" },
393+ { status: 429, expected: "RATE_LIMITED", headers: { "Retry-After": "17" }, retry: 17 },
394+ { status: 503, expected: "SOURCE_UNAVAILABLE" }
395+ ];
396+
397+ cases.forEach(function (item, index) {
398+ var target = harness();
399+ var errors;
400+ var configBegins;
401+ var responseStart;
402+ var lastError;
403+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
404+ sendRequest(target, "error-" + index);
405+ responseStart = target.Pebble.sent.length;
406+ target.xhr.instances[0].respond(item.status, "", item.headers);
407+
408+ errors = messagesOfType(target, T.ERROR);
409+ lastError = errors[errors.length - 1];
410+ assert.equal(lastError.ERROR_CODE, contracts.ERROR_CODE.indexOf(item.expected));
411+ assert.equal(lastError.REQUEST_ID, "error-" + index);
412+ assert.equal(lastError.FAVORITE_ID, fixture.favorite.id);
413+ assert.equal(lastError.RETRY_AFTER_SECONDS, item.retry);
414+ assert.equal(JSON.stringify(target.Pebble.sent).includes(TEST_KEY), false);
415+ assert.equal(target.xhr.instances.length, 1);
416+ target.clock.advance(3600000);
417+ assert.equal(target.xhr.instances.length, 1);
418+
419+ if (item.expected === "API_KEY_INVALID") {
420+ configBegins = messagesOfType(target, T.CONFIG_BEGIN);
421+ assert.equal(configBegins[configBegins.length - 1].KEY_STATUS, contracts.KEY_STATUS.INVALID);
422+ assert.deepEqual(
423+ target.Pebble.sent.slice(responseStart).map(function (message) { return message.MESSAGE_TYPE; }),
424+ [T.ERROR, T.CONFIG_BEGIN, T.FAVORITE, T.CONFIG_COMMIT]
425+ );
426+ sendRequest(target, "known-invalid");
427+ assert.equal(target.xhr.instances.length, 1);
428+ }
429+ });
430+});
431+
432+test("network failure emits one sanitized error and schedules no retry", function () {
433+ var target = harness();
434+ var errors;
435+ closeWith(target, configurationPayload("REPLACE", TEST_KEY));
436+ sendRequest(target, "network-error");
437+ target.xhr.instances[0].networkError();
438+ errors = messagesOfType(target, T.ERROR);
439+ assert.equal(errors.length, 1);
440+ assert.equal(errors[0].ERROR_CODE, contracts.ERROR_CODE.indexOf("SOURCE_UNAVAILABLE"));
441+ assert.equal(target.xhr.instances.length, 1);
442+ assert.equal(target.clock.timers.length, 0);
443+ assert.equal(JSON.stringify(errors).includes(TEST_KEY), false);
444+});
445+test("build bootstrap validates URLs and injects only the zero-delay ACK defer", async function () {
446+ var build = await import("../../../scripts/build.mjs");
447+ var configured;
448+ var captured = null;
449+ var scheduled = [];
450+ var deferred = false;
451+ var source;
452+
453+ assert.deepEqual(build.readBuildConfiguration(Object.create(null)), {
454+ backendUrl: "",
455+ configurationUrl: ""
456+ });
457+ assert.throws(function () {
458+ build.readBuildConfiguration({ LAPIN_FUTE_BACKEND_URL: "http://backend.example.test" });
459+ }, /absolute HTTPS URL/);
460+ assert.throws(function () {
461+ build.readBuildConfiguration({ LAPIN_FUTE_CONFIG_URL: "/configuration" });
462+ }, /absolute HTTPS URL/);
463+ assert.throws(function () {
464+ build.readBuildConfiguration({ LAPIN_FUTE_CONFIG_URL: "" });
465+ }, /absolute HTTPS URL/);
466+
467+ configured = build.readBuildConfiguration({
468+ LAPIN_FUTE_BACKEND_URL: "https://backend.example.test/departures",
469+ LAPIN_FUTE_CONFIG_URL: "https://config.example.test/index.html"
470+ });
471+ source = build.createBootstrap(fixture, configured);
472+ vm.runInNewContext(source, {
473+ Pebble: {},
474+ localStorage: {},
475+ XMLHttpRequest: function () {},
476+ Date: Date,
477+ setTimeout: function (callback, delay) {
478+ scheduled.push({ callback: callback, delay: delay });
479+ },
480+ require: function (specifier) {
481+ assert.equal(specifier, "./companion");
482+ return {
483+ createCompanion: function (options) {
484+ captured = options;
485+ }
486+ };
487+ }
488+ });
489+
490+ assert.notEqual(captured, null);
491+ assert.deepEqual(Object.keys(captured.clock), ["now"]);
492+ captured.defer(function () { deferred = true; });
493+ assert.equal(deferred, false);
494+ assert.equal(scheduled.length, 1);
495+ assert.equal(scheduled[0].delay, 0);
496+ scheduled[0].callback();
497+ assert.equal(deferred, true);
498+ assert.equal(captured.backendUrl, configured.backendUrl);
499+ assert.equal(captured.configurationUrl, configured.configurationUrl);
500+ assert.equal(JSON.stringify(captured.fixture), JSON.stringify(fixture));
501+ assert.equal(source.includes("LAPIN_FUTE_"), false);
502+ assert.equal(source.includes(TEST_KEY), false);
503+});
added packages/companion/test/fakes.js +186 -0
new file mode 100644
@@ -0,0 +1,186 @@
1+"use strict";
2+
3+function FakePebble(autoAck) {
4+ this.autoAck = autoAck !== false;
5+ this.listeners = Object.create(null);
6+ this.sent = [];
7+ this.pending = [];
8+ this.inFlight = 0;
9+ this.maxInFlight = 0;
10+ this.openedUrls = [];
11+}
12+
13+FakePebble.prototype.addEventListener = function (name, listener) {
14+ this.listeners[name] = listener;
15+};
16+
17+FakePebble.prototype.removeEventListener = function (name, listener) {
18+ if (this.listeners[name] === listener) delete this.listeners[name];
19+};
20+
21+FakePebble.prototype.emit = function (name, event) {
22+ if (this.listeners[name]) this.listeners[name](event || {});
23+};
24+
25+FakePebble.prototype.openURL = function (url) {
26+ this.openedUrls.push(url);
27+};
28+
29+FakePebble.prototype.sendAppMessage = function (message, success, failure) {
30+ var pending = { success: success, failure: failure };
31+ this.sent.push(message);
32+ this.pending.push(pending);
33+ this.inFlight += 1;
34+ this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
35+ if (this.autoAck) this.ack();
36+};
37+
38+FakePebble.prototype.ack = function () {
39+ var pending = this.pending.shift();
40+ if (!pending) throw new Error("No pending AppMessage");
41+ this.inFlight -= 1;
42+ pending.success();
43+};
44+
45+FakePebble.prototype.fail = function () {
46+ var pending = this.pending.shift();
47+ if (!pending) throw new Error("No pending AppMessage");
48+ this.inFlight -= 1;
49+ pending.failure({ error: "redacted fake failure" });
50+};
51+
52+function createDefer(autoRun) {
53+ var callbacks = [];
54+
55+ function defer(callback) {
56+ if (autoRun === true) {
57+ callback();
58+ return;
59+ }
60+ callbacks.push(callback);
61+ }
62+
63+ defer.pending = callbacks;
64+ defer.runNext = function () {
65+ var callback = callbacks.shift();
66+ if (!callback) throw new Error("No deferred callback");
67+ callback();
68+ };
69+ return defer;
70+}
71+
72+function FakeStorage() {
73+ this.values = Object.create(null);
74+ this.writes = [];
75+}
76+
77+FakeStorage.prototype.getItem = function (key) {
78+ return Object.prototype.hasOwnProperty.call(this.values, key) ? this.values[key] : null;
79+};
80+
81+FakeStorage.prototype.setItem = function (key, value) {
82+ this.values[key] = String(value);
83+ this.writes.push({ key: key, value: String(value) });
84+};
85+
86+FakeStorage.prototype.keys = function () {
87+ return Object.keys(this.values);
88+};
89+
90+function FakeClock(now) {
91+ this.time = typeof now === "number" ? now : 1788000000000;
92+ this.nextId = 1;
93+ this.timers = [];
94+ this.delays = [];
95+}
96+
97+FakeClock.prototype.now = function () {
98+ return this.time;
99+};
100+
101+FakeClock.prototype.setTimeout = function (callback, delay) {
102+ var id = this.nextId;
103+ this.nextId += 1;
104+ this.delays.push(delay);
105+ this.timers.push({ id: id, at: this.time + delay, callback: callback, cancelled: false });
106+ return id;
107+};
108+
109+FakeClock.prototype.clearTimeout = function (id) {
110+ this.timers.forEach(function (timer) {
111+ if (timer.id === id) timer.cancelled = true;
112+ });
113+};
114+
115+FakeClock.prototype.advance = function (milliseconds) {
116+ var target = this.time + milliseconds;
117+ var next;
118+ while (true) {
119+ next = null;
120+ this.timers.forEach(function (timer) {
121+ if (!timer.cancelled && timer.at <= target && (next === null || timer.at < next.at)) next = timer;
122+ });
123+ if (next === null) break;
124+ next.cancelled = true;
125+ this.time = next.at;
126+ next.callback();
127+ }
128+ this.time = target;
129+};
130+
131+function createXHRFactory() {
132+ var factory = { instances: [] };
133+
134+ function FakeXHR() {
135+ this.method = null;
136+ this.url = null;
137+ this.async = null;
138+ this.timeout = 0;
139+ this.headers = Object.create(null);
140+ this.body = null;
141+ this.status = 0;
142+ this.responseText = "";
143+ this.responseHeaders = Object.create(null);
144+ factory.instances.push(this);
145+ }
146+
147+ FakeXHR.prototype.open = function (method, url, async) {
148+ this.method = method;
149+ this.url = url;
150+ this.async = async;
151+ };
152+
153+ FakeXHR.prototype.setRequestHeader = function (name, value) {
154+ this.headers[name] = value;
155+ };
156+
157+ FakeXHR.prototype.send = function (body) {
158+ this.body = body;
159+ };
160+
161+ FakeXHR.prototype.getResponseHeader = function (name) {
162+ return Object.prototype.hasOwnProperty.call(this.responseHeaders, name) ? this.responseHeaders[name] : null;
163+ };
164+
165+ FakeXHR.prototype.respond = function (status, body, headers) {
166+ this.status = status;
167+ this.responseText = typeof body === "string" ? body : JSON.stringify(body);
168+ this.responseHeaders = headers || Object.create(null);
169+ this.onload();
170+ };
171+
172+ FakeXHR.prototype.networkError = function () {
173+ this.onerror();
174+ };
175+
176+ factory.XHR = FakeXHR;
177+ return factory;
178+}
179+
180+module.exports = {
181+ FakePebble: FakePebble,
182+ FakeStorage: FakeStorage,
183+ FakeClock: FakeClock,
184+ createDefer: createDefer,
185+ createXHRFactory: createXHRFactory
186+};
new file mode 100644
@@ -0,0 +1,186 @@
1+"use strict";
2+
3+function FakePebble(autoAck) {
4+ this.autoAck = autoAck !== false;
5+ this.listeners = Object.create(null);
6+ this.sent = [];
7+ this.pending = [];
8+ this.inFlight = 0;
9+ this.maxInFlight = 0;
10+ this.openedUrls = [];
11+}
12+
13+FakePebble.prototype.addEventListener = function (name, listener) {
14+ this.listeners[name] = listener;
15+};
16+
17+FakePebble.prototype.removeEventListener = function (name, listener) {
18+ if (this.listeners[name] === listener) delete this.listeners[name];
19+};
20+
21+FakePebble.prototype.emit = function (name, event) {
22+ if (this.listeners[name]) this.listeners[name](event || {});
23+};
24+
25+FakePebble.prototype.openURL = function (url) {
26+ this.openedUrls.push(url);
27+};
28+
29+FakePebble.prototype.sendAppMessage = function (message, success, failure) {
30+ var pending = { success: success, failure: failure };
31+ this.sent.push(message);
32+ this.pending.push(pending);
33+ this.inFlight += 1;
34+ this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
35+ if (this.autoAck) this.ack();
36+};
37+
38+FakePebble.prototype.ack = function () {
39+ var pending = this.pending.shift();
40+ if (!pending) throw new Error("No pending AppMessage");
41+ this.inFlight -= 1;
42+ pending.success();
43+};
44+
45+FakePebble.prototype.fail = function () {
46+ var pending = this.pending.shift();
47+ if (!pending) throw new Error("No pending AppMessage");
48+ this.inFlight -= 1;
49+ pending.failure({ error: "redacted fake failure" });
50+};
51+
52+function createDefer(autoRun) {
53+ var callbacks = [];
54+
55+ function defer(callback) {
56+ if (autoRun === true) {
57+ callback();
58+ return;
59+ }
60+ callbacks.push(callback);
61+ }
62+
63+ defer.pending = callbacks;
64+ defer.runNext = function () {
65+ var callback = callbacks.shift();
66+ if (!callback) throw new Error("No deferred callback");
67+ callback();
68+ };
69+ return defer;
70+}
71+
72+function FakeStorage() {
73+ this.values = Object.create(null);
74+ this.writes = [];
75+}
76+
77+FakeStorage.prototype.getItem = function (key) {
78+ return Object.prototype.hasOwnProperty.call(this.values, key) ? this.values[key] : null;
79+};
80+
81+FakeStorage.prototype.setItem = function (key, value) {
82+ this.values[key] = String(value);
83+ this.writes.push({ key: key, value: String(value) });
84+};
85+
86+FakeStorage.prototype.keys = function () {
87+ return Object.keys(this.values);
88+};
89+
90+function FakeClock(now) {
91+ this.time = typeof now === "number" ? now : 1788000000000;
92+ this.nextId = 1;
93+ this.timers = [];
94+ this.delays = [];
95+}
96+
97+FakeClock.prototype.now = function () {
98+ return this.time;
99+};
100+
101+FakeClock.prototype.setTimeout = function (callback, delay) {
102+ var id = this.nextId;
103+ this.nextId += 1;
104+ this.delays.push(delay);
105+ this.timers.push({ id: id, at: this.time + delay, callback: callback, cancelled: false });
106+ return id;
107+};
108+
109+FakeClock.prototype.clearTimeout = function (id) {
110+ this.timers.forEach(function (timer) {
111+ if (timer.id === id) timer.cancelled = true;
112+ });
113+};
114+
115+FakeClock.prototype.advance = function (milliseconds) {
116+ var target = this.time + milliseconds;
117+ var next;
118+ while (true) {
119+ next = null;
120+ this.timers.forEach(function (timer) {
121+ if (!timer.cancelled && timer.at <= target && (next === null || timer.at < next.at)) next = timer;
122+ });
123+ if (next === null) break;
124+ next.cancelled = true;
125+ this.time = next.at;
126+ next.callback();
127+ }
128+ this.time = target;
129+};
130+
131+function createXHRFactory() {
132+ var factory = { instances: [] };
133+
134+ function FakeXHR() {
135+ this.method = null;
136+ this.url = null;
137+ this.async = null;
138+ this.timeout = 0;
139+ this.headers = Object.create(null);
140+ this.body = null;
141+ this.status = 0;
142+ this.responseText = "";
143+ this.responseHeaders = Object.create(null);
144+ factory.instances.push(this);
145+ }
146+
147+ FakeXHR.prototype.open = function (method, url, async) {
148+ this.method = method;
149+ this.url = url;
150+ this.async = async;
151+ };
152+
153+ FakeXHR.prototype.setRequestHeader = function (name, value) {
154+ this.headers[name] = value;
155+ };
156+
157+ FakeXHR.prototype.send = function (body) {
158+ this.body = body;
159+ };
160+
161+ FakeXHR.prototype.getResponseHeader = function (name) {
162+ return Object.prototype.hasOwnProperty.call(this.responseHeaders, name) ? this.responseHeaders[name] : null;
163+ };
164+
165+ FakeXHR.prototype.respond = function (status, body, headers) {
166+ this.status = status;
167+ this.responseText = typeof body === "string" ? body : JSON.stringify(body);
168+ this.responseHeaders = headers || Object.create(null);
169+ this.onload();
170+ };
171+
172+ FakeXHR.prototype.networkError = function () {
173+ this.onerror();
174+ };
175+
176+ factory.XHR = FakeXHR;
177+ return factory;
178+}
179+
180+module.exports = {
181+ FakePebble: FakePebble,
182+ FakeStorage: FakeStorage,
183+ FakeClock: FakeClock,
184+ createDefer: createDefer,
185+ createXHRFactory: createXHRFactory
186+};
added packages/companion/test/protocol.test.js +181 -0
new file mode 100644
@@ -0,0 +1,181 @@
1+"use strict";
2+
3+var test = require("node:test");
4+var assert = require("node:assert/strict");
5+var path = require("node:path");
6+var url = require("node:url");
7+var companion = require("../src");
8+var fixture = require("../../../fixtures/departures/foundation.json");
9+var fakes = require("./fakes");
10+
11+var contracts = companion.contracts;
12+var codec = companion.codec;
13+
14+async function sharedContracts() {
15+ var contractPath = path.resolve(__dirname, "../../contracts/src/index.ts");
16+ return import(url.pathToFileURL(contractPath).href);
17+}
18+
19+test("PebbleKit constants exactly mirror the canonical alias and numeric contracts", async function () {
20+ var shared = await sharedContracts();
21+ assert.deepEqual(contracts.APP_MESSAGE_KEY_ORDER, shared.APP_MESSAGE_KEY_ORDER);
22+ assert.deepEqual(contracts.APP_MESSAGE_KEY, shared.APP_MESSAGE_KEY);
23+ assert.deepEqual(contracts.MESSAGE_TYPE, shared.MESSAGE_TYPE);
24+ assert.deepEqual(contracts.KEY_STATUS, shared.KEY_STATUS);
25+ assert.deepEqual(contracts.REQUEST_TRIGGER, shared.REQUEST_TRIGGER);
26+ assert.deepEqual(contracts.WIRE_LANGUAGE, shared.WIRE_LANGUAGE);
27+ assert.deepEqual(contracts.LIMITS, shared.LIMITS);
28+ assert.equal(contracts.SCHEMA_VERSION, shared.SCHEMA_VERSION);
29+ assert.equal(contracts.CACHE_FRESH_SECONDS, shared.CACHE_FRESH_SECONDS);
30+ assert.equal(contracts.FAVORITE_SETTLE_MS, shared.FAVORITE_SETTLE_MS);
31+});
32+
33+test("recorded fixture round trips through symbolic configuration and result payloads", async function () {
34+ var shared = await sharedContracts();
35+ var receiver = new shared.ProtocolReceiver();
36+ var configurationMessages;
37+ var resultMessages;
38+ var request;
39+
40+ assert.equal(shared.isFavorite(fixture.favorite), true);
41+ assert.equal(shared.isDepartureResult(fixture.result), true);
42+ configurationMessages = codec.encodeConfiguration(
43+ "config-round-trip",
44+ [fixture.favorite],
45+ contracts.KEY_STATUS.CONFIGURED,
46+ "fr"
47+ );
48+ configurationMessages.forEach(function (message) {
49+ assert.equal(Object.keys(message).some(function (key) { return /^[0-9]+$/.test(key); }), false);
50+ assert.equal(shared.isAppMessage(message), true);
51+ assert.equal(receiver.receive(message), true);
52+ });
53+ assert.equal(receiver.committed.configuration.language, "fr");
54+ assert.equal(receiver.committed.configuration.favorites.length, 1);
55+
56+ assert.equal(receiver.expectResponse(fixture.result.requestId, fixture.result.favoriteId), true);
57+ resultMessages = codec.encodeResult(fixture.result);
58+ resultMessages.forEach(function (message) {
59+ assert.equal(Object.keys(message).some(function (key) { return /^[0-9]+$/.test(key); }), false);
60+ assert.equal(shared.isAppMessage(message), true);
61+ assert.equal(receiver.receive(message), true);
62+ });
63+ assert.equal(receiver.committed.result.departures.length, 2);
64+
65+ request = codec.encodeRequest({
66+ requestId: "request-round-trip",
67+ favoriteId: fixture.favorite.id,
68+ trigger: contracts.REQUEST_TRIGGER.MANUAL_SELECT
69+ });
70+ assert.deepEqual(codec.decodeRequest(request), {
71+ requestId: "request-round-trip",
72+ favoriteId: fixture.favorite.id,
73+ trigger: contracts.REQUEST_TRIGGER.MANUAL_SELECT
74+ });
75+ assert.equal(shared.isAppMessage(request), true);
76+});
77+
78+test("configuration encoder requires a normalized contextual language", function () {
79+ assert.throws(function () {
80+ codec.encodeConfiguration("config-1", [], contracts.KEY_STATUS.MISSING, "fr_FR");
81+ }, /language must be en or fr/);
82+ assert.equal(
83+ codec.encodeConfiguration("config-1", [], contracts.KEY_STATUS.MISSING, "en")[0].DISPLAY_NAME,
84+ "en"
85+ );
86+});
87+
88+test("callback queue yields after each ACK and drains whole batches in FIFO order", function () {
89+ var Pebble = new fakes.FakePebble(false);
90+ var defer = fakes.createDefer(false);
91+ var failures = [];
92+ var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
93+
94+ queue.enqueue([{ id: "batch-a-1" }, { id: "batch-a-2" }]);
95+ queue.enqueue([{ id: "batch-b-1" }]);
96+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
97+ assert.equal(Pebble.maxInFlight, 1);
98+
99+ Pebble.ack();
100+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
101+ assert.equal(defer.pending.length, 1);
102+ queue.enqueue([{ id: "batch-c-1" }]);
103+ assert.equal(defer.pending.length, 1);
104+
105+ defer.runNext();
106+ Pebble.ack();
107+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [
108+ "batch-a-1",
109+ "batch-a-2"
110+ ]);
111+ defer.runNext();
112+ Pebble.ack();
113+ defer.runNext();
114+ Pebble.ack();
115+
116+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [
117+ "batch-a-1",
118+ "batch-a-2",
119+ "batch-b-1",
120+ "batch-c-1"
121+ ]);
122+ assert.equal(defer.pending.length, 0);
123+ assert.equal(queue.isSending(), false);
124+ assert.deepEqual(failures, []);
125+});
126+
127+test("clear invalidates a stale deferred advance without blocking a new batch", function () {
128+ var Pebble = new fakes.FakePebble(false);
129+ var defer = fakes.createDefer(false);
130+ var failures = [];
131+ var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
132+
133+ queue.enqueue([{ id: "discarded-1" }, { id: "discarded-2" }]);
134+ Pebble.ack();
135+ assert.equal(defer.pending.length, 1);
136+ queue.clear();
137+ queue.enqueue([{ id: "fresh" }]);
138+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["discarded-1", "fresh"]);
139+
140+ defer.runNext();
141+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["discarded-1", "fresh"]);
142+ Pebble.ack();
143+ assert.equal(queue.isSending(), false);
144+ assert.deepEqual(failures, []);
145+});
146+
147+test("a stale failure cannot clear or fail a newer queue generation", function () {
148+ var Pebble = new fakes.FakePebble(false);
149+ var defer = fakes.createDefer(false);
150+ var failures = [];
151+ var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
152+
153+ queue.enqueue([{ id: "discarded" }]);
154+ queue.clear();
155+ queue.enqueue([{ id: "fresh" }]);
156+ Pebble.fail();
157+
158+ assert.deepEqual(failures, []);
159+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["discarded"]);
160+ assert.equal(defer.pending.length, 1);
161+
162+ defer.runNext();
163+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["discarded", "fresh"]);
164+ Pebble.ack();
165+ assert.equal(queue.isSending(), false);
166+ assert.deepEqual(failures, []);
167+});
168+
169+test("failed AppMessage is dropped without retry or sequence advancement", function () {
170+ var Pebble = new fakes.FakePebble(false);
171+ var defer = fakes.createDefer(false);
172+ var failures = [];
173+ var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
174+
175+ queue.enqueue([{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }]);
176+ Pebble.fail();
177+ assert.equal(Pebble.sent.length, 1);
178+ assert.equal(defer.pending.length, 0);
179+ assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
180+ assert.equal(queue.isSending(), false);
181+});
new file mode 100644
@@ -0,0 +1,181 @@
1+"use strict";
2+
3+var test = require("node:test");
4+var assert = require("node:assert/strict");
5+var path = require("node:path");
6+var url = require("node:url");
7+var companion = require("../src");
8+var fixture = require("../../../fixtures/departures/foundation.json");
9+var fakes = require("./fakes");
10+
11+var contracts = companion.contracts;
12+var codec = companion.codec;
13+
14+async function sharedContracts() {
15+ var contractPath = path.resolve(__dirname, "../../contracts/src/index.ts");
16+ return import(url.pathToFileURL(contractPath).href);
17+}
18+
19+test("PebbleKit constants exactly mirror the canonical alias and numeric contracts", async function () {
20+ var shared = await sharedContracts();
21+ assert.deepEqual(contracts.APP_MESSAGE_KEY_ORDER, shared.APP_MESSAGE_KEY_ORDER);
22+ assert.deepEqual(contracts.APP_MESSAGE_KEY, shared.APP_MESSAGE_KEY);
23+ assert.deepEqual(contracts.MESSAGE_TYPE, shared.MESSAGE_TYPE);
24+ assert.deepEqual(contracts.KEY_STATUS, shared.KEY_STATUS);
25+ assert.deepEqual(contracts.REQUEST_TRIGGER, shared.REQUEST_TRIGGER);
26+ assert.deepEqual(contracts.WIRE_LANGUAGE, shared.WIRE_LANGUAGE);
27+ assert.deepEqual(contracts.LIMITS, shared.LIMITS);
28+ assert.equal(contracts.SCHEMA_VERSION, shared.SCHEMA_VERSION);
29+ assert.equal(contracts.CACHE_FRESH_SECONDS, shared.CACHE_FRESH_SECONDS);
30+ assert.equal(contracts.FAVORITE_SETTLE_MS, shared.FAVORITE_SETTLE_MS);
31+});
32+
33+test("recorded fixture round trips through symbolic configuration and result payloads", async function () {
34+ var shared = await sharedContracts();
35+ var receiver = new shared.ProtocolReceiver();
36+ var configurationMessages;
37+ var resultMessages;
38+ var request;
39+
40+ assert.equal(shared.isFavorite(fixture.favorite), true);
41+ assert.equal(shared.isDepartureResult(fixture.result), true);
42+ configurationMessages = codec.encodeConfiguration(
43+ "config-round-trip",
44+ [fixture.favorite],
45+ contracts.KEY_STATUS.CONFIGURED,
46+ "fr"
47+ );
48+ configurationMessages.forEach(function (message) {
49+ assert.equal(Object.keys(message).some(function (key) { return /^[0-9]+$/.test(key); }), false);
50+ assert.equal(shared.isAppMessage(message), true);
51+ assert.equal(receiver.receive(message), true);
52+ });
53+ assert.equal(receiver.committed.configuration.language, "fr");
54+ assert.equal(receiver.committed.configuration.favorites.length, 1);
55+
56+ assert.equal(receiver.expectResponse(fixture.result.requestId, fixture.result.favoriteId), true);
57+ resultMessages = codec.encodeResult(fixture.result);
58+ resultMessages.forEach(function (message) {
59+ assert.equal(Object.keys(message).some(function (key) { return /^[0-9]+$/.test(key); }), false);
60+ assert.equal(shared.isAppMessage(message), true);
61+ assert.equal(receiver.receive(message), true);
62+ });
63+ assert.equal(receiver.committed.result.departures.length, 2);
64+
65+ request = codec.encodeRequest({
66+ requestId: "request-round-trip",
67+ favoriteId: fixture.favorite.id,
68+ trigger: contracts.REQUEST_TRIGGER.MANUAL_SELECT
69+ });
70+ assert.deepEqual(codec.decodeRequest(request), {
71+ requestId: "request-round-trip",
72+ favoriteId: fixture.favorite.id,
73+ trigger: contracts.REQUEST_TRIGGER.MANUAL_SELECT
74+ });
75+ assert.equal(shared.isAppMessage(request), true);
76+});
77+
78+test("configuration encoder requires a normalized contextual language", function () {
79+ assert.throws(function () {
80+ codec.encodeConfiguration("config-1", [], contracts.KEY_STATUS.MISSING, "fr_FR");
81+ }, /language must be en or fr/);
82+ assert.equal(
83+ codec.encodeConfiguration("config-1", [], contracts.KEY_STATUS.MISSING, "en")[0].DISPLAY_NAME,
84+ "en"
85+ );
86+});
87+
88+test("callback queue yields after each ACK and drains whole batches in FIFO order", function () {
89+ var Pebble = new fakes.FakePebble(false);
90+ var defer = fakes.createDefer(false);
91+ var failures = [];
92+ var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
93+
94+ queue.enqueue([{ id: "batch-a-1" }, { id: "batch-a-2" }]);
95+ queue.enqueue([{ id: "batch-b-1" }]);
96+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
97+ assert.equal(Pebble.maxInFlight, 1);
98+
99+ Pebble.ack();
100+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["batch-a-1"]);
101+ assert.equal(defer.pending.length, 1);
102+ queue.enqueue([{ id: "batch-c-1" }]);
103+ assert.equal(defer.pending.length, 1);
104+
105+ defer.runNext();
106+ Pebble.ack();
107+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [
108+ "batch-a-1",
109+ "batch-a-2"
110+ ]);
111+ defer.runNext();
112+ Pebble.ack();
113+ defer.runNext();
114+ Pebble.ack();
115+
116+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), [
117+ "batch-a-1",
118+ "batch-a-2",
119+ "batch-b-1",
120+ "batch-c-1"
121+ ]);
122+ assert.equal(defer.pending.length, 0);
123+ assert.equal(queue.isSending(), false);
124+ assert.deepEqual(failures, []);
125+});
126+
127+test("clear invalidates a stale deferred advance without blocking a new batch", function () {
128+ var Pebble = new fakes.FakePebble(false);
129+ var defer = fakes.createDefer(false);
130+ var failures = [];
131+ var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
132+
133+ queue.enqueue([{ id: "discarded-1" }, { id: "discarded-2" }]);
134+ Pebble.ack();
135+ assert.equal(defer.pending.length, 1);
136+ queue.clear();
137+ queue.enqueue([{ id: "fresh" }]);
138+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["discarded-1", "fresh"]);
139+
140+ defer.runNext();
141+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["discarded-1", "fresh"]);
142+ Pebble.ack();
143+ assert.equal(queue.isSending(), false);
144+ assert.deepEqual(failures, []);
145+});
146+
147+test("a stale failure cannot clear or fail a newer queue generation", function () {
148+ var Pebble = new fakes.FakePebble(false);
149+ var defer = fakes.createDefer(false);
150+ var failures = [];
151+ var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
152+
153+ queue.enqueue([{ id: "discarded" }]);
154+ queue.clear();
155+ queue.enqueue([{ id: "fresh" }]);
156+ Pebble.fail();
157+
158+ assert.deepEqual(failures, []);
159+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["discarded"]);
160+ assert.equal(defer.pending.length, 1);
161+
162+ defer.runNext();
163+ assert.deepEqual(Pebble.sent.map(function (message) { return message.id; }), ["discarded", "fresh"]);
164+ Pebble.ack();
165+ assert.equal(queue.isSending(), false);
166+ assert.deepEqual(failures, []);
167+});
168+
169+test("failed AppMessage is dropped without retry or sequence advancement", function () {
170+ var Pebble = new fakes.FakePebble(false);
171+ var defer = fakes.createDefer(false);
172+ var failures = [];
173+ var queue = new companion.MessageQueue(Pebble, defer, function (code) { failures.push(code); });
174+
175+ queue.enqueue([{ SCHEMA_VERSION: 1 }, { MESSAGE_TYPE: 2 }]);
176+ Pebble.fail();
177+ assert.equal(Pebble.sent.length, 1);
178+ assert.equal(defer.pending.length, 0);
179+ assert.deepEqual(failures, ["APP_MESSAGE_FAILED"]);
180+ assert.equal(queue.isSending(), false);
181+});
added packages/config-page/index.html +39 -0
new file mode 100644
@@ -0,0 +1,39 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <meta name="referrer" content="no-referrer">
7+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'">
8+ <title>Lapin Futé</title>
9+ <link rel="stylesheet" href="./styles/config-page.css">
10+ </head>
11+ <body>
12+ <main>
13+ <h1 id="page-title">Lapin Futé</h1>
14+
15+ <section aria-labelledby="key-title">
16+ <h2 id="key-title">API key</h2>
17+ <p class="status" id="key-status"></p>
18+ <label class="field-label" for="key-input" id="key-label"></label>
19+ <div class="key-row">
20+ <input id="key-input" type="password" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false">
21+ <button type="button" id="key-toggle" aria-pressed="false">Show</button>
22+ </div>
23+ <p class="error" id="key-error" role="alert" hidden></p>
24+ <p class="pending" id="key-pending" hidden></p>
25+ <button type="button" id="key-remove" class="danger" hidden>Remove key</button>
26+ </section>
27+
28+ <section aria-labelledby="favorites-title">
29+ <h2 id="favorites-title">Favorites</h2>
30+ <p class="status" id="favorites-empty" hidden></p>
31+ <ul id="favorites-list"></ul>
32+ </section>
33+
34+ <button type="button" id="save" class="primary">Save</button>
35+ </main>
36+ <noscript><p class="status">This page requires JavaScript.</p></noscript>
37+ <script type="module" src="./src/config-page.js"></script>
38+ </body>
39+</html>
new file mode 100644
@@ -0,0 +1,39 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <meta name="referrer" content="no-referrer">
7+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; base-uri 'none'; form-action 'none'">
8+ <title>Lapin Futé</title>
9+ <link rel="stylesheet" href="./styles/config-page.css">
10+ </head>
11+ <body>
12+ <main>
13+ <h1 id="page-title">Lapin Futé</h1>
14+
15+ <section aria-labelledby="key-title">
16+ <h2 id="key-title">API key</h2>
17+ <p class="status" id="key-status"></p>
18+ <label class="field-label" for="key-input" id="key-label"></label>
19+ <div class="key-row">
20+ <input id="key-input" type="password" autocomplete="new-password" autocapitalize="off" autocorrect="off" spellcheck="false">
21+ <button type="button" id="key-toggle" aria-pressed="false">Show</button>
22+ </div>
23+ <p class="error" id="key-error" role="alert" hidden></p>
24+ <p class="pending" id="key-pending" hidden></p>
25+ <button type="button" id="key-remove" class="danger" hidden>Remove key</button>
26+ </section>
27+
28+ <section aria-labelledby="favorites-title">
29+ <h2 id="favorites-title">Favorites</h2>
30+ <p class="status" id="favorites-empty" hidden></p>
31+ <ul id="favorites-list"></ul>
32+ </section>
33+
34+ <button type="button" id="save" class="primary">Save</button>
35+ </main>
36+ <noscript><p class="status">This page requires JavaScript.</p></noscript>
37+ <script type="module" src="./src/config-page.js"></script>
38+ </body>
39+</html>
added packages/config-page/package.json +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+{
2+ "name": "@lapin-fute/config-page",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "test": "node --test test/config-page.test.ts"
8+ }
9+}
new file mode 100644
@@ -0,0 +1,9 @@
1+{
2+ "name": "@lapin-fute/config-page",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "test": "node --test test/config-page.test.ts"
8+ }
9+}
added packages/config-page/src/config-core.js +293 -0
new file mode 100644
@@ -0,0 +1,293 @@
1+// Pure state and serialization for the Lapin Futé configuration page.
2+//
3+// This module is deliberately DOM-free and dependency-free: it loads as an ES
4+// module in the browser (via index.html) and runs under node --test.
5+//
6+// Secret boundary: the personal PRIM key exists only in the page input value
7+// and in the one-time close fragment built from it. It is never persisted,
8+// never sent over HTTP, never placed in any path or query, and never logged.
9+// The opening fragment carries only the non-secret hasKey flag plus the watch
10+// language and the current favorites list.
11+//
12+// Canonical contract: packages/contracts/src/index.ts (schemaVersion 1). The
13+// mirrored constants below are asserted equal to the canonical module by
14+// test/config-page.test.ts; never change one side without the other.
15+
16+export const SCHEMA_VERSION = 1;
17+
18+export const LIMITS = {
19+ apiKeyUtf8Bytes: 512,
20+ idUtf8Bytes: 64,
21+ labelUtf8Bytes: 96,
22+ favorites: 8,
23+};
24+
25+const FAVORITE_FIELDS = [
26+ "schemaVersion",
27+ "id",
28+ "serviceId",
29+ "displayName",
30+ "stopLabel",
31+ "lineLabel",
32+ "destinationLabel",
33+ "sortOrder",
34+];
35+
36+const encoder = new TextEncoder();
37+
38+export function utf8Bytes(value) {
39+ return encoder.encode(value).byteLength;
40+}
41+
42+function boundedString(value, maximum) {
43+ return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
44+}
45+
46+// --- Locale -----------------------------------------------------------------
47+// French authored product copy is selected by any language tag beginning
48+// "fr"; every other (or unknown) value falls back to English. User-authored
49+// favorite labels and backend transport labels never appear in this copy and
50+// always cross the page verbatim.
51+
52+export function selectLocale(language) {
53+ return typeof language === "string" && language.toLowerCase().startsWith("fr") ? "fr" : "en";
54+}
55+
56+export const COPY = {
57+ en: {
58+ pageTitle: "Lapin Futé — Settings",
59+ keyTitle: "PRIM API key",
60+ keyStatusConfigured: "A key is configured on your phone.",
61+ keyStatusMissing: "No API key is configured.",
62+ keyLabel: "API key",
63+ keyPlaceholder: "Enter a new key",
64+ keyShow: "Show",
65+ keyHide: "Hide",
66+ keyRemove: "Remove key",
67+ keyUndoRemove: "Keep key",
68+ keyRemovePending: "The key will be removed when you save.",
69+ keyErrorTooLong: "The key must be at most 512 bytes.",
70+ keyErrorNewline: "The key must not contain line breaks.",
71+ favoritesTitle: "Favorites",
72+ favoritesEmpty: "No favorites yet.",
73+ favoriteMoveUp: "Move up",
74+ favoriteMoveDown: "Move down",
75+ favoriteRemove: "Remove",
76+ save: "Save",
77+ },
78+ fr: {
79+ pageTitle: "Lapin Futé — Réglages",
80+ keyTitle: "Clé API PRIM",
81+ keyStatusConfigured: "Une clé est configurée sur votre téléphone.",
82+ keyStatusMissing: "Aucune clé API n’est configurée.",
83+ keyLabel: "Clé API",
84+ keyPlaceholder: "Saisir une nouvelle clé",
85+ keyShow: "Afficher",
86+ keyHide: "Masquer",
87+ keyRemove: "Supprimer la clé",
88+ keyUndoRemove: "Conserver la clé",
89+ keyRemovePending: "La clé sera supprimée à l’enregistrement.",
90+ keyErrorTooLong: "La clé doit contenir au maximum 512 octets.",
91+ keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",
92+ favoritesTitle: "Favoris",
93+ favoritesEmpty: "Aucun favori pour le moment.",
94+ favoriteMoveUp: "Monter",
95+ favoriteMoveDown: "Descendre",
96+ favoriteRemove: "Supprimer",
97+ save: "Enregistrer",
98+ },
99+};
100+
101+export function copyFor(language) {
102+ return COPY[selectLocale(language)];
103+}
104+
105+// --- Favorites ---------------------------------------------------------------
106+
107+export function isFavoriteShape(value) {
108+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
109+ if (!Object.keys(value).every((field) => FAVORITE_FIELDS.includes(field))) return false;
110+ return (
111+ value.schemaVersion === SCHEMA_VERSION &&
112+ boundedString(value.id, LIMITS.idUtf8Bytes) &&
113+ boundedString(value.serviceId, LIMITS.idUtf8Bytes) &&
114+ (value.displayName === undefined || boundedString(value.displayName, LIMITS.labelUtf8Bytes)) &&
115+ boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) &&
116+ boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) &&
117+ boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) &&
118+ Number.isInteger(value.sortOrder)
119+ );
120+}
121+
122+function renumber(favorites) {
123+ return favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder }));
124+}
125+
126+// --- Opening fragment --------------------------------------------------------
127+// Expected shape (all values non-secret):
128+// #<encodeURIComponent(JSON.stringify({ hasKey, favorites, language }))>
129+// hasKey is the only credential-related field; the stored key itself is never
130+// present. Any other top-level shape is rejected before it can enter page state.
131+
132+const OPENING_FIELDS = ["hasKey", "favorites", "language"];
133+const MAX_OPENING_FRAGMENT_LENGTH = 32768;
134+
135+function emptyOpeningState() {
136+ return { hasKey: false, language: "en", locale: "en", favorites: [] };
137+}
138+
139+export function parseConfigFragment(hash) {
140+ let fragment;
141+ let parsed;
142+ if (typeof hash !== "string" || hash.length === 0 || hash.length > MAX_OPENING_FRAGMENT_LENGTH + 1) {
143+ return emptyOpeningState();
144+ }
145+ fragment = hash.charAt(0) === "#" ? hash.slice(1) : hash;
146+ if (fragment.length === 0 || fragment.length > MAX_OPENING_FRAGMENT_LENGTH) return emptyOpeningState();
147+ try {
148+ parsed = JSON.parse(decodeURIComponent(fragment));
149+ } catch {
150+ return emptyOpeningState();
151+ }
152+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return emptyOpeningState();
153+ if (Object.keys(parsed).length !== OPENING_FIELDS.length
154+ || !Object.keys(parsed).every((field) => OPENING_FIELDS.includes(field))) return emptyOpeningState();
155+ if (typeof parsed.hasKey !== "boolean"
156+ || !Array.isArray(parsed.favorites)
157+ || typeof parsed.language !== "string") return emptyOpeningState();
158+ return {
159+ hasKey: parsed.hasKey,
160+ language: parsed.language,
161+ locale: selectLocale(parsed.language),
162+ favorites: parsed.favorites.filter(isFavoriteShape).map((favorite) => ({ ...favorite })),
163+ };
164+}
165+
166+// --- State -------------------------------------------------------------------
167+
168+export const EMPTY_KEY_DRAFT = { value: "", removeRequested: false };
169+
170+export function initialConfigState(query) {
171+ return {
172+ hasKey: query.hasKey,
173+ keyDraft: { ...EMPTY_KEY_DRAFT },
174+ favorites: query.favorites,
175+ };
176+}
177+
178+// Last intent wins: typing a non-empty draft cancels a pending removal, and
179+// requesting removal clears the draft. Favorite edits renumber sortOrder over
180+// the whole list so display order stays dense and atomic.
181+
182+export function reduceConfigState(state, action) {
183+ switch (action.type) {
184+ case "key-draft": {
185+ const value = typeof action.value === "string" ? action.value : "";
186+ const removeRequested = value.length > 0 ? false : state.keyDraft.removeRequested;
187+ return { ...state, keyDraft: { value, removeRequested } };
188+ }
189+ case "key-remove-requested":
190+ if (!state.hasKey) return state;
191+ return { ...state, keyDraft: { value: "", removeRequested: true } };
192+ case "key-remove-cancelled":
193+ return { ...state, keyDraft: { ...state.keyDraft, removeRequested: false } };
194+ case "favorite-remove": {
195+ if (!state.favorites.some((favorite) => favorite.id === action.id)) return state;
196+ return {
197+ ...state,
198+ favorites: renumber(state.favorites.filter((favorite) => favorite.id !== action.id)),
199+ };
200+ }
201+ case "favorite-move": {
202+ const from = state.favorites.findIndex((favorite) => favorite.id === action.id);
203+ if (from < 0) return state;
204+ const to = Math.min(Math.max(from + action.delta, 0), state.favorites.length - 1);
205+ if (to === from) return state;
206+ const favorites = state.favorites.slice();
207+ const [moved] = favorites.splice(from, 1);
208+ favorites.splice(to, 0, moved);
209+ return { ...state, favorites: renumber(favorites) };
210+ }
211+ default:
212+ return state;
213+ }
214+}
215+
216+// --- Key update planning ------------------------------------------------------
217+// No key ever enters this module except through the draft; the planned
218+// ApiKeyUpdate carries the value only for REPLACE.
219+
220+export function apiKeyError(value) {
221+ if (/[\r\n]/u.test(value)) return "keyErrorNewline";
222+ if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong";
223+ return null;
224+}
225+
226+export function planApiKeyUpdate(hasKey, keyDraft) {
227+ if (keyDraft.value.length > 0) {
228+ return { schemaVersion: SCHEMA_VERSION, action: "REPLACE", value: keyDraft.value };
229+ }
230+ if (keyDraft.removeRequested && hasKey) {
231+ return { schemaVersion: SCHEMA_VERSION, action: "REMOVE" };
232+ }
233+ return { schemaVersion: SCHEMA_VERSION, action: "KEEP" };
234+}
235+
236+// Plans the full configuration payload: the key decision plus the whole
237+// favorite list (atomic replacement, capped at the contract maximum).
238+
239+export function planConfigResult(state) {
240+ const apiKeyUpdate = planApiKeyUpdate(state.hasKey, state.keyDraft);
241+ if (apiKeyUpdate.action === "REPLACE") {
242+ const error = apiKeyError(apiKeyUpdate.value);
243+ if (error !== null) return { ok: false, error };
244+ }
245+ return {
246+ ok: true,
247+ payload: {
248+ schemaVersion: SCHEMA_VERSION,
249+ apiKeyUpdate,
250+ favorites: state.favorites.slice(0, LIMITS.favorites).map(stampFavorite),
251+ },
252+ };
253+}
254+
255+function stampFavorite(favorite, sortOrder) {
256+ const stamped = {
257+ schemaVersion: SCHEMA_VERSION,
258+ id: favorite.id,
259+ serviceId: favorite.serviceId,
260+ stopLabel: favorite.stopLabel,
261+ lineLabel: favorite.lineLabel,
262+ destinationLabel: favorite.destinationLabel,
263+ sortOrder,
264+ };
265+ if (favorite.displayName !== undefined) stamped.displayName = favorite.displayName;
266+ return stamped;
267+}
268+
269+// --- One-time close fragment --------------------------------------------------
270+// The documented Pebble return channel: navigate exactly once to
271+// pebblejs://close#<encodeURIComponent(JSON.stringify(payload))>. The mobile
272+// app intercepts this navigation; it is not an HTTP request. A session
273+// refuses a second send.
274+
275+export const CLOSE_PREFIX = "pebblejs://close#";
276+
277+export function encodeCloseFragment(payload) {
278+ return CLOSE_PREFIX + encodeURIComponent(JSON.stringify(payload));
279+}
280+
281+export function createCloseSession() {
282+ let closed = false;
283+ return {
284+ get closed() {
285+ return closed;
286+ },
287+ close(payload) {
288+ if (closed) return null;
289+ closed = true;
290+ return encodeCloseFragment(payload);
291+ },
292+ };
293+}
new file mode 100644
@@ -0,0 +1,293 @@
1+// Pure state and serialization for the Lapin Futé configuration page.
2+//
3+// This module is deliberately DOM-free and dependency-free: it loads as an ES
4+// module in the browser (via index.html) and runs under node --test.
5+//
6+// Secret boundary: the personal PRIM key exists only in the page input value
7+// and in the one-time close fragment built from it. It is never persisted,
8+// never sent over HTTP, never placed in any path or query, and never logged.
9+// The opening fragment carries only the non-secret hasKey flag plus the watch
10+// language and the current favorites list.
11+//
12+// Canonical contract: packages/contracts/src/index.ts (schemaVersion 1). The
13+// mirrored constants below are asserted equal to the canonical module by
14+// test/config-page.test.ts; never change one side without the other.
15+
16+export const SCHEMA_VERSION = 1;
17+
18+export const LIMITS = {
19+ apiKeyUtf8Bytes: 512,
20+ idUtf8Bytes: 64,
21+ labelUtf8Bytes: 96,
22+ favorites: 8,
23+};
24+
25+const FAVORITE_FIELDS = [
26+ "schemaVersion",
27+ "id",
28+ "serviceId",
29+ "displayName",
30+ "stopLabel",
31+ "lineLabel",
32+ "destinationLabel",
33+ "sortOrder",
34+];
35+
36+const encoder = new TextEncoder();
37+
38+export function utf8Bytes(value) {
39+ return encoder.encode(value).byteLength;
40+}
41+
42+function boundedString(value, maximum) {
43+ return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
44+}
45+
46+// --- Locale -----------------------------------------------------------------
47+// French authored product copy is selected by any language tag beginning
48+// "fr"; every other (or unknown) value falls back to English. User-authored
49+// favorite labels and backend transport labels never appear in this copy and
50+// always cross the page verbatim.
51+
52+export function selectLocale(language) {
53+ return typeof language === "string" && language.toLowerCase().startsWith("fr") ? "fr" : "en";
54+}
55+
56+export const COPY = {
57+ en: {
58+ pageTitle: "Lapin Futé — Settings",
59+ keyTitle: "PRIM API key",
60+ keyStatusConfigured: "A key is configured on your phone.",
61+ keyStatusMissing: "No API key is configured.",
62+ keyLabel: "API key",
63+ keyPlaceholder: "Enter a new key",
64+ keyShow: "Show",
65+ keyHide: "Hide",
66+ keyRemove: "Remove key",
67+ keyUndoRemove: "Keep key",
68+ keyRemovePending: "The key will be removed when you save.",
69+ keyErrorTooLong: "The key must be at most 512 bytes.",
70+ keyErrorNewline: "The key must not contain line breaks.",
71+ favoritesTitle: "Favorites",
72+ favoritesEmpty: "No favorites yet.",
73+ favoriteMoveUp: "Move up",
74+ favoriteMoveDown: "Move down",
75+ favoriteRemove: "Remove",
76+ save: "Save",
77+ },
78+ fr: {
79+ pageTitle: "Lapin Futé — Réglages",
80+ keyTitle: "Clé API PRIM",
81+ keyStatusConfigured: "Une clé est configurée sur votre téléphone.",
82+ keyStatusMissing: "Aucune clé API n’est configurée.",
83+ keyLabel: "Clé API",
84+ keyPlaceholder: "Saisir une nouvelle clé",
85+ keyShow: "Afficher",
86+ keyHide: "Masquer",
87+ keyRemove: "Supprimer la clé",
88+ keyUndoRemove: "Conserver la clé",
89+ keyRemovePending: "La clé sera supprimée à l’enregistrement.",
90+ keyErrorTooLong: "La clé doit contenir au maximum 512 octets.",
91+ keyErrorNewline: "La clé ne doit pas contenir de retour à la ligne.",
92+ favoritesTitle: "Favoris",
93+ favoritesEmpty: "Aucun favori pour le moment.",
94+ favoriteMoveUp: "Monter",
95+ favoriteMoveDown: "Descendre",
96+ favoriteRemove: "Supprimer",
97+ save: "Enregistrer",
98+ },
99+};
100+
101+export function copyFor(language) {
102+ return COPY[selectLocale(language)];
103+}
104+
105+// --- Favorites ---------------------------------------------------------------
106+
107+export function isFavoriteShape(value) {
108+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
109+ if (!Object.keys(value).every((field) => FAVORITE_FIELDS.includes(field))) return false;
110+ return (
111+ value.schemaVersion === SCHEMA_VERSION &&
112+ boundedString(value.id, LIMITS.idUtf8Bytes) &&
113+ boundedString(value.serviceId, LIMITS.idUtf8Bytes) &&
114+ (value.displayName === undefined || boundedString(value.displayName, LIMITS.labelUtf8Bytes)) &&
115+ boundedString(value.stopLabel, LIMITS.labelUtf8Bytes) &&
116+ boundedString(value.lineLabel, LIMITS.labelUtf8Bytes) &&
117+ boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes) &&
118+ Number.isInteger(value.sortOrder)
119+ );
120+}
121+
122+function renumber(favorites) {
123+ return favorites.map((favorite, sortOrder) => ({ ...favorite, sortOrder }));
124+}
125+
126+// --- Opening fragment --------------------------------------------------------
127+// Expected shape (all values non-secret):
128+// #<encodeURIComponent(JSON.stringify({ hasKey, favorites, language }))>
129+// hasKey is the only credential-related field; the stored key itself is never
130+// present. Any other top-level shape is rejected before it can enter page state.
131+
132+const OPENING_FIELDS = ["hasKey", "favorites", "language"];
133+const MAX_OPENING_FRAGMENT_LENGTH = 32768;
134+
135+function emptyOpeningState() {
136+ return { hasKey: false, language: "en", locale: "en", favorites: [] };
137+}
138+
139+export function parseConfigFragment(hash) {
140+ let fragment;
141+ let parsed;
142+ if (typeof hash !== "string" || hash.length === 0 || hash.length > MAX_OPENING_FRAGMENT_LENGTH + 1) {
143+ return emptyOpeningState();
144+ }
145+ fragment = hash.charAt(0) === "#" ? hash.slice(1) : hash;
146+ if (fragment.length === 0 || fragment.length > MAX_OPENING_FRAGMENT_LENGTH) return emptyOpeningState();
147+ try {
148+ parsed = JSON.parse(decodeURIComponent(fragment));
149+ } catch {
150+ return emptyOpeningState();
151+ }
152+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return emptyOpeningState();
153+ if (Object.keys(parsed).length !== OPENING_FIELDS.length
154+ || !Object.keys(parsed).every((field) => OPENING_FIELDS.includes(field))) return emptyOpeningState();
155+ if (typeof parsed.hasKey !== "boolean"
156+ || !Array.isArray(parsed.favorites)
157+ || typeof parsed.language !== "string") return emptyOpeningState();
158+ return {
159+ hasKey: parsed.hasKey,
160+ language: parsed.language,
161+ locale: selectLocale(parsed.language),
162+ favorites: parsed.favorites.filter(isFavoriteShape).map((favorite) => ({ ...favorite })),
163+ };
164+}
165+
166+// --- State -------------------------------------------------------------------
167+
168+export const EMPTY_KEY_DRAFT = { value: "", removeRequested: false };
169+
170+export function initialConfigState(query) {
171+ return {
172+ hasKey: query.hasKey,
173+ keyDraft: { ...EMPTY_KEY_DRAFT },
174+ favorites: query.favorites,
175+ };
176+}
177+
178+// Last intent wins: typing a non-empty draft cancels a pending removal, and
179+// requesting removal clears the draft. Favorite edits renumber sortOrder over
180+// the whole list so display order stays dense and atomic.
181+
182+export function reduceConfigState(state, action) {
183+ switch (action.type) {
184+ case "key-draft": {
185+ const value = typeof action.value === "string" ? action.value : "";
186+ const removeRequested = value.length > 0 ? false : state.keyDraft.removeRequested;
187+ return { ...state, keyDraft: { value, removeRequested } };
188+ }
189+ case "key-remove-requested":
190+ if (!state.hasKey) return state;
191+ return { ...state, keyDraft: { value: "", removeRequested: true } };
192+ case "key-remove-cancelled":
193+ return { ...state, keyDraft: { ...state.keyDraft, removeRequested: false } };
194+ case "favorite-remove": {
195+ if (!state.favorites.some((favorite) => favorite.id === action.id)) return state;
196+ return {
197+ ...state,
198+ favorites: renumber(state.favorites.filter((favorite) => favorite.id !== action.id)),
199+ };
200+ }
201+ case "favorite-move": {
202+ const from = state.favorites.findIndex((favorite) => favorite.id === action.id);
203+ if (from < 0) return state;
204+ const to = Math.min(Math.max(from + action.delta, 0), state.favorites.length - 1);
205+ if (to === from) return state;
206+ const favorites = state.favorites.slice();
207+ const [moved] = favorites.splice(from, 1);
208+ favorites.splice(to, 0, moved);
209+ return { ...state, favorites: renumber(favorites) };
210+ }
211+ default:
212+ return state;
213+ }
214+}
215+
216+// --- Key update planning ------------------------------------------------------
217+// No key ever enters this module except through the draft; the planned
218+// ApiKeyUpdate carries the value only for REPLACE.
219+
220+export function apiKeyError(value) {
221+ if (/[\r\n]/u.test(value)) return "keyErrorNewline";
222+ if (utf8Bytes(value) > LIMITS.apiKeyUtf8Bytes) return "keyErrorTooLong";
223+ return null;
224+}
225+
226+export function planApiKeyUpdate(hasKey, keyDraft) {
227+ if (keyDraft.value.length > 0) {
228+ return { schemaVersion: SCHEMA_VERSION, action: "REPLACE", value: keyDraft.value };
229+ }
230+ if (keyDraft.removeRequested && hasKey) {
231+ return { schemaVersion: SCHEMA_VERSION, action: "REMOVE" };
232+ }
233+ return { schemaVersion: SCHEMA_VERSION, action: "KEEP" };
234+}
235+
236+// Plans the full configuration payload: the key decision plus the whole
237+// favorite list (atomic replacement, capped at the contract maximum).
238+
239+export function planConfigResult(state) {
240+ const apiKeyUpdate = planApiKeyUpdate(state.hasKey, state.keyDraft);
241+ if (apiKeyUpdate.action === "REPLACE") {
242+ const error = apiKeyError(apiKeyUpdate.value);
243+ if (error !== null) return { ok: false, error };
244+ }
245+ return {
246+ ok: true,
247+ payload: {
248+ schemaVersion: SCHEMA_VERSION,
249+ apiKeyUpdate,
250+ favorites: state.favorites.slice(0, LIMITS.favorites).map(stampFavorite),
251+ },
252+ };
253+}
254+
255+function stampFavorite(favorite, sortOrder) {
256+ const stamped = {
257+ schemaVersion: SCHEMA_VERSION,
258+ id: favorite.id,
259+ serviceId: favorite.serviceId,
260+ stopLabel: favorite.stopLabel,
261+ lineLabel: favorite.lineLabel,
262+ destinationLabel: favorite.destinationLabel,
263+ sortOrder,
264+ };
265+ if (favorite.displayName !== undefined) stamped.displayName = favorite.displayName;
266+ return stamped;
267+}
268+
269+// --- One-time close fragment --------------------------------------------------
270+// The documented Pebble return channel: navigate exactly once to
271+// pebblejs://close#<encodeURIComponent(JSON.stringify(payload))>. The mobile
272+// app intercepts this navigation; it is not an HTTP request. A session
273+// refuses a second send.
274+
275+export const CLOSE_PREFIX = "pebblejs://close#";
276+
277+export function encodeCloseFragment(payload) {
278+ return CLOSE_PREFIX + encodeURIComponent(JSON.stringify(payload));
279+}
280+
281+export function createCloseSession() {
282+ let closed = false;
283+ return {
284+ get closed() {
285+ return closed;
286+ },
287+ close(payload) {
288+ if (closed) return null;
289+ closed = true;
290+ return encodeCloseFragment(payload);
291+ },
292+ };
293+}
added packages/config-page/src/config-page.js +177 -0
new file mode 100644
@@ -0,0 +1,177 @@
1+// DOM wiring for the Lapin Futé configuration page. All state transitions and
2+// serialization live in config-core.js; this layer only renders and dispatches.
3+//
4+// Secret boundary: the personal PRIM key exists only in the masked input's
5+// value. The page holds no storage of any kind, performs no network requests,
6+// and never logs; its single navigation is the one-time close fragment, which
7+// the mobile app intercepts before any HTTP request could exist.
8+//
9+// The opening fragment carries only non-secret fields: hasKey, the watch
10+// language, and the current favorites list. The stored key itself is never
11+// present in the fragment, the DOM, or this source.
12+
13+import {
14+ copyFor,
15+ createCloseSession,
16+ initialConfigState,
17+ parseConfigFragment,
18+ planConfigResult,
19+ reduceConfigState,
20+} from "./config-core.js";
21+
22+function byId(id) {
23+ return document.getElementById(id);
24+}
25+
26+const elements = {
27+ pageTitle: byId("page-title"),
28+ keyTitle: byId("key-title"),
29+ keyStatus: byId("key-status"),
30+ keyLabel: byId("key-label"),
31+ keyInput: byId("key-input"),
32+ keyToggle: byId("key-toggle"),
33+ keyError: byId("key-error"),
34+ keyPending: byId("key-pending"),
35+ keyRemove: byId("key-remove"),
36+ favoritesTitle: byId("favorites-title"),
37+ favoritesEmpty: byId("favorites-empty"),
38+ favoritesList: byId("favorites-list"),
39+ save: byId("save"),
40+};
41+
42+const query = parseConfigFragment(window.location.hash);
43+const copy = copyFor(query.language);
44+let state = initialConfigState(query);
45+let revealed = false;
46+const session = createCloseSession();
47+
48+function setText(element, text) {
49+ if (element.textContent !== text) {
50+ element.textContent = text;
51+ }
52+}
53+
54+function dispatch(action) {
55+ const next = reduceConfigState(state, action);
56+ if (next !== state) {
57+ state = next;
58+ }
59+ render();
60+}
61+
62+function applyStaticCopy() {
63+ document.title = copy.pageTitle;
64+ document.documentElement.lang = query.locale;
65+ setText(elements.pageTitle, copy.pageTitle);
66+ setText(elements.keyTitle, copy.keyTitle);
67+ setText(elements.keyLabel, copy.keyLabel);
68+ elements.keyInput.placeholder = copy.keyPlaceholder;
69+ setText(elements.keyToggle, copy.keyShow);
70+ setText(elements.favoritesTitle, copy.favoritesTitle);
71+ setText(elements.favoritesEmpty, copy.favoritesEmpty);
72+ setText(elements.save, copy.save);
73+}
74+
75+function render() {
76+ renderKeySection();
77+ renderFavorites();
78+}
79+
80+function renderKeySection() {
81+ setText(elements.keyStatus, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);
82+ const pending = state.keyDraft.removeRequested;
83+ elements.keyPending.hidden = !pending;
84+ if (pending) {
85+ setText(elements.keyPending, copy.keyRemovePending);
86+ }
87+ elements.keyRemove.hidden = !state.hasKey;
88+ setText(elements.keyRemove, pending ? copy.keyUndoRemove : copy.keyRemove);
89+}
90+
91+function renderFavorites() {
92+ elements.favoritesList.replaceChildren();
93+ elements.favoritesEmpty.hidden = state.favorites.length > 0;
94+ state.favorites.forEach((favorite, index) => {
95+ elements.favoritesList.append(favoriteItem(favorite, index));
96+ });
97+}
98+
99+function favoriteItem(favorite, index) {
100+ const item = document.createElement("li");
101+ item.className = "favorite";
102+
103+ const heading = document.createElement("p");
104+ heading.className = "labels";
105+ heading.textContent = favorite.displayName ?? favorite.stopLabel;
106+
107+ const detail = document.createElement("p");
108+ detail.className = "sub";
109+ // Authored and transport labels cross every locale verbatim.
110+ detail.textContent = [favorite.stopLabel, favorite.lineLabel, favorite.destinationLabel].join(" · ");
111+
112+ const actions = document.createElement("div");
113+ actions.className = "favorite-actions";
114+ actions.append(
115+ listButton(copy.favoriteMoveUp, index === 0, () =>
116+ dispatch({ type: "favorite-move", id: favorite.id, delta: -1 }),
117+ ),
118+ listButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () =>
119+ dispatch({ type: "favorite-move", id: favorite.id, delta: 1 }),
120+ ),
121+ listButton(copy.favoriteRemove, false, () =>
122+ dispatch({ type: "favorite-remove", id: favorite.id }),
123+ ),
124+ );
125+
126+ item.append(heading, detail, actions);
127+ return item;
128+}
129+
130+function listButton(label, disabled, onClick) {
131+ const button = document.createElement("button");
132+ button.type = "button";
133+ button.textContent = label;
134+ button.disabled = disabled;
135+ button.addEventListener("click", onClick);
136+ return button;
137+}
138+
139+elements.keyInput.addEventListener("input", () => {
140+ elements.keyError.hidden = true;
141+ dispatch({ type: "key-draft", value: elements.keyInput.value });
142+});
143+
144+elements.keyToggle.addEventListener("click", () => {
145+ revealed = !revealed;
146+ elements.keyInput.type = revealed ? "text" : "password";
147+ setText(elements.keyToggle, revealed ? copy.keyHide : copy.keyShow);
148+ elements.keyToggle.setAttribute("aria-pressed", String(revealed));
149+ elements.keyInput.focus();
150+});
151+
152+elements.keyRemove.addEventListener("click", () => {
153+ dispatch(
154+ state.keyDraft.removeRequested
155+ ? { type: "key-remove-cancelled" }
156+ : { type: "key-remove-requested" },
157+ );
158+});
159+
160+elements.save.addEventListener("click", () => {
161+ const outcome = planConfigResult(state);
162+ if (!outcome.ok) {
163+ elements.keyError.textContent = copy[outcome.error];
164+ elements.keyError.hidden = false;
165+ elements.keyInput.focus();
166+ return;
167+ }
168+ const fragment = session.close(outcome.payload);
169+ if (fragment === null) {
170+ return;
171+ }
172+ elements.save.disabled = true;
173+ window.location.assign(fragment);
174+});
175+
176+applyStaticCopy();
177+render();
new file mode 100644
@@ -0,0 +1,177 @@
1+// DOM wiring for the Lapin Futé configuration page. All state transitions and
2+// serialization live in config-core.js; this layer only renders and dispatches.
3+//
4+// Secret boundary: the personal PRIM key exists only in the masked input's
5+// value. The page holds no storage of any kind, performs no network requests,
6+// and never logs; its single navigation is the one-time close fragment, which
7+// the mobile app intercepts before any HTTP request could exist.
8+//
9+// The opening fragment carries only non-secret fields: hasKey, the watch
10+// language, and the current favorites list. The stored key itself is never
11+// present in the fragment, the DOM, or this source.
12+
13+import {
14+ copyFor,
15+ createCloseSession,
16+ initialConfigState,
17+ parseConfigFragment,
18+ planConfigResult,
19+ reduceConfigState,
20+} from "./config-core.js";
21+
22+function byId(id) {
23+ return document.getElementById(id);
24+}
25+
26+const elements = {
27+ pageTitle: byId("page-title"),
28+ keyTitle: byId("key-title"),
29+ keyStatus: byId("key-status"),
30+ keyLabel: byId("key-label"),
31+ keyInput: byId("key-input"),
32+ keyToggle: byId("key-toggle"),
33+ keyError: byId("key-error"),
34+ keyPending: byId("key-pending"),
35+ keyRemove: byId("key-remove"),
36+ favoritesTitle: byId("favorites-title"),
37+ favoritesEmpty: byId("favorites-empty"),
38+ favoritesList: byId("favorites-list"),
39+ save: byId("save"),
40+};
41+
42+const query = parseConfigFragment(window.location.hash);
43+const copy = copyFor(query.language);
44+let state = initialConfigState(query);
45+let revealed = false;
46+const session = createCloseSession();
47+
48+function setText(element, text) {
49+ if (element.textContent !== text) {
50+ element.textContent = text;
51+ }
52+}
53+
54+function dispatch(action) {
55+ const next = reduceConfigState(state, action);
56+ if (next !== state) {
57+ state = next;
58+ }
59+ render();
60+}
61+
62+function applyStaticCopy() {
63+ document.title = copy.pageTitle;
64+ document.documentElement.lang = query.locale;
65+ setText(elements.pageTitle, copy.pageTitle);
66+ setText(elements.keyTitle, copy.keyTitle);
67+ setText(elements.keyLabel, copy.keyLabel);
68+ elements.keyInput.placeholder = copy.keyPlaceholder;
69+ setText(elements.keyToggle, copy.keyShow);
70+ setText(elements.favoritesTitle, copy.favoritesTitle);
71+ setText(elements.favoritesEmpty, copy.favoritesEmpty);
72+ setText(elements.save, copy.save);
73+}
74+
75+function render() {
76+ renderKeySection();
77+ renderFavorites();
78+}
79+
80+function renderKeySection() {
81+ setText(elements.keyStatus, state.hasKey ? copy.keyStatusConfigured : copy.keyStatusMissing);
82+ const pending = state.keyDraft.removeRequested;
83+ elements.keyPending.hidden = !pending;
84+ if (pending) {
85+ setText(elements.keyPending, copy.keyRemovePending);
86+ }
87+ elements.keyRemove.hidden = !state.hasKey;
88+ setText(elements.keyRemove, pending ? copy.keyUndoRemove : copy.keyRemove);
89+}
90+
91+function renderFavorites() {
92+ elements.favoritesList.replaceChildren();
93+ elements.favoritesEmpty.hidden = state.favorites.length > 0;
94+ state.favorites.forEach((favorite, index) => {
95+ elements.favoritesList.append(favoriteItem(favorite, index));
96+ });
97+}
98+
99+function favoriteItem(favorite, index) {
100+ const item = document.createElement("li");
101+ item.className = "favorite";
102+
103+ const heading = document.createElement("p");
104+ heading.className = "labels";
105+ heading.textContent = favorite.displayName ?? favorite.stopLabel;
106+
107+ const detail = document.createElement("p");
108+ detail.className = "sub";
109+ // Authored and transport labels cross every locale verbatim.
110+ detail.textContent = [favorite.stopLabel, favorite.lineLabel, favorite.destinationLabel].join(" · ");
111+
112+ const actions = document.createElement("div");
113+ actions.className = "favorite-actions";
114+ actions.append(
115+ listButton(copy.favoriteMoveUp, index === 0, () =>
116+ dispatch({ type: "favorite-move", id: favorite.id, delta: -1 }),
117+ ),
118+ listButton(copy.favoriteMoveDown, index === state.favorites.length - 1, () =>
119+ dispatch({ type: "favorite-move", id: favorite.id, delta: 1 }),
120+ ),
121+ listButton(copy.favoriteRemove, false, () =>
122+ dispatch({ type: "favorite-remove", id: favorite.id }),
123+ ),
124+ );
125+
126+ item.append(heading, detail, actions);
127+ return item;
128+}
129+
130+function listButton(label, disabled, onClick) {
131+ const button = document.createElement("button");
132+ button.type = "button";
133+ button.textContent = label;
134+ button.disabled = disabled;
135+ button.addEventListener("click", onClick);
136+ return button;
137+}
138+
139+elements.keyInput.addEventListener("input", () => {
140+ elements.keyError.hidden = true;
141+ dispatch({ type: "key-draft", value: elements.keyInput.value });
142+});
143+
144+elements.keyToggle.addEventListener("click", () => {
145+ revealed = !revealed;
146+ elements.keyInput.type = revealed ? "text" : "password";
147+ setText(elements.keyToggle, revealed ? copy.keyHide : copy.keyShow);
148+ elements.keyToggle.setAttribute("aria-pressed", String(revealed));
149+ elements.keyInput.focus();
150+});
151+
152+elements.keyRemove.addEventListener("click", () => {
153+ dispatch(
154+ state.keyDraft.removeRequested
155+ ? { type: "key-remove-cancelled" }
156+ : { type: "key-remove-requested" },
157+ );
158+});
159+
160+elements.save.addEventListener("click", () => {
161+ const outcome = planConfigResult(state);
162+ if (!outcome.ok) {
163+ elements.keyError.textContent = copy[outcome.error];
164+ elements.keyError.hidden = false;
165+ elements.keyInput.focus();
166+ return;
167+ }
168+ const fragment = session.close(outcome.payload);
169+ if (fragment === null) {
170+ return;
171+ }
172+ elements.save.disabled = true;
173+ window.location.assign(fragment);
174+});
175+
176+applyStaticCopy();
177+render();
added packages/config-page/styles/config-page.css +181 -0
new file mode 100644
@@ -0,0 +1,181 @@
1+/* Lapin Futé configuration page: mobile-first, framework-free. */
2+
3+:root {
4+ color-scheme: light dark;
5+ --bg: #f4f4f2;
6+ --surface: #ffffff;
7+ --text: #1c1c1e;
8+ --muted: #5c5c60;
9+ --accent: #0a6ebd;
10+ --on-accent: #ffffff;
11+ --danger: #b3261e;
12+ --border: #d9d9de;
13+}
14+
15+@media (prefers-color-scheme: dark) {
16+ :root {
17+ --bg: #141416;
18+ --surface: #1f1f23;
19+ --text: #f2f2f4;
20+ --muted: #a0a0a8;
21+ --accent: #6db3f2;
22+ --on-accent: #0b0b0d;
23+ --danger: #f2b8b5;
24+ --border: #3a3a40;
25+ }
26+}
27+
28+* {
29+ box-sizing: border-box;
30+}
31+
32+body {
33+ margin: 0;
34+ background: var(--bg);
35+ color: var(--text);
36+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
37+ -webkit-text-size-adjust: 100%;
38+}
39+
40+main {
41+ max-width: 30rem;
42+ margin: 0 auto;
43+ padding: 1rem 1rem 2rem;
44+}
45+
46+h1 {
47+ font-size: 1.35rem;
48+ margin: 0.25rem 0 1rem;
49+}
50+
51+h2 {
52+ font-size: 1.05rem;
53+ margin: 0 0 0.5rem;
54+}
55+
56+section {
57+ background: var(--surface);
58+ border: 1px solid var(--border);
59+ border-radius: 0.75rem;
60+ padding: 1rem;
61+ margin-bottom: 1rem;
62+}
63+
64+.status {
65+ color: var(--muted);
66+ margin: 0 0 0.75rem;
67+}
68+
69+.field-label {
70+ display: block;
71+ margin: 0 0 0.35rem;
72+ font-weight: 600;
73+}
74+
75+.key-row {
76+ display: flex;
77+ gap: 0.5rem;
78+}
79+
80+#key-input {
81+ flex: 1;
82+ min-width: 0;
83+ min-height: 2.75rem;
84+ padding: 0 0.75rem;
85+ border: 1px solid var(--border);
86+ border-radius: 0.5rem;
87+ background: var(--bg);
88+ color: var(--text);
89+ font: inherit;
90+}
91+
92+#key-input:focus-visible,
93+button:focus-visible {
94+ outline: 2px solid var(--accent);
95+ outline-offset: 1px;
96+}
97+
98+button {
99+ min-height: 2.75rem;
100+ padding: 0 0.9rem;
101+ border: 1px solid var(--border);
102+ border-radius: 0.5rem;
103+ background: var(--surface);
104+ color: var(--text);
105+ font: inherit;
106+}
107+
108+button:disabled {
109+ opacity: 0.5;
110+}
111+
112+.primary {
113+ width: 100%;
114+ background: var(--accent);
115+ border-color: var(--accent);
116+ color: var(--on-accent);
117+ font-weight: 600;
118+}
119+
120+.danger {
121+ color: var(--danger);
122+ border-color: var(--danger);
123+}
124+
125+.error {
126+ color: var(--danger);
127+ margin: 0.5rem 0 0;
128+}
129+
130+.pending {
131+ color: var(--danger);
132+ margin: 0.5rem 0;
133+}
134+
135+#key-remove {
136+ margin-top: 0.25rem;
137+}
138+
139+#favorites-list {
140+ list-style: none;
141+ margin: 0;
142+ padding: 0;
143+ display: grid;
144+ gap: 0.5rem;
145+}
146+
147+.favorite {
148+ border: 1px solid var(--border);
149+ border-radius: 0.5rem;
150+ padding: 0.5rem 0.75rem;
151+}
152+
153+.favorite .labels {
154+ margin: 0;
155+ font-weight: 600;
156+}
157+
158+.favorite .sub {
159+ margin: 0.1rem 0 0;
160+ color: var(--muted);
161+ font-size: 0.9rem;
162+ overflow-wrap: anywhere;
163+}
164+
165+.favorite-actions {
166+ display: flex;
167+ gap: 0.5rem;
168+ margin-top: 0.5rem;
169+}
170+
171+.favorite-actions button {
172+ min-height: 2.25rem;
173+ padding: 0 0.6rem;
174+ font-size: 0.9rem;
175+}
176+
177+@media (min-width: 30rem) {
178+ main {
179+ padding-top: 2rem;
180+ }
181+}
new file mode 100644
@@ -0,0 +1,181 @@
1+/* Lapin Futé configuration page: mobile-first, framework-free. */
2+
3+:root {
4+ color-scheme: light dark;
5+ --bg: #f4f4f2;
6+ --surface: #ffffff;
7+ --text: #1c1c1e;
8+ --muted: #5c5c60;
9+ --accent: #0a6ebd;
10+ --on-accent: #ffffff;
11+ --danger: #b3261e;
12+ --border: #d9d9de;
13+}
14+
15+@media (prefers-color-scheme: dark) {
16+ :root {
17+ --bg: #141416;
18+ --surface: #1f1f23;
19+ --text: #f2f2f4;
20+ --muted: #a0a0a8;
21+ --accent: #6db3f2;
22+ --on-accent: #0b0b0d;
23+ --danger: #f2b8b5;
24+ --border: #3a3a40;
25+ }
26+}
27+
28+* {
29+ box-sizing: border-box;
30+}
31+
32+body {
33+ margin: 0;
34+ background: var(--bg);
35+ color: var(--text);
36+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
37+ -webkit-text-size-adjust: 100%;
38+}
39+
40+main {
41+ max-width: 30rem;
42+ margin: 0 auto;
43+ padding: 1rem 1rem 2rem;
44+}
45+
46+h1 {
47+ font-size: 1.35rem;
48+ margin: 0.25rem 0 1rem;
49+}
50+
51+h2 {
52+ font-size: 1.05rem;
53+ margin: 0 0 0.5rem;
54+}
55+
56+section {
57+ background: var(--surface);
58+ border: 1px solid var(--border);
59+ border-radius: 0.75rem;
60+ padding: 1rem;
61+ margin-bottom: 1rem;
62+}
63+
64+.status {
65+ color: var(--muted);
66+ margin: 0 0 0.75rem;
67+}
68+
69+.field-label {
70+ display: block;
71+ margin: 0 0 0.35rem;
72+ font-weight: 600;
73+}
74+
75+.key-row {
76+ display: flex;
77+ gap: 0.5rem;
78+}
79+
80+#key-input {
81+ flex: 1;
82+ min-width: 0;
83+ min-height: 2.75rem;
84+ padding: 0 0.75rem;
85+ border: 1px solid var(--border);
86+ border-radius: 0.5rem;
87+ background: var(--bg);
88+ color: var(--text);
89+ font: inherit;
90+}
91+
92+#key-input:focus-visible,
93+button:focus-visible {
94+ outline: 2px solid var(--accent);
95+ outline-offset: 1px;
96+}
97+
98+button {
99+ min-height: 2.75rem;
100+ padding: 0 0.9rem;
101+ border: 1px solid var(--border);
102+ border-radius: 0.5rem;
103+ background: var(--surface);
104+ color: var(--text);
105+ font: inherit;
106+}
107+
108+button:disabled {
109+ opacity: 0.5;
110+}
111+
112+.primary {
113+ width: 100%;
114+ background: var(--accent);
115+ border-color: var(--accent);
116+ color: var(--on-accent);
117+ font-weight: 600;
118+}
119+
120+.danger {
121+ color: var(--danger);
122+ border-color: var(--danger);
123+}
124+
125+.error {
126+ color: var(--danger);
127+ margin: 0.5rem 0 0;
128+}
129+
130+.pending {
131+ color: var(--danger);
132+ margin: 0.5rem 0;
133+}
134+
135+#key-remove {
136+ margin-top: 0.25rem;
137+}
138+
139+#favorites-list {
140+ list-style: none;
141+ margin: 0;
142+ padding: 0;
143+ display: grid;
144+ gap: 0.5rem;
145+}
146+
147+.favorite {
148+ border: 1px solid var(--border);
149+ border-radius: 0.5rem;
150+ padding: 0.5rem 0.75rem;
151+}
152+
153+.favorite .labels {
154+ margin: 0;
155+ font-weight: 600;
156+}
157+
158+.favorite .sub {
159+ margin: 0.1rem 0 0;
160+ color: var(--muted);
161+ font-size: 0.9rem;
162+ overflow-wrap: anywhere;
163+}
164+
165+.favorite-actions {
166+ display: flex;
167+ gap: 0.5rem;
168+ margin-top: 0.5rem;
169+}
170+
171+.favorite-actions button {
172+ min-height: 2.25rem;
173+ padding: 0 0.6rem;
174+ font-size: 0.9rem;
175+}
176+
177+@media (min-width: 30rem) {
178+ main {
179+ padding-top: 2rem;
180+ }
181+}
added packages/config-page/test/config-page.test.ts +377 -0
new file mode 100644
@@ -0,0 +1,377 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { readFileSync } from "node:fs";
4+import { fileURLToPath } from "node:url";
5+import { dirname, join } from "node:path";
6+import { createRequire } from "node:module";
7+import {
8+ API_KEY_ACTION,
9+ LIMITS as CANONICAL_LIMITS,
10+ SCHEMA_VERSION as CANONICAL_SCHEMA_VERSION,
11+ isApiKeyUpdate,
12+ isFavorite,
13+ isPersonalApiKey,
14+ type Favorite,
15+} from "../../contracts/src/index.ts";
16+import {
17+ CLOSE_PREFIX,
18+ COPY,
19+ EMPTY_KEY_DRAFT,
20+ LIMITS,
21+ SCHEMA_VERSION,
22+ apiKeyError,
23+ copyFor,
24+ createCloseSession,
25+ encodeCloseFragment,
26+ initialConfigState,
27+ isFavoriteShape,
28+ parseConfigFragment,
29+ planApiKeyUpdate,
30+ planConfigResult,
31+ reduceConfigState,
32+ selectLocale,
33+ utf8Bytes,
34+} from "../src/config-core.js";
35+
36+const here = dirname(fileURLToPath(import.meta.url));
37+const require = createRequire(import.meta.url);
38+const companionConfiguration = require("../../companion/src/configuration.js");
39+
40+function favorite(id: string, sortOrder: number): Favorite {
41+ return {
42+ schemaVersion: 1,
43+ id,
44+ serviceId: `service:${id}`,
45+ stopLabel: `Arrêt ${id}`,
46+ lineLabel: "Métro 1",
47+ destinationLabel: "La Défense",
48+ sortOrder,
49+ };
50+}
51+
52+const fixtureList: Favorite[] = [favorite("home", 0), { ...favorite("work", 1), displayName: "Bureau" }];
53+
54+function openingFragment(value: Record<string, unknown>): string {
55+ return `#${encodeURIComponent(JSON.stringify(value))}`;
56+}
57+
58+function fragmentWith(favorites: Favorite[], overrides: Record<string, unknown> = {}): string {
59+ return openingFragment({ hasKey: true, favorites, language: "fr_FR", ...overrides });
60+}
61+
62+test("mirrored constants equal the canonical contract", () => {
63+ assert.equal(SCHEMA_VERSION, CANONICAL_SCHEMA_VERSION);
64+ assert.equal(SCHEMA_VERSION, 1);
65+ assert.equal(LIMITS.apiKeyUtf8Bytes, CANONICAL_LIMITS.apiKeyUtf8Bytes);
66+ assert.equal(LIMITS.idUtf8Bytes, CANONICAL_LIMITS.idUtf8Bytes);
67+ assert.equal(LIMITS.labelUtf8Bytes, CANONICAL_LIMITS.labelUtf8Bytes);
68+ assert.equal(LIMITS.favorites, CANONICAL_LIMITS.favorites);
69+});
70+
71+test("french language tags select french copy and everything else falls back to english", () => {
72+ for (const language of ["fr_FR", "fr", "fr-CA", "FR_fr"]) {
73+ assert.equal(selectLocale(language), "fr");
74+ }
75+ for (const language of ["en_US", "de_DE", "es-ES", "it_IT", "pt_PT", "", "xx_XX", undefined]) {
76+ assert.equal(selectLocale(language), "en");
77+ }
78+ assert.equal(copyFor("fr_FR"), COPY.fr);
79+ assert.equal(copyFor("en_US"), COPY.en);
80+ assert.equal(copyFor(undefined), COPY.en);
81+ assert.deepEqual(Object.keys(COPY.en).sort(), Object.keys(COPY.fr).sort());
82+ assert.notEqual(COPY.en.save, COPY.fr.save);
83+});
84+
85+test("authored and transport labels never appear in product copy", () => {
86+ const serialized = JSON.stringify(COPY);
87+ for (const label of ["Châtelet", "Métro 1", "La Défense", "Arrêt home", "Arrêt work", "Bureau"]) {
88+ assert.equal(serialized.includes(label), false);
89+ }
90+});
91+
92+test("opening fragment parsing is secret-free and validates favorites", () => {
93+ const query = parseConfigFragment(fragmentWith(fixtureList));
94+ assert.equal(query.hasKey, true);
95+ assert.equal(query.language, "fr_FR");
96+ assert.equal(query.locale, "fr");
97+ assert.deepEqual(query.favorites, fixtureList);
98+
99+ assert.equal(parseConfigFragment(fragmentWith([], { hasKey: false, language: "en_US" })).hasKey, false);
100+ assert.equal(
101+ parseConfigFragment(openingFragment({ favorites: [], language: "en_US" })).hasKey,
102+ false,
103+ );
104+ assert.equal(
105+ parseConfigFragment(
106+ openingFragment({ hasKey: "true", favorites: [], language: "en_US" }),
107+ ).hasKey,
108+ false,
109+ );
110+
111+ const broken = parseConfigFragment(
112+ openingFragment({ hasKey: true, favorites: "not-an-array", language: "en_US" }),
113+ );
114+ assert.deepEqual(broken.favorites, []);
115+
116+ const invalid = parseConfigFragment(
117+ openingFragment({
118+ hasKey: true,
119+ favorites: [
120+ favorite("ok", 0),
121+ { ...favorite("old", 1), schemaVersion: 2 },
122+ { ...favorite("huge", 2), stopLabel: "x".repeat(97) },
123+ "junk",
124+ ],
125+ language: "fr_FR",
126+ }),
127+ );
128+ assert.deepEqual(invalid.favorites.map((entry) => entry.id), ["ok"]);
129+});
130+test("the config page consumes the companion-produced opening fragment exactly", () => {
131+ const key = "stored-personal-key";
132+ const url = companionConfiguration.configurationUrl(
133+ "https://config.example.test/index.html",
134+ { schemaVersion: 1, favorites: fixtureList, apiKey: key },
135+ "fr_FR",
136+ );
137+ assert.equal(typeof url, "string");
138+ if (url === null) return;
139+ assert.equal(url.includes(key), false);
140+
141+ const openingState = JSON.parse(decodeURIComponent(url.slice(url.indexOf("#") + 1)));
142+ assert.deepEqual(Object.keys(openingState), ["hasKey", "favorites", "language"]);
143+ assert.deepEqual(openingState, { hasKey: true, favorites: fixtureList, language: "fr_FR" });
144+ assert.deepEqual(parseConfigFragment(new URL(url).hash), {
145+ hasKey: true,
146+ language: "fr_FR",
147+ locale: "fr",
148+ favorites: fixtureList,
149+ });
150+});
151+
152+
153+test("a planted key field never enters page state or the payload", () => {
154+ const parsed = parseConfigFragment(fragmentWith(fixtureList, { apiKey: "SECRET-VALUE" }));
155+ assert.equal(JSON.stringify(parsed).includes("SECRET-VALUE"), false);
156+ const outcome = planConfigResult(initialConfigState(parsed));
157+ assert.equal(outcome.ok, true);
158+ if (!outcome.ok) return;
159+ assert.equal(JSON.stringify(outcome.payload).includes("SECRET-VALUE"), false);
160+});
161+
162+test("key lifecycle plans KEEP, REPLACE, and REMOVE through pure state", () => {
163+ const missing = initialConfigState(
164+ parseConfigFragment(fragmentWith([], { hasKey: false, language: "en_US" })),
165+ );
166+ assert.deepEqual(planApiKeyUpdate(missing.hasKey, missing.keyDraft), {
167+ schemaVersion: 1,
168+ action: "KEEP",
169+ });
170+ assert.deepEqual(planApiKeyUpdate(true, EMPTY_KEY_DRAFT), { schemaVersion: 1, action: "KEEP" });
171+
172+ const entered = reduceConfigState(missing, { type: "key-draft", value: "first-key" });
173+ assert.deepEqual(planApiKeyUpdate(entered.hasKey, entered.keyDraft), {
174+ schemaVersion: 1,
175+ action: "REPLACE",
176+ value: "first-key",
177+ });
178+
179+ const configured = initialConfigState(parseConfigFragment(fragmentWith([], { language: "en_US" })));
180+ assert.deepEqual(planApiKeyUpdate(configured.hasKey, configured.keyDraft), {
181+ schemaVersion: 1,
182+ action: "KEEP",
183+ });
184+
185+ const removing = reduceConfigState(configured, { type: "key-remove-requested" });
186+ assert.equal(removing.keyDraft.value, "");
187+ const removeUpdate = planApiKeyUpdate(removing.hasKey, removing.keyDraft);
188+ assert.deepEqual(removeUpdate, { schemaVersion: 1, action: "REMOVE" });
189+ assert.equal("value" in removeUpdate, false);
190+
191+ const replaced = reduceConfigState(removing, { type: "key-draft", value: "second-key" });
192+ assert.deepEqual(planApiKeyUpdate(replaced.hasKey, replaced.keyDraft), {
193+ schemaVersion: 1,
194+ action: "REPLACE",
195+ value: "second-key",
196+ });
197+
198+ const cleared = reduceConfigState(replaced, { type: "key-draft", value: "" });
199+ assert.equal(cleared.keyDraft.removeRequested, false);
200+ assert.deepEqual(planApiKeyUpdate(cleared.hasKey, cleared.keyDraft), {
201+ schemaVersion: 1,
202+ action: "KEEP",
203+ });
204+
205+ const cancelled = reduceConfigState(removing, { type: "key-remove-cancelled" });
206+ assert.deepEqual(planApiKeyUpdate(cancelled.hasKey, cancelled.keyDraft), {
207+ schemaVersion: 1,
208+ action: "KEEP",
209+ });
210+
211+ // Removal is impossible without a configured key.
212+ assert.equal(reduceConfigState(missing, { type: "key-remove-requested" }), missing);
213+
214+ const keepUpdate = planApiKeyUpdate(true, { value: "", removeRequested: false });
215+ const replaceUpdate = planApiKeyUpdate(false, { value: "abc", removeRequested: false });
216+ assert.equal(isApiKeyUpdate(keepUpdate), true);
217+ assert.equal(isApiKeyUpdate(removeUpdate), true);
218+ assert.equal(isApiKeyUpdate(replaceUpdate), true);
219+ assert.ok(API_KEY_ACTION.includes(keepUpdate.action));
220+ assert.ok(API_KEY_ACTION.includes(replaceUpdate.action));
221+ assert.equal(isPersonalApiKey(replaceUpdate.value), true);
222+});
223+
224+test("replace validation rejects oversized or line-bearing keys in both locales", () => {
225+ assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null);
226+ assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong");
227+ assert.equal(apiKeyError("é".repeat(256)), null);
228+ assert.equal(utf8Bytes("é".repeat(256)), LIMITS.apiKeyUtf8Bytes);
229+ assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong");
230+ assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline");
231+ assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline");
232+
233+ const tooLong = planConfigResult({
234+ hasKey: false,
235+ keyDraft: { value: "k".repeat(LIMITS.apiKeyUtf8Bytes + 1), removeRequested: false },
236+ favorites: [],
237+ });
238+ assert.equal(tooLong.ok, false);
239+ if (tooLong.ok) return;
240+ assert.equal(tooLong.error, "keyErrorTooLong");
241+ for (const dictionary of [COPY.en, COPY.fr]) {
242+ assert.equal(typeof dictionary[tooLong.error], "string");
243+ }
244+
245+ const newline = planConfigResult({
246+ hasKey: true,
247+ keyDraft: { value: "a\nb", removeRequested: false },
248+ favorites: [],
249+ });
250+ assert.equal(newline.ok, false);
251+ if (newline.ok) return;
252+ assert.equal(newline.error, "keyErrorNewline");
253+
254+ const boundary = planConfigResult({
255+ hasKey: false,
256+ keyDraft: { value: "é".repeat(256), removeRequested: false },
257+ favorites: [],
258+ });
259+ assert.equal(boundary.ok, true);
260+ if (!boundary.ok) return;
261+ assert.equal(boundary.payload.apiKeyUpdate.action, "REPLACE");
262+ assert.equal(isPersonalApiKey(boundary.payload.apiKeyUpdate.value), true);
263+ assert.deepEqual(boundary.payload.favorites, []);
264+});
265+
266+test("favorite edits replace the whole list atomically with renumbered order", () => {
267+ const state = initialConfigState(parseConfigFragment(fragmentWith(fixtureList)));
268+
269+ const removed = reduceConfigState(state, { type: "favorite-remove", id: "home" });
270+ assert.deepEqual(removed.favorites, [{ ...fixtureList[1], sortOrder: 0 }]);
271+
272+ const moved = reduceConfigState(state, { type: "favorite-move", id: "work", delta: -1 });
273+ assert.deepEqual(moved.favorites.map((entry) => entry.id), ["work", "home"]);
274+ assert.deepEqual(moved.favorites.map((entry) => entry.sortOrder), [0, 1]);
275+
276+ assert.equal(reduceConfigState(state, { type: "favorite-remove", id: "nope" }), state);
277+ assert.equal(reduceConfigState(state, { type: "favorite-move", id: "home", delta: -1 }), state);
278+ assert.equal(reduceConfigState(state, { type: "favorite-move", id: "work", delta: 99 }), state);
279+
280+ const outcome = planConfigResult(removed);
281+ assert.equal(outcome.ok, true);
282+ if (!outcome.ok) return;
283+ assert.deepEqual(outcome.payload.favorites, [
284+ {
285+ schemaVersion: 1,
286+ id: "work",
287+ serviceId: "service:work",
288+ stopLabel: "Arrêt work",
289+ lineLabel: "Métro 1",
290+ destinationLabel: "La Défense",
291+ sortOrder: 0,
292+ displayName: "Bureau",
293+ },
294+ ]);
295+ for (const entry of outcome.payload.favorites) {
296+ assert.equal(isFavorite(entry), true);
297+ assert.equal(isFavoriteShape(entry), true);
298+ }
299+});
300+
301+test("payload favorites are capped at the contract maximum", () => {
302+ const many = Array.from({ length: LIMITS.favorites + 1 }, (_, index) => favorite(`f${index}`, index));
303+ const outcome = planConfigResult(initialConfigState(parseConfigFragment(fragmentWith(many))));
304+ assert.equal(outcome.ok, true);
305+ if (!outcome.ok) return;
306+ assert.equal(outcome.payload.favorites.length, LIMITS.favorites);
307+ for (const entry of outcome.payload.favorites) {
308+ assert.equal(isFavorite(entry), true);
309+ }
310+});
311+
312+test("the close fragment carries the key value only for REPLACE and is one-shot", () => {
313+ const session = createCloseSession();
314+ assert.equal(session.closed, false);
315+
316+ const entered = reduceConfigState(
317+ initialConfigState(parseConfigFragment(fragmentWith(fixtureList))),
318+ { type: "key-draft", value: "super-secret-key" },
319+ );
320+ const replaceOutcome = planConfigResult(entered);
321+ assert.equal(replaceOutcome.ok, true);
322+ if (!replaceOutcome.ok) return;
323+ assert.equal(isApiKeyUpdate(replaceOutcome.payload.apiKeyUpdate), true);
324+ assert.deepEqual(Object.keys(replaceOutcome.payload).sort(), [
325+ "apiKeyUpdate",
326+ "favorites",
327+ "schemaVersion",
328+ ]);
329+ assert.deepEqual(Object.keys(replaceOutcome.payload.apiKeyUpdate).sort(), [
330+ "action",
331+ "schemaVersion",
332+ "value",
333+ ]);
334+
335+ const fragment = session.close(replaceOutcome.payload);
336+ assert.equal(session.closed, true);
337+ assert.ok(fragment !== null && fragment.startsWith(CLOSE_PREFIX));
338+ if (fragment === null) return;
339+
340+ const encodedValue = encodeURIComponent(JSON.stringify(replaceOutcome.payload));
341+ assert.equal(fragment, CLOSE_PREFIX + encodedValue);
342+
343+ const decoded = JSON.parse(decodeURIComponent(fragment.slice(CLOSE_PREFIX.length)));
344+ assert.deepEqual(decoded, replaceOutcome.payload);
345+ assert.equal(decoded.apiKeyUpdate.action, "REPLACE");
346+ assert.equal(decoded.apiKeyUpdate.value, "super-secret-key");
347+
348+ assert.equal(session.close(replaceOutcome.payload), null);
349+
350+ // KEEP and REMOVE never carry any key value.
351+ const keepOutcome = planConfigResult(initialConfigState(parseConfigFragment(fragmentWith(fixtureList))));
352+ assert.equal(keepOutcome.ok, true);
353+ if (!keepOutcome.ok) return;
354+ assert.equal(isApiKeyUpdate(keepOutcome.payload.apiKeyUpdate), true);
355+ assert.deepEqual(Object.keys(keepOutcome.payload.apiKeyUpdate).sort(), ["action", "schemaVersion"]);
356+ const keepFragment = encodeCloseFragment(keepOutcome.payload);
357+ assert.equal(keepFragment.includes("super-secret-key"), false);
358+
359+ const removeOutcome = planConfigResult(
360+ reduceConfigState(initialConfigState(parseConfigFragment(fragmentWith([]))), {
361+ type: "key-remove-requested",
362+ }),
363+ );
364+ assert.equal(removeOutcome.ok, true);
365+ if (!removeOutcome.ok) return;
366+ assert.deepEqual(Object.keys(removeOutcome.payload.apiKeyUpdate).sort(), ["action", "schemaVersion"]);
367+ assert.equal(encodeCloseFragment(removeOutcome.payload).includes("super-secret-key"), false);
368+});
369+
370+test("page sources contain no storage, network, timer, or logging surface", () => {
371+ const forbidden =
372+ /fetch\(|XMLHttpRequest|localStorage|sessionStorage|document\.cookie|WebSocket|console\.|setInterval|setTimeout\(/;
373+ for (const file of ["config-core.js", "config-page.js"]) {
374+ const source = readFileSync(join(here, "../src", file), "utf8");
375+ assert.doesNotMatch(source, forbidden);
376+ }
377+});
new file mode 100644
@@ -0,0 +1,377 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { readFileSync } from "node:fs";
4+import { fileURLToPath } from "node:url";
5+import { dirname, join } from "node:path";
6+import { createRequire } from "node:module";
7+import {
8+ API_KEY_ACTION,
9+ LIMITS as CANONICAL_LIMITS,
10+ SCHEMA_VERSION as CANONICAL_SCHEMA_VERSION,
11+ isApiKeyUpdate,
12+ isFavorite,
13+ isPersonalApiKey,
14+ type Favorite,
15+} from "../../contracts/src/index.ts";
16+import {
17+ CLOSE_PREFIX,
18+ COPY,
19+ EMPTY_KEY_DRAFT,
20+ LIMITS,
21+ SCHEMA_VERSION,
22+ apiKeyError,
23+ copyFor,
24+ createCloseSession,
25+ encodeCloseFragment,
26+ initialConfigState,
27+ isFavoriteShape,
28+ parseConfigFragment,
29+ planApiKeyUpdate,
30+ planConfigResult,
31+ reduceConfigState,
32+ selectLocale,
33+ utf8Bytes,
34+} from "../src/config-core.js";
35+
36+const here = dirname(fileURLToPath(import.meta.url));
37+const require = createRequire(import.meta.url);
38+const companionConfiguration = require("../../companion/src/configuration.js");
39+
40+function favorite(id: string, sortOrder: number): Favorite {
41+ return {
42+ schemaVersion: 1,
43+ id,
44+ serviceId: `service:${id}`,
45+ stopLabel: `Arrêt ${id}`,
46+ lineLabel: "Métro 1",
47+ destinationLabel: "La Défense",
48+ sortOrder,
49+ };
50+}
51+
52+const fixtureList: Favorite[] = [favorite("home", 0), { ...favorite("work", 1), displayName: "Bureau" }];
53+
54+function openingFragment(value: Record<string, unknown>): string {
55+ return `#${encodeURIComponent(JSON.stringify(value))}`;
56+}
57+
58+function fragmentWith(favorites: Favorite[], overrides: Record<string, unknown> = {}): string {
59+ return openingFragment({ hasKey: true, favorites, language: "fr_FR", ...overrides });
60+}
61+
62+test("mirrored constants equal the canonical contract", () => {
63+ assert.equal(SCHEMA_VERSION, CANONICAL_SCHEMA_VERSION);
64+ assert.equal(SCHEMA_VERSION, 1);
65+ assert.equal(LIMITS.apiKeyUtf8Bytes, CANONICAL_LIMITS.apiKeyUtf8Bytes);
66+ assert.equal(LIMITS.idUtf8Bytes, CANONICAL_LIMITS.idUtf8Bytes);
67+ assert.equal(LIMITS.labelUtf8Bytes, CANONICAL_LIMITS.labelUtf8Bytes);
68+ assert.equal(LIMITS.favorites, CANONICAL_LIMITS.favorites);
69+});
70+
71+test("french language tags select french copy and everything else falls back to english", () => {
72+ for (const language of ["fr_FR", "fr", "fr-CA", "FR_fr"]) {
73+ assert.equal(selectLocale(language), "fr");
74+ }
75+ for (const language of ["en_US", "de_DE", "es-ES", "it_IT", "pt_PT", "", "xx_XX", undefined]) {
76+ assert.equal(selectLocale(language), "en");
77+ }
78+ assert.equal(copyFor("fr_FR"), COPY.fr);
79+ assert.equal(copyFor("en_US"), COPY.en);
80+ assert.equal(copyFor(undefined), COPY.en);
81+ assert.deepEqual(Object.keys(COPY.en).sort(), Object.keys(COPY.fr).sort());
82+ assert.notEqual(COPY.en.save, COPY.fr.save);
83+});
84+
85+test("authored and transport labels never appear in product copy", () => {
86+ const serialized = JSON.stringify(COPY);
87+ for (const label of ["Châtelet", "Métro 1", "La Défense", "Arrêt home", "Arrêt work", "Bureau"]) {
88+ assert.equal(serialized.includes(label), false);
89+ }
90+});
91+
92+test("opening fragment parsing is secret-free and validates favorites", () => {
93+ const query = parseConfigFragment(fragmentWith(fixtureList));
94+ assert.equal(query.hasKey, true);
95+ assert.equal(query.language, "fr_FR");
96+ assert.equal(query.locale, "fr");
97+ assert.deepEqual(query.favorites, fixtureList);
98+
99+ assert.equal(parseConfigFragment(fragmentWith([], { hasKey: false, language: "en_US" })).hasKey, false);
100+ assert.equal(
101+ parseConfigFragment(openingFragment({ favorites: [], language: "en_US" })).hasKey,
102+ false,
103+ );
104+ assert.equal(
105+ parseConfigFragment(
106+ openingFragment({ hasKey: "true", favorites: [], language: "en_US" }),
107+ ).hasKey,
108+ false,
109+ );
110+
111+ const broken = parseConfigFragment(
112+ openingFragment({ hasKey: true, favorites: "not-an-array", language: "en_US" }),
113+ );
114+ assert.deepEqual(broken.favorites, []);
115+
116+ const invalid = parseConfigFragment(
117+ openingFragment({
118+ hasKey: true,
119+ favorites: [
120+ favorite("ok", 0),
121+ { ...favorite("old", 1), schemaVersion: 2 },
122+ { ...favorite("huge", 2), stopLabel: "x".repeat(97) },
123+ "junk",
124+ ],
125+ language: "fr_FR",
126+ }),
127+ );
128+ assert.deepEqual(invalid.favorites.map((entry) => entry.id), ["ok"]);
129+});
130+test("the config page consumes the companion-produced opening fragment exactly", () => {
131+ const key = "stored-personal-key";
132+ const url = companionConfiguration.configurationUrl(
133+ "https://config.example.test/index.html",
134+ { schemaVersion: 1, favorites: fixtureList, apiKey: key },
135+ "fr_FR",
136+ );
137+ assert.equal(typeof url, "string");
138+ if (url === null) return;
139+ assert.equal(url.includes(key), false);
140+
141+ const openingState = JSON.parse(decodeURIComponent(url.slice(url.indexOf("#") + 1)));
142+ assert.deepEqual(Object.keys(openingState), ["hasKey", "favorites", "language"]);
143+ assert.deepEqual(openingState, { hasKey: true, favorites: fixtureList, language: "fr_FR" });
144+ assert.deepEqual(parseConfigFragment(new URL(url).hash), {
145+ hasKey: true,
146+ language: "fr_FR",
147+ locale: "fr",
148+ favorites: fixtureList,
149+ });
150+});
151+
152+
153+test("a planted key field never enters page state or the payload", () => {
154+ const parsed = parseConfigFragment(fragmentWith(fixtureList, { apiKey: "SECRET-VALUE" }));
155+ assert.equal(JSON.stringify(parsed).includes("SECRET-VALUE"), false);
156+ const outcome = planConfigResult(initialConfigState(parsed));
157+ assert.equal(outcome.ok, true);
158+ if (!outcome.ok) return;
159+ assert.equal(JSON.stringify(outcome.payload).includes("SECRET-VALUE"), false);
160+});
161+
162+test("key lifecycle plans KEEP, REPLACE, and REMOVE through pure state", () => {
163+ const missing = initialConfigState(
164+ parseConfigFragment(fragmentWith([], { hasKey: false, language: "en_US" })),
165+ );
166+ assert.deepEqual(planApiKeyUpdate(missing.hasKey, missing.keyDraft), {
167+ schemaVersion: 1,
168+ action: "KEEP",
169+ });
170+ assert.deepEqual(planApiKeyUpdate(true, EMPTY_KEY_DRAFT), { schemaVersion: 1, action: "KEEP" });
171+
172+ const entered = reduceConfigState(missing, { type: "key-draft", value: "first-key" });
173+ assert.deepEqual(planApiKeyUpdate(entered.hasKey, entered.keyDraft), {
174+ schemaVersion: 1,
175+ action: "REPLACE",
176+ value: "first-key",
177+ });
178+
179+ const configured = initialConfigState(parseConfigFragment(fragmentWith([], { language: "en_US" })));
180+ assert.deepEqual(planApiKeyUpdate(configured.hasKey, configured.keyDraft), {
181+ schemaVersion: 1,
182+ action: "KEEP",
183+ });
184+
185+ const removing = reduceConfigState(configured, { type: "key-remove-requested" });
186+ assert.equal(removing.keyDraft.value, "");
187+ const removeUpdate = planApiKeyUpdate(removing.hasKey, removing.keyDraft);
188+ assert.deepEqual(removeUpdate, { schemaVersion: 1, action: "REMOVE" });
189+ assert.equal("value" in removeUpdate, false);
190+
191+ const replaced = reduceConfigState(removing, { type: "key-draft", value: "second-key" });
192+ assert.deepEqual(planApiKeyUpdate(replaced.hasKey, replaced.keyDraft), {
193+ schemaVersion: 1,
194+ action: "REPLACE",
195+ value: "second-key",
196+ });
197+
198+ const cleared = reduceConfigState(replaced, { type: "key-draft", value: "" });
199+ assert.equal(cleared.keyDraft.removeRequested, false);
200+ assert.deepEqual(planApiKeyUpdate(cleared.hasKey, cleared.keyDraft), {
201+ schemaVersion: 1,
202+ action: "KEEP",
203+ });
204+
205+ const cancelled = reduceConfigState(removing, { type: "key-remove-cancelled" });
206+ assert.deepEqual(planApiKeyUpdate(cancelled.hasKey, cancelled.keyDraft), {
207+ schemaVersion: 1,
208+ action: "KEEP",
209+ });
210+
211+ // Removal is impossible without a configured key.
212+ assert.equal(reduceConfigState(missing, { type: "key-remove-requested" }), missing);
213+
214+ const keepUpdate = planApiKeyUpdate(true, { value: "", removeRequested: false });
215+ const replaceUpdate = planApiKeyUpdate(false, { value: "abc", removeRequested: false });
216+ assert.equal(isApiKeyUpdate(keepUpdate), true);
217+ assert.equal(isApiKeyUpdate(removeUpdate), true);
218+ assert.equal(isApiKeyUpdate(replaceUpdate), true);
219+ assert.ok(API_KEY_ACTION.includes(keepUpdate.action));
220+ assert.ok(API_KEY_ACTION.includes(replaceUpdate.action));
221+ assert.equal(isPersonalApiKey(replaceUpdate.value), true);
222+});
223+
224+test("replace validation rejects oversized or line-bearing keys in both locales", () => {
225+ assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes)), null);
226+ assert.equal(apiKeyError("k".repeat(LIMITS.apiKeyUtf8Bytes + 1)), "keyErrorTooLong");
227+ assert.equal(apiKeyError("é".repeat(256)), null);
228+ assert.equal(utf8Bytes("é".repeat(256)), LIMITS.apiKeyUtf8Bytes);
229+ assert.equal(apiKeyError("é".repeat(257)), "keyErrorTooLong");
230+ assert.equal(apiKeyError("line1\nline2"), "keyErrorNewline");
231+ assert.equal(apiKeyError("line1\rline2"), "keyErrorNewline");
232+
233+ const tooLong = planConfigResult({
234+ hasKey: false,
235+ keyDraft: { value: "k".repeat(LIMITS.apiKeyUtf8Bytes + 1), removeRequested: false },
236+ favorites: [],
237+ });
238+ assert.equal(tooLong.ok, false);
239+ if (tooLong.ok) return;
240+ assert.equal(tooLong.error, "keyErrorTooLong");
241+ for (const dictionary of [COPY.en, COPY.fr]) {
242+ assert.equal(typeof dictionary[tooLong.error], "string");
243+ }
244+
245+ const newline = planConfigResult({
246+ hasKey: true,
247+ keyDraft: { value: "a\nb", removeRequested: false },
248+ favorites: [],
249+ });
250+ assert.equal(newline.ok, false);
251+ if (newline.ok) return;
252+ assert.equal(newline.error, "keyErrorNewline");
253+
254+ const boundary = planConfigResult({
255+ hasKey: false,
256+ keyDraft: { value: "é".repeat(256), removeRequested: false },
257+ favorites: [],
258+ });
259+ assert.equal(boundary.ok, true);
260+ if (!boundary.ok) return;
261+ assert.equal(boundary.payload.apiKeyUpdate.action, "REPLACE");
262+ assert.equal(isPersonalApiKey(boundary.payload.apiKeyUpdate.value), true);
263+ assert.deepEqual(boundary.payload.favorites, []);
264+});
265+
266+test("favorite edits replace the whole list atomically with renumbered order", () => {
267+ const state = initialConfigState(parseConfigFragment(fragmentWith(fixtureList)));
268+
269+ const removed = reduceConfigState(state, { type: "favorite-remove", id: "home" });
270+ assert.deepEqual(removed.favorites, [{ ...fixtureList[1], sortOrder: 0 }]);
271+
272+ const moved = reduceConfigState(state, { type: "favorite-move", id: "work", delta: -1 });
273+ assert.deepEqual(moved.favorites.map((entry) => entry.id), ["work", "home"]);
274+ assert.deepEqual(moved.favorites.map((entry) => entry.sortOrder), [0, 1]);
275+
276+ assert.equal(reduceConfigState(state, { type: "favorite-remove", id: "nope" }), state);
277+ assert.equal(reduceConfigState(state, { type: "favorite-move", id: "home", delta: -1 }), state);
278+ assert.equal(reduceConfigState(state, { type: "favorite-move", id: "work", delta: 99 }), state);
279+
280+ const outcome = planConfigResult(removed);
281+ assert.equal(outcome.ok, true);
282+ if (!outcome.ok) return;
283+ assert.deepEqual(outcome.payload.favorites, [
284+ {
285+ schemaVersion: 1,
286+ id: "work",
287+ serviceId: "service:work",
288+ stopLabel: "Arrêt work",
289+ lineLabel: "Métro 1",
290+ destinationLabel: "La Défense",
291+ sortOrder: 0,
292+ displayName: "Bureau",
293+ },
294+ ]);
295+ for (const entry of outcome.payload.favorites) {
296+ assert.equal(isFavorite(entry), true);
297+ assert.equal(isFavoriteShape(entry), true);
298+ }
299+});
300+
301+test("payload favorites are capped at the contract maximum", () => {
302+ const many = Array.from({ length: LIMITS.favorites + 1 }, (_, index) => favorite(`f${index}`, index));
303+ const outcome = planConfigResult(initialConfigState(parseConfigFragment(fragmentWith(many))));
304+ assert.equal(outcome.ok, true);
305+ if (!outcome.ok) return;
306+ assert.equal(outcome.payload.favorites.length, LIMITS.favorites);
307+ for (const entry of outcome.payload.favorites) {
308+ assert.equal(isFavorite(entry), true);
309+ }
310+});
311+
312+test("the close fragment carries the key value only for REPLACE and is one-shot", () => {
313+ const session = createCloseSession();
314+ assert.equal(session.closed, false);
315+
316+ const entered = reduceConfigState(
317+ initialConfigState(parseConfigFragment(fragmentWith(fixtureList))),
318+ { type: "key-draft", value: "super-secret-key" },
319+ );
320+ const replaceOutcome = planConfigResult(entered);
321+ assert.equal(replaceOutcome.ok, true);
322+ if (!replaceOutcome.ok) return;
323+ assert.equal(isApiKeyUpdate(replaceOutcome.payload.apiKeyUpdate), true);
324+ assert.deepEqual(Object.keys(replaceOutcome.payload).sort(), [
325+ "apiKeyUpdate",
326+ "favorites",
327+ "schemaVersion",
328+ ]);
329+ assert.deepEqual(Object.keys(replaceOutcome.payload.apiKeyUpdate).sort(), [
330+ "action",
331+ "schemaVersion",
332+ "value",
333+ ]);
334+
335+ const fragment = session.close(replaceOutcome.payload);
336+ assert.equal(session.closed, true);
337+ assert.ok(fragment !== null && fragment.startsWith(CLOSE_PREFIX));
338+ if (fragment === null) return;
339+
340+ const encodedValue = encodeURIComponent(JSON.stringify(replaceOutcome.payload));
341+ assert.equal(fragment, CLOSE_PREFIX + encodedValue);
342+
343+ const decoded = JSON.parse(decodeURIComponent(fragment.slice(CLOSE_PREFIX.length)));
344+ assert.deepEqual(decoded, replaceOutcome.payload);
345+ assert.equal(decoded.apiKeyUpdate.action, "REPLACE");
346+ assert.equal(decoded.apiKeyUpdate.value, "super-secret-key");
347+
348+ assert.equal(session.close(replaceOutcome.payload), null);
349+
350+ // KEEP and REMOVE never carry any key value.
351+ const keepOutcome = planConfigResult(initialConfigState(parseConfigFragment(fragmentWith(fixtureList))));
352+ assert.equal(keepOutcome.ok, true);
353+ if (!keepOutcome.ok) return;
354+ assert.equal(isApiKeyUpdate(keepOutcome.payload.apiKeyUpdate), true);
355+ assert.deepEqual(Object.keys(keepOutcome.payload.apiKeyUpdate).sort(), ["action", "schemaVersion"]);
356+ const keepFragment = encodeCloseFragment(keepOutcome.payload);
357+ assert.equal(keepFragment.includes("super-secret-key"), false);
358+
359+ const removeOutcome = planConfigResult(
360+ reduceConfigState(initialConfigState(parseConfigFragment(fragmentWith([]))), {
361+ type: "key-remove-requested",
362+ }),
363+ );
364+ assert.equal(removeOutcome.ok, true);
365+ if (!removeOutcome.ok) return;
366+ assert.deepEqual(Object.keys(removeOutcome.payload.apiKeyUpdate).sort(), ["action", "schemaVersion"]);
367+ assert.equal(encodeCloseFragment(removeOutcome.payload).includes("super-secret-key"), false);
368+});
369+
370+test("page sources contain no storage, network, timer, or logging surface", () => {
371+ const forbidden =
372+ /fetch\(|XMLHttpRequest|localStorage|sessionStorage|document\.cookie|WebSocket|console\.|setInterval|setTimeout\(/;
373+ for (const file of ["config-core.js", "config-page.js"]) {
374+ const source = readFileSync(join(here, "../src", file), "utf8");
375+ assert.doesNotMatch(source, forbidden);
376+ }
377+});
added packages/contracts/src/index.ts +579 -0
new file mode 100644
@@ -0,0 +1,579 @@
1+export const SCHEMA_VERSION = 1 as const;
2+
3+export const LIMITS = {
4+ apiKeyUtf8Bytes: 512,
5+ idUtf8Bytes: 64,
6+ labelUtf8Bytes: 96,
7+ favorites: 8,
8+ departures: 4,
9+ httpTimeoutMs: 8_000,
10+ httpResponseBytes: 262_144,
11+} as const;
12+
13+export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const;
14+export const CACHE_FRESH_SECONDS = 60 as const;
15+export const FAVORITE_SETTLE_MS = 500 as const;
16+
17+export const FRESHNESS = ["REALTIME", "SCHEDULED", "MIXED", "STALE"] as const;
18+export const DEPARTURE_STATUS = ["ON_TIME", "DELAYED", "CANCELLED", "UNKNOWN"] as const;
19+export const ERROR_CODE = [
20+ "API_KEY_REQUIRED",
21+ "API_KEY_INVALID",
22+ "INVALID_SERVICE",
23+ "SOURCE_UNAVAILABLE",
24+ "RATE_LIMITED",
25+ "INVALID_RESPONSE",
26+] as const;
27+export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const;
28+export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const;
29+export const REQUEST_TRIGGER = { APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 } as const;
30+export const MESSAGE_TYPE = {
31+ REQUEST: 1,
32+ CONFIG_BEGIN: 2,
33+ FAVORITE: 3,
34+ CONFIG_COMMIT: 4,
35+ RESULT_BEGIN: 5,
36+ DEPARTURE: 6,
37+ RESULT_COMMIT: 7,
38+ ERROR: 8,
39+} as const;
40+export const APP_MESSAGE_KEY_ORDER = [
41+ "SCHEMA_VERSION",
42+ "MESSAGE_TYPE",
43+ "REQUEST_ID",
44+ "FAVORITE_ID",
45+ "SERVICE_ID",
46+ "DISPLAY_NAME",
47+ "STOP_LABEL",
48+ "LINE_LABEL",
49+ "DESTINATION_LABEL",
50+ "SORT_ORDER",
51+ "KEY_STATUS",
52+ "ITEM_COUNT",
53+ "ITEM_INDEX",
54+ "FETCHED_AT",
55+ "SOURCE_UPDATED_AT",
56+ "FRESHNESS",
57+ "EXPECTED_AT",
58+ "AIMED_AT",
59+ "MINUTES",
60+ "DEPARTURE_STATUS",
61+ "NEXT_INTERVAL_MINUTES",
62+ "ERROR_CODE",
63+ "OCCURRED_AT",
64+ "RETRY_AFTER_SECONDS",
65+ "REQUEST_TRIGGER",
66+] as const;
67+
68+export const APP_MESSAGE_KEY = {
69+ SCHEMA_VERSION: 0,
70+ MESSAGE_TYPE: 1,
71+ REQUEST_ID: 2,
72+ FAVORITE_ID: 3,
73+ SERVICE_ID: 4,
74+ DISPLAY_NAME: 5,
75+ STOP_LABEL: 6,
76+ LINE_LABEL: 7,
77+ DESTINATION_LABEL: 8,
78+ SORT_ORDER: 9,
79+ KEY_STATUS: 10,
80+ ITEM_COUNT: 11,
81+ ITEM_INDEX: 12,
82+ FETCHED_AT: 13,
83+ SOURCE_UPDATED_AT: 14,
84+ FRESHNESS: 15,
85+ EXPECTED_AT: 16,
86+ AIMED_AT: 17,
87+ MINUTES: 18,
88+ DEPARTURE_STATUS: 19,
89+ NEXT_INTERVAL_MINUTES: 20,
90+ ERROR_CODE: 21,
91+ OCCURRED_AT: 22,
92+ RETRY_AFTER_SECONDS: 23,
93+ REQUEST_TRIGGER: 24,
94+} as const;
95+export const WIRE_LANGUAGE = { EN: "en", FR: "fr" } as const;
96+
97+export type Freshness = (typeof FRESHNESS)[number];
98+export type DepartureStatus = (typeof DEPARTURE_STATUS)[number];
99+export type ErrorCode = (typeof ERROR_CODE)[number];
100+export type ApiKeyAction = (typeof API_KEY_ACTION)[number];
101+export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number];
102+export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE];
103+
104+export interface Favorite {
105+ schemaVersion: typeof SCHEMA_VERSION;
106+ id: string;
107+ serviceId: string;
108+ displayName?: string;
109+ stopLabel: string;
110+ lineLabel: string;
111+ destinationLabel: string;
112+ sortOrder: number;
113+}
114+
115+export interface Departure {
116+ expectedAt: number;
117+ aimedAt?: number;
118+ minutes: number;
119+ status: DepartureStatus;
120+ nextIntervalMinutes?: number;
121+}
122+
123+export interface DepartureResult {
124+ schemaVersion: typeof SCHEMA_VERSION;
125+ requestId: string;
126+ favoriteId: string;
127+ fetchedAt: number;
128+ sourceUpdatedAt?: number;
129+ freshness: Freshness;
130+ departures: Departure[];
131+}
132+
133+export interface ErrorResult {
134+ schemaVersion: typeof SCHEMA_VERSION;
135+ requestId: string;
136+ favoriteId?: string;
137+ code: ErrorCode;
138+ occurredAt: number;
139+ retryAfterSeconds?: number;
140+}
141+
142+export type ApiKeyUpdate =
143+ | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" }
144+ | { schemaVersion: typeof SCHEMA_VERSION; action: "REMOVE" }
145+ | { schemaVersion: typeof SCHEMA_VERSION; action: "REPLACE"; value: string };
146+
147+export type AppMessageValue = number | string;
148+export type AppMessage = Partial<Record<AppMessageKey, AppMessageValue>>;
149+
150+const encoder = new TextEncoder();
151+const UINT32_MAX = 0xffff_ffff;
152+const domainKeys = {
153+ favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "sortOrder"],
154+ departure: ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"],
155+ result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"],
156+ error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],
157+ keyUpdate: ["schemaVersion", "action", "value"],
158+} as const;
159+
160+function object(value: unknown): value is Record<string, unknown> {
161+ return typeof value === "object" && value !== null && !Array.isArray(value);
162+}
163+
164+function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
165+ return Object.keys(value).every((key) => allowed.includes(key));
166+}
167+
168+export function utf8Bytes(value: string): number {
169+ return encoder.encode(value).byteLength;
170+}
171+
172+function boundedString(value: unknown, maximum: number): value is string {
173+ return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
174+}
175+
176+function uint32(value: unknown): value is number {
177+ return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX;
178+}
179+
180+function optionalUint32(value: unknown): boolean {
181+ return value === undefined || uint32(value);
182+}
183+
184+function optionalBoundedString(value: unknown, maximum: number): boolean {
185+ return value === undefined || boundedString(value, maximum);
186+}
187+
188+export function isFavorite(value: unknown): value is Favorite {
189+ if (!object(value) || !exactKeys(value, domainKeys.favorite)) return false;
190+ return value.schemaVersion === SCHEMA_VERSION
191+ && boundedString(value.id, LIMITS.idUtf8Bytes)
192+ && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
193+ && optionalBoundedString(value.displayName, LIMITS.labelUtf8Bytes)
194+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
195+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
196+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
197+ && Number.isInteger(value.sortOrder)
198+ && Number(value.sortOrder) >= 0
199+ && Number(value.sortOrder) < LIMITS.favorites;
200+}
201+
202+export function isDeparture(value: unknown): value is Departure {
203+ if (!object(value) || !exactKeys(value, domainKeys.departure)) return false;
204+ return uint32(value.expectedAt)
205+ && optionalUint32(value.aimedAt)
206+ && Number.isInteger(value.minutes)
207+ && Number(value.minutes) >= -1_440
208+ && Number(value.minutes) <= 1_440
209+ && DEPARTURE_STATUS.includes(value.status as DepartureStatus)
210+ && (value.nextIntervalMinutes === undefined
211+ || (Number.isInteger(value.nextIntervalMinutes) && Number(value.nextIntervalMinutes) >= 0 && Number(value.nextIntervalMinutes) <= 1_440));
212+}
213+
214+export function isDepartureResult(value: unknown): value is DepartureResult {
215+ if (!object(value) || !exactKeys(value, domainKeys.result)) return false;
216+ return value.schemaVersion === SCHEMA_VERSION
217+ && boundedString(value.requestId, LIMITS.idUtf8Bytes)
218+ && boundedString(value.favoriteId, LIMITS.idUtf8Bytes)
219+ && uint32(value.fetchedAt)
220+ && optionalUint32(value.sourceUpdatedAt)
221+ && FRESHNESS.includes(value.freshness as Freshness)
222+ && Array.isArray(value.departures)
223+ && value.departures.length <= LIMITS.departures
224+ && value.departures.every(isDeparture);
225+}
226+
227+export function isErrorResult(value: unknown): value is ErrorResult {
228+ if (!object(value) || !exactKeys(value, domainKeys.error)) return false;
229+ return value.schemaVersion === SCHEMA_VERSION
230+ && boundedString(value.requestId, LIMITS.idUtf8Bytes)
231+ && optionalBoundedString(value.favoriteId, LIMITS.idUtf8Bytes)
232+ && ERROR_CODE.includes(value.code as ErrorCode)
233+ && uint32(value.occurredAt)
234+ && optionalUint32(value.retryAfterSeconds);
235+}
236+
237+export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate {
238+ if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false;
239+ if (value.action === "REPLACE") return boundedString(value.value, LIMITS.apiKeyUtf8Bytes);
240+ return (value.action === "KEEP" || value.action === "REMOVE") && value.value === undefined;
241+}
242+
243+export function isPersonalApiKey(value: unknown): value is string {
244+ return boundedString(value, LIMITS.apiKeyUtf8Bytes) && !/[\r\n]/u.test(value);
245+}
246+
247+export function dictionaryBytes(dataSizes: readonly number[]): number {
248+ if (!dataSizes.every((size) => Number.isInteger(size) && size >= 0)) throw new TypeError("Dictionary data sizes must be non-negative integers");
249+ return 1 + (7 * dataSizes.length) + dataSizes.reduce((total, size) => total + size, 0);
250+}
251+
252+export function cstringBytes(value: string): number {
253+ return utf8Bytes(value) + 1;
254+}
255+
256+function hasNumber(message: AppMessage, key: AppMessageKey): boolean {
257+ return typeof message[key] === "number" && Number.isInteger(message[key]);
258+}
259+
260+function hasString(message: AppMessage, key: AppMessageKey, max = LIMITS.idUtf8Bytes): boolean {
261+ return boundedString(message[key], max);
262+}
263+
264+function onlyMessageKeys(message: AppMessage, allowed: readonly AppMessageKey[]): boolean {
265+ return Object.keys(message).every((key) => allowed.includes(key as AppMessageKey));
266+}
267+
268+export function isWireLanguage(value: unknown): value is WireLanguage {
269+ return value === "en" || value === "fr";
270+}
271+
272+export function isAppMessage(value: unknown): value is AppMessage {
273+ if (!object(value)) return false;
274+ const message = value as AppMessage;
275+ const type = message.MESSAGE_TYPE;
276+ if (message.SCHEMA_VERSION !== SCHEMA_VERSION || !Number.isInteger(type)) return false;
277+ const common = ["SCHEMA_VERSION", "MESSAGE_TYPE"] as const;
278+ switch (type) {
279+ case MESSAGE_TYPE.REQUEST:
280+ return onlyMessageKeys(message, [...common, "REQUEST_ID", "FAVORITE_ID", "REQUEST_TRIGGER"])
281+ && hasString(message, "REQUEST_ID")
282+ && hasString(message, "FAVORITE_ID")
283+ && Object.values(REQUEST_TRIGGER).includes(message.REQUEST_TRIGGER as 0 | 1 | 2);
284+ case MESSAGE_TYPE.CONFIG_BEGIN:
285+ return onlyMessageKeys(message, [...common, "REQUEST_ID", "ITEM_COUNT", "KEY_STATUS", "DISPLAY_NAME"])
286+ && hasString(message, "REQUEST_ID")
287+ && hasNumber(message, "ITEM_COUNT")
288+ && Number(message.ITEM_COUNT) >= 0
289+ && Number(message.ITEM_COUNT) <= LIMITS.favorites
290+ && Object.values(KEY_STATUS).includes(message.KEY_STATUS as 0 | 1 | 2)
291+ && (message.DISPLAY_NAME === undefined || isWireLanguage(message.DISPLAY_NAME));
292+ case MESSAGE_TYPE.FAVORITE:
293+ return onlyMessageKeys(message, [
294+ ...common,
295+ "REQUEST_ID",
296+ "ITEM_INDEX",
297+ "FAVORITE_ID",
298+ "SERVICE_ID",
299+ "DISPLAY_NAME",
300+ "STOP_LABEL",
301+ "LINE_LABEL",
302+ "DESTINATION_LABEL",
303+ "SORT_ORDER",
304+ ])
305+ && hasString(message, "REQUEST_ID")
306+ && hasNumber(message, "ITEM_INDEX")
307+ && Number(message.ITEM_INDEX) >= 0
308+ && Number(message.ITEM_INDEX) < LIMITS.favorites
309+ && hasString(message, "FAVORITE_ID")
310+ && hasString(message, "SERVICE_ID")
311+ && (message.DISPLAY_NAME === undefined || hasString(message, "DISPLAY_NAME", LIMITS.labelUtf8Bytes))
312+ && hasString(message, "STOP_LABEL", LIMITS.labelUtf8Bytes)
313+ && hasString(message, "LINE_LABEL", LIMITS.labelUtf8Bytes)
314+ && hasString(message, "DESTINATION_LABEL", LIMITS.labelUtf8Bytes)
315+ && hasNumber(message, "SORT_ORDER")
316+ && Number(message.SORT_ORDER) >= 0
317+ && Number(message.SORT_ORDER) < LIMITS.favorites;
318+ case MESSAGE_TYPE.CONFIG_COMMIT:
319+ return onlyMessageKeys(message, [...common, "REQUEST_ID"])
320+ && hasString(message, "REQUEST_ID");
321+ case MESSAGE_TYPE.RESULT_BEGIN:
322+ return onlyMessageKeys(message, [
323+ ...common,
324+ "REQUEST_ID",
325+ "FAVORITE_ID",
326+ "ITEM_COUNT",
327+ "FETCHED_AT",
328+ "SOURCE_UPDATED_AT",
329+ "FRESHNESS",
330+ ])
331+ && hasString(message, "REQUEST_ID")
332+ && hasString(message, "FAVORITE_ID")
333+ && hasNumber(message, "ITEM_COUNT")
334+ && Number(message.ITEM_COUNT) >= 0
335+ && Number(message.ITEM_COUNT) <= LIMITS.departures
336+ && uint32(message.FETCHED_AT)
337+ && optionalUint32(message.SOURCE_UPDATED_AT)
338+ && [0, 1, 2, 3].includes(message.FRESHNESS as number);
339+ case MESSAGE_TYPE.DEPARTURE:
340+ return onlyMessageKeys(message, [
341+ ...common,
342+ "REQUEST_ID",
343+ "FAVORITE_ID",
344+ "ITEM_INDEX",
345+ "EXPECTED_AT",
346+ "AIMED_AT",
347+ "MINUTES",
348+ "DEPARTURE_STATUS",
349+ "NEXT_INTERVAL_MINUTES",
350+ ])
351+ && hasString(message, "REQUEST_ID")
352+ && hasString(message, "FAVORITE_ID")
353+ && hasNumber(message, "ITEM_INDEX")
354+ && Number(message.ITEM_INDEX) >= 0
355+ && Number(message.ITEM_INDEX) < LIMITS.departures
356+ && uint32(message.EXPECTED_AT)
357+ && optionalUint32(message.AIMED_AT)
358+ && hasNumber(message, "MINUTES")
359+ && Number(message.MINUTES) >= -1_440
360+ && Number(message.MINUTES) <= 1_440
361+ && [0, 1, 2, 3].includes(message.DEPARTURE_STATUS as number)
362+ && (message.NEXT_INTERVAL_MINUTES === undefined
363+ || (hasNumber(message, "NEXT_INTERVAL_MINUTES")
364+ && Number(message.NEXT_INTERVAL_MINUTES) >= 0
365+ && Number(message.NEXT_INTERVAL_MINUTES) <= 1_440));
366+ case MESSAGE_TYPE.RESULT_COMMIT:
367+ return onlyMessageKeys(message, [...common, "REQUEST_ID", "FAVORITE_ID"])
368+ && hasString(message, "REQUEST_ID")
369+ && hasString(message, "FAVORITE_ID");
370+ case MESSAGE_TYPE.ERROR:
371+ return onlyMessageKeys(message, [
372+ ...common,
373+ "REQUEST_ID",
374+ "FAVORITE_ID",
375+ "ERROR_CODE",
376+ "OCCURRED_AT",
377+ "RETRY_AFTER_SECONDS",
378+ ])
379+ && hasString(message, "REQUEST_ID")
380+ && (message.FAVORITE_ID === undefined || hasString(message, "FAVORITE_ID"))
381+ && [0, 1, 2, 3, 4, 5].includes(message.ERROR_CODE as number)
382+ && uint32(message.OCCURRED_AT)
383+ && optionalUint32(message.RETRY_AFTER_SECONDS);
384+ default:
385+ return false;
386+ }
387+}
388+
389+interface ConfigStaging {
390+ requestId: string;
391+ count: number;
392+ keyStatus: number;
393+ language: WireLanguage;
394+ items: AppMessage[];
395+}
396+
397+interface ResultStaging {
398+ requestId: string;
399+ favoriteId: string;
400+ count: number;
401+ begin: AppMessage;
402+ items: AppMessage[];
403+}
404+
405+export interface CommittedProtocolState {
406+ configuration?: {
407+ keyStatus: number;
408+ language: WireLanguage;
409+ favorites: AppMessage[];
410+ };
411+ result?: {
412+ begin: AppMessage;
413+ departures: AppMessage[];
414+ };
415+ error?: AppMessage;
416+}
417+
418+function copyMessage(message: AppMessage): AppMessage {
419+ return { ...message };
420+}
421+
422+export class ProtocolReceiver {
423+ readonly committed: CommittedProtocolState = {};
424+ #config?: ConfigStaging;
425+ #result?: ResultStaging;
426+ #expected?: { requestId: string; favoriteId: string };
427+
428+ expectResponse(requestId: string, favoriteId: string): boolean {
429+ if (!boundedString(requestId, LIMITS.idUtf8Bytes)
430+ || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
431+ this.#expected = { requestId, favoriteId };
432+ this.#result = undefined;
433+ return true;
434+ }
435+
436+ cancelExpectedResponse(): void {
437+ this.#expected = undefined;
438+ this.#result = undefined;
439+ }
440+
441+ discardStaging(): void {
442+ this.#config = undefined;
443+ this.#result = undefined;
444+ }
445+
446+ snapshot(): CommittedProtocolState {
447+ const snapshot: CommittedProtocolState = {};
448+ if (this.committed.configuration) {
449+ snapshot.configuration = {
450+ keyStatus: this.committed.configuration.keyStatus,
451+ language: this.committed.configuration.language,
452+ favorites: this.committed.configuration.favorites.map(copyMessage),
453+ };
454+ }
455+ if (this.committed.result) {
456+ snapshot.result = {
457+ begin: copyMessage(this.committed.result.begin),
458+ departures: this.committed.result.departures.map(copyMessage),
459+ };
460+ }
461+ if (this.committed.error) snapshot.error = copyMessage(this.committed.error);
462+ return snapshot;
463+ }
464+
465+ receive(value: unknown): boolean {
466+ if (!isAppMessage(value)) {
467+ this.discardStaging();
468+ return false;
469+ }
470+ const message = value;
471+ const type = message.MESSAGE_TYPE;
472+ const requestId = String(message.REQUEST_ID ?? "");
473+ if (type === MESSAGE_TYPE.CONFIG_BEGIN) {
474+ this.#config = {
475+ requestId,
476+ count: Number(message.ITEM_COUNT),
477+ keyStatus: Number(message.KEY_STATUS),
478+ language: isWireLanguage(message.DISPLAY_NAME) ? message.DISPLAY_NAME : "en",
479+ items: [],
480+ };
481+ return true;
482+ }
483+ if (type === MESSAGE_TYPE.FAVORITE) {
484+ const duplicate = this.#config?.items.some((item) => item.FAVORITE_ID === message.FAVORITE_ID) ?? false;
485+ if (!this.#config
486+ || this.#config.requestId !== requestId
487+ || Number(message.ITEM_INDEX) !== this.#config.items.length
488+ || this.#config.items.length >= this.#config.count
489+ || duplicate) {
490+ this.#config = undefined;
491+ return false;
492+ }
493+ this.#config.items.push(copyMessage(message));
494+ return true;
495+ }
496+ if (type === MESSAGE_TYPE.CONFIG_COMMIT) {
497+ if (!this.#config
498+ || this.#config.requestId !== requestId
499+ || this.#config.items.length !== this.#config.count) {
500+ this.#config = undefined;
501+ return false;
502+ }
503+ this.committed.configuration = {
504+ keyStatus: this.#config.keyStatus,
505+ language: this.#config.language,
506+ favorites: this.#config.items.map(copyMessage),
507+ };
508+ this.committed.result = undefined;
509+ this.committed.error = undefined;
510+ this.#config = undefined;
511+ this.cancelExpectedResponse();
512+ return true;
513+ }
514+ if (type === MESSAGE_TYPE.RESULT_BEGIN) {
515+ if (!this.#expected
516+ || this.#expected.requestId !== requestId
517+ || this.#expected.favoriteId !== message.FAVORITE_ID) {
518+ this.#result = undefined;
519+ return false;
520+ }
521+ this.#result = {
522+ requestId,
523+ favoriteId: String(message.FAVORITE_ID),
524+ count: Number(message.ITEM_COUNT),
525+ begin: copyMessage(message),
526+ items: [],
527+ };
528+ return true;
529+ }
530+ if (type === MESSAGE_TYPE.DEPARTURE) {
531+ if (!this.#result
532+ || !this.#expected
533+ || this.#result.requestId !== requestId
534+ || this.#result.favoriteId !== message.FAVORITE_ID
535+ || this.#expected.requestId !== requestId
536+ || this.#expected.favoriteId !== message.FAVORITE_ID
537+ || Number(message.ITEM_INDEX) !== this.#result.items.length
538+ || this.#result.items.length >= this.#result.count) {
539+ this.#result = undefined;
540+ return false;
541+ }
542+ this.#result.items.push(copyMessage(message));
543+ return true;
544+ }
545+ if (type === MESSAGE_TYPE.RESULT_COMMIT) {
546+ if (!this.#result
547+ || !this.#expected
548+ || this.#result.requestId !== requestId
549+ || this.#result.favoriteId !== message.FAVORITE_ID
550+ || this.#expected.requestId !== requestId
551+ || this.#expected.favoriteId !== message.FAVORITE_ID
552+ || this.#result.items.length !== this.#result.count) {
553+ this.#result = undefined;
554+ return false;
555+ }
556+ this.committed.result = {
557+ begin: copyMessage(this.#result.begin),
558+ departures: this.#result.items.map(copyMessage),
559+ };
560+ this.committed.error = undefined;
561+ this.cancelExpectedResponse();
562+ return true;
563+ }
564+ if (type === MESSAGE_TYPE.ERROR) {
565+ if (!this.#expected
566+ || this.#expected.requestId !== requestId
567+ || (message.FAVORITE_ID !== undefined
568+ && this.#expected.favoriteId !== message.FAVORITE_ID)) {
569+ this.#result = undefined;
570+ return false;
571+ }
572+ this.committed.error = copyMessage(message);
573+ this.cancelExpectedResponse();
574+ return true;
575+ }
576+ this.discardStaging();
577+ return false;
578+ }
579+}
new file mode 100644
@@ -0,0 +1,579 @@
1+export const SCHEMA_VERSION = 1 as const;
2+
3+export const LIMITS = {
4+ apiKeyUtf8Bytes: 512,
5+ idUtf8Bytes: 64,
6+ labelUtf8Bytes: 96,
7+ favorites: 8,
8+ departures: 4,
9+ httpTimeoutMs: 8_000,
10+ httpResponseBytes: 262_144,
11+} as const;
12+
13+export const PRIM_ORIGIN = "https://prim.iledefrance-mobilites.fr" as const;
14+export const CACHE_FRESH_SECONDS = 60 as const;
15+export const FAVORITE_SETTLE_MS = 500 as const;
16+
17+export const FRESHNESS = ["REALTIME", "SCHEDULED", "MIXED", "STALE"] as const;
18+export const DEPARTURE_STATUS = ["ON_TIME", "DELAYED", "CANCELLED", "UNKNOWN"] as const;
19+export const ERROR_CODE = [
20+ "API_KEY_REQUIRED",
21+ "API_KEY_INVALID",
22+ "INVALID_SERVICE",
23+ "SOURCE_UNAVAILABLE",
24+ "RATE_LIMITED",
25+ "INVALID_RESPONSE",
26+] as const;
27+export const API_KEY_ACTION = ["KEEP", "REPLACE", "REMOVE"] as const;
28+export const KEY_STATUS = { MISSING: 0, CONFIGURED: 1, INVALID: 2 } as const;
29+export const REQUEST_TRIGGER = { APP_OPEN: 0, FAVORITE_SELECTION: 1, MANUAL_SELECT: 2 } as const;
30+export const MESSAGE_TYPE = {
31+ REQUEST: 1,
32+ CONFIG_BEGIN: 2,
33+ FAVORITE: 3,
34+ CONFIG_COMMIT: 4,
35+ RESULT_BEGIN: 5,
36+ DEPARTURE: 6,
37+ RESULT_COMMIT: 7,
38+ ERROR: 8,
39+} as const;
40+export const APP_MESSAGE_KEY_ORDER = [
41+ "SCHEMA_VERSION",
42+ "MESSAGE_TYPE",
43+ "REQUEST_ID",
44+ "FAVORITE_ID",
45+ "SERVICE_ID",
46+ "DISPLAY_NAME",
47+ "STOP_LABEL",
48+ "LINE_LABEL",
49+ "DESTINATION_LABEL",
50+ "SORT_ORDER",
51+ "KEY_STATUS",
52+ "ITEM_COUNT",
53+ "ITEM_INDEX",
54+ "FETCHED_AT",
55+ "SOURCE_UPDATED_AT",
56+ "FRESHNESS",
57+ "EXPECTED_AT",
58+ "AIMED_AT",
59+ "MINUTES",
60+ "DEPARTURE_STATUS",
61+ "NEXT_INTERVAL_MINUTES",
62+ "ERROR_CODE",
63+ "OCCURRED_AT",
64+ "RETRY_AFTER_SECONDS",
65+ "REQUEST_TRIGGER",
66+] as const;
67+
68+export const APP_MESSAGE_KEY = {
69+ SCHEMA_VERSION: 0,
70+ MESSAGE_TYPE: 1,
71+ REQUEST_ID: 2,
72+ FAVORITE_ID: 3,
73+ SERVICE_ID: 4,
74+ DISPLAY_NAME: 5,
75+ STOP_LABEL: 6,
76+ LINE_LABEL: 7,
77+ DESTINATION_LABEL: 8,
78+ SORT_ORDER: 9,
79+ KEY_STATUS: 10,
80+ ITEM_COUNT: 11,
81+ ITEM_INDEX: 12,
82+ FETCHED_AT: 13,
83+ SOURCE_UPDATED_AT: 14,
84+ FRESHNESS: 15,
85+ EXPECTED_AT: 16,
86+ AIMED_AT: 17,
87+ MINUTES: 18,
88+ DEPARTURE_STATUS: 19,
89+ NEXT_INTERVAL_MINUTES: 20,
90+ ERROR_CODE: 21,
91+ OCCURRED_AT: 22,
92+ RETRY_AFTER_SECONDS: 23,
93+ REQUEST_TRIGGER: 24,
94+} as const;
95+export const WIRE_LANGUAGE = { EN: "en", FR: "fr" } as const;
96+
97+export type Freshness = (typeof FRESHNESS)[number];
98+export type DepartureStatus = (typeof DEPARTURE_STATUS)[number];
99+export type ErrorCode = (typeof ERROR_CODE)[number];
100+export type ApiKeyAction = (typeof API_KEY_ACTION)[number];
101+export type AppMessageKey = (typeof APP_MESSAGE_KEY_ORDER)[number];
102+export type WireLanguage = (typeof WIRE_LANGUAGE)[keyof typeof WIRE_LANGUAGE];
103+
104+export interface Favorite {
105+ schemaVersion: typeof SCHEMA_VERSION;
106+ id: string;
107+ serviceId: string;
108+ displayName?: string;
109+ stopLabel: string;
110+ lineLabel: string;
111+ destinationLabel: string;
112+ sortOrder: number;
113+}
114+
115+export interface Departure {
116+ expectedAt: number;
117+ aimedAt?: number;
118+ minutes: number;
119+ status: DepartureStatus;
120+ nextIntervalMinutes?: number;
121+}
122+
123+export interface DepartureResult {
124+ schemaVersion: typeof SCHEMA_VERSION;
125+ requestId: string;
126+ favoriteId: string;
127+ fetchedAt: number;
128+ sourceUpdatedAt?: number;
129+ freshness: Freshness;
130+ departures: Departure[];
131+}
132+
133+export interface ErrorResult {
134+ schemaVersion: typeof SCHEMA_VERSION;
135+ requestId: string;
136+ favoriteId?: string;
137+ code: ErrorCode;
138+ occurredAt: number;
139+ retryAfterSeconds?: number;
140+}
141+
142+export type ApiKeyUpdate =
143+ | { schemaVersion: typeof SCHEMA_VERSION; action: "KEEP" }
144+ | { schemaVersion: typeof SCHEMA_VERSION; action: "REMOVE" }
145+ | { schemaVersion: typeof SCHEMA_VERSION; action: "REPLACE"; value: string };
146+
147+export type AppMessageValue = number | string;
148+export type AppMessage = Partial<Record<AppMessageKey, AppMessageValue>>;
149+
150+const encoder = new TextEncoder();
151+const UINT32_MAX = 0xffff_ffff;
152+const domainKeys = {
153+ favorite: ["schemaVersion", "id", "serviceId", "displayName", "stopLabel", "lineLabel", "destinationLabel", "sortOrder"],
154+ departure: ["expectedAt", "aimedAt", "minutes", "status", "nextIntervalMinutes"],
155+ result: ["schemaVersion", "requestId", "favoriteId", "fetchedAt", "sourceUpdatedAt", "freshness", "departures"],
156+ error: ["schemaVersion", "requestId", "favoriteId", "code", "occurredAt", "retryAfterSeconds"],
157+ keyUpdate: ["schemaVersion", "action", "value"],
158+} as const;
159+
160+function object(value: unknown): value is Record<string, unknown> {
161+ return typeof value === "object" && value !== null && !Array.isArray(value);
162+}
163+
164+function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
165+ return Object.keys(value).every((key) => allowed.includes(key));
166+}
167+
168+export function utf8Bytes(value: string): number {
169+ return encoder.encode(value).byteLength;
170+}
171+
172+function boundedString(value: unknown, maximum: number): value is string {
173+ return typeof value === "string" && utf8Bytes(value) >= 1 && utf8Bytes(value) <= maximum;
174+}
175+
176+function uint32(value: unknown): value is number {
177+ return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= UINT32_MAX;
178+}
179+
180+function optionalUint32(value: unknown): boolean {
181+ return value === undefined || uint32(value);
182+}
183+
184+function optionalBoundedString(value: unknown, maximum: number): boolean {
185+ return value === undefined || boundedString(value, maximum);
186+}
187+
188+export function isFavorite(value: unknown): value is Favorite {
189+ if (!object(value) || !exactKeys(value, domainKeys.favorite)) return false;
190+ return value.schemaVersion === SCHEMA_VERSION
191+ && boundedString(value.id, LIMITS.idUtf8Bytes)
192+ && boundedString(value.serviceId, LIMITS.idUtf8Bytes)
193+ && optionalBoundedString(value.displayName, LIMITS.labelUtf8Bytes)
194+ && boundedString(value.stopLabel, LIMITS.labelUtf8Bytes)
195+ && boundedString(value.lineLabel, LIMITS.labelUtf8Bytes)
196+ && boundedString(value.destinationLabel, LIMITS.labelUtf8Bytes)
197+ && Number.isInteger(value.sortOrder)
198+ && Number(value.sortOrder) >= 0
199+ && Number(value.sortOrder) < LIMITS.favorites;
200+}
201+
202+export function isDeparture(value: unknown): value is Departure {
203+ if (!object(value) || !exactKeys(value, domainKeys.departure)) return false;
204+ return uint32(value.expectedAt)
205+ && optionalUint32(value.aimedAt)
206+ && Number.isInteger(value.minutes)
207+ && Number(value.minutes) >= -1_440
208+ && Number(value.minutes) <= 1_440
209+ && DEPARTURE_STATUS.includes(value.status as DepartureStatus)
210+ && (value.nextIntervalMinutes === undefined
211+ || (Number.isInteger(value.nextIntervalMinutes) && Number(value.nextIntervalMinutes) >= 0 && Number(value.nextIntervalMinutes) <= 1_440));
212+}
213+
214+export function isDepartureResult(value: unknown): value is DepartureResult {
215+ if (!object(value) || !exactKeys(value, domainKeys.result)) return false;
216+ return value.schemaVersion === SCHEMA_VERSION
217+ && boundedString(value.requestId, LIMITS.idUtf8Bytes)
218+ && boundedString(value.favoriteId, LIMITS.idUtf8Bytes)
219+ && uint32(value.fetchedAt)
220+ && optionalUint32(value.sourceUpdatedAt)
221+ && FRESHNESS.includes(value.freshness as Freshness)
222+ && Array.isArray(value.departures)
223+ && value.departures.length <= LIMITS.departures
224+ && value.departures.every(isDeparture);
225+}
226+
227+export function isErrorResult(value: unknown): value is ErrorResult {
228+ if (!object(value) || !exactKeys(value, domainKeys.error)) return false;
229+ return value.schemaVersion === SCHEMA_VERSION
230+ && boundedString(value.requestId, LIMITS.idUtf8Bytes)
231+ && optionalBoundedString(value.favoriteId, LIMITS.idUtf8Bytes)
232+ && ERROR_CODE.includes(value.code as ErrorCode)
233+ && uint32(value.occurredAt)
234+ && optionalUint32(value.retryAfterSeconds);
235+}
236+
237+export function isApiKeyUpdate(value: unknown): value is ApiKeyUpdate {
238+ if (!object(value) || !exactKeys(value, domainKeys.keyUpdate) || value.schemaVersion !== SCHEMA_VERSION) return false;
239+ if (value.action === "REPLACE") return boundedString(value.value, LIMITS.apiKeyUtf8Bytes);
240+ return (value.action === "KEEP" || value.action === "REMOVE") && value.value === undefined;
241+}
242+
243+export function isPersonalApiKey(value: unknown): value is string {
244+ return boundedString(value, LIMITS.apiKeyUtf8Bytes) && !/[\r\n]/u.test(value);
245+}
246+
247+export function dictionaryBytes(dataSizes: readonly number[]): number {
248+ if (!dataSizes.every((size) => Number.isInteger(size) && size >= 0)) throw new TypeError("Dictionary data sizes must be non-negative integers");
249+ return 1 + (7 * dataSizes.length) + dataSizes.reduce((total, size) => total + size, 0);
250+}
251+
252+export function cstringBytes(value: string): number {
253+ return utf8Bytes(value) + 1;
254+}
255+
256+function hasNumber(message: AppMessage, key: AppMessageKey): boolean {
257+ return typeof message[key] === "number" && Number.isInteger(message[key]);
258+}
259+
260+function hasString(message: AppMessage, key: AppMessageKey, max = LIMITS.idUtf8Bytes): boolean {
261+ return boundedString(message[key], max);
262+}
263+
264+function onlyMessageKeys(message: AppMessage, allowed: readonly AppMessageKey[]): boolean {
265+ return Object.keys(message).every((key) => allowed.includes(key as AppMessageKey));
266+}
267+
268+export function isWireLanguage(value: unknown): value is WireLanguage {
269+ return value === "en" || value === "fr";
270+}
271+
272+export function isAppMessage(value: unknown): value is AppMessage {
273+ if (!object(value)) return false;
274+ const message = value as AppMessage;
275+ const type = message.MESSAGE_TYPE;
276+ if (message.SCHEMA_VERSION !== SCHEMA_VERSION || !Number.isInteger(type)) return false;
277+ const common = ["SCHEMA_VERSION", "MESSAGE_TYPE"] as const;
278+ switch (type) {
279+ case MESSAGE_TYPE.REQUEST:
280+ return onlyMessageKeys(message, [...common, "REQUEST_ID", "FAVORITE_ID", "REQUEST_TRIGGER"])
281+ && hasString(message, "REQUEST_ID")
282+ && hasString(message, "FAVORITE_ID")
283+ && Object.values(REQUEST_TRIGGER).includes(message.REQUEST_TRIGGER as 0 | 1 | 2);
284+ case MESSAGE_TYPE.CONFIG_BEGIN:
285+ return onlyMessageKeys(message, [...common, "REQUEST_ID", "ITEM_COUNT", "KEY_STATUS", "DISPLAY_NAME"])
286+ && hasString(message, "REQUEST_ID")
287+ && hasNumber(message, "ITEM_COUNT")
288+ && Number(message.ITEM_COUNT) >= 0
289+ && Number(message.ITEM_COUNT) <= LIMITS.favorites
290+ && Object.values(KEY_STATUS).includes(message.KEY_STATUS as 0 | 1 | 2)
291+ && (message.DISPLAY_NAME === undefined || isWireLanguage(message.DISPLAY_NAME));
292+ case MESSAGE_TYPE.FAVORITE:
293+ return onlyMessageKeys(message, [
294+ ...common,
295+ "REQUEST_ID",
296+ "ITEM_INDEX",
297+ "FAVORITE_ID",
298+ "SERVICE_ID",
299+ "DISPLAY_NAME",
300+ "STOP_LABEL",
301+ "LINE_LABEL",
302+ "DESTINATION_LABEL",
303+ "SORT_ORDER",
304+ ])
305+ && hasString(message, "REQUEST_ID")
306+ && hasNumber(message, "ITEM_INDEX")
307+ && Number(message.ITEM_INDEX) >= 0
308+ && Number(message.ITEM_INDEX) < LIMITS.favorites
309+ && hasString(message, "FAVORITE_ID")
310+ && hasString(message, "SERVICE_ID")
311+ && (message.DISPLAY_NAME === undefined || hasString(message, "DISPLAY_NAME", LIMITS.labelUtf8Bytes))
312+ && hasString(message, "STOP_LABEL", LIMITS.labelUtf8Bytes)
313+ && hasString(message, "LINE_LABEL", LIMITS.labelUtf8Bytes)
314+ && hasString(message, "DESTINATION_LABEL", LIMITS.labelUtf8Bytes)
315+ && hasNumber(message, "SORT_ORDER")
316+ && Number(message.SORT_ORDER) >= 0
317+ && Number(message.SORT_ORDER) < LIMITS.favorites;
318+ case MESSAGE_TYPE.CONFIG_COMMIT:
319+ return onlyMessageKeys(message, [...common, "REQUEST_ID"])
320+ && hasString(message, "REQUEST_ID");
321+ case MESSAGE_TYPE.RESULT_BEGIN:
322+ return onlyMessageKeys(message, [
323+ ...common,
324+ "REQUEST_ID",
325+ "FAVORITE_ID",
326+ "ITEM_COUNT",
327+ "FETCHED_AT",
328+ "SOURCE_UPDATED_AT",
329+ "FRESHNESS",
330+ ])
331+ && hasString(message, "REQUEST_ID")
332+ && hasString(message, "FAVORITE_ID")
333+ && hasNumber(message, "ITEM_COUNT")
334+ && Number(message.ITEM_COUNT) >= 0
335+ && Number(message.ITEM_COUNT) <= LIMITS.departures
336+ && uint32(message.FETCHED_AT)
337+ && optionalUint32(message.SOURCE_UPDATED_AT)
338+ && [0, 1, 2, 3].includes(message.FRESHNESS as number);
339+ case MESSAGE_TYPE.DEPARTURE:
340+ return onlyMessageKeys(message, [
341+ ...common,
342+ "REQUEST_ID",
343+ "FAVORITE_ID",
344+ "ITEM_INDEX",
345+ "EXPECTED_AT",
346+ "AIMED_AT",
347+ "MINUTES",
348+ "DEPARTURE_STATUS",
349+ "NEXT_INTERVAL_MINUTES",
350+ ])
351+ && hasString(message, "REQUEST_ID")
352+ && hasString(message, "FAVORITE_ID")
353+ && hasNumber(message, "ITEM_INDEX")
354+ && Number(message.ITEM_INDEX) >= 0
355+ && Number(message.ITEM_INDEX) < LIMITS.departures
356+ && uint32(message.EXPECTED_AT)
357+ && optionalUint32(message.AIMED_AT)
358+ && hasNumber(message, "MINUTES")
359+ && Number(message.MINUTES) >= -1_440
360+ && Number(message.MINUTES) <= 1_440
361+ && [0, 1, 2, 3].includes(message.DEPARTURE_STATUS as number)
362+ && (message.NEXT_INTERVAL_MINUTES === undefined
363+ || (hasNumber(message, "NEXT_INTERVAL_MINUTES")
364+ && Number(message.NEXT_INTERVAL_MINUTES) >= 0
365+ && Number(message.NEXT_INTERVAL_MINUTES) <= 1_440));
366+ case MESSAGE_TYPE.RESULT_COMMIT:
367+ return onlyMessageKeys(message, [...common, "REQUEST_ID", "FAVORITE_ID"])
368+ && hasString(message, "REQUEST_ID")
369+ && hasString(message, "FAVORITE_ID");
370+ case MESSAGE_TYPE.ERROR:
371+ return onlyMessageKeys(message, [
372+ ...common,
373+ "REQUEST_ID",
374+ "FAVORITE_ID",
375+ "ERROR_CODE",
376+ "OCCURRED_AT",
377+ "RETRY_AFTER_SECONDS",
378+ ])
379+ && hasString(message, "REQUEST_ID")
380+ && (message.FAVORITE_ID === undefined || hasString(message, "FAVORITE_ID"))
381+ && [0, 1, 2, 3, 4, 5].includes(message.ERROR_CODE as number)
382+ && uint32(message.OCCURRED_AT)
383+ && optionalUint32(message.RETRY_AFTER_SECONDS);
384+ default:
385+ return false;
386+ }
387+}
388+
389+interface ConfigStaging {
390+ requestId: string;
391+ count: number;
392+ keyStatus: number;
393+ language: WireLanguage;
394+ items: AppMessage[];
395+}
396+
397+interface ResultStaging {
398+ requestId: string;
399+ favoriteId: string;
400+ count: number;
401+ begin: AppMessage;
402+ items: AppMessage[];
403+}
404+
405+export interface CommittedProtocolState {
406+ configuration?: {
407+ keyStatus: number;
408+ language: WireLanguage;
409+ favorites: AppMessage[];
410+ };
411+ result?: {
412+ begin: AppMessage;
413+ departures: AppMessage[];
414+ };
415+ error?: AppMessage;
416+}
417+
418+function copyMessage(message: AppMessage): AppMessage {
419+ return { ...message };
420+}
421+
422+export class ProtocolReceiver {
423+ readonly committed: CommittedProtocolState = {};
424+ #config?: ConfigStaging;
425+ #result?: ResultStaging;
426+ #expected?: { requestId: string; favoriteId: string };
427+
428+ expectResponse(requestId: string, favoriteId: string): boolean {
429+ if (!boundedString(requestId, LIMITS.idUtf8Bytes)
430+ || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
431+ this.#expected = { requestId, favoriteId };
432+ this.#result = undefined;
433+ return true;
434+ }
435+
436+ cancelExpectedResponse(): void {
437+ this.#expected = undefined;
438+ this.#result = undefined;
439+ }
440+
441+ discardStaging(): void {
442+ this.#config = undefined;
443+ this.#result = undefined;
444+ }
445+
446+ snapshot(): CommittedProtocolState {
447+ const snapshot: CommittedProtocolState = {};
448+ if (this.committed.configuration) {
449+ snapshot.configuration = {
450+ keyStatus: this.committed.configuration.keyStatus,
451+ language: this.committed.configuration.language,
452+ favorites: this.committed.configuration.favorites.map(copyMessage),
453+ };
454+ }
455+ if (this.committed.result) {
456+ snapshot.result = {
457+ begin: copyMessage(this.committed.result.begin),
458+ departures: this.committed.result.departures.map(copyMessage),
459+ };
460+ }
461+ if (this.committed.error) snapshot.error = copyMessage(this.committed.error);
462+ return snapshot;
463+ }
464+
465+ receive(value: unknown): boolean {
466+ if (!isAppMessage(value)) {
467+ this.discardStaging();
468+ return false;
469+ }
470+ const message = value;
471+ const type = message.MESSAGE_TYPE;
472+ const requestId = String(message.REQUEST_ID ?? "");
473+ if (type === MESSAGE_TYPE.CONFIG_BEGIN) {
474+ this.#config = {
475+ requestId,
476+ count: Number(message.ITEM_COUNT),
477+ keyStatus: Number(message.KEY_STATUS),
478+ language: isWireLanguage(message.DISPLAY_NAME) ? message.DISPLAY_NAME : "en",
479+ items: [],
480+ };
481+ return true;
482+ }
483+ if (type === MESSAGE_TYPE.FAVORITE) {
484+ const duplicate = this.#config?.items.some((item) => item.FAVORITE_ID === message.FAVORITE_ID) ?? false;
485+ if (!this.#config
486+ || this.#config.requestId !== requestId
487+ || Number(message.ITEM_INDEX) !== this.#config.items.length
488+ || this.#config.items.length >= this.#config.count
489+ || duplicate) {
490+ this.#config = undefined;
491+ return false;
492+ }
493+ this.#config.items.push(copyMessage(message));
494+ return true;
495+ }
496+ if (type === MESSAGE_TYPE.CONFIG_COMMIT) {
497+ if (!this.#config
498+ || this.#config.requestId !== requestId
499+ || this.#config.items.length !== this.#config.count) {
500+ this.#config = undefined;
501+ return false;
502+ }
503+ this.committed.configuration = {
504+ keyStatus: this.#config.keyStatus,
505+ language: this.#config.language,
506+ favorites: this.#config.items.map(copyMessage),
507+ };
508+ this.committed.result = undefined;
509+ this.committed.error = undefined;
510+ this.#config = undefined;
511+ this.cancelExpectedResponse();
512+ return true;
513+ }
514+ if (type === MESSAGE_TYPE.RESULT_BEGIN) {
515+ if (!this.#expected
516+ || this.#expected.requestId !== requestId
517+ || this.#expected.favoriteId !== message.FAVORITE_ID) {
518+ this.#result = undefined;
519+ return false;
520+ }
521+ this.#result = {
522+ requestId,
523+ favoriteId: String(message.FAVORITE_ID),
524+ count: Number(message.ITEM_COUNT),
525+ begin: copyMessage(message),
526+ items: [],
527+ };
528+ return true;
529+ }
530+ if (type === MESSAGE_TYPE.DEPARTURE) {
531+ if (!this.#result
532+ || !this.#expected
533+ || this.#result.requestId !== requestId
534+ || this.#result.favoriteId !== message.FAVORITE_ID
535+ || this.#expected.requestId !== requestId
536+ || this.#expected.favoriteId !== message.FAVORITE_ID
537+ || Number(message.ITEM_INDEX) !== this.#result.items.length
538+ || this.#result.items.length >= this.#result.count) {
539+ this.#result = undefined;
540+ return false;
541+ }
542+ this.#result.items.push(copyMessage(message));
543+ return true;
544+ }
545+ if (type === MESSAGE_TYPE.RESULT_COMMIT) {
546+ if (!this.#result
547+ || !this.#expected
548+ || this.#result.requestId !== requestId
549+ || this.#result.favoriteId !== message.FAVORITE_ID
550+ || this.#expected.requestId !== requestId
551+ || this.#expected.favoriteId !== message.FAVORITE_ID
552+ || this.#result.items.length !== this.#result.count) {
553+ this.#result = undefined;
554+ return false;
555+ }
556+ this.committed.result = {
557+ begin: copyMessage(this.#result.begin),
558+ departures: this.#result.items.map(copyMessage),
559+ };
560+ this.committed.error = undefined;
561+ this.cancelExpectedResponse();
562+ return true;
563+ }
564+ if (type === MESSAGE_TYPE.ERROR) {
565+ if (!this.#expected
566+ || this.#expected.requestId !== requestId
567+ || (message.FAVORITE_ID !== undefined
568+ && this.#expected.favoriteId !== message.FAVORITE_ID)) {
569+ this.#result = undefined;
570+ return false;
571+ }
572+ this.committed.error = copyMessage(message);
573+ this.cancelExpectedResponse();
574+ return true;
575+ }
576+ this.discardStaging();
577+ return false;
578+ }
579+}
added packages/contracts/test/contracts.test.ts +221 -0
new file mode 100644
@@ -0,0 +1,221 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ API_KEY_ACTION,
5+ APP_MESSAGE_KEY,
6+ APP_MESSAGE_KEY_ORDER,
7+ DEPARTURE_STATUS,
8+ ERROR_CODE,
9+ FRESHNESS,
10+ KEY_STATUS,
11+ LIMITS,
12+ MESSAGE_TYPE,
13+ ProtocolReceiver,
14+ REQUEST_TRIGGER,
15+ SCHEMA_VERSION,
16+ cstringBytes,
17+ dictionaryBytes,
18+ isApiKeyUpdate,
19+ isAppMessage,
20+ isDepartureResult,
21+ isErrorResult,
22+ isFavorite,
23+ isPersonalApiKey,
24+ utf8Bytes,
25+ type AppMessage,
26+} from "../src/index.ts";
27+
28+const favorite = {
29+ schemaVersion: SCHEMA_VERSION,
30+ id: "home",
31+ serviceId: "opaque:service:1",
32+ displayName: "Maison",
33+ stopLabel: "Châtelet",
34+ lineLabel: "Métro 1",
35+ destinationLabel: "La Défense",
36+ sortOrder: 0,
37+};
38+
39+const result = {
40+ schemaVersion: SCHEMA_VERSION,
41+ requestId: "request-1",
42+ favoriteId: "home",
43+ fetchedAt: 1_788_000_000,
44+ sourceUpdatedAt: 1_787_999_990,
45+ freshness: "REALTIME",
46+ departures: [{
47+ expectedAt: 1_788_000_120,
48+ aimedAt: 1_788_000_100,
49+ minutes: 2,
50+ status: "DELAYED",
51+ nextIntervalMinutes: 4,
52+ }],
53+};
54+
55+function message(type: number, values: AppMessage = {}): AppMessage {
56+ return { SCHEMA_VERSION, MESSAGE_TYPE: type, ...values };
57+}
58+
59+test("domain contracts expose every frozen enum and accept every field", () => {
60+ assert.deepEqual(FRESHNESS, ["REALTIME", "SCHEDULED", "MIXED", "STALE"]);
61+ assert.deepEqual(DEPARTURE_STATUS, ["ON_TIME", "DELAYED", "CANCELLED", "UNKNOWN"]);
62+ assert.deepEqual(ERROR_CODE, [
63+ "API_KEY_REQUIRED",
64+ "API_KEY_INVALID",
65+ "INVALID_SERVICE",
66+ "SOURCE_UNAVAILABLE",
67+ "RATE_LIMITED",
68+ "INVALID_RESPONSE",
69+ ]);
70+ assert.deepEqual(API_KEY_ACTION, ["KEEP", "REPLACE", "REMOVE"]);
71+ assert.equal(isFavorite(favorite), true);
72+ assert.equal(isDepartureResult(result), true);
73+ assert.equal(isErrorResult({
74+ schemaVersion: 1,
75+ requestId: "r",
76+ favoriteId: "home",
77+ code: "API_KEY_INVALID",
78+ occurredAt: 1,
79+ retryAfterSeconds: 60,
80+ }), true);
81+});
82+
83+test("domain validators enforce exact fields, versions, bounds, and secret rules", () => {
84+ assert.equal(isFavorite({ ...favorite, unknown: true }), false);
85+ assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);
86+ assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);
87+ assert.equal(utf8Bytes("é".repeat(48)), LIMITS.labelUtf8Bytes);
88+ assert.equal(isDepartureResult({
89+ ...result,
90+ departures: Array.from({ length: LIMITS.departures + 1 }, () => result.departures[0]),
91+ }), false);
92+ assert.equal(isDepartureResult({ ...result, freshness: "FRESH" }), false);
93+ assert.equal(isErrorResult({
94+ schemaVersion: 1,
95+ requestId: "r",
96+ code: "SECRET",
97+ occurredAt: 1,
98+ }), false);
99+ assert.equal(isApiKeyUpdate({ schemaVersion: 1, action: "KEEP" }), true);
100+ assert.equal(isApiKeyUpdate({ schemaVersion: 1, action: "REMOVE" }), true);
101+ assert.equal(isApiKeyUpdate({
102+ schemaVersion: 1,
103+ action: "REPLACE",
104+ value: "personal-key",
105+ }), true);
106+ assert.equal(isApiKeyUpdate({ schemaVersion: 1, action: "KEEP", value: "secret" }), false);
107+ assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true);
108+ assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false);
109+ assert.equal(isPersonalApiKey("key\nheader"), false);
110+});
111+
112+test("symbolic payload aliases retain the frozen explicit numeric order", () => {
113+ assert.equal(APP_MESSAGE_KEY_ORDER.length, 25);
114+ APP_MESSAGE_KEY_ORDER.forEach((name, index) => {
115+ assert.equal(APP_MESSAGE_KEY[name], index);
116+ });
117+ assert.deepEqual(
118+ Object.values(APP_MESSAGE_KEY).sort((left, right) => left - right),
119+ Array.from({ length: 25 }, (_, index) => index),
120+ );
121+ assert.deepEqual(KEY_STATUS, { MISSING: 0, CONFIGURED: 1, INVALID: 2 });
122+ assert.deepEqual(REQUEST_TRIGGER, {
123+ APP_OPEN: 0,
124+ FAVORITE_SELECTION: 1,
125+ MANUAL_SELECT: 2,
126+ });
127+ assert.equal(Object.keys(APP_MESSAGE_KEY).some((name) => /API_KEY|SECRET|TOKEN/u.test(name)), false);
128+});
129+
130+test("dictionary sizing follows Pebble's measured-data formula", () => {
131+ assert.equal(dictionaryBytes([]), 1);
132+ assert.equal(dictionaryBytes([1, 4, cstringBytes("é")]), 1 + (7 * 3) + 1 + 4 + 3);
133+ assert.throws(() => dictionaryBytes([-1]), TypeError);
134+});
135+
136+test("AppMessage validation accepts aliases only and contextualizes DISPLAY_NAME", () => {
137+ const request = message(MESSAGE_TYPE.REQUEST, {
138+ REQUEST_ID: "request-1",
139+ FAVORITE_ID: "home",
140+ REQUEST_TRIGGER: REQUEST_TRIGGER.MANUAL_SELECT,
141+ });
142+ assert.equal(isAppMessage(request), true);
143+ assert.equal(isAppMessage({
144+ ...request,
145+ 0: SCHEMA_VERSION,
146+ }), false);
147+ assert.equal(isAppMessage(message(MESSAGE_TYPE.CONFIG_BEGIN, {
148+ REQUEST_ID: "config-1",
149+ ITEM_COUNT: 0,
150+ KEY_STATUS: KEY_STATUS.CONFIGURED,
151+ DISPLAY_NAME: "fr",
152+ })), true);
153+ assert.equal(isAppMessage(message(MESSAGE_TYPE.CONFIG_BEGIN, {
154+ REQUEST_ID: "config-1",
155+ ITEM_COUNT: 0,
156+ KEY_STATUS: KEY_STATUS.CONFIGURED,
157+ DISPLAY_NAME: "fr_FR",
158+ })), false);
159+});
160+
161+test("configuration commits language and favorites atomically", () => {
162+ const receiver = new ProtocolReceiver();
163+ assert.equal(receiver.receive(message(MESSAGE_TYPE.CONFIG_BEGIN, {
164+ REQUEST_ID: "config-1",
165+ ITEM_COUNT: 1,
166+ KEY_STATUS: KEY_STATUS.CONFIGURED,
167+ DISPLAY_NAME: "fr",
168+ })), true);
169+ assert.equal(receiver.receive(message(MESSAGE_TYPE.FAVORITE, {
170+ REQUEST_ID: "config-1",
171+ ITEM_INDEX: 0,
172+ FAVORITE_ID: "home",
173+ SERVICE_ID: "opaque:1",
174+ DISPLAY_NAME: "Maison",
175+ STOP_LABEL: "Châtelet",
176+ LINE_LABEL: "1",
177+ DESTINATION_LABEL: "La Défense",
178+ SORT_ORDER: 0,
179+ })), true);
180+ assert.equal(receiver.committed.configuration, undefined);
181+ assert.equal(receiver.receive(message(MESSAGE_TYPE.CONFIG_COMMIT, {
182+ REQUEST_ID: "config-1",
183+ })), true);
184+ assert.equal(receiver.committed.configuration?.language, "fr");
185+ assert.equal(receiver.committed.configuration?.favorites.length, 1);
186+});
187+
188+test("result staging requires request and favorite binding and preserves committed state", () => {
189+ const receiver = new ProtocolReceiver();
190+ const begin = message(MESSAGE_TYPE.RESULT_BEGIN, {
191+ REQUEST_ID: "request-1",
192+ FAVORITE_ID: "home",
193+ ITEM_COUNT: 1,
194+ FETCHED_AT: 1_788_000_000,
195+ FRESHNESS: 0,
196+ });
197+ const item = message(MESSAGE_TYPE.DEPARTURE, {
198+ REQUEST_ID: "request-1",
199+ FAVORITE_ID: "home",
200+ ITEM_INDEX: 0,
201+ EXPECTED_AT: 1_788_000_120,
202+ MINUTES: 2,
203+ DEPARTURE_STATUS: 0,
204+ });
205+ const commit = message(MESSAGE_TYPE.RESULT_COMMIT, {
206+ REQUEST_ID: "request-1",
207+ FAVORITE_ID: "home",
208+ });
209+
210+ assert.equal(receiver.receive(begin), false);
211+ assert.equal(receiver.expectResponse("request-1", "home"), true);
212+ assert.equal(receiver.receive(begin), true);
213+ assert.equal(receiver.receive(item), true);
214+ assert.equal(receiver.receive(commit), true);
215+ const committed = receiver.committed.result;
216+
217+ assert.equal(receiver.expectResponse("request-2", "home"), true);
218+ assert.equal(receiver.receive({ ...begin, REQUEST_ID: "late-request" }), false);
219+ assert.equal(receiver.receive({ ...begin, SCHEMA_VERSION: 99 }), false);
220+ assert.equal(receiver.committed.result, committed);
221+});
new file mode 100644
@@ -0,0 +1,221 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ API_KEY_ACTION,
5+ APP_MESSAGE_KEY,
6+ APP_MESSAGE_KEY_ORDER,
7+ DEPARTURE_STATUS,
8+ ERROR_CODE,
9+ FRESHNESS,
10+ KEY_STATUS,
11+ LIMITS,
12+ MESSAGE_TYPE,
13+ ProtocolReceiver,
14+ REQUEST_TRIGGER,
15+ SCHEMA_VERSION,
16+ cstringBytes,
17+ dictionaryBytes,
18+ isApiKeyUpdate,
19+ isAppMessage,
20+ isDepartureResult,
21+ isErrorResult,
22+ isFavorite,
23+ isPersonalApiKey,
24+ utf8Bytes,
25+ type AppMessage,
26+} from "../src/index.ts";
27+
28+const favorite = {
29+ schemaVersion: SCHEMA_VERSION,
30+ id: "home",
31+ serviceId: "opaque:service:1",
32+ displayName: "Maison",
33+ stopLabel: "Châtelet",
34+ lineLabel: "Métro 1",
35+ destinationLabel: "La Défense",
36+ sortOrder: 0,
37+};
38+
39+const result = {
40+ schemaVersion: SCHEMA_VERSION,
41+ requestId: "request-1",
42+ favoriteId: "home",
43+ fetchedAt: 1_788_000_000,
44+ sourceUpdatedAt: 1_787_999_990,
45+ freshness: "REALTIME",
46+ departures: [{
47+ expectedAt: 1_788_000_120,
48+ aimedAt: 1_788_000_100,
49+ minutes: 2,
50+ status: "DELAYED",
51+ nextIntervalMinutes: 4,
52+ }],
53+};
54+
55+function message(type: number, values: AppMessage = {}): AppMessage {
56+ return { SCHEMA_VERSION, MESSAGE_TYPE: type, ...values };
57+}
58+
59+test("domain contracts expose every frozen enum and accept every field", () => {
60+ assert.deepEqual(FRESHNESS, ["REALTIME", "SCHEDULED", "MIXED", "STALE"]);
61+ assert.deepEqual(DEPARTURE_STATUS, ["ON_TIME", "DELAYED", "CANCELLED", "UNKNOWN"]);
62+ assert.deepEqual(ERROR_CODE, [
63+ "API_KEY_REQUIRED",
64+ "API_KEY_INVALID",
65+ "INVALID_SERVICE",
66+ "SOURCE_UNAVAILABLE",
67+ "RATE_LIMITED",
68+ "INVALID_RESPONSE",
69+ ]);
70+ assert.deepEqual(API_KEY_ACTION, ["KEEP", "REPLACE", "REMOVE"]);
71+ assert.equal(isFavorite(favorite), true);
72+ assert.equal(isDepartureResult(result), true);
73+ assert.equal(isErrorResult({
74+ schemaVersion: 1,
75+ requestId: "r",
76+ favoriteId: "home",
77+ code: "API_KEY_INVALID",
78+ occurredAt: 1,
79+ retryAfterSeconds: 60,
80+ }), true);
81+});
82+
83+test("domain validators enforce exact fields, versions, bounds, and secret rules", () => {
84+ assert.equal(isFavorite({ ...favorite, unknown: true }), false);
85+ assert.equal(isFavorite({ ...favorite, schemaVersion: 2 }), false);
86+ assert.equal(isFavorite({ ...favorite, stopLabel: "é".repeat(49) }), false);
87+ assert.equal(utf8Bytes("é".repeat(48)), LIMITS.labelUtf8Bytes);
88+ assert.equal(isDepartureResult({
89+ ...result,
90+ departures: Array.from({ length: LIMITS.departures + 1 }, () => result.departures[0]),
91+ }), false);
92+ assert.equal(isDepartureResult({ ...result, freshness: "FRESH" }), false);
93+ assert.equal(isErrorResult({
94+ schemaVersion: 1,
95+ requestId: "r",
96+ code: "SECRET",
97+ occurredAt: 1,
98+ }), false);
99+ assert.equal(isApiKeyUpdate({ schemaVersion: 1, action: "KEEP" }), true);
100+ assert.equal(isApiKeyUpdate({ schemaVersion: 1, action: "REMOVE" }), true);
101+ assert.equal(isApiKeyUpdate({
102+ schemaVersion: 1,
103+ action: "REPLACE",
104+ value: "personal-key",
105+ }), true);
106+ assert.equal(isApiKeyUpdate({ schemaVersion: 1, action: "KEEP", value: "secret" }), false);
107+ assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes)), true);
108+ assert.equal(isPersonalApiKey("x".repeat(LIMITS.apiKeyUtf8Bytes + 1)), false);
109+ assert.equal(isPersonalApiKey("key\nheader"), false);
110+});
111+
112+test("symbolic payload aliases retain the frozen explicit numeric order", () => {
113+ assert.equal(APP_MESSAGE_KEY_ORDER.length, 25);
114+ APP_MESSAGE_KEY_ORDER.forEach((name, index) => {
115+ assert.equal(APP_MESSAGE_KEY[name], index);
116+ });
117+ assert.deepEqual(
118+ Object.values(APP_MESSAGE_KEY).sort((left, right) => left - right),
119+ Array.from({ length: 25 }, (_, index) => index),
120+ );
121+ assert.deepEqual(KEY_STATUS, { MISSING: 0, CONFIGURED: 1, INVALID: 2 });
122+ assert.deepEqual(REQUEST_TRIGGER, {
123+ APP_OPEN: 0,
124+ FAVORITE_SELECTION: 1,
125+ MANUAL_SELECT: 2,
126+ });
127+ assert.equal(Object.keys(APP_MESSAGE_KEY).some((name) => /API_KEY|SECRET|TOKEN/u.test(name)), false);
128+});
129+
130+test("dictionary sizing follows Pebble's measured-data formula", () => {
131+ assert.equal(dictionaryBytes([]), 1);
132+ assert.equal(dictionaryBytes([1, 4, cstringBytes("é")]), 1 + (7 * 3) + 1 + 4 + 3);
133+ assert.throws(() => dictionaryBytes([-1]), TypeError);
134+});
135+
136+test("AppMessage validation accepts aliases only and contextualizes DISPLAY_NAME", () => {
137+ const request = message(MESSAGE_TYPE.REQUEST, {
138+ REQUEST_ID: "request-1",
139+ FAVORITE_ID: "home",
140+ REQUEST_TRIGGER: REQUEST_TRIGGER.MANUAL_SELECT,
141+ });
142+ assert.equal(isAppMessage(request), true);
143+ assert.equal(isAppMessage({
144+ ...request,
145+ 0: SCHEMA_VERSION,
146+ }), false);
147+ assert.equal(isAppMessage(message(MESSAGE_TYPE.CONFIG_BEGIN, {
148+ REQUEST_ID: "config-1",
149+ ITEM_COUNT: 0,
150+ KEY_STATUS: KEY_STATUS.CONFIGURED,
151+ DISPLAY_NAME: "fr",
152+ })), true);
153+ assert.equal(isAppMessage(message(MESSAGE_TYPE.CONFIG_BEGIN, {
154+ REQUEST_ID: "config-1",
155+ ITEM_COUNT: 0,
156+ KEY_STATUS: KEY_STATUS.CONFIGURED,
157+ DISPLAY_NAME: "fr_FR",
158+ })), false);
159+});
160+
161+test("configuration commits language and favorites atomically", () => {
162+ const receiver = new ProtocolReceiver();
163+ assert.equal(receiver.receive(message(MESSAGE_TYPE.CONFIG_BEGIN, {
164+ REQUEST_ID: "config-1",
165+ ITEM_COUNT: 1,
166+ KEY_STATUS: KEY_STATUS.CONFIGURED,
167+ DISPLAY_NAME: "fr",
168+ })), true);
169+ assert.equal(receiver.receive(message(MESSAGE_TYPE.FAVORITE, {
170+ REQUEST_ID: "config-1",
171+ ITEM_INDEX: 0,
172+ FAVORITE_ID: "home",
173+ SERVICE_ID: "opaque:1",
174+ DISPLAY_NAME: "Maison",
175+ STOP_LABEL: "Châtelet",
176+ LINE_LABEL: "1",
177+ DESTINATION_LABEL: "La Défense",
178+ SORT_ORDER: 0,
179+ })), true);
180+ assert.equal(receiver.committed.configuration, undefined);
181+ assert.equal(receiver.receive(message(MESSAGE_TYPE.CONFIG_COMMIT, {
182+ REQUEST_ID: "config-1",
183+ })), true);
184+ assert.equal(receiver.committed.configuration?.language, "fr");
185+ assert.equal(receiver.committed.configuration?.favorites.length, 1);
186+});
187+
188+test("result staging requires request and favorite binding and preserves committed state", () => {
189+ const receiver = new ProtocolReceiver();
190+ const begin = message(MESSAGE_TYPE.RESULT_BEGIN, {
191+ REQUEST_ID: "request-1",
192+ FAVORITE_ID: "home",
193+ ITEM_COUNT: 1,
194+ FETCHED_AT: 1_788_000_000,
195+ FRESHNESS: 0,
196+ });
197+ const item = message(MESSAGE_TYPE.DEPARTURE, {
198+ REQUEST_ID: "request-1",
199+ FAVORITE_ID: "home",
200+ ITEM_INDEX: 0,
201+ EXPECTED_AT: 1_788_000_120,
202+ MINUTES: 2,
203+ DEPARTURE_STATUS: 0,
204+ });
205+ const commit = message(MESSAGE_TYPE.RESULT_COMMIT, {
206+ REQUEST_ID: "request-1",
207+ FAVORITE_ID: "home",
208+ });
209+
210+ assert.equal(receiver.receive(begin), false);
211+ assert.equal(receiver.expectResponse("request-1", "home"), true);
212+ assert.equal(receiver.receive(begin), true);
213+ assert.equal(receiver.receive(item), true);
214+ assert.equal(receiver.receive(commit), true);
215+ const committed = receiver.committed.result;
216+
217+ assert.equal(receiver.expectResponse("request-2", "home"), true);
218+ assert.equal(receiver.receive({ ...begin, REQUEST_ID: "late-request" }), false);
219+ assert.equal(receiver.receive({ ...begin, SCHEMA_VERSION: 99 }), false);
220+ assert.equal(receiver.committed.result, committed);
221+});
added packages/watch/.lock-waf_linux_build +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure']
2+config_cmd = 'configure'
3+environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2942,unix/unix:/tmp/.ICE-unix/2942', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_KXfteX', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '6424', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_XpcTHG', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2300', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a0523b089c70c29608f76da5ef3ae9', 'MANAGERPIDFDID': '8555', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'}
4+files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript']
5+hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%'
6+launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
7+options = {'colors': 'auto', 'jobs': 8, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None}
8+out_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch/build'
9+run_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
10+top_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
new file mode 100644
@@ -0,0 +1,10 @@
1+argv = ['/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/pebble/waf', 'configure']
2+config_cmd = 'configure'
3+environ = {'IMSETTINGS_INTEGRATE_DESKTOP': 'yes', 'npm_command': 'run', 'SESSION_MANAGER': 'local/unix:@/tmp/.ICE-unix/2942,unix/unix:/tmp/.ICE-unix/2942', 'npm_config_yes': 'true', 'GHOSTTY_BIN_DIR': '/usr/bin', 'npm_config_userconfig': '/home/anthodev/.npmrc', 'COLORTERM': 'truecolor', 'HERDR_PANE_ID': 'w6:p2', 'XDG_CONFIG_DIRS': '/home/anthodev/.config/kdedefaults:/etc/xdg:/usr/share/kde-settings/kde-profile/default/xdg', 'npm_config_cache': '/home/anthodev/.npm', 'PYTHONUNBUFFERED': '1', 'PIP_NO_INPUT': '1', 'LESS': 'FRX', 'XDG_SESSION_PATH': '/org/freedesktop/DisplayManager/Session1', 'HISTCONTROL': 'ignoredups', 'XDG_MENU_PREFIX': 'plasma-', 'TERM_PROGRAM_VERSION': '1.3.1-4.fc44', 'COMPOSER_NO_INTERACTION': '1', 'OMPCODE': '1', 'HISTSIZE': '1000', 'TF_IN_AUTOMATION': '1', 'ICEAUTHORITY': '/run/user/1000/iceauth_KXfteX', 'PNPM_UPDATE_NOTIFIER': 'false', 'NODE': '/usr/bin/node-24', 'GLAB_PAGER': 'cat', 'SSH_AUTH_SOCK': '/home/anthodev/.1password/agent.sock', 'CLOUDSDK_CORE_DISABLE_PROMPTS': '1', 'DOCKER_USER': '1000:1000', '__ABBR_TIPS_VALUES': 'git rebase -i HEAD~2 git rebase --continue git rebase develop gitmoji -c helium-browser --disable-features=WaylandWpColorManagerV1 just eza -al --icons', 'AGENT': '1', 'MEMORY_PRESSURE_WRITE': 'c29tZSAyMDAwMDAgMjAwMDAwMAA=', 'COLOR': '0', 'npm_config_local_prefix': '/home/anthodev/dev/perso/js/lapin-fute', 'XMODIFIERS': '@im=none', 'HOMEBREW_PREFIX': '/home/linuxbrew/.linuxbrew', 'DESKTOP_SESSION': '/usr/share/wayland-sessions/plasma.desktop', 'GTK_RC_FILES': '/etc/gtk/gtkrc:/home/anthodev/.gtkrc:/home/anthodev/.config/gtkrc', 'GDK_CORE_DEVICE_EVENTS': '1', 'npm_config_globalconfig': '/etc/npmrc', 'GPG_TTY': 'not a tty', 'EDITOR': 'true', 'GH_PAGER': 'cat', 'XDG_SEAT': 'seat0', 'GH_PROMPT_DISABLED': '1', 'PWD': '/home/anthodev/dev/perso/js/lapin-fute', 'XDG_SESSION_DESKTOP': 'KDE', 'LOGNAME': 'anthodev', 'XDG_SESSION_TYPE': 'wayland', 'PNPM_HOME': '/home/anthodev/.local/share/pnpm', 'npm_config_init_module': '/home/anthodev/.npm-init.js', 'SYSTEMD_EXEC_PID': '6424', '_': '/usr/bin/node', 'XAUTHORITY': '/run/user/1000/xauth_XpcTHG', 'KWIN_DISABLE_TONEMAPPING': '1', 'SDL_VIDEO_MINIMIZE_ON_FOCUS_LOSS': '0', 'POSH_SHELL': 'fish', 'ABBR_TIPS_PROMPT': '\\n\U0001f4a1 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}', 'CLAUDECODE': '1', 'XKB_DEFAULT_MODEL': 'pc105', 'GTK2_RC_FILES': '/etc/gtk-2.0/gtkrc:/home/anthodev/.gtkrc-2.0:/home/anthodev/.config/gtkrc-2.0', '__ABBR_TIPS_KEYS': 'a__gr2 a__grc a__grd a__gtc a__helium-browser a__j a__xl', 'GHOSTTY_SHELL_FEATURES': 'path,sudo,title', 'HOME': '/home/anthodev', '__PI_NATIVE_VARIANT_CACHE': 'modern', 'DELTA_PAGER': 'cat', 'SSH_ASKPASS': '/usr/bin/false', 'LANG': 'fr_FR.UTF-8', '_JAVA_AWT_WM_NONREPARENTING': '1', 'LS_COLORS': '', 'XDG_CURRENT_DESKTOP': 'KDE', 'POSH_SHELL_VERSION': '4.8.1', 'npm_package_version': '0.1.0', 'POSH_SESSION_ID': 'aed4658f-12ea-4aca-a036-894bd3ef6bd0', 'MEMORY_PRESSURE_WATCH': '/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-com.mitchellh.ghostty.service/memory.pressure', 'WAYLAND_DISPLAY': 'wayland-0', 'AWS_PAGER': '', 'YARN_ENABLE_PROGRESS_BARS': '0', '_fish_env_initialized': '1', 'CONDA_PROMPT_MODIFIER': 'false', 'XDG_SEAT_PATH': '/org/freedesktop/DisplayManager/Seat0', 'MANAGERPID': '2300', 'IMSETTINGS_MODULE': 'none', 'INIT_CWD': '/home/anthodev/dev/perso/js/lapin-fute', 'POSH_CONFIG': '/home/anthodev/.config/.zsh/anthodev.omp.json', 'STEAM_FRAME_FORCE_CLOSE': '1', 'KDE_SESSION_UID': '1000', 'PSQL_PAGER': 'cat', 'INFOPATH': '/home/linuxbrew/.linuxbrew/share/info:', 'npm_lifecycle_script': 'node scripts/build.mjs', 'NVM_DIR': '/home/anthodev/.nvm', 'MOZ_GMP_PATH': '/usr/lib64/mozilla/plugins/gmp-gmpopenh264/system-installed', 'XKB_DEFAULT_LAYOUT': 'us', 'npm_config_npm_version': '11.16.0', 'GHOSTTY_RESOURCES_DIR': '/usr/share/ghostty', 'XDG_SESSION_CLASS': 'user', 'TERMINFO': '/usr/share/terminfo', 'TERM': 'dumb', 'npm_package_name': 'lapin-fute', 'BAT_PAGER': 'cat', 'npm_config_prefix': '/home/anthodev/.npm-global', 'LESSOPEN': '||/usr/bin/lesspipe.sh %s', 'USER': 'anthodev', 'YARN_ENABLE_TELEMETRY': '0', 'GIT_PAGER': 'cat', 'PIP_DISABLE_PIP_VERSION_CHECK': '1', 'HERDR_BIN_PATH': '/home/linuxbrew/.linuxbrew/Cellar/herdr/0.8.2/bin/herdr', 'GID': '1000', 'HOMEBREW_CELLAR': '/home/linuxbrew/.linuxbrew/Cellar', 'QT_WAYLAND_RECONNECT': '1', 'KDE_SESSION_VERSION': '6', 'PAM_KWALLET5_LOGIN': '/run/user/1000/kwallet5.socket', 'MANPAGER': 'cat', 'VISUAL': 'true', 'DISPLAY': ':0', 'npm_lifecycle_event': 'build', 'SHLVL': '1', 'NVM_CD_FLAGS': '', 'GIT_EDITOR': 'true', 'PAGER': 'cat', 'HOMEBREW_REPOSITORY': '/home/linuxbrew/.linuxbrew/Homebrew', 'npm_config_fund': 'false', 'SYSTEMD_PAGER': 'cat', 'XDG_VTNR': '2', 'XDG_SESSION_ID': '2', 'ATUIN_SESSION': '01a0523b089c70c29608f76da5ef3ae9', 'MANAGERPIDFDID': '8555', 'npm_config_user_agent': 'npm/11.16.0 node/v24.18.0 linux x64 workspaces/false', 'NO_COLOR': '1', 'TF_INPUT': '0', 'npm_execpath': '/usr/lib/node_modules_24/npm/bin/npm-cli.js', 'npm_config_progress': 'false', 'XDG_RUNTIME_DIR': '/run/user/1000', 'MYSQL_PAGER': 'cat', 'HOMEBREW_PAGER': 'cat', 'PNPM_DISABLE_SELF_UPDATE_CHECK': 'true', 'DEBUGINFOD_URLS': 'ima:enforcing https://debuginfod.fedoraproject.org/ ima:ignore ', 'npm_package_json': '/home/anthodev/dev/perso/js/lapin-fute/package.json', 'DEBUGINFOD_IMA_CERT_PATH': '/etc/keys/ima:', 'LC_ALL': 'C.UTF-8', 'KDEDIRS': '/usr', 'XKB_DEFAULT_VARIANT': 'intl', 'CARGO_TERM_PROGRESS_WHEN': 'never', 'HERDR_ENV': '1', 'XDG_DATA_DIRS': '/home/anthodev/.local/share/flatpak/exports/share:/var/lib/flatpak/exports/share:/usr/local/share:/usr/share', 'KDE_FULL_SESSION': 'true', 'npm_config_allow_scripts': '', 'npm_config_noproxy': '', 'PATH': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable-tools:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/arm-none-eabi/bin:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages/pebble_tool/commands/sdk:/home/anthodev/.local/share/pebble-sdk/SDKs/4.33.1/toolchain/bin:/home/anthodev/dev/perso/js/lapin-fute/node_modules/.bin:/home/anthodev/dev/perso/js/node_modules/.bin:/home/anthodev/dev/perso/node_modules/.bin:/home/anthodev/dev/node_modules/.bin:/home/anthodev/node_modules/.bin:/home/node_modules/.bin:/node_modules/.bin:/usr/lib/node_modules_24/npm/node_modules/@npmcli/run-script/lib/node-gyp-bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.local/bin:/home/anthodev/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/anthodev/.atuin/bin:/home/anthodev/.local/bin:/home/anthodev/.bun/bin:/home/anthodev/go/bin:/usr/local/bin:/usr/bin:/sbin:/home/anthodev/.npm-global/bin:/home/anthodev/.local/share/pnpm/bin:/home/anthodev/.cargo/bin:/usr/lib64/ccache:/bin', 'HERDR_TAB_ID': 'w6:t2', 'npm_config_node_gyp': '/usr/lib/node_modules_24/npm/node_modules/node-gyp/bin/node-gyp.js', 'CI': 'true', 'DBUS_SESSION_BUS_ADDRESS': 'unix:path=/run/user/1000/bus', 'npm_config_global_prefix': '/home/anthodev/.npm-global', 'npm_config_update_notifier': 'false', 'KDE_APPLICATIONS_AS_SCOPE': '1', 'HERDR_SOCKET_PATH': '/home/anthodev/.config/herdr/herdr.sock', 'HERDR_WORKSPACE_ID': 'w6', 'MAIL': '/var/spool/mail/anthodev', 'POWERLINE_COMMAND': 'oh-my-posh', 'npm_config_audit': 'false', 'ABBR_TIPS_REGEXES': '(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*', 'DEBIAN_FRONTEND': 'noninteractive', 'GIT_TERMINAL_PROMPT': '0', 'npm_node_execpath': '/usr/bin/node-24', 'npm_package_engines_node': '24.18.0', 'TERM_PROGRAM': 'ghostty', 'MODDABLE': '/var/tmp/pebble-sdk/SDKs/4.33.1/toolchain/moddable', 'PYTHONHOME': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/.venv', 'PYTHONPATH': '/home/anthodev/.local/share/uv/tools/pebble-tool/bin:/usr/lib64/python314.zip:/usr/lib64/python3.14:/usr/lib64/python3.14/lib-dynload:/home/anthodev/.local/share/uv/tools/pebble-tool/lib64/python3.14/site-packages:/home/anthodev/.local/share/uv/tools/pebble-tool/lib/python3.14/site-packages', 'NODE_PATH': '/home/anthodev/.local/share/pebble-sdk/SDKs/current/sdk-core/../node_modules', 'NOCLIMB': '1'}
4+files = ['/home/anthodev/dev/perso/js/lapin-fute/packages/watch/wscript']
5+hash = b'\xc6\xb0\xec\xd9\xadu[\xb0m\xd2\xe0\x84N\xf01%'
6+launch_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
7+options = {'colors': 'auto', 'jobs': 8, 'keep': 0, 'verbose': 0, 'zones': '', 'profile': 0, 'pdb': 0, 'whelp': 0, 'version': False, 'out': '', 'top': '', 'no_lock_in_run': '', 'no_lock_in_out': '', 'no_lock_in_top': '', 'prefix': '/usr/local', 'bindir': None, 'libdir': None, 'progress_bar': 0, 'targets': '', 'files': '', 'destdir': '', 'force': False, 'distcheck_args': None, 'debug': False, 'no_groups': False, 'sandbox': False, 'timestamp': None}
8+out_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch/build'
9+run_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
10+top_dir = '/home/anthodev/dev/perso/js/lapin-fute/packages/watch'
added packages/watch/package.json +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+{
2+ "name": "lapin-fute-watch",
3+ "author": "Anthodev",
4+ "version": "0.1.0",
5+ "keywords": [
6+ "pebble-app"
7+ ],
8+ "private": true,
9+ "type": "commonjs",
10+ "dependencies": {},
11+ "pebble": {
12+ "displayName": "Lapin Futé",
13+ "uuid": "f1e58d7b-8e42-4b21-a9bf-67c0f4b59d02",
14+ "projectType": "moddable",
15+ "sdkVersion": "3",
16+ "enableMultiJS": true,
17+ "targetPlatforms": [
18+ "emery",
19+ "gabbro"
20+ ],
21+ "watchapp": {
22+ "watchface": false
23+ },
24+ "capabilities": [
25+ "configurable"
26+ ],
27+ "messageKeys": {
28+ "SCHEMA_VERSION": 0,
29+ "MESSAGE_TYPE": 1,
30+ "REQUEST_ID": 2,
31+ "FAVORITE_ID": 3,
32+ "SERVICE_ID": 4,
33+ "DISPLAY_NAME": 5,
34+ "STOP_LABEL": 6,
35+ "LINE_LABEL": 7,
36+ "DESTINATION_LABEL": 8,
37+ "SORT_ORDER": 9,
38+ "KEY_STATUS": 10,
39+ "ITEM_COUNT": 11,
40+ "ITEM_INDEX": 12,
41+ "FETCHED_AT": 13,
42+ "SOURCE_UPDATED_AT": 14,
43+ "FRESHNESS": 15,
44+ "EXPECTED_AT": 16,
45+ "AIMED_AT": 17,
46+ "MINUTES": 18,
47+ "DEPARTURE_STATUS": 19,
48+ "NEXT_INTERVAL_MINUTES": 20,
49+ "ERROR_CODE": 21,
50+ "OCCURRED_AT": 22,
51+ "RETRY_AFTER_SECONDS": 23,
52+ "REQUEST_TRIGGER": 24
53+ },
54+ "resources": {
55+ "media": []
56+ }
57+ }
58+}
new file mode 100644
@@ -0,0 +1,58 @@
1+{
2+ "name": "lapin-fute-watch",
3+ "author": "Anthodev",
4+ "version": "0.1.0",
5+ "keywords": [
6+ "pebble-app"
7+ ],
8+ "private": true,
9+ "type": "commonjs",
10+ "dependencies": {},
11+ "pebble": {
12+ "displayName": "Lapin Futé",
13+ "uuid": "f1e58d7b-8e42-4b21-a9bf-67c0f4b59d02",
14+ "projectType": "moddable",
15+ "sdkVersion": "3",
16+ "enableMultiJS": true,
17+ "targetPlatforms": [
18+ "emery",
19+ "gabbro"
20+ ],
21+ "watchapp": {
22+ "watchface": false
23+ },
24+ "capabilities": [
25+ "configurable"
26+ ],
27+ "messageKeys": {
28+ "SCHEMA_VERSION": 0,
29+ "MESSAGE_TYPE": 1,
30+ "REQUEST_ID": 2,
31+ "FAVORITE_ID": 3,
32+ "SERVICE_ID": 4,
33+ "DISPLAY_NAME": 5,
34+ "STOP_LABEL": 6,
35+ "LINE_LABEL": 7,
36+ "DESTINATION_LABEL": 8,
37+ "SORT_ORDER": 9,
38+ "KEY_STATUS": 10,
39+ "ITEM_COUNT": 11,
40+ "ITEM_INDEX": 12,
41+ "FETCHED_AT": 13,
42+ "SOURCE_UPDATED_AT": 14,
43+ "FRESHNESS": 15,
44+ "EXPECTED_AT": 16,
45+ "AIMED_AT": 17,
46+ "MINUTES": 18,
47+ "DEPARTURE_STATUS": 19,
48+ "NEXT_INTERVAL_MINUTES": 20,
49+ "ERROR_CODE": 21,
50+ "OCCURRED_AT": 22,
51+ "RETRY_AFTER_SECONDS": 23,
52+ "REQUEST_TRIGGER": 24
53+ },
54+ "resources": {
55+ "media": []
56+ }
57+ }
58+}
added packages/watch/src/c/mdbl.c +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+#include <pebble.h>
2+
3+#define XS_STACK_BYTES (4U * 1024U)
4+#define XS_SLOT_BYTES (32U * 1024U)
5+#define XS_CHUNK_BYTES (20U * 1024U)
6+#define XS_ARENA_TOTAL_BYTES \
7+ (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES)
8+#define XS_ARENA_BUDGET_BYTES (56U * 1024U)
9+
10+#ifdef PBL_DEBUG
11+#define XS_CREATION_FLAGS \
12+ (kModdableCreationFlagDebug | kModdableCreationFlagLogInstrumentation)
13+#else
14+#define XS_CREATION_FLAGS 0U
15+#endif
16+
17+#if (XS_STACK_BYTES == 0U) || (XS_SLOT_BYTES == 0U) || \
18+ (XS_CHUNK_BYTES == 0U)
19+#error "XS stack, slot, and chunk sizes must all be nonzero"
20+#endif
21+
22+#if XS_ARENA_TOTAL_BYTES > XS_ARENA_BUDGET_BYTES
23+#error "XS arenas exceed the 56 KiB RAM budget"
24+#endif
25+
26+int main(void) {
27+ Window *window = window_create();
28+ window_stack_push(window, true);
29+
30+ ModdableCreationRecord creation = {
31+ .recordSize = sizeof(creation),
32+ .stack = XS_STACK_BYTES,
33+ .slot = XS_SLOT_BYTES,
34+ .chunk = XS_CHUNK_BYTES,
35+ .flags = XS_CREATION_FLAGS,
36+ };
37+ moddable_createMachine(&creation);
38+
39+ window_destroy(window);
40+}
new file mode 100644
@@ -0,0 +1,40 @@
1+#include <pebble.h>
2+
3+#define XS_STACK_BYTES (4U * 1024U)
4+#define XS_SLOT_BYTES (32U * 1024U)
5+#define XS_CHUNK_BYTES (20U * 1024U)
6+#define XS_ARENA_TOTAL_BYTES \
7+ (XS_STACK_BYTES + XS_SLOT_BYTES + XS_CHUNK_BYTES)
8+#define XS_ARENA_BUDGET_BYTES (56U * 1024U)
9+
10+#ifdef PBL_DEBUG
11+#define XS_CREATION_FLAGS \
12+ (kModdableCreationFlagDebug | kModdableCreationFlagLogInstrumentation)
13+#else
14+#define XS_CREATION_FLAGS 0U
15+#endif
16+
17+#if (XS_STACK_BYTES == 0U) || (XS_SLOT_BYTES == 0U) || \
18+ (XS_CHUNK_BYTES == 0U)
19+#error "XS stack, slot, and chunk sizes must all be nonzero"
20+#endif
21+
22+#if XS_ARENA_TOTAL_BYTES > XS_ARENA_BUDGET_BYTES
23+#error "XS arenas exceed the 56 KiB RAM budget"
24+#endif
25+
26+int main(void) {
27+ Window *window = window_create();
28+ window_stack_push(window, true);
29+
30+ ModdableCreationRecord creation = {
31+ .recordSize = sizeof(creation),
32+ .stack = XS_STACK_BYTES,
33+ .slot = XS_SLOT_BYTES,
34+ .chunk = XS_CHUNK_BYTES,
35+ .flags = XS_CREATION_FLAGS,
36+ };
37+ moddable_createMachine(&creation);
38+
39+ window_destroy(window);
40+}
added packages/watch/src/embeddedjs/contracts.js +169 -0
new file mode 100644
@@ -0,0 +1,169 @@
1+export const SCHEMA_VERSION = 1;
2+
3+export const LIMITS = Object.freeze({
4+ idUtf8Bytes: 64,
5+ labelUtf8Bytes: 96,
6+ favorites: 8,
7+ departures: 4,
8+ favoriteSettleMs: 500,
9+ requestQueue: 4
10+});
11+
12+export const FRESHNESS = Object.freeze([
13+ "REALTIME",
14+ "SCHEDULED",
15+ "MIXED",
16+ "STALE"
17+]);
18+
19+export const DEPARTURE_STATUS = Object.freeze([
20+ "ON_TIME",
21+ "DELAYED",
22+ "CANCELLED",
23+ "UNKNOWN"
24+]);
25+
26+export const ERROR_CODE = Object.freeze([
27+ "API_KEY_REQUIRED",
28+ "API_KEY_INVALID",
29+ "INVALID_SERVICE",
30+ "SOURCE_UNAVAILABLE",
31+ "RATE_LIMITED",
32+ "INVALID_RESPONSE"
33+]);
34+
35+export const KEY_STATUS = Object.freeze({
36+ MISSING: 0,
37+ CONFIGURED: 1,
38+ INVALID: 2
39+});
40+
41+export const REQUEST_TRIGGER = Object.freeze({
42+ APP_OPEN: 0,
43+ FAVORITE_SELECTION: 1,
44+ MANUAL_SELECT: 2
45+});
46+
47+export const MESSAGE_TYPE = Object.freeze({
48+ REQUEST: 1,
49+ CONFIG_BEGIN: 2,
50+ FAVORITE: 3,
51+ CONFIG_COMMIT: 4,
52+ RESULT_BEGIN: 5,
53+ DEPARTURE: 6,
54+ RESULT_COMMIT: 7,
55+ ERROR: 8
56+});
57+
58+export const APP_MESSAGE_KEY_ORDER = Object.freeze([
59+ "SCHEMA_VERSION",
60+ "MESSAGE_TYPE",
61+ "REQUEST_ID",
62+ "FAVORITE_ID",
63+ "SERVICE_ID",
64+ "DISPLAY_NAME",
65+ "STOP_LABEL",
66+ "LINE_LABEL",
67+ "DESTINATION_LABEL",
68+ "SORT_ORDER",
69+ "KEY_STATUS",
70+ "ITEM_COUNT",
71+ "ITEM_INDEX",
72+ "FETCHED_AT",
73+ "SOURCE_UPDATED_AT",
74+ "FRESHNESS",
75+ "EXPECTED_AT",
76+ "AIMED_AT",
77+ "MINUTES",
78+ "DEPARTURE_STATUS",
79+ "NEXT_INTERVAL_MINUTES",
80+ "ERROR_CODE",
81+ "OCCURRED_AT",
82+ "RETRY_AFTER_SECONDS",
83+ "REQUEST_TRIGGER"
84+]);
85+
86+export const APP_MESSAGE_KEY = Object.freeze({
87+ SCHEMA_VERSION: 0,
88+ MESSAGE_TYPE: 1,
89+ REQUEST_ID: 2,
90+ FAVORITE_ID: 3,
91+ SERVICE_ID: 4,
92+ DISPLAY_NAME: 5,
93+ STOP_LABEL: 6,
94+ LINE_LABEL: 7,
95+ DESTINATION_LABEL: 8,
96+ SORT_ORDER: 9,
97+ KEY_STATUS: 10,
98+ ITEM_COUNT: 11,
99+ ITEM_INDEX: 12,
100+ FETCHED_AT: 13,
101+ SOURCE_UPDATED_AT: 14,
102+ FRESHNESS: 15,
103+ EXPECTED_AT: 16,
104+ AIMED_AT: 17,
105+ MINUTES: 18,
106+ DEPARTURE_STATUS: 19,
107+ NEXT_INTERVAL_MINUTES: 20,
108+ ERROR_CODE: 21,
109+ OCCURRED_AT: 22,
110+ RETRY_AFTER_SECONDS: 23,
111+ REQUEST_TRIGGER: 24
112+});
113+// SDK 4.33.1 auto-numbers array-form Message keys from 10000.
114+export const APP_MESSAGE_KEY_MAP = new Map();
115+for (let index = 0; index < APP_MESSAGE_KEY_ORDER.length; index += 1) {
116+ const alias = APP_MESSAGE_KEY_ORDER[index];
117+ APP_MESSAGE_KEY_MAP.set(alias, APP_MESSAGE_KEY[alias]);
118+}
119+
120+export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });
121+
122+export function utf8Bytes(value) {
123+ let total = 0;
124+ for (let index = 0; index < value.length; index += 1) {
125+ const code = value.charCodeAt(index);
126+ if (code <= 0x7f) {
127+ total += 1;
128+ } else if (code <= 0x7ff) {
129+ total += 2;
130+ } else if (code >= 0xd800 && code <= 0xdbff) {
131+ const next = value.charCodeAt(index + 1);
132+ if (next >= 0xdc00 && next <= 0xdfff) {
133+ total += 4;
134+ index += 1;
135+ } else {
136+ total += 3;
137+ }
138+ } else {
139+ total += 3;
140+ }
141+ }
142+ return total;
143+}
144+
145+export function boundedString(value, maximum) {
146+ if (typeof value !== "string") return false;
147+ const size = utf8Bytes(value);
148+ return size >= 1 && size <= maximum;
149+}
150+
151+export function uint32(value) {
152+ return typeof value === "number"
153+ && isFinite(value)
154+ && Math.floor(value) === value
155+ && value >= 0
156+ && value <= 0xffffffff;
157+}
158+
159+export function isWireLanguage(value) {
160+ return value === LANGUAGE.EN || value === LANGUAGE.FR;
161+}
162+
163+export function enumHasValue(values, value) {
164+ const names = Object.keys(values);
165+ for (let index = 0; index < names.length; index += 1) {
166+ if (values[names[index]] === value) return true;
167+ }
168+ return false;
169+}
new file mode 100644
@@ -0,0 +1,169 @@
1+export const SCHEMA_VERSION = 1;
2+
3+export const LIMITS = Object.freeze({
4+ idUtf8Bytes: 64,
5+ labelUtf8Bytes: 96,
6+ favorites: 8,
7+ departures: 4,
8+ favoriteSettleMs: 500,
9+ requestQueue: 4
10+});
11+
12+export const FRESHNESS = Object.freeze([
13+ "REALTIME",
14+ "SCHEDULED",
15+ "MIXED",
16+ "STALE"
17+]);
18+
19+export const DEPARTURE_STATUS = Object.freeze([
20+ "ON_TIME",
21+ "DELAYED",
22+ "CANCELLED",
23+ "UNKNOWN"
24+]);
25+
26+export const ERROR_CODE = Object.freeze([
27+ "API_KEY_REQUIRED",
28+ "API_KEY_INVALID",
29+ "INVALID_SERVICE",
30+ "SOURCE_UNAVAILABLE",
31+ "RATE_LIMITED",
32+ "INVALID_RESPONSE"
33+]);
34+
35+export const KEY_STATUS = Object.freeze({
36+ MISSING: 0,
37+ CONFIGURED: 1,
38+ INVALID: 2
39+});
40+
41+export const REQUEST_TRIGGER = Object.freeze({
42+ APP_OPEN: 0,
43+ FAVORITE_SELECTION: 1,
44+ MANUAL_SELECT: 2
45+});
46+
47+export const MESSAGE_TYPE = Object.freeze({
48+ REQUEST: 1,
49+ CONFIG_BEGIN: 2,
50+ FAVORITE: 3,
51+ CONFIG_COMMIT: 4,
52+ RESULT_BEGIN: 5,
53+ DEPARTURE: 6,
54+ RESULT_COMMIT: 7,
55+ ERROR: 8
56+});
57+
58+export const APP_MESSAGE_KEY_ORDER = Object.freeze([
59+ "SCHEMA_VERSION",
60+ "MESSAGE_TYPE",
61+ "REQUEST_ID",
62+ "FAVORITE_ID",
63+ "SERVICE_ID",
64+ "DISPLAY_NAME",
65+ "STOP_LABEL",
66+ "LINE_LABEL",
67+ "DESTINATION_LABEL",
68+ "SORT_ORDER",
69+ "KEY_STATUS",
70+ "ITEM_COUNT",
71+ "ITEM_INDEX",
72+ "FETCHED_AT",
73+ "SOURCE_UPDATED_AT",
74+ "FRESHNESS",
75+ "EXPECTED_AT",
76+ "AIMED_AT",
77+ "MINUTES",
78+ "DEPARTURE_STATUS",
79+ "NEXT_INTERVAL_MINUTES",
80+ "ERROR_CODE",
81+ "OCCURRED_AT",
82+ "RETRY_AFTER_SECONDS",
83+ "REQUEST_TRIGGER"
84+]);
85+
86+export const APP_MESSAGE_KEY = Object.freeze({
87+ SCHEMA_VERSION: 0,
88+ MESSAGE_TYPE: 1,
89+ REQUEST_ID: 2,
90+ FAVORITE_ID: 3,
91+ SERVICE_ID: 4,
92+ DISPLAY_NAME: 5,
93+ STOP_LABEL: 6,
94+ LINE_LABEL: 7,
95+ DESTINATION_LABEL: 8,
96+ SORT_ORDER: 9,
97+ KEY_STATUS: 10,
98+ ITEM_COUNT: 11,
99+ ITEM_INDEX: 12,
100+ FETCHED_AT: 13,
101+ SOURCE_UPDATED_AT: 14,
102+ FRESHNESS: 15,
103+ EXPECTED_AT: 16,
104+ AIMED_AT: 17,
105+ MINUTES: 18,
106+ DEPARTURE_STATUS: 19,
107+ NEXT_INTERVAL_MINUTES: 20,
108+ ERROR_CODE: 21,
109+ OCCURRED_AT: 22,
110+ RETRY_AFTER_SECONDS: 23,
111+ REQUEST_TRIGGER: 24
112+});
113+// SDK 4.33.1 auto-numbers array-form Message keys from 10000.
114+export const APP_MESSAGE_KEY_MAP = new Map();
115+for (let index = 0; index < APP_MESSAGE_KEY_ORDER.length; index += 1) {
116+ const alias = APP_MESSAGE_KEY_ORDER[index];
117+ APP_MESSAGE_KEY_MAP.set(alias, APP_MESSAGE_KEY[alias]);
118+}
119+
120+export const LANGUAGE = Object.freeze({ EN: "en", FR: "fr" });
121+
122+export function utf8Bytes(value) {
123+ let total = 0;
124+ for (let index = 0; index < value.length; index += 1) {
125+ const code = value.charCodeAt(index);
126+ if (code <= 0x7f) {
127+ total += 1;
128+ } else if (code <= 0x7ff) {
129+ total += 2;
130+ } else if (code >= 0xd800 && code <= 0xdbff) {
131+ const next = value.charCodeAt(index + 1);
132+ if (next >= 0xdc00 && next <= 0xdfff) {
133+ total += 4;
134+ index += 1;
135+ } else {
136+ total += 3;
137+ }
138+ } else {
139+ total += 3;
140+ }
141+ }
142+ return total;
143+}
144+
145+export function boundedString(value, maximum) {
146+ if (typeof value !== "string") return false;
147+ const size = utf8Bytes(value);
148+ return size >= 1 && size <= maximum;
149+}
150+
151+export function uint32(value) {
152+ return typeof value === "number"
153+ && isFinite(value)
154+ && Math.floor(value) === value
155+ && value >= 0
156+ && value <= 0xffffffff;
157+}
158+
159+export function isWireLanguage(value) {
160+ return value === LANGUAGE.EN || value === LANGUAGE.FR;
161+}
162+
163+export function enumHasValue(values, value) {
164+ const names = Object.keys(values);
165+ for (let index = 0; index < names.length; index += 1) {
166+ if (values[names[index]] === value) return true;
167+ }
168+ return false;
169+}
added packages/watch/src/embeddedjs/controller.js +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+import { QUEUE_STATE } from "./message-queue.js";
2+import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
3+
4+function eventName(received) {
5+ if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED";
6+ if (received === RECEIVE_RESULT.RESULT_COMMITTED) return "RESULT_COMMITTED";
7+ if (received === RECEIVE_RESULT.ERROR_COMMITTED) return "ERROR_COMMITTED";
8+ return null;
9+}
10+
11+export function createController(options) {
12+ if (!options
13+ || !options.clock
14+ || typeof options.clock.now !== "function"
15+ || !options.queue
16+ || typeof options.queue.enqueue !== "function"
17+ || !options.model
18+ || typeof options.model.snapshot !== "function"
19+ || !options.view
20+ || typeof options.view.render !== "function") {
21+ throw new TypeError("controller adapters are required");
22+ }
23+
24+ const clock = options.clock;
25+ const queue = options.queue;
26+ const model = options.model;
27+ const view = options.view;
28+ const receiver = new ProtocolReceiver();
29+ let sequence = 0;
30+ let started = false;
31+ let closed = false;
32+
33+ function render() {
34+ view.render(model.snapshot());
35+ }
36+
37+ function nextRequestId() {
38+ sequence += 1;
39+ if (sequence > 1679615) sequence = 1;
40+ const seconds = Math.max(0, Math.floor(clock.now() / 1000));
41+ return "foundation-" + seconds.toString(36) + "-" + sequence.toString(36);
42+ }
43+
44+ function requestFixture() {
45+ if (closed) return false;
46+ const request = model.beginFixtureRequest(nextRequestId());
47+ if (!request) return false;
48+ if (!receiver.expectResponse(request.requestId, request.favoriteId)
49+ || !queue.enqueue(encodeRequest(request))) {
50+ receiver.cancelExpectedResponse();
51+ model.markSendFailure();
52+ render();
53+ return false;
54+ }
55+ render();
56+ return true;
57+ }
58+
59+ const controller = {
60+ start() {
61+ if (closed || started) return controller;
62+ started = true;
63+ render();
64+ return controller;
65+ },
66+
67+ onReadable(message) {
68+ if (closed) return RECEIVE_RESULT.REJECTED;
69+ const received = receiver.receive(message);
70+ const event = eventName(received);
71+ if (event !== null) {
72+ const effect = model.commitProtocol(receiver.snapshot(), event);
73+ render();
74+ if (effect.requestFixture) requestFixture();
75+ }
76+ return received;
77+ },
78+
79+ onQueueState(state) {
80+ if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;
81+ receiver.cancelExpectedResponse();
82+ model.markSendFailure();
83+ render();
84+ },
85+
86+ redraw() {
87+ if (!closed) render();
88+ },
89+
90+ close() {
91+ if (closed) return;
92+ closed = true;
93+ receiver.cancelExpectedResponse();
94+ receiver.discardStaging();
95+ queue.close();
96+ if (typeof view.close === "function") view.close();
97+ }
98+ };
99+
100+ return controller;
101+}
new file mode 100644
@@ -0,0 +1,101 @@
1+import { QUEUE_STATE } from "./message-queue.js";
2+import { ProtocolReceiver, RECEIVE_RESULT, encodeRequest } from "./protocol.js";
3+
4+function eventName(received) {
5+ if (received === RECEIVE_RESULT.CONFIG_COMMITTED) return "CONFIG_COMMITTED";
6+ if (received === RECEIVE_RESULT.RESULT_COMMITTED) return "RESULT_COMMITTED";
7+ if (received === RECEIVE_RESULT.ERROR_COMMITTED) return "ERROR_COMMITTED";
8+ return null;
9+}
10+
11+export function createController(options) {
12+ if (!options
13+ || !options.clock
14+ || typeof options.clock.now !== "function"
15+ || !options.queue
16+ || typeof options.queue.enqueue !== "function"
17+ || !options.model
18+ || typeof options.model.snapshot !== "function"
19+ || !options.view
20+ || typeof options.view.render !== "function") {
21+ throw new TypeError("controller adapters are required");
22+ }
23+
24+ const clock = options.clock;
25+ const queue = options.queue;
26+ const model = options.model;
27+ const view = options.view;
28+ const receiver = new ProtocolReceiver();
29+ let sequence = 0;
30+ let started = false;
31+ let closed = false;
32+
33+ function render() {
34+ view.render(model.snapshot());
35+ }
36+
37+ function nextRequestId() {
38+ sequence += 1;
39+ if (sequence > 1679615) sequence = 1;
40+ const seconds = Math.max(0, Math.floor(clock.now() / 1000));
41+ return "foundation-" + seconds.toString(36) + "-" + sequence.toString(36);
42+ }
43+
44+ function requestFixture() {
45+ if (closed) return false;
46+ const request = model.beginFixtureRequest(nextRequestId());
47+ if (!request) return false;
48+ if (!receiver.expectResponse(request.requestId, request.favoriteId)
49+ || !queue.enqueue(encodeRequest(request))) {
50+ receiver.cancelExpectedResponse();
51+ model.markSendFailure();
52+ render();
53+ return false;
54+ }
55+ render();
56+ return true;
57+ }
58+
59+ const controller = {
60+ start() {
61+ if (closed || started) return controller;
62+ started = true;
63+ render();
64+ return controller;
65+ },
66+
67+ onReadable(message) {
68+ if (closed) return RECEIVE_RESULT.REJECTED;
69+ const received = receiver.receive(message);
70+ const event = eventName(received);
71+ if (event !== null) {
72+ const effect = model.commitProtocol(receiver.snapshot(), event);
73+ render();
74+ if (effect.requestFixture) requestFixture();
75+ }
76+ return received;
77+ },
78+
79+ onQueueState(state) {
80+ if (closed || !state || state.type !== QUEUE_STATE.FAILED) return;
81+ receiver.cancelExpectedResponse();
82+ model.markSendFailure();
83+ render();
84+ },
85+
86+ redraw() {
87+ if (!closed) render();
88+ },
89+
90+ close() {
91+ if (closed) return;
92+ closed = true;
93+ receiver.cancelExpectedResponse();
94+ receiver.discardStaging();
95+ queue.close();
96+ if (typeof view.close === "function") view.close();
97+ }
98+ };
99+
100+ return controller;
101+}
added packages/watch/src/embeddedjs/localization.js +44 -0
new file mode 100644
@@ -0,0 +1,44 @@
1+import { LANGUAGE as CONTRACT_LANGUAGE } from "./contracts.js";
2+
3+export const LANGUAGE = CONTRACT_LANGUAGE;
4+
5+export const COPY = Object.freeze({
6+ en: Object.freeze({
7+ appName: "Lapin Futé",
8+ synchronizing: "Synchronizing fixture…",
9+ waiting: "Waiting for recorded departures…",
10+ unavailable: "Fixture unavailable",
11+ noDepartures: "No recorded departures",
12+ recordedFixture: "Recorded foundation fixture",
13+ now: "Now",
14+ departed: "Departed"
15+ }),
16+ fr: Object.freeze({
17+ appName: "Lapin Futé",
18+ synchronizing: "Synchronisation du scénario…",
19+ waiting: "En attente des départs enregistrés…",
20+ unavailable: "Scénario indisponible",
21+ noDepartures: "Aucun départ enregistré",
22+ recordedFixture: "Scénario de fondation enregistré",
23+ now: "À quai",
24+ departed: "Parti"
25+ })
26+});
27+
28+export function normalizeLanguage(tag) {
29+ if (typeof tag !== "string") return LANGUAGE.EN;
30+ return tag.toLowerCase().split(/[-_]/)[0] === LANGUAGE.FR
31+ ? LANGUAGE.FR
32+ : LANGUAGE.EN;
33+}
34+
35+export function copy(language, id) {
36+ const table = COPY[normalizeLanguage(language)];
37+ return Object.prototype.hasOwnProperty.call(table, id) ? table[id] : "";
38+}
39+
40+export function formatMinutes(language, minutes) {
41+ if (minutes < 0) return copy(language, "departed");
42+ if (minutes === 0) return copy(language, "now");
43+ return String(minutes) + " min";
44+}
new file mode 100644
@@ -0,0 +1,44 @@
1+import { LANGUAGE as CONTRACT_LANGUAGE } from "./contracts.js";
2+
3+export const LANGUAGE = CONTRACT_LANGUAGE;
4+
5+export const COPY = Object.freeze({
6+ en: Object.freeze({
7+ appName: "Lapin Futé",
8+ synchronizing: "Synchronizing fixture…",
9+ waiting: "Waiting for recorded departures…",
10+ unavailable: "Fixture unavailable",
11+ noDepartures: "No recorded departures",
12+ recordedFixture: "Recorded foundation fixture",
13+ now: "Now",
14+ departed: "Departed"
15+ }),
16+ fr: Object.freeze({
17+ appName: "Lapin Futé",
18+ synchronizing: "Synchronisation du scénario…",
19+ waiting: "En attente des départs enregistrés…",
20+ unavailable: "Scénario indisponible",
21+ noDepartures: "Aucun départ enregistré",
22+ recordedFixture: "Scénario de fondation enregistré",
23+ now: "À quai",
24+ departed: "Parti"
25+ })
26+});
27+
28+export function normalizeLanguage(tag) {
29+ if (typeof tag !== "string") return LANGUAGE.EN;
30+ return tag.toLowerCase().split(/[-_]/)[0] === LANGUAGE.FR
31+ ? LANGUAGE.FR
32+ : LANGUAGE.EN;
33+}
34+
35+export function copy(language, id) {
36+ const table = COPY[normalizeLanguage(language)];
37+ return Object.prototype.hasOwnProperty.call(table, id) ? table[id] : "";
38+}
39+
40+export function formatMinutes(language, minutes) {
41+ if (minutes < 0) return copy(language, "departed");
42+ if (minutes === 0) return copy(language, "now");
43+ return String(minutes) + " min";
44+}
added packages/watch/src/embeddedjs/main.js +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+import Message from "pebble/message";
2+import { APP_MESSAGE_KEY_MAP } from "./contracts.js";
3+import { createController } from "./controller.js";
4+import MessageQueue from "./message-queue.js";
5+import WatchModel from "./model.js";
6+import { createWatchView } from "./ui.js";
7+
8+function screenInfo() {
9+ return {
10+ width: screen.width,
11+ height: screen.height,
12+ round: screen.round === true
13+ };
14+}
15+
16+let queue = null;
17+let controller = null;
18+let pendingChannel = null;
19+const pendingMessages = [];
20+const view = createWatchView(screenInfo());
21+const message = new Message({
22+ keys: APP_MESSAGE_KEY_MAP,
23+ onReadable() {
24+ const incoming = this.read();
25+ if (controller) controller.onReadable(incoming);
26+ else pendingMessages.push(incoming);
27+ },
28+ onWritable() {
29+ if (queue) queue.writable();
30+ else pendingChannel = "writable";
31+ },
32+ onSuspend() {
33+ if (queue) queue.suspend();
34+ else pendingChannel = "suspended";
35+ }
36+});
37+
38+queue = new MessageQueue(message, {
39+ onState(state) {
40+ if (controller) controller.onQueueState(state);
41+ }
42+});
43+
44+controller = createController({
45+ clock: { now: Date.now },
46+ queue,
47+ model: new WatchModel(),
48+ view
49+});
50+controller.start();
51+if (pendingChannel === "writable") queue.writable();
52+else if (pendingChannel === "suspended") queue.suspend();
53+pendingMessages.forEach(function (incoming) {
54+ controller.onReadable(incoming);
55+});
56+
57+watch.addEventListener("resize", function () {
58+ view.resize(screenInfo());
59+ controller.redraw();
60+});
61+
62+export default view.application;
new file mode 100644
@@ -0,0 +1,62 @@
1+import Message from "pebble/message";
2+import { APP_MESSAGE_KEY_MAP } from "./contracts.js";
3+import { createController } from "./controller.js";
4+import MessageQueue from "./message-queue.js";
5+import WatchModel from "./model.js";
6+import { createWatchView } from "./ui.js";
7+
8+function screenInfo() {
9+ return {
10+ width: screen.width,
11+ height: screen.height,
12+ round: screen.round === true
13+ };
14+}
15+
16+let queue = null;
17+let controller = null;
18+let pendingChannel = null;
19+const pendingMessages = [];
20+const view = createWatchView(screenInfo());
21+const message = new Message({
22+ keys: APP_MESSAGE_KEY_MAP,
23+ onReadable() {
24+ const incoming = this.read();
25+ if (controller) controller.onReadable(incoming);
26+ else pendingMessages.push(incoming);
27+ },
28+ onWritable() {
29+ if (queue) queue.writable();
30+ else pendingChannel = "writable";
31+ },
32+ onSuspend() {
33+ if (queue) queue.suspend();
34+ else pendingChannel = "suspended";
35+ }
36+});
37+
38+queue = new MessageQueue(message, {
39+ onState(state) {
40+ if (controller) controller.onQueueState(state);
41+ }
42+});
43+
44+controller = createController({
45+ clock: { now: Date.now },
46+ queue,
47+ model: new WatchModel(),
48+ view
49+});
50+controller.start();
51+if (pendingChannel === "writable") queue.writable();
52+else if (pendingChannel === "suspended") queue.suspend();
53+pendingMessages.forEach(function (incoming) {
54+ controller.onReadable(incoming);
55+});
56+
57+watch.addEventListener("resize", function () {
58+ view.resize(screenInfo());
59+ controller.redraw();
60+});
61+
62+export default view.application;
added packages/watch/src/embeddedjs/manifest.json +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+{
2+ "include": [
3+ "$(MODDABLE)/examples/manifest_mod.json",
4+ "$(MODDABLE)/examples/manifest_typings.json"
5+ ],
6+ "modules": {
7+ "*": [
8+ "./main",
9+ "./controller",
10+ "./model",
11+ "./protocol",
12+ "./message-queue",
13+ "./localization",
14+ "./ui",
15+ "./contracts"
16+ ]
17+ }
18+}
new file mode 100644
@@ -0,0 +1,18 @@
1+{
2+ "include": [
3+ "$(MODDABLE)/examples/manifest_mod.json",
4+ "$(MODDABLE)/examples/manifest_typings.json"
5+ ],
6+ "modules": {
7+ "*": [
8+ "./main",
9+ "./controller",
10+ "./model",
11+ "./protocol",
12+ "./message-queue",
13+ "./localization",
14+ "./ui",
15+ "./contracts"
16+ ]
17+ }
18+}
added packages/watch/src/embeddedjs/message-queue.js +96 -0
new file mode 100644
@@ -0,0 +1,96 @@
1+import { LIMITS } from "./contracts.js";
2+
3+export const QUEUE_STATE = Object.freeze({
4+ WRITABLE: "writable",
5+ SUSPENDED: "suspended",
6+ CLEARED: "cleared",
7+ FAILED: "failed",
8+ CLOSED: "closed"
9+});
10+
11+export class MessageQueue {
12+ constructor(message, options = {}) {
13+ if (!message || typeof message.write !== "function") {
14+ throw new TypeError("Message.write is required");
15+ }
16+ const capacity = options.capacity === undefined ? LIMITS.requestQueue : options.capacity;
17+ if (!Number.isInteger(capacity) || capacity !== LIMITS.requestQueue) {
18+ throw new RangeError("watch message queue capacity must be 4");
19+ }
20+ this.message = message;
21+ this.capacity = capacity;
22+ this.onState = typeof options.onState === "function" ? options.onState : function () {};
23+ this.items = [];
24+ this.credit = false;
25+ this.closed = false;
26+ this.generation = 0;
27+ }
28+
29+ enqueue(message) {
30+ if (this.closed || !(message instanceof Map)) return false;
31+ if (this.items.length >= this.capacity) {
32+ this.fail("QUEUE_OVERFLOW");
33+ return false;
34+ }
35+ this.items.push(message);
36+ this.flushOne();
37+ return true;
38+ }
39+
40+ writable() {
41+ if (this.closed) return false;
42+ this.credit = true;
43+ this.onState({ type: QUEUE_STATE.WRITABLE });
44+ return this.flushOne();
45+ }
46+
47+ suspend() {
48+ if (this.closed) return;
49+ this.credit = false;
50+ this.onState({ type: QUEUE_STATE.SUSPENDED });
51+ }
52+
53+ clear() {
54+ this.generation += 1;
55+ this.items.length = 0;
56+ this.credit = false;
57+ if (!this.closed) this.onState({ type: QUEUE_STATE.CLEARED, generation: this.generation });
58+ }
59+
60+ close() {
61+ if (this.closed) return;
62+ this.items.length = 0;
63+ this.credit = false;
64+ this.closed = true;
65+ this.generation += 1;
66+ if (typeof this.message.close === "function") this.message.close();
67+ this.onState({ type: QUEUE_STATE.CLOSED, generation: this.generation });
68+ }
69+
70+ flushOne() {
71+ if (this.closed || !this.credit || this.items.length === 0) return false;
72+ const pending = this.items[0];
73+ try {
74+ this.message.write(pending);
75+ } catch (ignored) {
76+ this.fail("MESSAGE_WRITE_FAILED");
77+ return false;
78+ }
79+ this.items.shift();
80+ this.credit = false;
81+ return true;
82+ }
83+
84+ fail(reason) {
85+ this.generation += 1;
86+ this.items.length = 0;
87+ this.credit = false;
88+ this.onState({
89+ type: QUEUE_STATE.FAILED,
90+ reason,
91+ generation: this.generation
92+ });
93+ }
94+}
95+
96+export default MessageQueue;
new file mode 100644
@@ -0,0 +1,96 @@
1+import { LIMITS } from "./contracts.js";
2+
3+export const QUEUE_STATE = Object.freeze({
4+ WRITABLE: "writable",
5+ SUSPENDED: "suspended",
6+ CLEARED: "cleared",
7+ FAILED: "failed",
8+ CLOSED: "closed"
9+});
10+
11+export class MessageQueue {
12+ constructor(message, options = {}) {
13+ if (!message || typeof message.write !== "function") {
14+ throw new TypeError("Message.write is required");
15+ }
16+ const capacity = options.capacity === undefined ? LIMITS.requestQueue : options.capacity;
17+ if (!Number.isInteger(capacity) || capacity !== LIMITS.requestQueue) {
18+ throw new RangeError("watch message queue capacity must be 4");
19+ }
20+ this.message = message;
21+ this.capacity = capacity;
22+ this.onState = typeof options.onState === "function" ? options.onState : function () {};
23+ this.items = [];
24+ this.credit = false;
25+ this.closed = false;
26+ this.generation = 0;
27+ }
28+
29+ enqueue(message) {
30+ if (this.closed || !(message instanceof Map)) return false;
31+ if (this.items.length >= this.capacity) {
32+ this.fail("QUEUE_OVERFLOW");
33+ return false;
34+ }
35+ this.items.push(message);
36+ this.flushOne();
37+ return true;
38+ }
39+
40+ writable() {
41+ if (this.closed) return false;
42+ this.credit = true;
43+ this.onState({ type: QUEUE_STATE.WRITABLE });
44+ return this.flushOne();
45+ }
46+
47+ suspend() {
48+ if (this.closed) return;
49+ this.credit = false;
50+ this.onState({ type: QUEUE_STATE.SUSPENDED });
51+ }
52+
53+ clear() {
54+ this.generation += 1;
55+ this.items.length = 0;
56+ this.credit = false;
57+ if (!this.closed) this.onState({ type: QUEUE_STATE.CLEARED, generation: this.generation });
58+ }
59+
60+ close() {
61+ if (this.closed) return;
62+ this.items.length = 0;
63+ this.credit = false;
64+ this.closed = true;
65+ this.generation += 1;
66+ if (typeof this.message.close === "function") this.message.close();
67+ this.onState({ type: QUEUE_STATE.CLOSED, generation: this.generation });
68+ }
69+
70+ flushOne() {
71+ if (this.closed || !this.credit || this.items.length === 0) return false;
72+ const pending = this.items[0];
73+ try {
74+ this.message.write(pending);
75+ } catch (ignored) {
76+ this.fail("MESSAGE_WRITE_FAILED");
77+ return false;
78+ }
79+ this.items.shift();
80+ this.credit = false;
81+ return true;
82+ }
83+
84+ fail(reason) {
85+ this.generation += 1;
86+ this.items.length = 0;
87+ this.credit = false;
88+ this.onState({
89+ type: QUEUE_STATE.FAILED,
90+ reason,
91+ generation: this.generation
92+ });
93+ }
94+}
95+
96+export default MessageQueue;
added packages/watch/src/embeddedjs/model.js +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+import { KEY_STATUS, REQUEST_TRIGGER } from "./contracts.js";
2+
3+function copyFavorite(favorite) {
4+ if (!favorite) return null;
5+ const copy = {
6+ id: favorite.id,
7+ serviceId: favorite.serviceId,
8+ stopLabel: favorite.stopLabel,
9+ lineLabel: favorite.lineLabel,
10+ destinationLabel: favorite.destinationLabel,
11+ sortOrder: favorite.sortOrder
12+ };
13+ if (favorite.displayName !== undefined) copy.displayName = favorite.displayName;
14+ return copy;
15+}
16+
17+function matchingFavorite(configuration) {
18+ if (!configuration || configuration.favorites.length === 0) return null;
19+ return configuration.favorites[0];
20+}
21+function fixtureFavorite(configuration) {
22+ return configuration && configuration.keyStatus === KEY_STATUS.CONFIGURED
23+ ? matchingFavorite(configuration)
24+ : null;
25+}
26+
27+
28+export class WatchModel {
29+ constructor() {
30+ this.configuration = null;
31+ this.result = null;
32+ this.error = null;
33+ this.expected = null;
34+ this.appOpenPending = true;
35+ this.sendFailed = false;
36+ }
37+
38+ commitProtocol(protocolSnapshot, event) {
39+ this.configuration = protocolSnapshot.configuration;
40+ this.result = protocolSnapshot.result;
41+ this.error = protocolSnapshot.error;
42+
43+ if (event === "CONFIG_COMMITTED") {
44+ this.expected = null;
45+ this.sendFailed = false;
46+ if (this.appOpenPending && fixtureFavorite(this.configuration)) {
47+ this.appOpenPending = false;
48+ return { requestFixture: true };
49+ }
50+ } else if (event === "RESULT_COMMITTED" || event === "ERROR_COMMITTED") {
51+ this.expected = null;
52+ this.sendFailed = false;
53+ }
54+ return { requestFixture: false };
55+ }
56+
57+ beginFixtureRequest(requestId) {
58+ const favorite = fixtureFavorite(this.configuration);
59+ if (!favorite || this.expected) return null;
60+ this.expected = {
61+ requestId,
62+ favoriteId: favorite.id,
63+ trigger: REQUEST_TRIGGER.APP_OPEN
64+ };
65+ this.sendFailed = false;
66+ return { ...this.expected };
67+ }
68+
69+ markSendFailure() {
70+ this.expected = null;
71+ this.sendFailed = true;
72+ }
73+
74+ snapshot() {
75+ const favorite = matchingFavorite(this.configuration);
76+ const result = favorite && this.result && this.result.favoriteId === favorite.id
77+ ? this.result
78+ : null;
79+ const error = favorite
80+ && this.error
81+ && (this.error.favoriteId === undefined || this.error.favoriteId === favorite.id)
82+ ? this.error
83+ : null;
84+ return {
85+ language: this.configuration ? this.configuration.language : "en",
86+ favorite: copyFavorite(favorite),
87+ result,
88+ error,
89+ sendFailed: this.sendFailed,
90+ expectedRequest: this.expected ? { ...this.expected } : null
91+ };
92+ }
93+}
94+
95+export default WatchModel;
new file mode 100644
@@ -0,0 +1,95 @@
1+import { KEY_STATUS, REQUEST_TRIGGER } from "./contracts.js";
2+
3+function copyFavorite(favorite) {
4+ if (!favorite) return null;
5+ const copy = {
6+ id: favorite.id,
7+ serviceId: favorite.serviceId,
8+ stopLabel: favorite.stopLabel,
9+ lineLabel: favorite.lineLabel,
10+ destinationLabel: favorite.destinationLabel,
11+ sortOrder: favorite.sortOrder
12+ };
13+ if (favorite.displayName !== undefined) copy.displayName = favorite.displayName;
14+ return copy;
15+}
16+
17+function matchingFavorite(configuration) {
18+ if (!configuration || configuration.favorites.length === 0) return null;
19+ return configuration.favorites[0];
20+}
21+function fixtureFavorite(configuration) {
22+ return configuration && configuration.keyStatus === KEY_STATUS.CONFIGURED
23+ ? matchingFavorite(configuration)
24+ : null;
25+}
26+
27+
28+export class WatchModel {
29+ constructor() {
30+ this.configuration = null;
31+ this.result = null;
32+ this.error = null;
33+ this.expected = null;
34+ this.appOpenPending = true;
35+ this.sendFailed = false;
36+ }
37+
38+ commitProtocol(protocolSnapshot, event) {
39+ this.configuration = protocolSnapshot.configuration;
40+ this.result = protocolSnapshot.result;
41+ this.error = protocolSnapshot.error;
42+
43+ if (event === "CONFIG_COMMITTED") {
44+ this.expected = null;
45+ this.sendFailed = false;
46+ if (this.appOpenPending && fixtureFavorite(this.configuration)) {
47+ this.appOpenPending = false;
48+ return { requestFixture: true };
49+ }
50+ } else if (event === "RESULT_COMMITTED" || event === "ERROR_COMMITTED") {
51+ this.expected = null;
52+ this.sendFailed = false;
53+ }
54+ return { requestFixture: false };
55+ }
56+
57+ beginFixtureRequest(requestId) {
58+ const favorite = fixtureFavorite(this.configuration);
59+ if (!favorite || this.expected) return null;
60+ this.expected = {
61+ requestId,
62+ favoriteId: favorite.id,
63+ trigger: REQUEST_TRIGGER.APP_OPEN
64+ };
65+ this.sendFailed = false;
66+ return { ...this.expected };
67+ }
68+
69+ markSendFailure() {
70+ this.expected = null;
71+ this.sendFailed = true;
72+ }
73+
74+ snapshot() {
75+ const favorite = matchingFavorite(this.configuration);
76+ const result = favorite && this.result && this.result.favoriteId === favorite.id
77+ ? this.result
78+ : null;
79+ const error = favorite
80+ && this.error
81+ && (this.error.favoriteId === undefined || this.error.favoriteId === favorite.id)
82+ ? this.error
83+ : null;
84+ return {
85+ language: this.configuration ? this.configuration.language : "en",
86+ favorite: copyFavorite(favorite),
87+ result,
88+ error,
89+ sendFailed: this.sendFailed,
90+ expectedRequest: this.expected ? { ...this.expected } : null
91+ };
92+ }
93+}
94+
95+export default WatchModel;
added packages/watch/src/embeddedjs/package.json +3 -0
new file mode 100644
@@ -0,0 +1,3 @@
1+{
2+ "type": "module"
3+}
new file mode 100644
@@ -0,0 +1,3 @@
1+{
2+ "type": "module"
3+}
added packages/watch/src/embeddedjs/protocol.js +456 -0
new file mode 100644
@@ -0,0 +1,456 @@
1+import {
2+ DEPARTURE_STATUS,
3+ ERROR_CODE,
4+ FRESHNESS,
5+ KEY_STATUS,
6+ LANGUAGE,
7+ LIMITS,
8+ MESSAGE_TYPE,
9+ REQUEST_TRIGGER,
10+ SCHEMA_VERSION,
11+ boundedString,
12+ enumHasValue,
13+ isWireLanguage,
14+ uint32
15+} from "./contracts.js";
16+
17+export const RECEIVE_RESULT = Object.freeze({
18+ REJECTED: 0,
19+ STAGED: 1,
20+ CONFIG_COMMITTED: 2,
21+ RESULT_COMMITTED: 3,
22+ ERROR_COMMITTED: 4
23+});
24+
25+const COMMON_KEYS = Object.freeze(["SCHEMA_VERSION", "MESSAGE_TYPE"]);
26+
27+function onlyKeys(message, allowed) {
28+ for (const key of message.keys()) {
29+ if (typeof key !== "string" || allowed.indexOf(key) === -1) return false;
30+ }
31+ return true;
32+}
33+
34+function integer(message, key) {
35+ const value = message.get(key);
36+ return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
37+}
38+
39+function string(message, key, maximum = LIMITS.idUtf8Bytes) {
40+ return boundedString(message.get(key), maximum);
41+}
42+
43+function optionalUint32(message, key) {
44+ return !message.has(key) || uint32(message.get(key));
45+}
46+
47+function aliases(message) {
48+ const decoded = Object.create(null);
49+ for (const entry of message) decoded[entry[0]] = entry[1];
50+ return decoded;
51+}
52+
53+export function decodeMessage(message) {
54+ if (!(message instanceof Map)
55+ || message.get("SCHEMA_VERSION") !== SCHEMA_VERSION
56+ || !integer(message, "MESSAGE_TYPE")) return null;
57+
58+ const type = message.get("MESSAGE_TYPE");
59+ let valid = false;
60+ switch (type) {
61+ case MESSAGE_TYPE.REQUEST:
62+ valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID", "REQUEST_TRIGGER"]))
63+ && string(message, "REQUEST_ID")
64+ && string(message, "FAVORITE_ID")
65+ && enumHasValue(REQUEST_TRIGGER, message.get("REQUEST_TRIGGER"));
66+ break;
67+ case MESSAGE_TYPE.CONFIG_BEGIN:
68+ valid = onlyKeys(message, COMMON_KEYS.concat([
69+ "REQUEST_ID",
70+ "ITEM_COUNT",
71+ "KEY_STATUS",
72+ "DISPLAY_NAME"
73+ ]))
74+ && string(message, "REQUEST_ID")
75+ && integer(message, "ITEM_COUNT")
76+ && message.get("ITEM_COUNT") >= 0
77+ && message.get("ITEM_COUNT") <= LIMITS.favorites
78+ && enumHasValue(KEY_STATUS, message.get("KEY_STATUS"))
79+ && (!message.has("DISPLAY_NAME") || isWireLanguage(message.get("DISPLAY_NAME")));
80+ break;
81+ case MESSAGE_TYPE.FAVORITE:
82+ valid = onlyKeys(message, COMMON_KEYS.concat([
83+ "REQUEST_ID",
84+ "ITEM_INDEX",
85+ "FAVORITE_ID",
86+ "SERVICE_ID",
87+ "DISPLAY_NAME",
88+ "STOP_LABEL",
89+ "LINE_LABEL",
90+ "DESTINATION_LABEL",
91+ "SORT_ORDER"
92+ ]))
93+ && string(message, "REQUEST_ID")
94+ && integer(message, "ITEM_INDEX")
95+ && message.get("ITEM_INDEX") >= 0
96+ && message.get("ITEM_INDEX") < LIMITS.favorites
97+ && string(message, "FAVORITE_ID")
98+ && string(message, "SERVICE_ID")
99+ && (!message.has("DISPLAY_NAME") || string(message, "DISPLAY_NAME", LIMITS.labelUtf8Bytes))
100+ && string(message, "STOP_LABEL", LIMITS.labelUtf8Bytes)
101+ && string(message, "LINE_LABEL", LIMITS.labelUtf8Bytes)
102+ && string(message, "DESTINATION_LABEL", LIMITS.labelUtf8Bytes)
103+ && integer(message, "SORT_ORDER")
104+ && message.get("SORT_ORDER") >= 0
105+ && message.get("SORT_ORDER") < LIMITS.favorites;
106+ break;
107+ case MESSAGE_TYPE.CONFIG_COMMIT:
108+ valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID"]))
109+ && string(message, "REQUEST_ID");
110+ break;
111+ case MESSAGE_TYPE.RESULT_BEGIN:
112+ valid = onlyKeys(message, COMMON_KEYS.concat([
113+ "REQUEST_ID",
114+ "FAVORITE_ID",
115+ "ITEM_COUNT",
116+ "FETCHED_AT",
117+ "SOURCE_UPDATED_AT",
118+ "FRESHNESS"
119+ ]))
120+ && string(message, "REQUEST_ID")
121+ && string(message, "FAVORITE_ID")
122+ && integer(message, "ITEM_COUNT")
123+ && message.get("ITEM_COUNT") >= 0
124+ && message.get("ITEM_COUNT") <= LIMITS.departures
125+ && uint32(message.get("FETCHED_AT"))
126+ && optionalUint32(message, "SOURCE_UPDATED_AT")
127+ && integer(message, "FRESHNESS")
128+ && message.get("FRESHNESS") >= 0
129+ && message.get("FRESHNESS") < FRESHNESS.length;
130+ break;
131+ case MESSAGE_TYPE.DEPARTURE:
132+ valid = onlyKeys(message, COMMON_KEYS.concat([
133+ "REQUEST_ID",
134+ "FAVORITE_ID",
135+ "ITEM_INDEX",
136+ "EXPECTED_AT",
137+ "AIMED_AT",
138+ "MINUTES",
139+ "DEPARTURE_STATUS",
140+ "NEXT_INTERVAL_MINUTES"
141+ ]))
142+ && string(message, "REQUEST_ID")
143+ && string(message, "FAVORITE_ID")
144+ && integer(message, "ITEM_INDEX")
145+ && message.get("ITEM_INDEX") >= 0
146+ && message.get("ITEM_INDEX") < LIMITS.departures
147+ && uint32(message.get("EXPECTED_AT"))
148+ && optionalUint32(message, "AIMED_AT")
149+ && integer(message, "MINUTES")
150+ && message.get("MINUTES") >= -1440
151+ && message.get("MINUTES") <= 1440
152+ && integer(message, "DEPARTURE_STATUS")
153+ && message.get("DEPARTURE_STATUS") >= 0
154+ && message.get("DEPARTURE_STATUS") < DEPARTURE_STATUS.length
155+ && (!message.has("NEXT_INTERVAL_MINUTES")
156+ || (integer(message, "NEXT_INTERVAL_MINUTES")
157+ && message.get("NEXT_INTERVAL_MINUTES") >= 0
158+ && message.get("NEXT_INTERVAL_MINUTES") <= 1440));
159+ break;
160+ case MESSAGE_TYPE.RESULT_COMMIT:
161+ valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID"]))
162+ && string(message, "REQUEST_ID")
163+ && string(message, "FAVORITE_ID");
164+ break;
165+ case MESSAGE_TYPE.ERROR:
166+ valid = onlyKeys(message, COMMON_KEYS.concat([
167+ "REQUEST_ID",
168+ "FAVORITE_ID",
169+ "ERROR_CODE",
170+ "OCCURRED_AT",
171+ "RETRY_AFTER_SECONDS"
172+ ]))
173+ && string(message, "REQUEST_ID")
174+ && (!message.has("FAVORITE_ID") || string(message, "FAVORITE_ID"))
175+ && integer(message, "ERROR_CODE")
176+ && message.get("ERROR_CODE") >= 0
177+ && message.get("ERROR_CODE") < ERROR_CODE.length
178+ && uint32(message.get("OCCURRED_AT"))
179+ && optionalUint32(message, "RETRY_AFTER_SECONDS");
180+ break;
181+ default:
182+ return null;
183+ }
184+ if (!valid) return null;
185+ return aliases(message);
186+}
187+
188+export function encodeRequest(request) {
189+ if (!request
190+ || !boundedString(request.requestId, LIMITS.idUtf8Bytes)
191+ || !boundedString(request.favoriteId, LIMITS.idUtf8Bytes)
192+ || !enumHasValue(REQUEST_TRIGGER, request.trigger)) {
193+ throw new TypeError("request does not match the watch protocol");
194+ }
195+ return new Map([
196+ ["SCHEMA_VERSION", SCHEMA_VERSION],
197+ ["MESSAGE_TYPE", MESSAGE_TYPE.REQUEST],
198+ ["REQUEST_ID", request.requestId],
199+ ["FAVORITE_ID", request.favoriteId],
200+ ["REQUEST_TRIGGER", request.trigger]
201+ ]);
202+}
203+
204+function copyFavorite(favorite) {
205+ const copy = {
206+ id: favorite.id,
207+ serviceId: favorite.serviceId,
208+ stopLabel: favorite.stopLabel,
209+ lineLabel: favorite.lineLabel,
210+ destinationLabel: favorite.destinationLabel,
211+ sortOrder: favorite.sortOrder
212+ };
213+ if (favorite.displayName !== undefined) copy.displayName = favorite.displayName;
214+ return copy;
215+}
216+
217+function copyDeparture(departure) {
218+ const copy = {
219+ expectedAt: departure.expectedAt,
220+ minutes: departure.minutes,
221+ status: departure.status
222+ };
223+ if (departure.aimedAt !== undefined) copy.aimedAt = departure.aimedAt;
224+ if (departure.nextIntervalMinutes !== undefined) {
225+ copy.nextIntervalMinutes = departure.nextIntervalMinutes;
226+ }
227+ return copy;
228+}
229+
230+function copyConfiguration(configuration) {
231+ if (!configuration) return null;
232+ return {
233+ keyStatus: configuration.keyStatus,
234+ language: configuration.language,
235+ favorites: configuration.favorites.map(copyFavorite)
236+ };
237+}
238+
239+function copyResult(result) {
240+ if (!result) return null;
241+ const copy = {
242+ requestId: result.requestId,
243+ favoriteId: result.favoriteId,
244+ fetchedAt: result.fetchedAt,
245+ freshness: result.freshness,
246+ departures: result.departures.map(copyDeparture)
247+ };
248+ if (result.sourceUpdatedAt !== undefined) copy.sourceUpdatedAt = result.sourceUpdatedAt;
249+ return copy;
250+}
251+
252+function copyError(error) {
253+ if (!error) return null;
254+ const copy = {
255+ requestId: error.requestId,
256+ code: error.code,
257+ occurredAt: error.occurredAt
258+ };
259+ if (error.favoriteId !== undefined) copy.favoriteId = error.favoriteId;
260+ if (error.retryAfterSeconds !== undefined) copy.retryAfterSeconds = error.retryAfterSeconds;
261+ return copy;
262+}
263+
264+export class ProtocolReceiver {
265+ constructor() {
266+ this.configuration = null;
267+ this.result = null;
268+ this.error = null;
269+ this.configStage = null;
270+ this.resultStage = null;
271+ this.expected = null;
272+ }
273+
274+ expectResponse(requestId, favoriteId) {
275+ if (!boundedString(requestId, LIMITS.idUtf8Bytes)
276+ || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
277+ this.expected = { requestId, favoriteId };
278+ this.resultStage = null;
279+ return true;
280+ }
281+
282+ cancelExpectedResponse() {
283+ this.expected = null;
284+ this.resultStage = null;
285+ }
286+
287+ discardStaging() {
288+ this.configStage = null;
289+ this.resultStage = null;
290+ }
291+
292+ snapshot() {
293+ return {
294+ configuration: copyConfiguration(this.configuration),
295+ result: copyResult(this.result),
296+ error: copyError(this.error)
297+ };
298+ }
299+
300+ receive(message) {
301+ const decoded = decodeMessage(message);
302+ if (!decoded) {
303+ this.discardStaging();
304+ return RECEIVE_RESULT.REJECTED;
305+ }
306+
307+ const type = decoded.MESSAGE_TYPE;
308+ const requestId = decoded.REQUEST_ID || "";
309+ if (type === MESSAGE_TYPE.CONFIG_BEGIN) {
310+ this.configStage = {
311+ requestId,
312+ count: decoded.ITEM_COUNT,
313+ keyStatus: decoded.KEY_STATUS,
314+ language: isWireLanguage(decoded.DISPLAY_NAME) ? decoded.DISPLAY_NAME : LANGUAGE.EN,
315+ favorites: []
316+ };
317+ return RECEIVE_RESULT.STAGED;
318+ }
319+
320+ if (type === MESSAGE_TYPE.FAVORITE) {
321+ const stage = this.configStage;
322+ let duplicate = false;
323+ if (stage) {
324+ for (let index = 0; index < stage.favorites.length; index += 1) {
325+ if (stage.favorites[index].id === decoded.FAVORITE_ID) duplicate = true;
326+ }
327+ }
328+ if (!stage
329+ || stage.requestId !== requestId
330+ || decoded.ITEM_INDEX !== stage.favorites.length
331+ || stage.favorites.length >= stage.count
332+ || duplicate) {
333+ this.configStage = null;
334+ return RECEIVE_RESULT.REJECTED;
335+ }
336+ const favorite = {
337+ id: decoded.FAVORITE_ID,
338+ serviceId: decoded.SERVICE_ID,
339+ stopLabel: decoded.STOP_LABEL,
340+ lineLabel: decoded.LINE_LABEL,
341+ destinationLabel: decoded.DESTINATION_LABEL,
342+ sortOrder: decoded.SORT_ORDER
343+ };
344+ if (decoded.DISPLAY_NAME !== undefined) favorite.displayName = decoded.DISPLAY_NAME;
345+ stage.favorites.push(favorite);
346+ return RECEIVE_RESULT.STAGED;
347+ }
348+
349+ if (type === MESSAGE_TYPE.CONFIG_COMMIT) {
350+ const stage = this.configStage;
351+ if (!stage
352+ || stage.requestId !== requestId
353+ || stage.favorites.length !== stage.count) {
354+ this.configStage = null;
355+ return RECEIVE_RESULT.REJECTED;
356+ }
357+ this.configuration = {
358+ keyStatus: stage.keyStatus,
359+ language: stage.language,
360+ favorites: stage.favorites.map(copyFavorite)
361+ };
362+ this.result = null;
363+ this.error = null;
364+ this.configStage = null;
365+ this.cancelExpectedResponse();
366+ return RECEIVE_RESULT.CONFIG_COMMITTED;
367+ }
368+
369+ if (type === MESSAGE_TYPE.RESULT_BEGIN) {
370+ if (!this.expected
371+ || this.expected.requestId !== requestId
372+ || this.expected.favoriteId !== decoded.FAVORITE_ID) {
373+ this.resultStage = null;
374+ return RECEIVE_RESULT.REJECTED;
375+ }
376+ this.resultStage = {
377+ requestId,
378+ favoriteId: decoded.FAVORITE_ID,
379+ count: decoded.ITEM_COUNT,
380+ fetchedAt: decoded.FETCHED_AT,
381+ sourceUpdatedAt: decoded.SOURCE_UPDATED_AT,
382+ freshness: FRESHNESS[decoded.FRESHNESS],
383+ departures: []
384+ };
385+ return RECEIVE_RESULT.STAGED;
386+ }
387+
388+ if (type === MESSAGE_TYPE.DEPARTURE) {
389+ const stage = this.resultStage;
390+ if (!stage
391+ || !this.expected
392+ || stage.requestId !== requestId
393+ || stage.favoriteId !== decoded.FAVORITE_ID
394+ || this.expected.requestId !== requestId
395+ || this.expected.favoriteId !== decoded.FAVORITE_ID
396+ || decoded.ITEM_INDEX !== stage.departures.length
397+ || stage.departures.length >= stage.count) {
398+ this.resultStage = null;
399+ return RECEIVE_RESULT.REJECTED;
400+ }
401+ const departure = {
402+ expectedAt: decoded.EXPECTED_AT,
403+ minutes: decoded.MINUTES,
404+ status: DEPARTURE_STATUS[decoded.DEPARTURE_STATUS]
405+ };
406+ if (decoded.AIMED_AT !== undefined) departure.aimedAt = decoded.AIMED_AT;
407+ if (decoded.NEXT_INTERVAL_MINUTES !== undefined) {
408+ departure.nextIntervalMinutes = decoded.NEXT_INTERVAL_MINUTES;
409+ }
410+ stage.departures.push(departure);
411+ return RECEIVE_RESULT.STAGED;
412+ }
413+
414+ if (type === MESSAGE_TYPE.RESULT_COMMIT) {
415+ const stage = this.resultStage;
416+ if (!stage
417+ || !this.expected
418+ || stage.requestId !== requestId
419+ || stage.favoriteId !== decoded.FAVORITE_ID
420+ || this.expected.requestId !== requestId
421+ || this.expected.favoriteId !== decoded.FAVORITE_ID
422+ || stage.departures.length !== stage.count) {
423+ this.resultStage = null;
424+ return RECEIVE_RESULT.REJECTED;
425+ }
426+ this.result = copyResult(stage);
427+ this.error = null;
428+ this.cancelExpectedResponse();
429+ return RECEIVE_RESULT.RESULT_COMMITTED;
430+ }
431+
432+ if (type === MESSAGE_TYPE.ERROR) {
433+ if (!this.expected
434+ || this.expected.requestId !== requestId
435+ || (decoded.FAVORITE_ID !== undefined
436+ && this.expected.favoriteId !== decoded.FAVORITE_ID)) {
437+ this.resultStage = null;
438+ return RECEIVE_RESULT.REJECTED;
439+ }
440+ this.error = {
441+ requestId,
442+ code: ERROR_CODE[decoded.ERROR_CODE],
443+ occurredAt: decoded.OCCURRED_AT
444+ };
445+ if (decoded.FAVORITE_ID !== undefined) this.error.favoriteId = decoded.FAVORITE_ID;
446+ if (decoded.RETRY_AFTER_SECONDS !== undefined) {
447+ this.error.retryAfterSeconds = decoded.RETRY_AFTER_SECONDS;
448+ }
449+ this.cancelExpectedResponse();
450+ return RECEIVE_RESULT.ERROR_COMMITTED;
451+ }
452+
453+ this.discardStaging();
454+ return RECEIVE_RESULT.REJECTED;
455+ }
456+}
new file mode 100644
@@ -0,0 +1,456 @@
1+import {
2+ DEPARTURE_STATUS,
3+ ERROR_CODE,
4+ FRESHNESS,
5+ KEY_STATUS,
6+ LANGUAGE,
7+ LIMITS,
8+ MESSAGE_TYPE,
9+ REQUEST_TRIGGER,
10+ SCHEMA_VERSION,
11+ boundedString,
12+ enumHasValue,
13+ isWireLanguage,
14+ uint32
15+} from "./contracts.js";
16+
17+export const RECEIVE_RESULT = Object.freeze({
18+ REJECTED: 0,
19+ STAGED: 1,
20+ CONFIG_COMMITTED: 2,
21+ RESULT_COMMITTED: 3,
22+ ERROR_COMMITTED: 4
23+});
24+
25+const COMMON_KEYS = Object.freeze(["SCHEMA_VERSION", "MESSAGE_TYPE"]);
26+
27+function onlyKeys(message, allowed) {
28+ for (const key of message.keys()) {
29+ if (typeof key !== "string" || allowed.indexOf(key) === -1) return false;
30+ }
31+ return true;
32+}
33+
34+function integer(message, key) {
35+ const value = message.get(key);
36+ return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
37+}
38+
39+function string(message, key, maximum = LIMITS.idUtf8Bytes) {
40+ return boundedString(message.get(key), maximum);
41+}
42+
43+function optionalUint32(message, key) {
44+ return !message.has(key) || uint32(message.get(key));
45+}
46+
47+function aliases(message) {
48+ const decoded = Object.create(null);
49+ for (const entry of message) decoded[entry[0]] = entry[1];
50+ return decoded;
51+}
52+
53+export function decodeMessage(message) {
54+ if (!(message instanceof Map)
55+ || message.get("SCHEMA_VERSION") !== SCHEMA_VERSION
56+ || !integer(message, "MESSAGE_TYPE")) return null;
57+
58+ const type = message.get("MESSAGE_TYPE");
59+ let valid = false;
60+ switch (type) {
61+ case MESSAGE_TYPE.REQUEST:
62+ valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID", "REQUEST_TRIGGER"]))
63+ && string(message, "REQUEST_ID")
64+ && string(message, "FAVORITE_ID")
65+ && enumHasValue(REQUEST_TRIGGER, message.get("REQUEST_TRIGGER"));
66+ break;
67+ case MESSAGE_TYPE.CONFIG_BEGIN:
68+ valid = onlyKeys(message, COMMON_KEYS.concat([
69+ "REQUEST_ID",
70+ "ITEM_COUNT",
71+ "KEY_STATUS",
72+ "DISPLAY_NAME"
73+ ]))
74+ && string(message, "REQUEST_ID")
75+ && integer(message, "ITEM_COUNT")
76+ && message.get("ITEM_COUNT") >= 0
77+ && message.get("ITEM_COUNT") <= LIMITS.favorites
78+ && enumHasValue(KEY_STATUS, message.get("KEY_STATUS"))
79+ && (!message.has("DISPLAY_NAME") || isWireLanguage(message.get("DISPLAY_NAME")));
80+ break;
81+ case MESSAGE_TYPE.FAVORITE:
82+ valid = onlyKeys(message, COMMON_KEYS.concat([
83+ "REQUEST_ID",
84+ "ITEM_INDEX",
85+ "FAVORITE_ID",
86+ "SERVICE_ID",
87+ "DISPLAY_NAME",
88+ "STOP_LABEL",
89+ "LINE_LABEL",
90+ "DESTINATION_LABEL",
91+ "SORT_ORDER"
92+ ]))
93+ && string(message, "REQUEST_ID")
94+ && integer(message, "ITEM_INDEX")
95+ && message.get("ITEM_INDEX") >= 0
96+ && message.get("ITEM_INDEX") < LIMITS.favorites
97+ && string(message, "FAVORITE_ID")
98+ && string(message, "SERVICE_ID")
99+ && (!message.has("DISPLAY_NAME") || string(message, "DISPLAY_NAME", LIMITS.labelUtf8Bytes))
100+ && string(message, "STOP_LABEL", LIMITS.labelUtf8Bytes)
101+ && string(message, "LINE_LABEL", LIMITS.labelUtf8Bytes)
102+ && string(message, "DESTINATION_LABEL", LIMITS.labelUtf8Bytes)
103+ && integer(message, "SORT_ORDER")
104+ && message.get("SORT_ORDER") >= 0
105+ && message.get("SORT_ORDER") < LIMITS.favorites;
106+ break;
107+ case MESSAGE_TYPE.CONFIG_COMMIT:
108+ valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID"]))
109+ && string(message, "REQUEST_ID");
110+ break;
111+ case MESSAGE_TYPE.RESULT_BEGIN:
112+ valid = onlyKeys(message, COMMON_KEYS.concat([
113+ "REQUEST_ID",
114+ "FAVORITE_ID",
115+ "ITEM_COUNT",
116+ "FETCHED_AT",
117+ "SOURCE_UPDATED_AT",
118+ "FRESHNESS"
119+ ]))
120+ && string(message, "REQUEST_ID")
121+ && string(message, "FAVORITE_ID")
122+ && integer(message, "ITEM_COUNT")
123+ && message.get("ITEM_COUNT") >= 0
124+ && message.get("ITEM_COUNT") <= LIMITS.departures
125+ && uint32(message.get("FETCHED_AT"))
126+ && optionalUint32(message, "SOURCE_UPDATED_AT")
127+ && integer(message, "FRESHNESS")
128+ && message.get("FRESHNESS") >= 0
129+ && message.get("FRESHNESS") < FRESHNESS.length;
130+ break;
131+ case MESSAGE_TYPE.DEPARTURE:
132+ valid = onlyKeys(message, COMMON_KEYS.concat([
133+ "REQUEST_ID",
134+ "FAVORITE_ID",
135+ "ITEM_INDEX",
136+ "EXPECTED_AT",
137+ "AIMED_AT",
138+ "MINUTES",
139+ "DEPARTURE_STATUS",
140+ "NEXT_INTERVAL_MINUTES"
141+ ]))
142+ && string(message, "REQUEST_ID")
143+ && string(message, "FAVORITE_ID")
144+ && integer(message, "ITEM_INDEX")
145+ && message.get("ITEM_INDEX") >= 0
146+ && message.get("ITEM_INDEX") < LIMITS.departures
147+ && uint32(message.get("EXPECTED_AT"))
148+ && optionalUint32(message, "AIMED_AT")
149+ && integer(message, "MINUTES")
150+ && message.get("MINUTES") >= -1440
151+ && message.get("MINUTES") <= 1440
152+ && integer(message, "DEPARTURE_STATUS")
153+ && message.get("DEPARTURE_STATUS") >= 0
154+ && message.get("DEPARTURE_STATUS") < DEPARTURE_STATUS.length
155+ && (!message.has("NEXT_INTERVAL_MINUTES")
156+ || (integer(message, "NEXT_INTERVAL_MINUTES")
157+ && message.get("NEXT_INTERVAL_MINUTES") >= 0
158+ && message.get("NEXT_INTERVAL_MINUTES") <= 1440));
159+ break;
160+ case MESSAGE_TYPE.RESULT_COMMIT:
161+ valid = onlyKeys(message, COMMON_KEYS.concat(["REQUEST_ID", "FAVORITE_ID"]))
162+ && string(message, "REQUEST_ID")
163+ && string(message, "FAVORITE_ID");
164+ break;
165+ case MESSAGE_TYPE.ERROR:
166+ valid = onlyKeys(message, COMMON_KEYS.concat([
167+ "REQUEST_ID",
168+ "FAVORITE_ID",
169+ "ERROR_CODE",
170+ "OCCURRED_AT",
171+ "RETRY_AFTER_SECONDS"
172+ ]))
173+ && string(message, "REQUEST_ID")
174+ && (!message.has("FAVORITE_ID") || string(message, "FAVORITE_ID"))
175+ && integer(message, "ERROR_CODE")
176+ && message.get("ERROR_CODE") >= 0
177+ && message.get("ERROR_CODE") < ERROR_CODE.length
178+ && uint32(message.get("OCCURRED_AT"))
179+ && optionalUint32(message, "RETRY_AFTER_SECONDS");
180+ break;
181+ default:
182+ return null;
183+ }
184+ if (!valid) return null;
185+ return aliases(message);
186+}
187+
188+export function encodeRequest(request) {
189+ if (!request
190+ || !boundedString(request.requestId, LIMITS.idUtf8Bytes)
191+ || !boundedString(request.favoriteId, LIMITS.idUtf8Bytes)
192+ || !enumHasValue(REQUEST_TRIGGER, request.trigger)) {
193+ throw new TypeError("request does not match the watch protocol");
194+ }
195+ return new Map([
196+ ["SCHEMA_VERSION", SCHEMA_VERSION],
197+ ["MESSAGE_TYPE", MESSAGE_TYPE.REQUEST],
198+ ["REQUEST_ID", request.requestId],
199+ ["FAVORITE_ID", request.favoriteId],
200+ ["REQUEST_TRIGGER", request.trigger]
201+ ]);
202+}
203+
204+function copyFavorite(favorite) {
205+ const copy = {
206+ id: favorite.id,
207+ serviceId: favorite.serviceId,
208+ stopLabel: favorite.stopLabel,
209+ lineLabel: favorite.lineLabel,
210+ destinationLabel: favorite.destinationLabel,
211+ sortOrder: favorite.sortOrder
212+ };
213+ if (favorite.displayName !== undefined) copy.displayName = favorite.displayName;
214+ return copy;
215+}
216+
217+function copyDeparture(departure) {
218+ const copy = {
219+ expectedAt: departure.expectedAt,
220+ minutes: departure.minutes,
221+ status: departure.status
222+ };
223+ if (departure.aimedAt !== undefined) copy.aimedAt = departure.aimedAt;
224+ if (departure.nextIntervalMinutes !== undefined) {
225+ copy.nextIntervalMinutes = departure.nextIntervalMinutes;
226+ }
227+ return copy;
228+}
229+
230+function copyConfiguration(configuration) {
231+ if (!configuration) return null;
232+ return {
233+ keyStatus: configuration.keyStatus,
234+ language: configuration.language,
235+ favorites: configuration.favorites.map(copyFavorite)
236+ };
237+}
238+
239+function copyResult(result) {
240+ if (!result) return null;
241+ const copy = {
242+ requestId: result.requestId,
243+ favoriteId: result.favoriteId,
244+ fetchedAt: result.fetchedAt,
245+ freshness: result.freshness,
246+ departures: result.departures.map(copyDeparture)
247+ };
248+ if (result.sourceUpdatedAt !== undefined) copy.sourceUpdatedAt = result.sourceUpdatedAt;
249+ return copy;
250+}
251+
252+function copyError(error) {
253+ if (!error) return null;
254+ const copy = {
255+ requestId: error.requestId,
256+ code: error.code,
257+ occurredAt: error.occurredAt
258+ };
259+ if (error.favoriteId !== undefined) copy.favoriteId = error.favoriteId;
260+ if (error.retryAfterSeconds !== undefined) copy.retryAfterSeconds = error.retryAfterSeconds;
261+ return copy;
262+}
263+
264+export class ProtocolReceiver {
265+ constructor() {
266+ this.configuration = null;
267+ this.result = null;
268+ this.error = null;
269+ this.configStage = null;
270+ this.resultStage = null;
271+ this.expected = null;
272+ }
273+
274+ expectResponse(requestId, favoriteId) {
275+ if (!boundedString(requestId, LIMITS.idUtf8Bytes)
276+ || !boundedString(favoriteId, LIMITS.idUtf8Bytes)) return false;
277+ this.expected = { requestId, favoriteId };
278+ this.resultStage = null;
279+ return true;
280+ }
281+
282+ cancelExpectedResponse() {
283+ this.expected = null;
284+ this.resultStage = null;
285+ }
286+
287+ discardStaging() {
288+ this.configStage = null;
289+ this.resultStage = null;
290+ }
291+
292+ snapshot() {
293+ return {
294+ configuration: copyConfiguration(this.configuration),
295+ result: copyResult(this.result),
296+ error: copyError(this.error)
297+ };
298+ }
299+
300+ receive(message) {
301+ const decoded = decodeMessage(message);
302+ if (!decoded) {
303+ this.discardStaging();
304+ return RECEIVE_RESULT.REJECTED;
305+ }
306+
307+ const type = decoded.MESSAGE_TYPE;
308+ const requestId = decoded.REQUEST_ID || "";
309+ if (type === MESSAGE_TYPE.CONFIG_BEGIN) {
310+ this.configStage = {
311+ requestId,
312+ count: decoded.ITEM_COUNT,
313+ keyStatus: decoded.KEY_STATUS,
314+ language: isWireLanguage(decoded.DISPLAY_NAME) ? decoded.DISPLAY_NAME : LANGUAGE.EN,
315+ favorites: []
316+ };
317+ return RECEIVE_RESULT.STAGED;
318+ }
319+
320+ if (type === MESSAGE_TYPE.FAVORITE) {
321+ const stage = this.configStage;
322+ let duplicate = false;
323+ if (stage) {
324+ for (let index = 0; index < stage.favorites.length; index += 1) {
325+ if (stage.favorites[index].id === decoded.FAVORITE_ID) duplicate = true;
326+ }
327+ }
328+ if (!stage
329+ || stage.requestId !== requestId
330+ || decoded.ITEM_INDEX !== stage.favorites.length
331+ || stage.favorites.length >= stage.count
332+ || duplicate) {
333+ this.configStage = null;
334+ return RECEIVE_RESULT.REJECTED;
335+ }
336+ const favorite = {
337+ id: decoded.FAVORITE_ID,
338+ serviceId: decoded.SERVICE_ID,
339+ stopLabel: decoded.STOP_LABEL,
340+ lineLabel: decoded.LINE_LABEL,
341+ destinationLabel: decoded.DESTINATION_LABEL,
342+ sortOrder: decoded.SORT_ORDER
343+ };
344+ if (decoded.DISPLAY_NAME !== undefined) favorite.displayName = decoded.DISPLAY_NAME;
345+ stage.favorites.push(favorite);
346+ return RECEIVE_RESULT.STAGED;
347+ }
348+
349+ if (type === MESSAGE_TYPE.CONFIG_COMMIT) {
350+ const stage = this.configStage;
351+ if (!stage
352+ || stage.requestId !== requestId
353+ || stage.favorites.length !== stage.count) {
354+ this.configStage = null;
355+ return RECEIVE_RESULT.REJECTED;
356+ }
357+ this.configuration = {
358+ keyStatus: stage.keyStatus,
359+ language: stage.language,
360+ favorites: stage.favorites.map(copyFavorite)
361+ };
362+ this.result = null;
363+ this.error = null;
364+ this.configStage = null;
365+ this.cancelExpectedResponse();
366+ return RECEIVE_RESULT.CONFIG_COMMITTED;
367+ }
368+
369+ if (type === MESSAGE_TYPE.RESULT_BEGIN) {
370+ if (!this.expected
371+ || this.expected.requestId !== requestId
372+ || this.expected.favoriteId !== decoded.FAVORITE_ID) {
373+ this.resultStage = null;
374+ return RECEIVE_RESULT.REJECTED;
375+ }
376+ this.resultStage = {
377+ requestId,
378+ favoriteId: decoded.FAVORITE_ID,
379+ count: decoded.ITEM_COUNT,
380+ fetchedAt: decoded.FETCHED_AT,
381+ sourceUpdatedAt: decoded.SOURCE_UPDATED_AT,
382+ freshness: FRESHNESS[decoded.FRESHNESS],
383+ departures: []
384+ };
385+ return RECEIVE_RESULT.STAGED;
386+ }
387+
388+ if (type === MESSAGE_TYPE.DEPARTURE) {
389+ const stage = this.resultStage;
390+ if (!stage
391+ || !this.expected
392+ || stage.requestId !== requestId
393+ || stage.favoriteId !== decoded.FAVORITE_ID
394+ || this.expected.requestId !== requestId
395+ || this.expected.favoriteId !== decoded.FAVORITE_ID
396+ || decoded.ITEM_INDEX !== stage.departures.length
397+ || stage.departures.length >= stage.count) {
398+ this.resultStage = null;
399+ return RECEIVE_RESULT.REJECTED;
400+ }
401+ const departure = {
402+ expectedAt: decoded.EXPECTED_AT,
403+ minutes: decoded.MINUTES,
404+ status: DEPARTURE_STATUS[decoded.DEPARTURE_STATUS]
405+ };
406+ if (decoded.AIMED_AT !== undefined) departure.aimedAt = decoded.AIMED_AT;
407+ if (decoded.NEXT_INTERVAL_MINUTES !== undefined) {
408+ departure.nextIntervalMinutes = decoded.NEXT_INTERVAL_MINUTES;
409+ }
410+ stage.departures.push(departure);
411+ return RECEIVE_RESULT.STAGED;
412+ }
413+
414+ if (type === MESSAGE_TYPE.RESULT_COMMIT) {
415+ const stage = this.resultStage;
416+ if (!stage
417+ || !this.expected
418+ || stage.requestId !== requestId
419+ || stage.favoriteId !== decoded.FAVORITE_ID
420+ || this.expected.requestId !== requestId
421+ || this.expected.favoriteId !== decoded.FAVORITE_ID
422+ || stage.departures.length !== stage.count) {
423+ this.resultStage = null;
424+ return RECEIVE_RESULT.REJECTED;
425+ }
426+ this.result = copyResult(stage);
427+ this.error = null;
428+ this.cancelExpectedResponse();
429+ return RECEIVE_RESULT.RESULT_COMMITTED;
430+ }
431+
432+ if (type === MESSAGE_TYPE.ERROR) {
433+ if (!this.expected
434+ || this.expected.requestId !== requestId
435+ || (decoded.FAVORITE_ID !== undefined
436+ && this.expected.favoriteId !== decoded.FAVORITE_ID)) {
437+ this.resultStage = null;
438+ return RECEIVE_RESULT.REJECTED;
439+ }
440+ this.error = {
441+ requestId,
442+ code: ERROR_CODE[decoded.ERROR_CODE],
443+ occurredAt: decoded.OCCURRED_AT
444+ };
445+ if (decoded.FAVORITE_ID !== undefined) this.error.favoriteId = decoded.FAVORITE_ID;
446+ if (decoded.RETRY_AFTER_SECONDS !== undefined) {
447+ this.error.retryAfterSeconds = decoded.RETRY_AFTER_SECONDS;
448+ }
449+ this.cancelExpectedResponse();
450+ return RECEIVE_RESULT.ERROR_COMMITTED;
451+ }
452+
453+ this.discardStaging();
454+ return RECEIVE_RESULT.REJECTED;
455+ }
456+}
added packages/watch/src/embeddedjs/ui.js +132 -0
new file mode 100644
@@ -0,0 +1,132 @@
1+import {} from "piu/MC";
2+import { copy, formatMinutes } from "./localization.js";
3+
4+const BACKGROUND_SKIN = new Skin({ fill: "white" });
5+const HEADER_STYLE = new Style({
6+ font: "bold 18px Gothic",
7+ color: "black",
8+ horizontal: "center",
9+ leading: 1
10+});
11+const BODY_STYLE = new Style({
12+ font: "18px Gothic",
13+ color: "black",
14+ horizontal: "center",
15+ leading: 2
16+});
17+const FOOTER_STYLE = new Style({
18+ font: "14px Gothic",
19+ color: "black",
20+ horizontal: "center"
21+});
22+
23+function geometry(screenInfo) {
24+ const width = Math.max(1, Number(screenInfo.width) || 1);
25+ const height = Math.max(1, Number(screenInfo.height) || 1);
26+ const round = screenInfo.round === true;
27+ const horizontal = round
28+ ? Math.max(26, Math.floor(width * 0.13))
29+ : Math.max(8, Math.floor(width * 0.04));
30+ const vertical = round
31+ ? Math.max(18, Math.floor(height * 0.08))
32+ : Math.max(6, Math.floor(height * 0.025));
33+ const headerHeight = Math.floor(height * (round ? 0.27 : 0.25));
34+ const footerHeight = Math.floor(height * 0.12);
35+ return {
36+ rows: round ? 2 : 3,
37+ header: {
38+ left: horizontal,
39+ right: horizontal,
40+ top: vertical,
41+ height: headerHeight
42+ },
43+ body: {
44+ left: horizontal,
45+ right: horizontal,
46+ top: vertical + headerHeight,
47+ bottom: vertical + footerHeight
48+ },
49+ footer: {
50+ left: horizontal,
51+ right: horizontal,
52+ bottom: vertical,
53+ height: footerHeight
54+ }
55+ };
56+}
57+
58+function setString(content, value) {
59+ if (content.string !== value) content.string = value;
60+}
61+
62+function headerText(snapshot) {
63+ if (!snapshot.favorite) return copy(snapshot.language, "appName");
64+ return snapshot.favorite.lineLabel + " → " + snapshot.favorite.destinationLabel;
65+}
66+
67+function bodyText(snapshot, rowLimit) {
68+ if (snapshot.result) {
69+ if (snapshot.result.departures.length === 0) {
70+ return copy(snapshot.language, "noDepartures");
71+ }
72+ const lines = [];
73+ const count = Math.min(rowLimit, snapshot.result.departures.length);
74+ for (let index = 0; index < count; index += 1) {
75+ lines.push(formatMinutes(snapshot.language, snapshot.result.departures[index].minutes));
76+ }
77+ return lines.join("\n");
78+ }
79+ if (snapshot.error || snapshot.sendFailed) return copy(snapshot.language, "unavailable");
80+ return snapshot.favorite
81+ ? copy(snapshot.language, "waiting")
82+ : copy(snapshot.language, "synchronizing");
83+}
84+
85+function footerText(snapshot) {
86+ if (snapshot.result) return copy(snapshot.language, "recordedFixture");
87+ if (!snapshot.favorite) return "";
88+ return snapshot.favorite.displayName || snapshot.favorite.stopLabel;
89+}
90+
91+export function createWatchView(screenInfo) {
92+ let layout = geometry(screenInfo);
93+ const header = new Text(null, {
94+ ...layout.header,
95+ style: HEADER_STYLE,
96+ string: ""
97+ });
98+ const body = new Text(null, {
99+ ...layout.body,
100+ style: BODY_STYLE,
101+ string: ""
102+ });
103+ const footer = new Text(null, {
104+ ...layout.footer,
105+ style: FOOTER_STYLE,
106+ string: ""
107+ });
108+ const application = new Application(null, {
109+ skin: BACKGROUND_SKIN,
110+ touchCount: 0,
111+ displayListLength: 2048,
112+ contents: [header, body, footer]
113+ });
114+
115+ return {
116+ application,
117+ render(snapshot) {
118+ setString(header, headerText(snapshot));
119+ setString(body, bodyText(snapshot, layout.rows));
120+ setString(footer, footerText(snapshot));
121+ },
122+ resize(nextScreenInfo) {
123+ layout = geometry(nextScreenInfo);
124+ header.coordinates = layout.header;
125+ body.coordinates = layout.body;
126+ footer.coordinates = layout.footer;
127+ },
128+ close() {
129+ application.empty();
130+ }
131+ };
132+}
new file mode 100644
@@ -0,0 +1,132 @@
1+import {} from "piu/MC";
2+import { copy, formatMinutes } from "./localization.js";
3+
4+const BACKGROUND_SKIN = new Skin({ fill: "white" });
5+const HEADER_STYLE = new Style({
6+ font: "bold 18px Gothic",
7+ color: "black",
8+ horizontal: "center",
9+ leading: 1
10+});
11+const BODY_STYLE = new Style({
12+ font: "18px Gothic",
13+ color: "black",
14+ horizontal: "center",
15+ leading: 2
16+});
17+const FOOTER_STYLE = new Style({
18+ font: "14px Gothic",
19+ color: "black",
20+ horizontal: "center"
21+});
22+
23+function geometry(screenInfo) {
24+ const width = Math.max(1, Number(screenInfo.width) || 1);
25+ const height = Math.max(1, Number(screenInfo.height) || 1);
26+ const round = screenInfo.round === true;
27+ const horizontal = round
28+ ? Math.max(26, Math.floor(width * 0.13))
29+ : Math.max(8, Math.floor(width * 0.04));
30+ const vertical = round
31+ ? Math.max(18, Math.floor(height * 0.08))
32+ : Math.max(6, Math.floor(height * 0.025));
33+ const headerHeight = Math.floor(height * (round ? 0.27 : 0.25));
34+ const footerHeight = Math.floor(height * 0.12);
35+ return {
36+ rows: round ? 2 : 3,
37+ header: {
38+ left: horizontal,
39+ right: horizontal,
40+ top: vertical,
41+ height: headerHeight
42+ },
43+ body: {
44+ left: horizontal,
45+ right: horizontal,
46+ top: vertical + headerHeight,
47+ bottom: vertical + footerHeight
48+ },
49+ footer: {
50+ left: horizontal,
51+ right: horizontal,
52+ bottom: vertical,
53+ height: footerHeight
54+ }
55+ };
56+}
57+
58+function setString(content, value) {
59+ if (content.string !== value) content.string = value;
60+}
61+
62+function headerText(snapshot) {
63+ if (!snapshot.favorite) return copy(snapshot.language, "appName");
64+ return snapshot.favorite.lineLabel + " → " + snapshot.favorite.destinationLabel;
65+}
66+
67+function bodyText(snapshot, rowLimit) {
68+ if (snapshot.result) {
69+ if (snapshot.result.departures.length === 0) {
70+ return copy(snapshot.language, "noDepartures");
71+ }
72+ const lines = [];
73+ const count = Math.min(rowLimit, snapshot.result.departures.length);
74+ for (let index = 0; index < count; index += 1) {
75+ lines.push(formatMinutes(snapshot.language, snapshot.result.departures[index].minutes));
76+ }
77+ return lines.join("\n");
78+ }
79+ if (snapshot.error || snapshot.sendFailed) return copy(snapshot.language, "unavailable");
80+ return snapshot.favorite
81+ ? copy(snapshot.language, "waiting")
82+ : copy(snapshot.language, "synchronizing");
83+}
84+
85+function footerText(snapshot) {
86+ if (snapshot.result) return copy(snapshot.language, "recordedFixture");
87+ if (!snapshot.favorite) return "";
88+ return snapshot.favorite.displayName || snapshot.favorite.stopLabel;
89+}
90+
91+export function createWatchView(screenInfo) {
92+ let layout = geometry(screenInfo);
93+ const header = new Text(null, {
94+ ...layout.header,
95+ style: HEADER_STYLE,
96+ string: ""
97+ });
98+ const body = new Text(null, {
99+ ...layout.body,
100+ style: BODY_STYLE,
101+ string: ""
102+ });
103+ const footer = new Text(null, {
104+ ...layout.footer,
105+ style: FOOTER_STYLE,
106+ string: ""
107+ });
108+ const application = new Application(null, {
109+ skin: BACKGROUND_SKIN,
110+ touchCount: 0,
111+ displayListLength: 2048,
112+ contents: [header, body, footer]
113+ });
114+
115+ return {
116+ application,
117+ render(snapshot) {
118+ setString(header, headerText(snapshot));
119+ setString(body, bodyText(snapshot, layout.rows));
120+ setString(footer, footerText(snapshot));
121+ },
122+ resize(nextScreenInfo) {
123+ layout = geometry(nextScreenInfo);
124+ header.coordinates = layout.header;
125+ body.coordinates = layout.body;
126+ footer.coordinates = layout.footer;
127+ },
128+ close() {
129+ application.empty();
130+ }
131+ };
132+}
added packages/watch/test/fixture-boot.test.js +102 -0
new file mode 100644
@@ -0,0 +1,102 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { createRequire } from "node:module";
4+import { createController } from "../src/embeddedjs/controller.js";
5+import WatchModel from "../src/embeddedjs/model.js";
6+
7+const require = createRequire(import.meta.url);
8+const companion = require("../../companion/src/index.js");
9+const fixture = require("../../../fixtures/departures/foundation.json");
10+const fakes = require("../../companion/test/fakes.js");
11+
12+class WatchQueue {
13+ constructor() {
14+ this.messages = [];
15+ }
16+
17+ enqueue(message) {
18+ this.messages.push(message);
19+ return true;
20+ }
21+
22+ close() {}
23+}
24+
25+class WatchView {
26+ constructor() {
27+ this.snapshots = [];
28+ }
29+
30+ render(snapshot) {
31+ this.snapshots.push(snapshot);
32+ }
33+
34+ close() {}
35+}
36+
37+function aliasMap(dictionary) {
38+ return new Map(Object.entries(dictionary));
39+}
40+
41+test("foundation fixture completes the PKJS to Alloy round trip without a key or XHR", () => {
42+ const Pebble = new fakes.FakePebble(true);
43+ const storage = new fakes.FakeStorage();
44+ const phoneClock = new fakes.FakeClock(fixture.result.fetchedAt * 1000);
45+ const xhr = fakes.createXHRFactory();
46+ companion.createCompanion({
47+ Pebble,
48+ storage,
49+ XHR: xhr.XHR,
50+ clock: phoneClock,
51+ defer: fakes.createDefer(true),
52+ backendUrl: "",
53+ configurationUrl: "",
54+ fixture
55+ });
56+
57+ const watchQueue = new WatchQueue();
58+ const watchView = new WatchView();
59+ const controller = createController({
60+ clock: { now: () => phoneClock.now() },
61+ queue: watchQueue,
62+ view: watchView,
63+ model: new WatchModel()
64+ }).start();
65+
66+ Pebble.emit("ready");
67+ const configurationMessages = Pebble.sent.slice();
68+ assert.deepEqual(configurationMessages.map((message) => message.MESSAGE_TYPE), [
69+ companion.contracts.MESSAGE_TYPE.CONFIG_BEGIN,
70+ companion.contracts.MESSAGE_TYPE.FAVORITE,
71+ companion.contracts.MESSAGE_TYPE.CONFIG_COMMIT
72+ ]);
73+ assert.equal(configurationMessages[0].KEY_STATUS, companion.contracts.KEY_STATUS.CONFIGURED);
74+ assert.equal(configurationMessages[0].DISPLAY_NAME, "en");
75+ configurationMessages.forEach((message) => controller.onReadable(aliasMap(message)));
76+
77+ assert.equal(watchQueue.messages.length, 1);
78+ const request = Object.fromEntries(watchQueue.messages[0]);
79+ assert.equal(request.REQUEST_TRIGGER, companion.contracts.REQUEST_TRIGGER.APP_OPEN);
80+ assert.equal(Object.keys(request).some((key) => /^\d+$/u.test(key)), false);
81+
82+ const resultStart = Pebble.sent.length;
83+ Pebble.emit("appmessage", { payload: request });
84+ const resultMessages = Pebble.sent.slice(resultStart);
85+ assert.deepEqual(resultMessages.map((message) => message.MESSAGE_TYPE), [
86+ companion.contracts.MESSAGE_TYPE.RESULT_BEGIN,
87+ companion.contracts.MESSAGE_TYPE.DEPARTURE,
88+ companion.contracts.MESSAGE_TYPE.DEPARTURE,
89+ companion.contracts.MESSAGE_TYPE.RESULT_COMMIT
90+ ]);
91+ resultMessages.forEach((message) => controller.onReadable(aliasMap(message)));
92+
93+ const finalSnapshot = watchView.snapshots.at(-1);
94+ assert.equal(finalSnapshot.favorite.id, fixture.favorite.id);
95+ assert.equal(finalSnapshot.result.favoriteId, fixture.result.favoriteId);
96+ assert.equal(finalSnapshot.result.departures.length, 2);
97+ assert.equal(finalSnapshot.result.departures[0].minutes, fixture.result.departures[0].minutes);
98+ assert.equal(finalSnapshot.sendFailed, false);
99+ assert.equal(xhr.instances.length, 0);
100+ assert.equal(storage.writes.length, 0);
101+ controller.close();
102+});
new file mode 100644
@@ -0,0 +1,102 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { createRequire } from "node:module";
4+import { createController } from "../src/embeddedjs/controller.js";
5+import WatchModel from "../src/embeddedjs/model.js";
6+
7+const require = createRequire(import.meta.url);
8+const companion = require("../../companion/src/index.js");
9+const fixture = require("../../../fixtures/departures/foundation.json");
10+const fakes = require("../../companion/test/fakes.js");
11+
12+class WatchQueue {
13+ constructor() {
14+ this.messages = [];
15+ }
16+
17+ enqueue(message) {
18+ this.messages.push(message);
19+ return true;
20+ }
21+
22+ close() {}
23+}
24+
25+class WatchView {
26+ constructor() {
27+ this.snapshots = [];
28+ }
29+
30+ render(snapshot) {
31+ this.snapshots.push(snapshot);
32+ }
33+
34+ close() {}
35+}
36+
37+function aliasMap(dictionary) {
38+ return new Map(Object.entries(dictionary));
39+}
40+
41+test("foundation fixture completes the PKJS to Alloy round trip without a key or XHR", () => {
42+ const Pebble = new fakes.FakePebble(true);
43+ const storage = new fakes.FakeStorage();
44+ const phoneClock = new fakes.FakeClock(fixture.result.fetchedAt * 1000);
45+ const xhr = fakes.createXHRFactory();
46+ companion.createCompanion({
47+ Pebble,
48+ storage,
49+ XHR: xhr.XHR,
50+ clock: phoneClock,
51+ defer: fakes.createDefer(true),
52+ backendUrl: "",
53+ configurationUrl: "",
54+ fixture
55+ });
56+
57+ const watchQueue = new WatchQueue();
58+ const watchView = new WatchView();
59+ const controller = createController({
60+ clock: { now: () => phoneClock.now() },
61+ queue: watchQueue,
62+ view: watchView,
63+ model: new WatchModel()
64+ }).start();
65+
66+ Pebble.emit("ready");
67+ const configurationMessages = Pebble.sent.slice();
68+ assert.deepEqual(configurationMessages.map((message) => message.MESSAGE_TYPE), [
69+ companion.contracts.MESSAGE_TYPE.CONFIG_BEGIN,
70+ companion.contracts.MESSAGE_TYPE.FAVORITE,
71+ companion.contracts.MESSAGE_TYPE.CONFIG_COMMIT
72+ ]);
73+ assert.equal(configurationMessages[0].KEY_STATUS, companion.contracts.KEY_STATUS.CONFIGURED);
74+ assert.equal(configurationMessages[0].DISPLAY_NAME, "en");
75+ configurationMessages.forEach((message) => controller.onReadable(aliasMap(message)));
76+
77+ assert.equal(watchQueue.messages.length, 1);
78+ const request = Object.fromEntries(watchQueue.messages[0]);
79+ assert.equal(request.REQUEST_TRIGGER, companion.contracts.REQUEST_TRIGGER.APP_OPEN);
80+ assert.equal(Object.keys(request).some((key) => /^\d+$/u.test(key)), false);
81+
82+ const resultStart = Pebble.sent.length;
83+ Pebble.emit("appmessage", { payload: request });
84+ const resultMessages = Pebble.sent.slice(resultStart);
85+ assert.deepEqual(resultMessages.map((message) => message.MESSAGE_TYPE), [
86+ companion.contracts.MESSAGE_TYPE.RESULT_BEGIN,
87+ companion.contracts.MESSAGE_TYPE.DEPARTURE,
88+ companion.contracts.MESSAGE_TYPE.DEPARTURE,
89+ companion.contracts.MESSAGE_TYPE.RESULT_COMMIT
90+ ]);
91+ resultMessages.forEach((message) => controller.onReadable(aliasMap(message)));
92+
93+ const finalSnapshot = watchView.snapshots.at(-1);
94+ assert.equal(finalSnapshot.favorite.id, fixture.favorite.id);
95+ assert.equal(finalSnapshot.result.favoriteId, fixture.result.favoriteId);
96+ assert.equal(finalSnapshot.result.departures.length, 2);
97+ assert.equal(finalSnapshot.result.departures[0].minutes, fixture.result.departures[0].minutes);
98+ assert.equal(finalSnapshot.sendFailed, false);
99+ assert.equal(xhr.instances.length, 0);
100+ assert.equal(storage.writes.length, 0);
101+ controller.close();
102+});
added packages/watch/test/localization.test.js +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ COPY,
5+ copy,
6+ formatMinutes,
7+ normalizeLanguage
8+} from "../src/embeddedjs/localization.js";
9+
10+test("foundation fixture copy has complete French and English tables", () => {
11+ assert.deepEqual(Object.keys(COPY.fr).sort(), Object.keys(COPY.en).sort());
12+ Object.keys(COPY.en).forEach((key) => {
13+ assert.equal(typeof COPY.en[key], "string");
14+ assert.equal(COPY.en[key].length > 0, true);
15+ assert.equal(typeof COPY.fr[key], "string");
16+ assert.equal(COPY.fr[key].length > 0, true);
17+ });
18+});
19+
20+test("fr language subtags select French and every other input falls back to English", () => {
21+ ["fr", "fr-FR", "fr_FR", "fr-CA", "FR_fr"].forEach((tag) => {
22+ assert.equal(normalizeLanguage(tag), "fr");
23+ });
24+ [undefined, null, "", "en", "en-US", "de-DE", "french"].forEach((tag) => {
25+ assert.equal(normalizeLanguage(tag), "en");
26+ });
27+ assert.equal(copy("fr_FR", "waiting"), "En attente des départs enregistrés…");
28+ assert.equal(copy("de_DE", "waiting"), "Waiting for recorded departures…");
29+});
30+
31+test("fixture countdown formatting is bilingual and does not translate transport labels", () => {
32+ assert.equal(formatMinutes("en", -1), "Departed");
33+ assert.equal(formatMinutes("fr", 0), "À quai");
34+ assert.equal(formatMinutes("en", 12), "12 min");
35+ const labels = {
36+ displayName: "Bureau – côté Seine",
37+ stopLabel: "Saint-Michel Notre-Dame",
38+ lineLabel: "RER B",
39+ destinationLabel: "Aéroport Charles-de-Gaulle 2 TGV"
40+ };
41+ assert.deepEqual({ ...labels }, labels);
42+ assert.equal(copy("fr", labels.stopLabel), "");
43+});
new file mode 100644
@@ -0,0 +1,43 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ COPY,
5+ copy,
6+ formatMinutes,
7+ normalizeLanguage
8+} from "../src/embeddedjs/localization.js";
9+
10+test("foundation fixture copy has complete French and English tables", () => {
11+ assert.deepEqual(Object.keys(COPY.fr).sort(), Object.keys(COPY.en).sort());
12+ Object.keys(COPY.en).forEach((key) => {
13+ assert.equal(typeof COPY.en[key], "string");
14+ assert.equal(COPY.en[key].length > 0, true);
15+ assert.equal(typeof COPY.fr[key], "string");
16+ assert.equal(COPY.fr[key].length > 0, true);
17+ });
18+});
19+
20+test("fr language subtags select French and every other input falls back to English", () => {
21+ ["fr", "fr-FR", "fr_FR", "fr-CA", "FR_fr"].forEach((tag) => {
22+ assert.equal(normalizeLanguage(tag), "fr");
23+ });
24+ [undefined, null, "", "en", "en-US", "de-DE", "french"].forEach((tag) => {
25+ assert.equal(normalizeLanguage(tag), "en");
26+ });
27+ assert.equal(copy("fr_FR", "waiting"), "En attente des départs enregistrés…");
28+ assert.equal(copy("de_DE", "waiting"), "Waiting for recorded departures…");
29+});
30+
31+test("fixture countdown formatting is bilingual and does not translate transport labels", () => {
32+ assert.equal(formatMinutes("en", -1), "Departed");
33+ assert.equal(formatMinutes("fr", 0), "À quai");
34+ assert.equal(formatMinutes("en", 12), "12 min");
35+ const labels = {
36+ displayName: "Bureau – côté Seine",
37+ stopLabel: "Saint-Michel Notre-Dame",
38+ lineLabel: "RER B",
39+ destinationLabel: "Aéroport Charles-de-Gaulle 2 TGV"
40+ };
41+ assert.deepEqual({ ...labels }, labels);
42+ assert.equal(copy("fr", labels.stopLabel), "");
43+});
added packages/watch/test/manifest.test.js +174 -0
new file mode 100644
@@ -0,0 +1,174 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { createRequire } from "node:module";
4+import { existsSync, readFileSync, readdirSync } from "node:fs";
5+import { fileURLToPath } from "node:url";
6+import {
7+ APP_MESSAGE_KEY as WATCH_MESSAGE_KEY,
8+ APP_MESSAGE_KEY_MAP as WATCH_MESSAGE_KEY_MAP,
9+ APP_MESSAGE_KEY_ORDER as WATCH_MESSAGE_KEY_ORDER,
10+ LANGUAGE as WATCH_LANGUAGE
11+} from "../src/embeddedjs/contracts.js";
12+import {
13+ APP_MESSAGE_KEY as CANONICAL_MESSAGE_KEY,
14+ APP_MESSAGE_KEY_ORDER as CANONICAL_MESSAGE_KEY_ORDER,
15+ WIRE_LANGUAGE as CANONICAL_LANGUAGE
16+} from "../../contracts/src/index.ts";
17+
18+const require = createRequire(import.meta.url);
19+const companionContracts = require("../../companion/src/contracts.js");
20+const watchRoot = fileURLToPath(new URL("..", import.meta.url));
21+const packageMetadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
22+const embeddedPackageMetadata = JSON.parse(
23+ readFileSync(new URL("../src/embeddedjs/package.json", import.meta.url), "utf8")
24+);
25+const testPackageMetadata = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
26+const manifest = JSON.parse(readFileSync(new URL("../src/embeddedjs/manifest.json", import.meta.url), "utf8"));
27+const wscript = readFileSync(new URL("../wscript", import.meta.url), "utf8");
28+const mdblSource = readFileSync(new URL("../src/c/mdbl.c", import.meta.url), "utf8");
29+const expectedModules = [
30+ "./main",
31+ "./controller",
32+ "./model",
33+ "./protocol",
34+ "./message-queue",
35+ "./localization",
36+ "./ui",
37+ "./contracts"
38+];
39+
40+function moduleSource(name) {
41+ return readFileSync(new URL("../src/embeddedjs/" + name.slice(2) + ".js", import.meta.url), "utf8");
42+}
43+
44+function xsArenaBytes(field) {
45+ const constant = `XS_${field.toUpperCase()}_BYTES`;
46+ const match = mdblSource.match(
47+ new RegExp(`^#define ${constant} \\((\\d+)U \\* 1024U\\)$`, "mu")
48+ );
49+ assert.notEqual(match, null, `${constant} must be an explicit KiB allocation`);
50+ return Number(match[1]) * 1024;
51+}
52+
53+test("watch package is one multi-module Alloy app for emery and gabbro", () => {
54+ assert.equal(packageMetadata.pebble.projectType, "moddable");
55+ assert.equal(packageMetadata.pebble.enableMultiJS, true);
56+ assert.deepEqual(packageMetadata.pebble.targetPlatforms, ["emery", "gabbro"]);
57+ assert.deepEqual(packageMetadata.pebble.resources, { media: [] });
58+ assert.deepEqual(packageMetadata.pebble.capabilities, ["configurable"]);
59+ assert.equal(existsSync(new URL("../appinfo.json", import.meta.url)), false);
60+ assert.deepEqual(readdirSync(watchRoot + "/src/c").sort(), ["mdbl.c"]);
61+});
62+
63+test("Alloy bootstrap uses one bounded explicit XS creation record", () => {
64+ const arenas = {
65+ stack: xsArenaBytes("stack"),
66+ slot: xsArenaBytes("slot"),
67+ chunk: xsArenaBytes("chunk")
68+ };
69+ const arenaValues = Object.values(arenas);
70+ const arenaTotal = arenaValues.reduce((total, bytes) => total + bytes, 0);
71+
72+ assert.deepEqual(arenas, {
73+ stack: 4 * 1024,
74+ slot: 32 * 1024,
75+ chunk: 20 * 1024
76+ });
77+ assert.equal(arenaValues.every((bytes) => bytes > 0), true);
78+ assert.equal(arenaTotal, 56 * 1024);
79+ assert.equal(arenaTotal > 32 * 1024, true, "record must exceed the firmware static default");
80+ assert.equal(arenaTotal <= 56 * 1024, true, "record must fit the frozen RAM budget");
81+
82+ const records = [
83+ ...mdblSource.matchAll(/ModdableCreationRecord\s+creation\s*=\s*\{([\s\S]*?)\};/gu)
84+ ];
85+ assert.equal(records.length, 1);
86+ const record = records[0][1];
87+ assert.match(record, /\.recordSize\s*=\s*sizeof\(creation\)\s*,/u);
88+ for (const field of Object.keys(arenas)) {
89+ const constant = `XS_${field.toUpperCase()}_BYTES`;
90+ assert.match(record, new RegExp(`\\.${field}\\s*=\\s*${constant}\\s*,`, "u"));
91+ }
92+ assert.match(record, /\.flags\s*=\s*XS_CREATION_FLAGS\s*,/u);
93+ assert.match(
94+ mdblSource,
95+ /\(kModdableCreationFlagDebug \| kModdableCreationFlagLogInstrumentation\)/u
96+ );
97+ assert.match(mdblSource, /#else\s+#define XS_CREATION_FLAGS 0U\s+#endif/u);
98+ assert.equal((mdblSource.match(/\bmoddable_createMachine\s*\(/gu) ?? []).length, 1);
99+ assert.match(mdblSource, /\bmoddable_createMachine\s*\(\s*&creation\s*\)\s*;/u);
100+ assert.doesNotMatch(mdblSource, /\bmoddable_createMachine\s*\(\s*(?:NULL|0)\s*\)/u);
101+});
102+
103+test("Node package boundaries keep generated webpack CommonJS and embedded/test JS ESM", () => {
104+ assert.equal(packageMetadata.type, "commonjs");
105+ assert.equal(embeddedPackageMetadata.type, "module");
106+ assert.equal(testPackageMetadata.type, "module");
107+});
108+
109+test("Waf builds every target before bundling generated PKJS into one package", () => {
110+ assert.match(wscript, /for platform in ctx\.env\.TARGET_PLATFORMS:/u);
111+ assert.match(wscript, /ctx\.pbl_build\(source=ctx\.path\.ant_glob\('src\/c\/\*\*\/\*\.c'\)/u);
112+ assert.match(wscript, /ctx\.pbl_bundle\(binaries=binaries,/u);
113+ assert.match(wscript, /'src\/pkjs\/\*\*\/\*\.js'/u);
114+ assert.match(wscript, /js_entry_file='src\/pkjs\/index\.js'/u);
115+});
116+
117+test("Alloy Message receives an explicit frozen-ID key Map", () => {
118+ const source = moduleSource("./main");
119+ const expectedEntries = CANONICAL_MESSAGE_KEY_ORDER.map((alias) => [
120+ alias,
121+ CANONICAL_MESSAGE_KEY[alias]
122+ ]);
123+
124+ assert.equal(WATCH_MESSAGE_KEY_MAP instanceof Map, true);
125+ assert.equal(Array.isArray(WATCH_MESSAGE_KEY_MAP), false);
126+ assert.deepEqual([...WATCH_MESSAGE_KEY_MAP], expectedEntries);
127+ assert.match(source, /keys:\s*APP_MESSAGE_KEY_MAP/u);
128+ assert.doesNotMatch(source, /keys:\s*APP_MESSAGE_KEY_ORDER/u);
129+ assert.doesNotMatch(source, /\bformat\s*:/u);
130+});
131+
132+test("package, canonical, companion, and embedded numeric maps are identical", () => {
133+ assert.deepEqual(packageMetadata.pebble.messageKeys, CANONICAL_MESSAGE_KEY);
134+ assert.deepEqual(WATCH_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);
135+ assert.deepEqual(companionContracts.APP_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);
136+ assert.deepEqual(WATCH_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);
137+ assert.deepEqual(companionContracts.APP_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);
138+ assert.deepEqual(WATCH_LANGUAGE, CANONICAL_LANGUAGE);
139+ assert.deepEqual(companionContracts.WIRE_LANGUAGE, CANONICAL_LANGUAGE);
140+ assert.deepEqual(
141+ Object.entries(packageMetadata.pebble.messageKeys)
142+ .sort((left, right) => left[1] - right[1])
143+ .map((entry) => entry[0]),
144+ CANONICAL_MESSAGE_KEY_ORDER
145+ );
146+ assert.deepEqual(
147+ Object.values(packageMetadata.pebble.messageKeys).sort((left, right) => left - right),
148+ Array.from({ length: 25 }, (_, index) => index)
149+ );
150+});
151+
152+test("manifest declares every embedded module and no FFI or watch network module", () => {
153+ assert.deepEqual(manifest.include, [
154+ "$(MODDABLE)/examples/manifest_mod.json",
155+ "$(MODDABLE)/examples/manifest_typings.json"
156+ ]);
157+ assert.deepEqual(manifest.modules["*"], expectedModules);
158+ assert.equal(Object.prototype.hasOwnProperty.call(manifest, "ffi"), false);
159+
160+ const declared = new Set(expectedModules);
161+ const bareImports = new Set();
162+ expectedModules.forEach((name) => {
163+ const source = moduleSource(name);
164+ const imports = source.matchAll(/from\s+["']([^"']+)["']/gu);
165+ for (const match of imports) {
166+ if (match[1].startsWith(".")) {
167+ assert.equal(declared.has(match[1].replace(/\.js$/u, "")), true, match[1]);
168+ } else {
169+ bareImports.add(match[1]);
170+ }
171+ }
172+ });
173+ assert.deepEqual([...bareImports].sort(), ["pebble/message", "piu/MC"]);
174+});
new file mode 100644
@@ -0,0 +1,174 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import { createRequire } from "node:module";
4+import { existsSync, readFileSync, readdirSync } from "node:fs";
5+import { fileURLToPath } from "node:url";
6+import {
7+ APP_MESSAGE_KEY as WATCH_MESSAGE_KEY,
8+ APP_MESSAGE_KEY_MAP as WATCH_MESSAGE_KEY_MAP,
9+ APP_MESSAGE_KEY_ORDER as WATCH_MESSAGE_KEY_ORDER,
10+ LANGUAGE as WATCH_LANGUAGE
11+} from "../src/embeddedjs/contracts.js";
12+import {
13+ APP_MESSAGE_KEY as CANONICAL_MESSAGE_KEY,
14+ APP_MESSAGE_KEY_ORDER as CANONICAL_MESSAGE_KEY_ORDER,
15+ WIRE_LANGUAGE as CANONICAL_LANGUAGE
16+} from "../../contracts/src/index.ts";
17+
18+const require = createRequire(import.meta.url);
19+const companionContracts = require("../../companion/src/contracts.js");
20+const watchRoot = fileURLToPath(new URL("..", import.meta.url));
21+const packageMetadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
22+const embeddedPackageMetadata = JSON.parse(
23+ readFileSync(new URL("../src/embeddedjs/package.json", import.meta.url), "utf8")
24+);
25+const testPackageMetadata = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
26+const manifest = JSON.parse(readFileSync(new URL("../src/embeddedjs/manifest.json", import.meta.url), "utf8"));
27+const wscript = readFileSync(new URL("../wscript", import.meta.url), "utf8");
28+const mdblSource = readFileSync(new URL("../src/c/mdbl.c", import.meta.url), "utf8");
29+const expectedModules = [
30+ "./main",
31+ "./controller",
32+ "./model",
33+ "./protocol",
34+ "./message-queue",
35+ "./localization",
36+ "./ui",
37+ "./contracts"
38+];
39+
40+function moduleSource(name) {
41+ return readFileSync(new URL("../src/embeddedjs/" + name.slice(2) + ".js", import.meta.url), "utf8");
42+}
43+
44+function xsArenaBytes(field) {
45+ const constant = `XS_${field.toUpperCase()}_BYTES`;
46+ const match = mdblSource.match(
47+ new RegExp(`^#define ${constant} \\((\\d+)U \\* 1024U\\)$`, "mu")
48+ );
49+ assert.notEqual(match, null, `${constant} must be an explicit KiB allocation`);
50+ return Number(match[1]) * 1024;
51+}
52+
53+test("watch package is one multi-module Alloy app for emery and gabbro", () => {
54+ assert.equal(packageMetadata.pebble.projectType, "moddable");
55+ assert.equal(packageMetadata.pebble.enableMultiJS, true);
56+ assert.deepEqual(packageMetadata.pebble.targetPlatforms, ["emery", "gabbro"]);
57+ assert.deepEqual(packageMetadata.pebble.resources, { media: [] });
58+ assert.deepEqual(packageMetadata.pebble.capabilities, ["configurable"]);
59+ assert.equal(existsSync(new URL("../appinfo.json", import.meta.url)), false);
60+ assert.deepEqual(readdirSync(watchRoot + "/src/c").sort(), ["mdbl.c"]);
61+});
62+
63+test("Alloy bootstrap uses one bounded explicit XS creation record", () => {
64+ const arenas = {
65+ stack: xsArenaBytes("stack"),
66+ slot: xsArenaBytes("slot"),
67+ chunk: xsArenaBytes("chunk")
68+ };
69+ const arenaValues = Object.values(arenas);
70+ const arenaTotal = arenaValues.reduce((total, bytes) => total + bytes, 0);
71+
72+ assert.deepEqual(arenas, {
73+ stack: 4 * 1024,
74+ slot: 32 * 1024,
75+ chunk: 20 * 1024
76+ });
77+ assert.equal(arenaValues.every((bytes) => bytes > 0), true);
78+ assert.equal(arenaTotal, 56 * 1024);
79+ assert.equal(arenaTotal > 32 * 1024, true, "record must exceed the firmware static default");
80+ assert.equal(arenaTotal <= 56 * 1024, true, "record must fit the frozen RAM budget");
81+
82+ const records = [
83+ ...mdblSource.matchAll(/ModdableCreationRecord\s+creation\s*=\s*\{([\s\S]*?)\};/gu)
84+ ];
85+ assert.equal(records.length, 1);
86+ const record = records[0][1];
87+ assert.match(record, /\.recordSize\s*=\s*sizeof\(creation\)\s*,/u);
88+ for (const field of Object.keys(arenas)) {
89+ const constant = `XS_${field.toUpperCase()}_BYTES`;
90+ assert.match(record, new RegExp(`\\.${field}\\s*=\\s*${constant}\\s*,`, "u"));
91+ }
92+ assert.match(record, /\.flags\s*=\s*XS_CREATION_FLAGS\s*,/u);
93+ assert.match(
94+ mdblSource,
95+ /\(kModdableCreationFlagDebug \| kModdableCreationFlagLogInstrumentation\)/u
96+ );
97+ assert.match(mdblSource, /#else\s+#define XS_CREATION_FLAGS 0U\s+#endif/u);
98+ assert.equal((mdblSource.match(/\bmoddable_createMachine\s*\(/gu) ?? []).length, 1);
99+ assert.match(mdblSource, /\bmoddable_createMachine\s*\(\s*&creation\s*\)\s*;/u);
100+ assert.doesNotMatch(mdblSource, /\bmoddable_createMachine\s*\(\s*(?:NULL|0)\s*\)/u);
101+});
102+
103+test("Node package boundaries keep generated webpack CommonJS and embedded/test JS ESM", () => {
104+ assert.equal(packageMetadata.type, "commonjs");
105+ assert.equal(embeddedPackageMetadata.type, "module");
106+ assert.equal(testPackageMetadata.type, "module");
107+});
108+
109+test("Waf builds every target before bundling generated PKJS into one package", () => {
110+ assert.match(wscript, /for platform in ctx\.env\.TARGET_PLATFORMS:/u);
111+ assert.match(wscript, /ctx\.pbl_build\(source=ctx\.path\.ant_glob\('src\/c\/\*\*\/\*\.c'\)/u);
112+ assert.match(wscript, /ctx\.pbl_bundle\(binaries=binaries,/u);
113+ assert.match(wscript, /'src\/pkjs\/\*\*\/\*\.js'/u);
114+ assert.match(wscript, /js_entry_file='src\/pkjs\/index\.js'/u);
115+});
116+
117+test("Alloy Message receives an explicit frozen-ID key Map", () => {
118+ const source = moduleSource("./main");
119+ const expectedEntries = CANONICAL_MESSAGE_KEY_ORDER.map((alias) => [
120+ alias,
121+ CANONICAL_MESSAGE_KEY[alias]
122+ ]);
123+
124+ assert.equal(WATCH_MESSAGE_KEY_MAP instanceof Map, true);
125+ assert.equal(Array.isArray(WATCH_MESSAGE_KEY_MAP), false);
126+ assert.deepEqual([...WATCH_MESSAGE_KEY_MAP], expectedEntries);
127+ assert.match(source, /keys:\s*APP_MESSAGE_KEY_MAP/u);
128+ assert.doesNotMatch(source, /keys:\s*APP_MESSAGE_KEY_ORDER/u);
129+ assert.doesNotMatch(source, /\bformat\s*:/u);
130+});
131+
132+test("package, canonical, companion, and embedded numeric maps are identical", () => {
133+ assert.deepEqual(packageMetadata.pebble.messageKeys, CANONICAL_MESSAGE_KEY);
134+ assert.deepEqual(WATCH_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);
135+ assert.deepEqual(companionContracts.APP_MESSAGE_KEY, CANONICAL_MESSAGE_KEY);
136+ assert.deepEqual(WATCH_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);
137+ assert.deepEqual(companionContracts.APP_MESSAGE_KEY_ORDER, CANONICAL_MESSAGE_KEY_ORDER);
138+ assert.deepEqual(WATCH_LANGUAGE, CANONICAL_LANGUAGE);
139+ assert.deepEqual(companionContracts.WIRE_LANGUAGE, CANONICAL_LANGUAGE);
140+ assert.deepEqual(
141+ Object.entries(packageMetadata.pebble.messageKeys)
142+ .sort((left, right) => left[1] - right[1])
143+ .map((entry) => entry[0]),
144+ CANONICAL_MESSAGE_KEY_ORDER
145+ );
146+ assert.deepEqual(
147+ Object.values(packageMetadata.pebble.messageKeys).sort((left, right) => left - right),
148+ Array.from({ length: 25 }, (_, index) => index)
149+ );
150+});
151+
152+test("manifest declares every embedded module and no FFI or watch network module", () => {
153+ assert.deepEqual(manifest.include, [
154+ "$(MODDABLE)/examples/manifest_mod.json",
155+ "$(MODDABLE)/examples/manifest_typings.json"
156+ ]);
157+ assert.deepEqual(manifest.modules["*"], expectedModules);
158+ assert.equal(Object.prototype.hasOwnProperty.call(manifest, "ffi"), false);
159+
160+ const declared = new Set(expectedModules);
161+ const bareImports = new Set();
162+ expectedModules.forEach((name) => {
163+ const source = moduleSource(name);
164+ const imports = source.matchAll(/from\s+["']([^"']+)["']/gu);
165+ for (const match of imports) {
166+ if (match[1].startsWith(".")) {
167+ assert.equal(declared.has(match[1].replace(/\.js$/u, "")), true, match[1]);
168+ } else {
169+ bareImports.add(match[1]);
170+ }
171+ }
172+ });
173+ assert.deepEqual([...bareImports].sort(), ["pebble/message", "piu/MC"]);
174+});
added packages/watch/test/message-queue.test.js +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import MessageQueue, { QUEUE_STATE } from "../src/embeddedjs/message-queue.js";
4+
5+class FakeMessage {
6+ constructor() {
7+ this.writes = [];
8+ this.throwNext = false;
9+ this.closed = 0;
10+ }
11+
12+ write(message) {
13+ if (this.throwNext) {
14+ this.throwNext = false;
15+ throw new Error("fake write failure");
16+ }
17+ this.writes.push(message);
18+ }
19+
20+ close() {
21+ this.closed += 1;
22+ }
23+}
24+
25+function payload(id) {
26+ return new Map([["REQUEST_ID", id]]);
27+}
28+
29+test("queue starts suspended and consumes at most one write credit", () => {
30+ const message = new FakeMessage();
31+ const queue = new MessageQueue(message);
32+ queue.enqueue(payload("one"));
33+ queue.enqueue(payload("two"));
34+ assert.equal(message.writes.length, 0);
35+
36+ assert.equal(queue.writable(), true);
37+ assert.equal(message.writes.length, 1);
38+ assert.equal(message.writes[0].get("REQUEST_ID"), "one");
39+ assert.equal(queue.items.length, 1);
40+
41+ assert.equal(queue.writable(), true);
42+ assert.equal(message.writes.length, 2);
43+ assert.equal(message.writes[1].get("REQUEST_ID"), "two");
44+ assert.equal(queue.items.length, 0);
45+});
46+
47+test("unused writable credit sends one future item and suspend revokes it", () => {
48+ const message = new FakeMessage();
49+ const queue = new MessageQueue(message);
50+ assert.equal(queue.writable(), false);
51+ queue.enqueue(payload("ready-later"));
52+ assert.equal(message.writes.length, 1);
53+
54+ queue.writable();
55+ queue.suspend();
56+ queue.enqueue(payload("paused"));
57+ assert.equal(message.writes.length, 1);
58+ queue.writable();
59+ assert.equal(message.writes.length, 2);
60+ assert.equal(message.writes[1].get("REQUEST_ID"), "paused");
61+});
62+
63+test("successful writes leave the queue immediately and are never replayed", () => {
64+ const message = new FakeMessage();
65+ const queue = new MessageQueue(message);
66+ const first = payload("at-most-once");
67+ queue.enqueue(first);
68+ queue.writable();
69+ assert.equal(queue.items.length, 0);
70+ queue.suspend();
71+ queue.writable();
72+ assert.deepEqual(message.writes, [first]);
73+});
74+
75+test("capacity four overflow clears unsent work and reports one failure", () => {
76+ const message = new FakeMessage();
77+ const states = [];
78+ const queue = new MessageQueue(message, { onState: (state) => states.push(state) });
79+ for (let index = 0; index < 4; index += 1) queue.enqueue(payload(String(index)));
80+ assert.equal(queue.items.length, 4);
81+ assert.equal(queue.enqueue(payload("overflow")), false);
82+ assert.equal(queue.items.length, 0);
83+ assert.deepEqual(states.at(-1), {
84+ type: QUEUE_STATE.FAILED,
85+ reason: "QUEUE_OVERFLOW",
86+ generation: 1
87+ });
88+});
89+
90+test("write throws clear every unsent item without retry", () => {
91+ const message = new FakeMessage();
92+ const states = [];
93+ const queue = new MessageQueue(message, { onState: (state) => states.push(state) });
94+ message.throwNext = true;
95+ queue.enqueue(payload("failed"));
96+ queue.enqueue(payload("also-cleared"));
97+ assert.equal(queue.writable(), false);
98+ assert.equal(message.writes.length, 0);
99+ assert.equal(queue.items.length, 0);
100+ assert.deepEqual(states.at(-1), {
101+ type: QUEUE_STATE.FAILED,
102+ reason: "MESSAGE_WRITE_FAILED",
103+ generation: 1
104+ });
105+});
106+
107+test("clear revokes stale credit and close disposes the Message exactly once", () => {
108+ const message = new FakeMessage();
109+ const queue = new MessageQueue(message);
110+ queue.writable();
111+ queue.clear();
112+ queue.enqueue(payload("after-clear"));
113+ assert.equal(message.writes.length, 0);
114+ queue.close();
115+ queue.close();
116+ assert.equal(message.closed, 1);
117+ assert.equal(queue.enqueue(payload("closed")), false);
118+});
new file mode 100644
@@ -0,0 +1,118 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import MessageQueue, { QUEUE_STATE } from "../src/embeddedjs/message-queue.js";
4+
5+class FakeMessage {
6+ constructor() {
7+ this.writes = [];
8+ this.throwNext = false;
9+ this.closed = 0;
10+ }
11+
12+ write(message) {
13+ if (this.throwNext) {
14+ this.throwNext = false;
15+ throw new Error("fake write failure");
16+ }
17+ this.writes.push(message);
18+ }
19+
20+ close() {
21+ this.closed += 1;
22+ }
23+}
24+
25+function payload(id) {
26+ return new Map([["REQUEST_ID", id]]);
27+}
28+
29+test("queue starts suspended and consumes at most one write credit", () => {
30+ const message = new FakeMessage();
31+ const queue = new MessageQueue(message);
32+ queue.enqueue(payload("one"));
33+ queue.enqueue(payload("two"));
34+ assert.equal(message.writes.length, 0);
35+
36+ assert.equal(queue.writable(), true);
37+ assert.equal(message.writes.length, 1);
38+ assert.equal(message.writes[0].get("REQUEST_ID"), "one");
39+ assert.equal(queue.items.length, 1);
40+
41+ assert.equal(queue.writable(), true);
42+ assert.equal(message.writes.length, 2);
43+ assert.equal(message.writes[1].get("REQUEST_ID"), "two");
44+ assert.equal(queue.items.length, 0);
45+});
46+
47+test("unused writable credit sends one future item and suspend revokes it", () => {
48+ const message = new FakeMessage();
49+ const queue = new MessageQueue(message);
50+ assert.equal(queue.writable(), false);
51+ queue.enqueue(payload("ready-later"));
52+ assert.equal(message.writes.length, 1);
53+
54+ queue.writable();
55+ queue.suspend();
56+ queue.enqueue(payload("paused"));
57+ assert.equal(message.writes.length, 1);
58+ queue.writable();
59+ assert.equal(message.writes.length, 2);
60+ assert.equal(message.writes[1].get("REQUEST_ID"), "paused");
61+});
62+
63+test("successful writes leave the queue immediately and are never replayed", () => {
64+ const message = new FakeMessage();
65+ const queue = new MessageQueue(message);
66+ const first = payload("at-most-once");
67+ queue.enqueue(first);
68+ queue.writable();
69+ assert.equal(queue.items.length, 0);
70+ queue.suspend();
71+ queue.writable();
72+ assert.deepEqual(message.writes, [first]);
73+});
74+
75+test("capacity four overflow clears unsent work and reports one failure", () => {
76+ const message = new FakeMessage();
77+ const states = [];
78+ const queue = new MessageQueue(message, { onState: (state) => states.push(state) });
79+ for (let index = 0; index < 4; index += 1) queue.enqueue(payload(String(index)));
80+ assert.equal(queue.items.length, 4);
81+ assert.equal(queue.enqueue(payload("overflow")), false);
82+ assert.equal(queue.items.length, 0);
83+ assert.deepEqual(states.at(-1), {
84+ type: QUEUE_STATE.FAILED,
85+ reason: "QUEUE_OVERFLOW",
86+ generation: 1
87+ });
88+});
89+
90+test("write throws clear every unsent item without retry", () => {
91+ const message = new FakeMessage();
92+ const states = [];
93+ const queue = new MessageQueue(message, { onState: (state) => states.push(state) });
94+ message.throwNext = true;
95+ queue.enqueue(payload("failed"));
96+ queue.enqueue(payload("also-cleared"));
97+ assert.equal(queue.writable(), false);
98+ assert.equal(message.writes.length, 0);
99+ assert.equal(queue.items.length, 0);
100+ assert.deepEqual(states.at(-1), {
101+ type: QUEUE_STATE.FAILED,
102+ reason: "MESSAGE_WRITE_FAILED",
103+ generation: 1
104+ });
105+});
106+
107+test("clear revokes stale credit and close disposes the Message exactly once", () => {
108+ const message = new FakeMessage();
109+ const queue = new MessageQueue(message);
110+ queue.writable();
111+ queue.clear();
112+ queue.enqueue(payload("after-clear"));
113+ assert.equal(message.writes.length, 0);
114+ queue.close();
115+ queue.close();
116+ assert.equal(message.closed, 1);
117+ assert.equal(queue.enqueue(payload("closed")), false);
118+});
added packages/watch/test/model.test.js +177 -0
new file mode 100644
@@ -0,0 +1,177 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ KEY_STATUS,
5+ MESSAGE_TYPE,
6+ REQUEST_TRIGGER,
7+ SCHEMA_VERSION
8+} from "../src/embeddedjs/contracts.js";
9+import { createController } from "../src/embeddedjs/controller.js";
10+import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
11+import WatchModel from "../src/embeddedjs/model.js";
12+
13+const FAVORITE = {
14+ id: "foundation-home",
15+ serviceId: "opaque:foundation:service:1",
16+ displayName: "Commute home",
17+ stopLabel: "Châtelet",
18+ lineLabel: "Métro 1",
19+ destinationLabel: "La Défense",
20+ sortOrder: 0
21+};
22+
23+function wire(type, entries = []) {
24+ return new Map([
25+ ["SCHEMA_VERSION", SCHEMA_VERSION],
26+ ["MESSAGE_TYPE", type],
27+ ...entries
28+ ]);
29+}
30+
31+function configurationMessages(id) {
32+ return [
33+ wire(MESSAGE_TYPE.CONFIG_BEGIN, [
34+ ["REQUEST_ID", id],
35+ ["ITEM_COUNT", 1],
36+ ["KEY_STATUS", KEY_STATUS.CONFIGURED],
37+ ["DISPLAY_NAME", "fr"]
38+ ]),
39+ wire(MESSAGE_TYPE.FAVORITE, [
40+ ["REQUEST_ID", id],
41+ ["ITEM_INDEX", 0],
42+ ["FAVORITE_ID", FAVORITE.id],
43+ ["SERVICE_ID", FAVORITE.serviceId],
44+ ["DISPLAY_NAME", FAVORITE.displayName],
45+ ["STOP_LABEL", FAVORITE.stopLabel],
46+ ["LINE_LABEL", FAVORITE.lineLabel],
47+ ["DESTINATION_LABEL", FAVORITE.destinationLabel],
48+ ["SORT_ORDER", FAVORITE.sortOrder]
49+ ]),
50+ wire(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]])
51+ ];
52+}
53+
54+function resultMessages(requestId) {
55+ return [
56+ wire(MESSAGE_TYPE.RESULT_BEGIN, [
57+ ["REQUEST_ID", requestId],
58+ ["FAVORITE_ID", FAVORITE.id],
59+ ["ITEM_COUNT", 1],
60+ ["FETCHED_AT", 1_788_000_000],
61+ ["FRESHNESS", 0]
62+ ]),
63+ wire(MESSAGE_TYPE.DEPARTURE, [
64+ ["REQUEST_ID", requestId],
65+ ["FAVORITE_ID", FAVORITE.id],
66+ ["ITEM_INDEX", 0],
67+ ["EXPECTED_AT", 1_788_000_120],
68+ ["MINUTES", 2],
69+ ["DEPARTURE_STATUS", 0],
70+ ["NEXT_INTERVAL_MINUTES", 4]
71+ ]),
72+ wire(MESSAGE_TYPE.RESULT_COMMIT, [
73+ ["REQUEST_ID", requestId],
74+ ["FAVORITE_ID", FAVORITE.id]
75+ ])
76+ ];
77+}
78+
79+class FakeQueue {
80+ constructor() {
81+ this.messages = [];
82+ this.closed = 0;
83+ }
84+
85+ enqueue(message) {
86+ this.messages.push(message);
87+ return true;
88+ }
89+
90+ close() {
91+ this.closed += 1;
92+ }
93+}
94+
95+class FakeView {
96+ constructor() {
97+ this.renders = [];
98+ }
99+
100+ render(snapshot) {
101+ this.renders.push(snapshot);
102+ }
103+
104+ close() {}
105+}
106+
107+function harness() {
108+ const queue = new FakeQueue();
109+ const view = new FakeView();
110+ const controller = createController({
111+ clock: { now: () => 1_788_000_000_000 },
112+ queue,
113+ model: new WatchModel(),
114+ view
115+ }).start();
116+ return { queue, view, controller };
117+}
118+
119+test("foundation model exposes the first committed favorite and one fixture request", () => {
120+ const model = new WatchModel();
121+ assert.deepEqual(model.commitProtocol({
122+ configuration: {
123+ keyStatus: KEY_STATUS.MISSING,
124+ language: "en",
125+ favorites: [FAVORITE]
126+ },
127+ result: null,
128+ error: null
129+ }, "CONFIG_COMMITTED"), { requestFixture: false });
130+ assert.equal(model.beginFixtureRequest("missing-key"), null);
131+
132+ const effect = model.commitProtocol({
133+ configuration: {
134+ keyStatus: KEY_STATUS.CONFIGURED,
135+ language: "fr",
136+ favorites: [FAVORITE]
137+ },
138+ result: null,
139+ error: null
140+ }, "CONFIG_COMMITTED");
141+
142+ assert.deepEqual(effect, { requestFixture: true });
143+ const request = model.beginFixtureRequest("foundation-1");
144+ assert.deepEqual(request, {
145+ requestId: "foundation-1",
146+ favoriteId: FAVORITE.id,
147+ trigger: REQUEST_TRIGGER.APP_OPEN
148+ });
149+ assert.equal(model.beginFixtureRequest("foundation-duplicate"), null);
150+ assert.equal(model.snapshot().favorite.id, FAVORITE.id);
151+ assert.equal(model.snapshot().language, "fr");
152+});
153+
154+test("controller performs one APP_OPEN fixture round trip and preserves the committed result", () => {
155+ const target = harness();
156+ configurationMessages("config-1").forEach(target.controller.onReadable);
157+
158+ assert.equal(target.queue.messages.length, 1);
159+ assert.equal(target.queue.messages[0].get("REQUEST_TRIGGER"), REQUEST_TRIGGER.APP_OPEN);
160+ const requestId = target.queue.messages[0].get("REQUEST_ID");
161+ resultMessages(requestId).forEach(target.controller.onReadable);
162+
163+ const committed = target.view.renders.at(-1);
164+ assert.equal(committed.favorite.id, FAVORITE.id);
165+ assert.equal(committed.result.favoriteId, FAVORITE.id);
166+ assert.equal(committed.result.departures[0].minutes, 2);
167+
168+ const rejected = target.controller.onReadable(new Map([
169+ ["SCHEMA_VERSION", SCHEMA_VERSION + 1],
170+ ["MESSAGE_TYPE", MESSAGE_TYPE.CONFIG_BEGIN]
171+ ]));
172+ assert.equal(rejected, RECEIVE_RESULT.REJECTED);
173+ assert.equal(target.view.renders.at(-1), committed);
174+
175+ configurationMessages("config-2").forEach(target.controller.onReadable);
176+ assert.equal(target.queue.messages.length, 1);
177+});
new file mode 100644
@@ -0,0 +1,177 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ KEY_STATUS,
5+ MESSAGE_TYPE,
6+ REQUEST_TRIGGER,
7+ SCHEMA_VERSION
8+} from "../src/embeddedjs/contracts.js";
9+import { createController } from "../src/embeddedjs/controller.js";
10+import { RECEIVE_RESULT } from "../src/embeddedjs/protocol.js";
11+import WatchModel from "../src/embeddedjs/model.js";
12+
13+const FAVORITE = {
14+ id: "foundation-home",
15+ serviceId: "opaque:foundation:service:1",
16+ displayName: "Commute home",
17+ stopLabel: "Châtelet",
18+ lineLabel: "Métro 1",
19+ destinationLabel: "La Défense",
20+ sortOrder: 0
21+};
22+
23+function wire(type, entries = []) {
24+ return new Map([
25+ ["SCHEMA_VERSION", SCHEMA_VERSION],
26+ ["MESSAGE_TYPE", type],
27+ ...entries
28+ ]);
29+}
30+
31+function configurationMessages(id) {
32+ return [
33+ wire(MESSAGE_TYPE.CONFIG_BEGIN, [
34+ ["REQUEST_ID", id],
35+ ["ITEM_COUNT", 1],
36+ ["KEY_STATUS", KEY_STATUS.CONFIGURED],
37+ ["DISPLAY_NAME", "fr"]
38+ ]),
39+ wire(MESSAGE_TYPE.FAVORITE, [
40+ ["REQUEST_ID", id],
41+ ["ITEM_INDEX", 0],
42+ ["FAVORITE_ID", FAVORITE.id],
43+ ["SERVICE_ID", FAVORITE.serviceId],
44+ ["DISPLAY_NAME", FAVORITE.displayName],
45+ ["STOP_LABEL", FAVORITE.stopLabel],
46+ ["LINE_LABEL", FAVORITE.lineLabel],
47+ ["DESTINATION_LABEL", FAVORITE.destinationLabel],
48+ ["SORT_ORDER", FAVORITE.sortOrder]
49+ ]),
50+ wire(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]])
51+ ];
52+}
53+
54+function resultMessages(requestId) {
55+ return [
56+ wire(MESSAGE_TYPE.RESULT_BEGIN, [
57+ ["REQUEST_ID", requestId],
58+ ["FAVORITE_ID", FAVORITE.id],
59+ ["ITEM_COUNT", 1],
60+ ["FETCHED_AT", 1_788_000_000],
61+ ["FRESHNESS", 0]
62+ ]),
63+ wire(MESSAGE_TYPE.DEPARTURE, [
64+ ["REQUEST_ID", requestId],
65+ ["FAVORITE_ID", FAVORITE.id],
66+ ["ITEM_INDEX", 0],
67+ ["EXPECTED_AT", 1_788_000_120],
68+ ["MINUTES", 2],
69+ ["DEPARTURE_STATUS", 0],
70+ ["NEXT_INTERVAL_MINUTES", 4]
71+ ]),
72+ wire(MESSAGE_TYPE.RESULT_COMMIT, [
73+ ["REQUEST_ID", requestId],
74+ ["FAVORITE_ID", FAVORITE.id]
75+ ])
76+ ];
77+}
78+
79+class FakeQueue {
80+ constructor() {
81+ this.messages = [];
82+ this.closed = 0;
83+ }
84+
85+ enqueue(message) {
86+ this.messages.push(message);
87+ return true;
88+ }
89+
90+ close() {
91+ this.closed += 1;
92+ }
93+}
94+
95+class FakeView {
96+ constructor() {
97+ this.renders = [];
98+ }
99+
100+ render(snapshot) {
101+ this.renders.push(snapshot);
102+ }
103+
104+ close() {}
105+}
106+
107+function harness() {
108+ const queue = new FakeQueue();
109+ const view = new FakeView();
110+ const controller = createController({
111+ clock: { now: () => 1_788_000_000_000 },
112+ queue,
113+ model: new WatchModel(),
114+ view
115+ }).start();
116+ return { queue, view, controller };
117+}
118+
119+test("foundation model exposes the first committed favorite and one fixture request", () => {
120+ const model = new WatchModel();
121+ assert.deepEqual(model.commitProtocol({
122+ configuration: {
123+ keyStatus: KEY_STATUS.MISSING,
124+ language: "en",
125+ favorites: [FAVORITE]
126+ },
127+ result: null,
128+ error: null
129+ }, "CONFIG_COMMITTED"), { requestFixture: false });
130+ assert.equal(model.beginFixtureRequest("missing-key"), null);
131+
132+ const effect = model.commitProtocol({
133+ configuration: {
134+ keyStatus: KEY_STATUS.CONFIGURED,
135+ language: "fr",
136+ favorites: [FAVORITE]
137+ },
138+ result: null,
139+ error: null
140+ }, "CONFIG_COMMITTED");
141+
142+ assert.deepEqual(effect, { requestFixture: true });
143+ const request = model.beginFixtureRequest("foundation-1");
144+ assert.deepEqual(request, {
145+ requestId: "foundation-1",
146+ favoriteId: FAVORITE.id,
147+ trigger: REQUEST_TRIGGER.APP_OPEN
148+ });
149+ assert.equal(model.beginFixtureRequest("foundation-duplicate"), null);
150+ assert.equal(model.snapshot().favorite.id, FAVORITE.id);
151+ assert.equal(model.snapshot().language, "fr");
152+});
153+
154+test("controller performs one APP_OPEN fixture round trip and preserves the committed result", () => {
155+ const target = harness();
156+ configurationMessages("config-1").forEach(target.controller.onReadable);
157+
158+ assert.equal(target.queue.messages.length, 1);
159+ assert.equal(target.queue.messages[0].get("REQUEST_TRIGGER"), REQUEST_TRIGGER.APP_OPEN);
160+ const requestId = target.queue.messages[0].get("REQUEST_ID");
161+ resultMessages(requestId).forEach(target.controller.onReadable);
162+
163+ const committed = target.view.renders.at(-1);
164+ assert.equal(committed.favorite.id, FAVORITE.id);
165+ assert.equal(committed.result.favoriteId, FAVORITE.id);
166+ assert.equal(committed.result.departures[0].minutes, 2);
167+
168+ const rejected = target.controller.onReadable(new Map([
169+ ["SCHEMA_VERSION", SCHEMA_VERSION + 1],
170+ ["MESSAGE_TYPE", MESSAGE_TYPE.CONFIG_BEGIN]
171+ ]));
172+ assert.equal(rejected, RECEIVE_RESULT.REJECTED);
173+ assert.equal(target.view.renders.at(-1), committed);
174+
175+ configurationMessages("config-2").forEach(target.controller.onReadable);
176+ assert.equal(target.queue.messages.length, 1);
177+});
added packages/watch/test/package.json +3 -0
new file mode 100644
@@ -0,0 +1,3 @@
1+{
2+ "type": "module"
3+}
new file mode 100644
@@ -0,0 +1,3 @@
1+{
2+ "type": "module"
3+}
added packages/watch/test/protocol.test.js +254 -0
new file mode 100644
@@ -0,0 +1,254 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ KEY_STATUS,
5+ MESSAGE_TYPE,
6+ REQUEST_TRIGGER,
7+ SCHEMA_VERSION
8+} from "../src/embeddedjs/contracts.js";
9+import {
10+ ProtocolReceiver,
11+ RECEIVE_RESULT,
12+ decodeMessage,
13+ encodeRequest
14+} from "../src/embeddedjs/protocol.js";
15+
16+function message(type, entries = []) {
17+ return new Map([
18+ ["SCHEMA_VERSION", SCHEMA_VERSION],
19+ ["MESSAGE_TYPE", type],
20+ ...entries
21+ ]);
22+}
23+
24+function configBegin(id = "config-1", count = 1, language = "fr") {
25+ return message(MESSAGE_TYPE.CONFIG_BEGIN, [
26+ ["REQUEST_ID", id],
27+ ["ITEM_COUNT", count],
28+ ["KEY_STATUS", KEY_STATUS.CONFIGURED],
29+ ["DISPLAY_NAME", language]
30+ ]);
31+}
32+
33+function favorite(id = "config-1", index = 0, favoriteId = "home") {
34+ return message(MESSAGE_TYPE.FAVORITE, [
35+ ["REQUEST_ID", id],
36+ ["ITEM_INDEX", index],
37+ ["FAVORITE_ID", favoriteId],
38+ ["SERVICE_ID", "opaque:service:1"],
39+ ["DISPLAY_NAME", "Maison"],
40+ ["STOP_LABEL", "Châtelet"],
41+ ["LINE_LABEL", "Métro 1"],
42+ ["DESTINATION_LABEL", "La Défense"],
43+ ["SORT_ORDER", index]
44+ ]);
45+}
46+
47+function configCommit(id = "config-1") {
48+ return message(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]]);
49+}
50+
51+function resultBegin(requestId = "request-1", favoriteId = "home", count = 1) {
52+ return message(MESSAGE_TYPE.RESULT_BEGIN, [
53+ ["REQUEST_ID", requestId],
54+ ["FAVORITE_ID", favoriteId],
55+ ["ITEM_COUNT", count],
56+ ["FETCHED_AT", 1_788_000_000],
57+ ["SOURCE_UPDATED_AT", 1_787_999_990],
58+ ["FRESHNESS", 0]
59+ ]);
60+}
61+
62+function departure(requestId = "request-1", favoriteId = "home", index = 0) {
63+ return message(MESSAGE_TYPE.DEPARTURE, [
64+ ["REQUEST_ID", requestId],
65+ ["FAVORITE_ID", favoriteId],
66+ ["ITEM_INDEX", index],
67+ ["EXPECTED_AT", 1_788_000_120],
68+ ["AIMED_AT", 1_788_000_100],
69+ ["MINUTES", 2],
70+ ["DEPARTURE_STATUS", 1],
71+ ["NEXT_INTERVAL_MINUTES", 4]
72+ ]);
73+}
74+
75+function resultCommit(requestId = "request-1", favoriteId = "home") {
76+ return message(MESSAGE_TYPE.RESULT_COMMIT, [
77+ ["REQUEST_ID", requestId],
78+ ["FAVORITE_ID", favoriteId]
79+ ]);
80+}
81+
82+function errorMessage(requestId = "request-1", favoriteId = "home") {
83+ return message(MESSAGE_TYPE.ERROR, [
84+ ["REQUEST_ID", requestId],
85+ ["FAVORITE_ID", favoriteId],
86+ ["ERROR_CODE", 3],
87+ ["OCCURRED_AT", 1_788_000_001],
88+ ["RETRY_AFTER_SECONDS", 30]
89+ ]);
90+}
91+
92+function commitConfiguration(receiver) {
93+ assert.equal(receiver.receive(configBegin()), RECEIVE_RESULT.STAGED);
94+ assert.equal(receiver.receive(favorite()), RECEIVE_RESULT.STAGED);
95+ assert.equal(receiver.receive(configCommit()), RECEIVE_RESULT.CONFIG_COMMITTED);
96+}
97+
98+function commitResult(receiver) {
99+ assert.equal(receiver.expectResponse("request-1", "home"), true);
100+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
101+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
102+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
103+}
104+
105+test("decoder validates all eight message types and request encoder returns aliases", () => {
106+ const request = encodeRequest({
107+ requestId: "request-1",
108+ favoriteId: "home",
109+ trigger: REQUEST_TRIGGER.MANUAL_SELECT
110+ });
111+ const all = [
112+ request,
113+ configBegin(),
114+ favorite(),
115+ configCommit(),
116+ resultBegin(),
117+ departure(),
118+ resultCommit(),
119+ errorMessage()
120+ ];
121+ all.forEach((value) => assert.notEqual(decodeMessage(value), null));
122+ assert.deepEqual([...request.keys()], [
123+ "SCHEMA_VERSION",
124+ "MESSAGE_TYPE",
125+ "REQUEST_ID",
126+ "FAVORITE_ID",
127+ "REQUEST_TRIGGER"
128+ ]);
129+ assert.equal([...request.keys()].some((key) => /^\d+$/u.test(key)), false);
130+ assert.throws(() => encodeRequest({
131+ requestId: "request-1",
132+ favoriteId: "home",
133+ trigger: 99
134+ }), TypeError);
135+});
136+
137+test("decoder enforces exact aliases, types, UTF-8 bounds, and contextual locale", () => {
138+ const numericAlias = configBegin();
139+ numericAlias.set(0, SCHEMA_VERSION);
140+ assert.equal(decodeMessage(numericAlias), null);
141+
142+ const unknownAlias = favorite();
143+ unknownAlias.set("UNKNOWN", 1);
144+ assert.equal(decodeMessage(unknownAlias), null);
145+
146+ const longLabel = favorite();
147+ longLabel.set("STOP_LABEL", "é".repeat(49));
148+ assert.equal(decodeMessage(longLabel), null);
149+
150+ assert.notEqual(decodeMessage(configBegin("config-1", 0, "en")), null);
151+ assert.notEqual(decodeMessage(configBegin("config-1", 0, "fr")), null);
152+ assert.equal(decodeMessage(configBegin("config-1", 0, "fr_FR")), null);
153+ assert.equal(decodeMessage(configBegin("config-1", 0, "de")), null);
154+});
155+
156+test("configuration language and favorites stay private until complete commit", () => {
157+ const receiver = new ProtocolReceiver();
158+ assert.equal(receiver.receive(configBegin()), RECEIVE_RESULT.STAGED);
159+ assert.equal(receiver.snapshot().configuration, null);
160+ assert.equal(receiver.receive(favorite()), RECEIVE_RESULT.STAGED);
161+ assert.equal(receiver.snapshot().configuration, null);
162+ assert.equal(receiver.receive(configCommit()), RECEIVE_RESULT.CONFIG_COMMITTED);
163+ assert.deepEqual(receiver.snapshot().configuration, {
164+ keyStatus: KEY_STATUS.CONFIGURED,
165+ language: "fr",
166+ favorites: [{
167+ id: "home",
168+ serviceId: "opaque:service:1",
169+ displayName: "Maison",
170+ stopLabel: "Châtelet",
171+ lineLabel: "Métro 1",
172+ destinationLabel: "La Défense",
173+ sortOrder: 0
174+ }]
175+ });
176+});
177+
178+test("out-of-order, duplicate, and incomplete configuration cannot replace committed state", () => {
179+ const receiver = new ProtocolReceiver();
180+ commitConfiguration(receiver);
181+ const committed = receiver.snapshot().configuration;
182+
183+ assert.equal(receiver.receive(configBegin("config-2", 2, "en")), RECEIVE_RESULT.STAGED);
184+ assert.equal(receiver.receive(favorite("config-2", 1, "work")), RECEIVE_RESULT.REJECTED);
185+ assert.equal(receiver.receive(configCommit("config-2")), RECEIVE_RESULT.REJECTED);
186+ assert.deepEqual(receiver.snapshot().configuration, committed);
187+
188+ assert.equal(receiver.receive(configBegin("config-3", 2, "en")), RECEIVE_RESULT.STAGED);
189+ assert.equal(receiver.receive(favorite("config-3", 0, "home")), RECEIVE_RESULT.STAGED);
190+ assert.equal(receiver.receive(favorite("config-3", 1, "home")), RECEIVE_RESULT.REJECTED);
191+ assert.equal(receiver.receive(configCommit("config-3")), RECEIVE_RESULT.REJECTED);
192+ assert.deepEqual(receiver.snapshot().configuration, committed);
193+});
194+
195+test("result stages atomically only for the expected request and favorite", () => {
196+ const receiver = new ProtocolReceiver();
197+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.REJECTED);
198+ assert.equal(receiver.expectResponse("request-1", "home"), true);
199+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
200+ assert.equal(receiver.snapshot().result, null);
201+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
202+ assert.equal(receiver.snapshot().result, null);
203+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
204+ assert.deepEqual(receiver.snapshot().result, {
205+ requestId: "request-1",
206+ favoriteId: "home",
207+ fetchedAt: 1_788_000_000,
208+ sourceUpdatedAt: 1_787_999_990,
209+ freshness: "REALTIME",
210+ departures: [{
211+ expectedAt: 1_788_000_120,
212+ aimedAt: 1_788_000_100,
213+ minutes: 2,
214+ status: "DELAYED",
215+ nextIntervalMinutes: 4
216+ }]
217+ });
218+});
219+
220+test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {
221+ const receiver = new ProtocolReceiver();
222+ commitConfiguration(receiver);
223+ commitResult(receiver);
224+ const committed = receiver.snapshot();
225+
226+ assert.equal(receiver.expectResponse("request-2", "home"), true);
227+ assert.equal(receiver.receive(resultBegin("request-1", "home", 0)), RECEIVE_RESULT.REJECTED);
228+ assert.equal(receiver.receive(resultBegin("request-2", "home", 2)), RECEIVE_RESULT.STAGED);
229+ assert.equal(receiver.receive(departure("request-2", "home", 0)), RECEIVE_RESULT.STAGED);
230+ assert.equal(receiver.receive(resultCommit("request-2", "home")), RECEIVE_RESULT.REJECTED);
231+ assert.deepEqual(receiver.snapshot(), committed);
232+
233+ assert.equal(receiver.receive(errorMessage("request-1", "home")), RECEIVE_RESULT.REJECTED);
234+ const unsupported = configBegin("config-new", 0, "en");
235+ unsupported.set("SCHEMA_VERSION", 99);
236+ assert.equal(receiver.receive(unsupported), RECEIVE_RESULT.REJECTED);
237+ assert.deepEqual(receiver.snapshot(), committed);
238+});
239+
240+test("matching error commits without destroying the prior complete result", () => {
241+ const receiver = new ProtocolReceiver();
242+ commitResult(receiver);
243+ const result = receiver.snapshot().result;
244+ assert.equal(receiver.expectResponse("request-2", "home"), true);
245+ assert.equal(receiver.receive(errorMessage("request-2", "home")), RECEIVE_RESULT.ERROR_COMMITTED);
246+ assert.deepEqual(receiver.snapshot().result, result);
247+ assert.deepEqual(receiver.snapshot().error, {
248+ requestId: "request-2",
249+ favoriteId: "home",
250+ code: "SOURCE_UNAVAILABLE",
251+ occurredAt: 1_788_000_001,
252+ retryAfterSeconds: 30
253+ });
254+});
new file mode 100644
@@ -0,0 +1,254 @@
1+import test from "node:test";
2+import assert from "node:assert/strict";
3+import {
4+ KEY_STATUS,
5+ MESSAGE_TYPE,
6+ REQUEST_TRIGGER,
7+ SCHEMA_VERSION
8+} from "../src/embeddedjs/contracts.js";
9+import {
10+ ProtocolReceiver,
11+ RECEIVE_RESULT,
12+ decodeMessage,
13+ encodeRequest
14+} from "../src/embeddedjs/protocol.js";
15+
16+function message(type, entries = []) {
17+ return new Map([
18+ ["SCHEMA_VERSION", SCHEMA_VERSION],
19+ ["MESSAGE_TYPE", type],
20+ ...entries
21+ ]);
22+}
23+
24+function configBegin(id = "config-1", count = 1, language = "fr") {
25+ return message(MESSAGE_TYPE.CONFIG_BEGIN, [
26+ ["REQUEST_ID", id],
27+ ["ITEM_COUNT", count],
28+ ["KEY_STATUS", KEY_STATUS.CONFIGURED],
29+ ["DISPLAY_NAME", language]
30+ ]);
31+}
32+
33+function favorite(id = "config-1", index = 0, favoriteId = "home") {
34+ return message(MESSAGE_TYPE.FAVORITE, [
35+ ["REQUEST_ID", id],
36+ ["ITEM_INDEX", index],
37+ ["FAVORITE_ID", favoriteId],
38+ ["SERVICE_ID", "opaque:service:1"],
39+ ["DISPLAY_NAME", "Maison"],
40+ ["STOP_LABEL", "Châtelet"],
41+ ["LINE_LABEL", "Métro 1"],
42+ ["DESTINATION_LABEL", "La Défense"],
43+ ["SORT_ORDER", index]
44+ ]);
45+}
46+
47+function configCommit(id = "config-1") {
48+ return message(MESSAGE_TYPE.CONFIG_COMMIT, [["REQUEST_ID", id]]);
49+}
50+
51+function resultBegin(requestId = "request-1", favoriteId = "home", count = 1) {
52+ return message(MESSAGE_TYPE.RESULT_BEGIN, [
53+ ["REQUEST_ID", requestId],
54+ ["FAVORITE_ID", favoriteId],
55+ ["ITEM_COUNT", count],
56+ ["FETCHED_AT", 1_788_000_000],
57+ ["SOURCE_UPDATED_AT", 1_787_999_990],
58+ ["FRESHNESS", 0]
59+ ]);
60+}
61+
62+function departure(requestId = "request-1", favoriteId = "home", index = 0) {
63+ return message(MESSAGE_TYPE.DEPARTURE, [
64+ ["REQUEST_ID", requestId],
65+ ["FAVORITE_ID", favoriteId],
66+ ["ITEM_INDEX", index],
67+ ["EXPECTED_AT", 1_788_000_120],
68+ ["AIMED_AT", 1_788_000_100],
69+ ["MINUTES", 2],
70+ ["DEPARTURE_STATUS", 1],
71+ ["NEXT_INTERVAL_MINUTES", 4]
72+ ]);
73+}
74+
75+function resultCommit(requestId = "request-1", favoriteId = "home") {
76+ return message(MESSAGE_TYPE.RESULT_COMMIT, [
77+ ["REQUEST_ID", requestId],
78+ ["FAVORITE_ID", favoriteId]
79+ ]);
80+}
81+
82+function errorMessage(requestId = "request-1", favoriteId = "home") {
83+ return message(MESSAGE_TYPE.ERROR, [
84+ ["REQUEST_ID", requestId],
85+ ["FAVORITE_ID", favoriteId],
86+ ["ERROR_CODE", 3],
87+ ["OCCURRED_AT", 1_788_000_001],
88+ ["RETRY_AFTER_SECONDS", 30]
89+ ]);
90+}
91+
92+function commitConfiguration(receiver) {
93+ assert.equal(receiver.receive(configBegin()), RECEIVE_RESULT.STAGED);
94+ assert.equal(receiver.receive(favorite()), RECEIVE_RESULT.STAGED);
95+ assert.equal(receiver.receive(configCommit()), RECEIVE_RESULT.CONFIG_COMMITTED);
96+}
97+
98+function commitResult(receiver) {
99+ assert.equal(receiver.expectResponse("request-1", "home"), true);
100+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
101+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
102+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
103+}
104+
105+test("decoder validates all eight message types and request encoder returns aliases", () => {
106+ const request = encodeRequest({
107+ requestId: "request-1",
108+ favoriteId: "home",
109+ trigger: REQUEST_TRIGGER.MANUAL_SELECT
110+ });
111+ const all = [
112+ request,
113+ configBegin(),
114+ favorite(),
115+ configCommit(),
116+ resultBegin(),
117+ departure(),
118+ resultCommit(),
119+ errorMessage()
120+ ];
121+ all.forEach((value) => assert.notEqual(decodeMessage(value), null));
122+ assert.deepEqual([...request.keys()], [
123+ "SCHEMA_VERSION",
124+ "MESSAGE_TYPE",
125+ "REQUEST_ID",
126+ "FAVORITE_ID",
127+ "REQUEST_TRIGGER"
128+ ]);
129+ assert.equal([...request.keys()].some((key) => /^\d+$/u.test(key)), false);
130+ assert.throws(() => encodeRequest({
131+ requestId: "request-1",
132+ favoriteId: "home",
133+ trigger: 99
134+ }), TypeError);
135+});
136+
137+test("decoder enforces exact aliases, types, UTF-8 bounds, and contextual locale", () => {
138+ const numericAlias = configBegin();
139+ numericAlias.set(0, SCHEMA_VERSION);
140+ assert.equal(decodeMessage(numericAlias), null);
141+
142+ const unknownAlias = favorite();
143+ unknownAlias.set("UNKNOWN", 1);
144+ assert.equal(decodeMessage(unknownAlias), null);
145+
146+ const longLabel = favorite();
147+ longLabel.set("STOP_LABEL", "é".repeat(49));
148+ assert.equal(decodeMessage(longLabel), null);
149+
150+ assert.notEqual(decodeMessage(configBegin("config-1", 0, "en")), null);
151+ assert.notEqual(decodeMessage(configBegin("config-1", 0, "fr")), null);
152+ assert.equal(decodeMessage(configBegin("config-1", 0, "fr_FR")), null);
153+ assert.equal(decodeMessage(configBegin("config-1", 0, "de")), null);
154+});
155+
156+test("configuration language and favorites stay private until complete commit", () => {
157+ const receiver = new ProtocolReceiver();
158+ assert.equal(receiver.receive(configBegin()), RECEIVE_RESULT.STAGED);
159+ assert.equal(receiver.snapshot().configuration, null);
160+ assert.equal(receiver.receive(favorite()), RECEIVE_RESULT.STAGED);
161+ assert.equal(receiver.snapshot().configuration, null);
162+ assert.equal(receiver.receive(configCommit()), RECEIVE_RESULT.CONFIG_COMMITTED);
163+ assert.deepEqual(receiver.snapshot().configuration, {
164+ keyStatus: KEY_STATUS.CONFIGURED,
165+ language: "fr",
166+ favorites: [{
167+ id: "home",
168+ serviceId: "opaque:service:1",
169+ displayName: "Maison",
170+ stopLabel: "Châtelet",
171+ lineLabel: "Métro 1",
172+ destinationLabel: "La Défense",
173+ sortOrder: 0
174+ }]
175+ });
176+});
177+
178+test("out-of-order, duplicate, and incomplete configuration cannot replace committed state", () => {
179+ const receiver = new ProtocolReceiver();
180+ commitConfiguration(receiver);
181+ const committed = receiver.snapshot().configuration;
182+
183+ assert.equal(receiver.receive(configBegin("config-2", 2, "en")), RECEIVE_RESULT.STAGED);
184+ assert.equal(receiver.receive(favorite("config-2", 1, "work")), RECEIVE_RESULT.REJECTED);
185+ assert.equal(receiver.receive(configCommit("config-2")), RECEIVE_RESULT.REJECTED);
186+ assert.deepEqual(receiver.snapshot().configuration, committed);
187+
188+ assert.equal(receiver.receive(configBegin("config-3", 2, "en")), RECEIVE_RESULT.STAGED);
189+ assert.equal(receiver.receive(favorite("config-3", 0, "home")), RECEIVE_RESULT.STAGED);
190+ assert.equal(receiver.receive(favorite("config-3", 1, "home")), RECEIVE_RESULT.REJECTED);
191+ assert.equal(receiver.receive(configCommit("config-3")), RECEIVE_RESULT.REJECTED);
192+ assert.deepEqual(receiver.snapshot().configuration, committed);
193+});
194+
195+test("result stages atomically only for the expected request and favorite", () => {
196+ const receiver = new ProtocolReceiver();
197+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.REJECTED);
198+ assert.equal(receiver.expectResponse("request-1", "home"), true);
199+ assert.equal(receiver.receive(resultBegin()), RECEIVE_RESULT.STAGED);
200+ assert.equal(receiver.snapshot().result, null);
201+ assert.equal(receiver.receive(departure()), RECEIVE_RESULT.STAGED);
202+ assert.equal(receiver.snapshot().result, null);
203+ assert.equal(receiver.receive(resultCommit()), RECEIVE_RESULT.RESULT_COMMITTED);
204+ assert.deepEqual(receiver.snapshot().result, {
205+ requestId: "request-1",
206+ favoriteId: "home",
207+ fetchedAt: 1_788_000_000,
208+ sourceUpdatedAt: 1_787_999_990,
209+ freshness: "REALTIME",
210+ departures: [{
211+ expectedAt: 1_788_000_120,
212+ aimedAt: 1_788_000_100,
213+ minutes: 2,
214+ status: "DELAYED",
215+ nextIntervalMinutes: 4
216+ }]
217+ });
218+});
219+
220+test("late items, count mismatches, unsupported schema, and mismatched errors preserve state", () => {
221+ const receiver = new ProtocolReceiver();
222+ commitConfiguration(receiver);
223+ commitResult(receiver);
224+ const committed = receiver.snapshot();
225+
226+ assert.equal(receiver.expectResponse("request-2", "home"), true);
227+ assert.equal(receiver.receive(resultBegin("request-1", "home", 0)), RECEIVE_RESULT.REJECTED);
228+ assert.equal(receiver.receive(resultBegin("request-2", "home", 2)), RECEIVE_RESULT.STAGED);
229+ assert.equal(receiver.receive(departure("request-2", "home", 0)), RECEIVE_RESULT.STAGED);
230+ assert.equal(receiver.receive(resultCommit("request-2", "home")), RECEIVE_RESULT.REJECTED);
231+ assert.deepEqual(receiver.snapshot(), committed);
232+
233+ assert.equal(receiver.receive(errorMessage("request-1", "home")), RECEIVE_RESULT.REJECTED);
234+ const unsupported = configBegin("config-new", 0, "en");
235+ unsupported.set("SCHEMA_VERSION", 99);
236+ assert.equal(receiver.receive(unsupported), RECEIVE_RESULT.REJECTED);
237+ assert.deepEqual(receiver.snapshot(), committed);
238+});
239+
240+test("matching error commits without destroying the prior complete result", () => {
241+ const receiver = new ProtocolReceiver();
242+ commitResult(receiver);
243+ const result = receiver.snapshot().result;
244+ assert.equal(receiver.expectResponse("request-2", "home"), true);
245+ assert.equal(receiver.receive(errorMessage("request-2", "home")), RECEIVE_RESULT.ERROR_COMMITTED);
246+ assert.deepEqual(receiver.snapshot().result, result);
247+ assert.deepEqual(receiver.snapshot().error, {
248+ requestId: "request-2",
249+ favoriteId: "home",
250+ code: "SOURCE_UNAVAILABLE",
251+ occurredAt: 1_788_000_001,
252+ retryAfterSeconds: 30
253+ });
254+});
added packages/watch/wscript +54 -0
new file mode 100644
@@ -0,0 +1,54 @@
1+#
2+# This file is the default set of rules to compile a Pebble application.
3+#
4+# Feel free to customize this to your needs.
5+#
6+import os.path
7+
8+top = '.'
9+out = 'build'
10+
11+
12+def options(ctx):
13+ ctx.load('pebble_sdk')
14+
15+
16+def configure(ctx):
17+ """
18+ This method is used to configure your build. ctx.load(`pebble_sdk`) automatically configures
19+ a build for each valid platform in `targetPlatforms`. Platform-specific configuration: add your
20+ change after calling ctx.load('pebble_sdk') and make sure to set the correct environment first.
21+ Universal configuration: add your change prior to calling ctx.load('pebble_sdk').
22+ """
23+ ctx.load('pebble_sdk')
24+
25+
26+def build(ctx):
27+ ctx.load('pebble_sdk')
28+
29+ build_worker = os.path.exists('worker_src')
30+ binaries = []
31+
32+ cached_env = ctx.env
33+ for platform in ctx.env.TARGET_PLATFORMS:
34+ ctx.env = ctx.all_envs[platform]
35+ ctx.set_group(ctx.env.PLATFORM_NAME)
36+ app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR)
37+ ctx.pbl_build(source=ctx.path.ant_glob('src/c/**/*.c'), target=app_elf, bin_type='app')
38+
39+ if build_worker:
40+ worker_elf = '{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR)
41+ binaries.append({'platform': platform, 'app_elf': app_elf, 'worker_elf': worker_elf})
42+ ctx.pbl_build(source=ctx.path.ant_glob('worker_src/c/**/*.c'),
43+ target=worker_elf,
44+ bin_type='worker')
45+ else:
46+ binaries.append({'platform': platform, 'app_elf': app_elf})
47+ ctx.env = cached_env
48+
49+ ctx.set_group('bundle')
50+ ctx.pbl_bundle(binaries=binaries,
51+ js=ctx.path.ant_glob(['src/pkjs/**/*.js',
52+ 'src/pkjs/**/*.json',
53+ 'src/common/**/*.js']),
54+ js_entry_file='src/pkjs/index.js')
new file mode 100644
@@ -0,0 +1,54 @@
1+#
2+# This file is the default set of rules to compile a Pebble application.
3+#
4+# Feel free to customize this to your needs.
5+#
6+import os.path
7+
8+top = '.'
9+out = 'build'
10+
11+
12+def options(ctx):
13+ ctx.load('pebble_sdk')
14+
15+
16+def configure(ctx):
17+ """
18+ This method is used to configure your build. ctx.load(`pebble_sdk`) automatically configures
19+ a build for each valid platform in `targetPlatforms`. Platform-specific configuration: add your
20+ change after calling ctx.load('pebble_sdk') and make sure to set the correct environment first.
21+ Universal configuration: add your change prior to calling ctx.load('pebble_sdk').
22+ """
23+ ctx.load('pebble_sdk')
24+
25+
26+def build(ctx):
27+ ctx.load('pebble_sdk')
28+
29+ build_worker = os.path.exists('worker_src')
30+ binaries = []
31+
32+ cached_env = ctx.env
33+ for platform in ctx.env.TARGET_PLATFORMS:
34+ ctx.env = ctx.all_envs[platform]
35+ ctx.set_group(ctx.env.PLATFORM_NAME)
36+ app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR)
37+ ctx.pbl_build(source=ctx.path.ant_glob('src/c/**/*.c'), target=app_elf, bin_type='app')
38+
39+ if build_worker:
40+ worker_elf = '{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR)
41+ binaries.append({'platform': platform, 'app_elf': app_elf, 'worker_elf': worker_elf})
42+ ctx.pbl_build(source=ctx.path.ant_glob('worker_src/c/**/*.c'),
43+ target=worker_elf,
44+ bin_type='worker')
45+ else:
46+ binaries.append({'platform': platform, 'app_elf': app_elf})
47+ ctx.env = cached_env
48+
49+ ctx.set_group('bundle')
50+ ctx.pbl_bundle(binaries=binaries,
51+ js=ctx.path.ant_glob(['src/pkjs/**/*.js',
52+ 'src/pkjs/**/*.json',
53+ 'src/common/**/*.js']),
54+ js_entry_file='src/pkjs/index.js')
added scripts/build.mjs +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+import { spawnSync } from "node:child_process";
2+import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+import { basename, join } from "node:path";
4+import { fileURLToPath } from "node:url";
5+
6+const root = fileURLToPath(new URL("..", import.meta.url));
7+const companionSource = join(root, "packages/companion/src");
8+const pkjsTarget = join(root, "packages/watch/src/pkjs");
9+function optionalHttpsUrl(environment, name) {
10+ let parsed;
11+ const message = `${name} must be an absolute HTTPS URL`;
12+ if (!Object.prototype.hasOwnProperty.call(environment, name)) return "";
13+ if (typeof environment[name] !== "string" || environment[name].length === 0) {
14+ throw new TypeError(message);
15+ }
16+ try {
17+ parsed = new URL(environment[name]);
18+ } catch {
19+ throw new TypeError(message);
20+ }
21+ if (parsed.protocol !== "https:"
22+ || parsed.hostname.length === 0
23+ || parsed.username.length > 0
24+ || parsed.password.length > 0) throw new TypeError(message);
25+ return parsed.href;
26+}
27+
28+export function readBuildConfiguration(environment) {
29+ return {
30+ backendUrl: optionalHttpsUrl(environment, "LAPIN_FUTE_BACKEND_URL"),
31+ configurationUrl: optionalHttpsUrl(environment, "LAPIN_FUTE_CONFIG_URL"),
32+ };
33+}
34+
35+export function createBootstrap(fixture, configuration) {
36+ return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\nvar fixture = ${JSON.stringify(fixture)};\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)},\n fixture: fixture\n});\n`;
37+}
38+
39+function run(command, args, cwd = root) {
40+ const result = spawnSync(command, args, { cwd, stdio: "inherit" });
41+ if (result.error?.code === "ENOENT") {
42+ console.error(`${command} is required but not installed. See docs/toolchain.md; installation is intentionally user-managed.`);
43+ process.exit(127);
44+ }
45+ if (result.error) throw result.error;
46+ if (result.status !== 0) process.exit(result.status ?? 1);
47+}
48+
49+function build() {
50+ const configuration = readBuildConfiguration(process.env);
51+ const fixture = JSON.parse(readFileSync(join(root, "fixtures/departures/foundation.json"), "utf8"));
52+
53+ run(process.execPath, ["scripts/test.mjs"]);
54+ run(process.execPath, ["scripts/measure-radio.mjs"]);
55+
56+ rmSync(pkjsTarget, { recursive: true, force: true });
57+ mkdirSync(pkjsTarget, { recursive: true });
58+ for (const source of ["contracts.js", "codec.js", "configuration.js", "message-queue.js"]) {
59+ cpSync(join(companionSource, source), join(pkjsTarget, basename(source)));
60+ }
61+ cpSync(join(companionSource, "index.js"), join(pkjsTarget, "companion.js"));
62+ writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(fixture, configuration));
63+
64+ const watch = join(root, "packages/watch");
65+ run("pebble", ["clean"], watch);
66+ run("pebble", ["build"], watch);
67+ run(process.execPath, ["scripts/verify-watch-build.mjs"]);
68+}
69+
70+if (process.argv[1] === fileURLToPath(import.meta.url)) build();
new file mode 100644
@@ -0,0 +1,70 @@
1+import { spawnSync } from "node:child_process";
2+import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+import { basename, join } from "node:path";
4+import { fileURLToPath } from "node:url";
5+
6+const root = fileURLToPath(new URL("..", import.meta.url));
7+const companionSource = join(root, "packages/companion/src");
8+const pkjsTarget = join(root, "packages/watch/src/pkjs");
9+function optionalHttpsUrl(environment, name) {
10+ let parsed;
11+ const message = `${name} must be an absolute HTTPS URL`;
12+ if (!Object.prototype.hasOwnProperty.call(environment, name)) return "";
13+ if (typeof environment[name] !== "string" || environment[name].length === 0) {
14+ throw new TypeError(message);
15+ }
16+ try {
17+ parsed = new URL(environment[name]);
18+ } catch {
19+ throw new TypeError(message);
20+ }
21+ if (parsed.protocol !== "https:"
22+ || parsed.hostname.length === 0
23+ || parsed.username.length > 0
24+ || parsed.password.length > 0) throw new TypeError(message);
25+ return parsed.href;
26+}
27+
28+export function readBuildConfiguration(environment) {
29+ return {
30+ backendUrl: optionalHttpsUrl(environment, "LAPIN_FUTE_BACKEND_URL"),
31+ configurationUrl: optionalHttpsUrl(environment, "LAPIN_FUTE_CONFIG_URL"),
32+ };
33+}
34+
35+export function createBootstrap(fixture, configuration) {
36+ return `"use strict";\n\nvar createCompanion = require("./companion").createCompanion;\nvar fixture = ${JSON.stringify(fixture)};\n\ncreateCompanion({\n Pebble: Pebble,\n storage: localStorage,\n XHR: XMLHttpRequest,\n clock: { now: Date.now },\n defer: function (callback) { setTimeout(callback, 0); },\n backendUrl: ${JSON.stringify(configuration.backendUrl)},\n configurationUrl: ${JSON.stringify(configuration.configurationUrl)},\n fixture: fixture\n});\n`;
37+}
38+
39+function run(command, args, cwd = root) {
40+ const result = spawnSync(command, args, { cwd, stdio: "inherit" });
41+ if (result.error?.code === "ENOENT") {
42+ console.error(`${command} is required but not installed. See docs/toolchain.md; installation is intentionally user-managed.`);
43+ process.exit(127);
44+ }
45+ if (result.error) throw result.error;
46+ if (result.status !== 0) process.exit(result.status ?? 1);
47+}
48+
49+function build() {
50+ const configuration = readBuildConfiguration(process.env);
51+ const fixture = JSON.parse(readFileSync(join(root, "fixtures/departures/foundation.json"), "utf8"));
52+
53+ run(process.execPath, ["scripts/test.mjs"]);
54+ run(process.execPath, ["scripts/measure-radio.mjs"]);
55+
56+ rmSync(pkjsTarget, { recursive: true, force: true });
57+ mkdirSync(pkjsTarget, { recursive: true });
58+ for (const source of ["contracts.js", "codec.js", "configuration.js", "message-queue.js"]) {
59+ cpSync(join(companionSource, source), join(pkjsTarget, basename(source)));
60+ }
61+ cpSync(join(companionSource, "index.js"), join(pkjsTarget, "companion.js"));
62+ writeFileSync(join(pkjsTarget, "index.js"), createBootstrap(fixture, configuration));
63+
64+ const watch = join(root, "packages/watch");
65+ run("pebble", ["clean"], watch);
66+ run("pebble", ["build"], watch);
67+ run(process.execPath, ["scripts/verify-watch-build.mjs"]);
68+}
69+
70+if (process.argv[1] === fileURLToPath(import.meta.url)) build();
added scripts/measure-radio.mjs +48 -0
new file mode 100644
@@ -0,0 +1,48 @@
1+import { createRequire } from "node:module";
2+import { readFileSync } from "node:fs";
3+
4+const require = createRequire(import.meta.url);
5+const codec = require("../packages/companion/src/codec.js");
6+const contracts = require("../packages/companion/src/contracts.js");
7+const fixture = JSON.parse(readFileSync(
8+ new URL("../fixtures/departures/foundation.json", import.meta.url),
9+ "utf8"
10+));
11+
12+function dictionarySize(dictionary) {
13+ const sizes = Object.values(dictionary).map((value) => (
14+ typeof value === "string" ? Buffer.byteLength(value, "utf8") + 1 : 4
15+ ));
16+ return 1 + (7 * sizes.length) + sizes.reduce((total, size) => total + size, 0);
17+}
18+
19+const request = codec.encodeRequest({
20+ requestId: fixture.result.requestId,
21+ favoriteId: fixture.favorite.id,
22+ trigger: contracts.REQUEST_TRIGGER.APP_OPEN,
23+});
24+const response = codec.encodeResult(fixture.result);
25+const messages = [request, ...response];
26+const sizes = messages.map(dictionarySize);
27+const bytes = sizes.reduce((total, size) => total + size, 0);
28+const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1));
29+const report = {
30+ fixture: "fixtures/departures/foundation.json",
31+ messages: messages.length,
32+ encodedBytes: bytes,
33+ requestBytes: sizes[0],
34+ largestPhoneDictionaryBytes,
35+ scenario: "steady-state APP_OPEN request plus two-departure result",
36+ budget: {
37+ messages: 7,
38+ encodedBytes: 2048,
39+ requestBytes: 192,
40+ phoneDictionaryBytes: 768,
41+ },
42+ pass: messages.length <= 7
43+ && bytes <= 2048
44+ && sizes[0] <= 192
45+ && largestPhoneDictionaryBytes <= 768,
46+};
47+console.log(JSON.stringify(report, null, 2));
48+if (!report.pass) process.exit(1);
new file mode 100644
@@ -0,0 +1,48 @@
1+import { createRequire } from "node:module";
2+import { readFileSync } from "node:fs";
3+
4+const require = createRequire(import.meta.url);
5+const codec = require("../packages/companion/src/codec.js");
6+const contracts = require("../packages/companion/src/contracts.js");
7+const fixture = JSON.parse(readFileSync(
8+ new URL("../fixtures/departures/foundation.json", import.meta.url),
9+ "utf8"
10+));
11+
12+function dictionarySize(dictionary) {
13+ const sizes = Object.values(dictionary).map((value) => (
14+ typeof value === "string" ? Buffer.byteLength(value, "utf8") + 1 : 4
15+ ));
16+ return 1 + (7 * sizes.length) + sizes.reduce((total, size) => total + size, 0);
17+}
18+
19+const request = codec.encodeRequest({
20+ requestId: fixture.result.requestId,
21+ favoriteId: fixture.favorite.id,
22+ trigger: contracts.REQUEST_TRIGGER.APP_OPEN,
23+});
24+const response = codec.encodeResult(fixture.result);
25+const messages = [request, ...response];
26+const sizes = messages.map(dictionarySize);
27+const bytes = sizes.reduce((total, size) => total + size, 0);
28+const largestPhoneDictionaryBytes = Math.max(...sizes.slice(1));
29+const report = {
30+ fixture: "fixtures/departures/foundation.json",
31+ messages: messages.length,
32+ encodedBytes: bytes,
33+ requestBytes: sizes[0],
34+ largestPhoneDictionaryBytes,
35+ scenario: "steady-state APP_OPEN request plus two-departure result",
36+ budget: {
37+ messages: 7,
38+ encodedBytes: 2048,
39+ requestBytes: 192,
40+ phoneDictionaryBytes: 768,
41+ },
42+ pass: messages.length <= 7
43+ && bytes <= 2048
44+ && sizes[0] <= 192
45+ && largestPhoneDictionaryBytes <= 768,
46+};
47+console.log(JSON.stringify(report, null, 2));
48+if (!report.pass) process.exit(1);
added scripts/test.mjs +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+import { spawnSync } from "node:child_process";
2+
3+function run(command, args) {
4+ const result = spawnSync(command, args, { stdio: "inherit" });
5+ if (result.error) throw result.error;
6+ if (result.status !== 0) process.exit(result.status ?? 1);
7+}
8+
9+run(process.execPath, [
10+ "--test",
11+ "packages/contracts/test/contracts.test.ts",
12+ "packages/backend/test/backend.test.ts",
13+ "packages/config-page/test/config-page.test.ts",
14+ "packages/companion/test/protocol.test.js",
15+ "packages/companion/test/companion.test.js",
16+ "packages/watch/test/manifest.test.js",
17+ "packages/watch/test/protocol.test.js",
18+ "packages/watch/test/message-queue.test.js",
19+ "packages/watch/test/model.test.js",
20+ "packages/watch/test/localization.test.js",
21+ "packages/watch/test/fixture-boot.test.js",
22+]);
new file mode 100644
@@ -0,0 +1,22 @@
1+import { spawnSync } from "node:child_process";
2+
3+function run(command, args) {
4+ const result = spawnSync(command, args, { stdio: "inherit" });
5+ if (result.error) throw result.error;
6+ if (result.status !== 0) process.exit(result.status ?? 1);
7+}
8+
9+run(process.execPath, [
10+ "--test",
11+ "packages/contracts/test/contracts.test.ts",
12+ "packages/backend/test/backend.test.ts",
13+ "packages/config-page/test/config-page.test.ts",
14+ "packages/companion/test/protocol.test.js",
15+ "packages/companion/test/companion.test.js",
16+ "packages/watch/test/manifest.test.js",
17+ "packages/watch/test/protocol.test.js",
18+ "packages/watch/test/message-queue.test.js",
19+ "packages/watch/test/model.test.js",
20+ "packages/watch/test/localization.test.js",
21+ "packages/watch/test/fixture-boot.test.js",
22+]);
added scripts/verify-watch-build.mjs +42 -0
new file mode 100644
@@ -0,0 +1,42 @@
1+import { readFileSync } from "node:fs";
2+import { fileURLToPath } from "node:url";
3+import {
4+ APP_MESSAGE_KEY,
5+ APP_MESSAGE_KEY_ORDER
6+} from "../packages/contracts/src/index.ts";
7+
8+const packagePath = new URL("../packages/watch/package.json", import.meta.url);
9+const generatedPath = new URL(
10+ "../packages/watch/build/js/message_keys.json",
11+ import.meta.url
12+);
13+
14+function assertExactMap(actual, source) {
15+ const entries = Object.entries(actual).sort((left, right) => left[1] - right[1]);
16+ if (entries.length !== APP_MESSAGE_KEY_ORDER.length) {
17+ throw new Error(`${source} must contain exactly 25 AppMessage aliases`);
18+ }
19+ entries.forEach(([name, value], index) => {
20+ if (name !== APP_MESSAGE_KEY_ORDER[index] || value !== APP_MESSAGE_KEY[name]) {
21+ throw new Error(
22+ `${source} AppMessage mismatch at ${index}: expected ${APP_MESSAGE_KEY_ORDER[index]}=${index}`
23+ );
24+ }
25+ });
26+}
27+
28+export function verifyWatchBuild() {
29+ const packageMetadata = JSON.parse(readFileSync(packagePath, "utf8"));
30+ const generated = JSON.parse(readFileSync(generatedPath, "utf8"));
31+ assertExactMap(packageMetadata.pebble.messageKeys, "packages/watch/package.json");
32+ assertExactMap(generated, "packages/watch/build/js/message_keys.json");
33+ return {
34+ aliases: APP_MESSAGE_KEY_ORDER.length,
35+ first: APP_MESSAGE_KEY_ORDER[0],
36+ last: APP_MESSAGE_KEY_ORDER[APP_MESSAGE_KEY_ORDER.length - 1]
37+ };
38+}
39+
40+if (process.argv[1] === fileURLToPath(import.meta.url)) {
41+ console.log(JSON.stringify(verifyWatchBuild()));
42+}
new file mode 100644
@@ -0,0 +1,42 @@
1+import { readFileSync } from "node:fs";
2+import { fileURLToPath } from "node:url";
3+import {
4+ APP_MESSAGE_KEY,
5+ APP_MESSAGE_KEY_ORDER
6+} from "../packages/contracts/src/index.ts";
7+
8+const packagePath = new URL("../packages/watch/package.json", import.meta.url);
9+const generatedPath = new URL(
10+ "../packages/watch/build/js/message_keys.json",
11+ import.meta.url
12+);
13+
14+function assertExactMap(actual, source) {
15+ const entries = Object.entries(actual).sort((left, right) => left[1] - right[1]);
16+ if (entries.length !== APP_MESSAGE_KEY_ORDER.length) {
17+ throw new Error(`${source} must contain exactly 25 AppMessage aliases`);
18+ }
19+ entries.forEach(([name, value], index) => {
20+ if (name !== APP_MESSAGE_KEY_ORDER[index] || value !== APP_MESSAGE_KEY[name]) {
21+ throw new Error(
22+ `${source} AppMessage mismatch at ${index}: expected ${APP_MESSAGE_KEY_ORDER[index]}=${index}`
23+ );
24+ }
25+ });
26+}
27+
28+export function verifyWatchBuild() {
29+ const packageMetadata = JSON.parse(readFileSync(packagePath, "utf8"));
30+ const generated = JSON.parse(readFileSync(generatedPath, "utf8"));
31+ assertExactMap(packageMetadata.pebble.messageKeys, "packages/watch/package.json");
32+ assertExactMap(generated, "packages/watch/build/js/message_keys.json");
33+ return {
34+ aliases: APP_MESSAGE_KEY_ORDER.length,
35+ first: APP_MESSAGE_KEY_ORDER[0],
36+ last: APP_MESSAGE_KEY_ORDER[APP_MESSAGE_KEY_ORDER.length - 1]
37+ };
38+}
39+
40+if (process.argv[1] === fileURLToPath(import.meta.url)) {
41+ console.log(JSON.stringify(verifyWatchBuild()));
42+}