feat: establish Alloy app foundation (#10)Unverified
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 | +} | ||