@thomasfosterau/legation-browser 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +842 -0
- package/LICENSE +21 -0
- package/README.md +322 -1
- package/dist/index.d.ts +656 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1109 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/package.json +49 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Thomas Foster
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,324 @@
|
|
|
1
1
|
# @thomasfosterau/legation-browser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Browser-runtime adapters for the legation engine: a service-worker engine host, an elected main-thread host, an IndexedDB client store, tab mirrors over BroadcastChannel, a Cache API HTTP cache, and Background Sync.
|
|
4
|
+
|
|
5
|
+
Code that depends on browser globals (`WebSocket`, `indexedDB`, `BroadcastChannel`, `navigator`) lives here, not in [`@thomasfosterau/legation`](../effect), so the core runs the same in browsers, workers, edge runtimes and Node ([ADR-0006](../../docs/adr/0006-dependency-based-package-boundaries.md)).
|
|
6
|
+
|
|
7
|
+
The recommended topology runs the engine in the per-origin service worker, and every tab mirrors it ([ADR-0010](../../docs/adr/0010-service-worker-hosted-client-engine.md)). Apps that cannot run a service worker run the same host on a tab's main thread, elected once per `clientGroupId` (see [ElectedHost](#electedhost)). Either way one engine per client group owns the durable store, and every other tab is a mirror.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @thomasfosterau/legation-browser @thomasfosterau/legation effect @effect/platform-browser
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Effect version
|
|
16
|
+
|
|
17
|
+
Peers `effect ^4.0.0`. `@effect/platform-browser` is peered `^4.0.0` too. Tested against `4.1.2` (the version CI pins) and `4.0.0` (the floor; CI runs the tests against it on every PR). Install one copy of `effect` in your app: two copies break `Context` identity.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
The shared domain, imported by the tab, the service worker and the server:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// domain.ts
|
|
25
|
+
import { Schema } from "effect";
|
|
26
|
+
import { Mutation, Node, type Query } from "@thomasfosterau/legation";
|
|
27
|
+
|
|
28
|
+
export const Todo = Schema.Struct({
|
|
29
|
+
id: Schema.String,
|
|
30
|
+
title: Schema.String,
|
|
31
|
+
done: Schema.Boolean,
|
|
32
|
+
});
|
|
33
|
+
const { id: _id, ...attributes } = Todo.fields;
|
|
34
|
+
|
|
35
|
+
export const nodes = Node.registry([
|
|
36
|
+
Node.make("todos", { attributes, filterable: { done: ["eq"] } }),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export const setTodo = Mutation.make("todo/set", Todo);
|
|
40
|
+
export const mutations = Mutation.registry([
|
|
41
|
+
{ mutator: setTodo, local: (todo, writer) => writer.put("todos", todo.id, todo) },
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export const openTodos: Query.Read = { collection: "todos", where: { done: false } };
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A tab holds no engine. It mirrors the one the service worker serves, over the `legation` BroadcastChannel:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// page.ts
|
|
51
|
+
import { Client } from "@thomasfosterau/legation";
|
|
52
|
+
import { BroadcastMirrorChannel } from "@thomasfosterau/legation-browser";
|
|
53
|
+
import { Effect, Stream } from "effect";
|
|
54
|
+
import { mutations, nodes, openTodos } from "./domain.js";
|
|
55
|
+
|
|
56
|
+
const program = Effect.gen(function* () {
|
|
57
|
+
const channel = yield* BroadcastMirrorChannel.make("legation");
|
|
58
|
+
const client = yield* Client.connect({ channel, nodes, mutations });
|
|
59
|
+
yield* client.subscribe(openTodos);
|
|
60
|
+
yield* client.mutate("todo/set", { id: "1", title: "Buy milk", done: false });
|
|
61
|
+
yield* client
|
|
62
|
+
.observe(openTodos)
|
|
63
|
+
.pipe(Stream.runForEach((rows) => Effect.sync(() => console.log(`${rows.length} open`))));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
Effect.runFork(Effect.scoped(program));
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The service worker runs the engine. It is built from the same domain, over IndexedDB and the standard remote transport:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// service-worker.ts
|
|
73
|
+
import { Effect, Layer } from "effect";
|
|
74
|
+
import { IndexedDb } from "@effect/platform-browser";
|
|
75
|
+
import { Transport } from "@thomasfosterau/legation";
|
|
76
|
+
import { ClientEngine, RpcTransport } from "@thomasfosterau/legation/internal";
|
|
77
|
+
import {
|
|
78
|
+
BackgroundSync,
|
|
79
|
+
BrowserConnectivity,
|
|
80
|
+
IndexedDbClientStore,
|
|
81
|
+
ServiceWorkerHost,
|
|
82
|
+
} from "@thomasfosterau/legation-browser";
|
|
83
|
+
import { mutations, nodes } from "./domain.js";
|
|
84
|
+
|
|
85
|
+
const clientGroupId = "todo-group";
|
|
86
|
+
|
|
87
|
+
// 1. Top level, and synchronous: a worker woken for a `sync` event dispatches
|
|
88
|
+
// it straight after evaluating this script, so the listener must exist now.
|
|
89
|
+
// An app with sign-in passes an Effect that resolves its user's fingerprint.
|
|
90
|
+
const backgroundSync = BackgroundSync.listen({ identity: BackgroundSync.identityUnchecked });
|
|
91
|
+
|
|
92
|
+
// 2. The durable store. A worker has no `window`, so IndexedDB comes from the
|
|
93
|
+
// worker's own globals.
|
|
94
|
+
const indexedDb = Layer.succeed(IndexedDb.IndexedDb)(IndexedDb.make({ indexedDB, IDBKeyRange }));
|
|
95
|
+
const storeLayer = IndexedDbClientStore.layer({ database: "todo-sw" }).pipe(
|
|
96
|
+
Layer.provide(indexedDb)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// 3. The carrier: the standard remote transport (WebSocket, HTTP fallback),
|
|
100
|
+
// wrapped with browser connectivity signals.
|
|
101
|
+
const transportLayer = Layer.effect(Transport.ClientTransport)(
|
|
102
|
+
RpcTransport.fromOrigin({ clientGroupId, origin: "https://sync.example.com" }).pipe(
|
|
103
|
+
Effect.flatMap(BrowserConnectivity.withBrowserConnectivity)
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// 4. The engine, then the host that serves the tabs from it.
|
|
108
|
+
const engineLayer = ClientEngine.layer({
|
|
109
|
+
clientId: `sw-${clientGroupId}`,
|
|
110
|
+
clientGroupId,
|
|
111
|
+
mutators: mutations,
|
|
112
|
+
syncLoop: { pullInterval: "15 seconds", pushDebounce: "300 millis" },
|
|
113
|
+
}).pipe(Layer.provide(Layer.merge(storeLayer, transportLayer)));
|
|
114
|
+
|
|
115
|
+
Effect.runFork(
|
|
116
|
+
ServiceWorkerHost.run({ registry: nodes.collections, backgroundSync }).pipe(
|
|
117
|
+
Effect.provide(engineLayer),
|
|
118
|
+
Effect.scoped
|
|
119
|
+
)
|
|
120
|
+
);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The tab's `BroadcastMirrorChannel` name and the host's default channel name are both `legation`, so the two find each other. The host keeps running until its scope closes.
|
|
124
|
+
|
|
125
|
+
## API
|
|
126
|
+
|
|
127
|
+
### Modules
|
|
128
|
+
|
|
129
|
+
| Module | What it is |
|
|
130
|
+
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
131
|
+
| `ServiceWorkerHost` | `run` and `start`: the recommended host. It serves the engine to the tabs from a service worker, and `start` also returns what a JSON:API façade needs. |
|
|
132
|
+
| `ElectedHost` | `run`: elects one tab per client group, with `navigator.locks`, to host the engine on its main thread. The other tabs mirror it. |
|
|
133
|
+
| `IndexedDbClientStore` | The durable `ClientStore` over IndexedDB: `layer` (needs an `IndexedDb` service), `layerWindow` (the page's `indexedDB`) and `layerNamespaced` (one replica per identity). |
|
|
134
|
+
| `BroadcastMirrorChannel` | `make(name)`: the mirror seam over a named `BroadcastChannel`. The mirror types (`EngineHost`, `MirrorChannel`, `MirrorProtocol`, `Mirror`) are also re-exported from here. |
|
|
135
|
+
| `CacheApiHttpCache` | `open` and `fetchHandler`: a `ResponseCacheStore` over the Cache API, with stale-while-revalidate. |
|
|
136
|
+
| `HttpCacheInvalidation` | `collectionChanges` and `invalidateOnChanges`: evict cached responses as collections change, by the `collection:{name}` tag. |
|
|
137
|
+
| `BrowserConnectivity` | `withBrowserConnectivity(transport)`: adds `navigator.onLine` and return-to-visible signals to a `ClientTransport`. |
|
|
138
|
+
| `BackgroundSync` | `listen`, `register` and `identityUnchecked`: flush the journal after the tab has closed. |
|
|
139
|
+
| `PushPoke` | `listen`: turn the library's Web Push into a Background Sync registration or a pull, so a client with no open tab converges. |
|
|
140
|
+
|
|
141
|
+
### ServiceWorkerHost
|
|
142
|
+
|
|
143
|
+
`ServiceWorkerHost.run(options)` serves mirrors and runs the engine's sync loop (pull on poke, push and pull on reconnect). It never returns, so keep its scope open. The options are `channelName` (default `"legation"`), `registry` (the collection registry the mirrors' related reads resolve against: pass `nodes.collections` whenever a tab uses `include`), `nodes` and `urls` (for a page that pins a URL), `backgroundSync` and `pushPoke`.
|
|
144
|
+
|
|
145
|
+
The engine comes from the context, which is why the example builds it with `ClientEngine.layer` from the `internal` entry point. `ServiceWorkerHost.start(options)` does the same and returns a `Host`: the `engine`, and `registrations`: every registration the host holds for its tabs, each with the pulled state of its windows. A covered read needs the windows its rows link to pulled (ADR-0029 amendment #426), so a registration made just now is listed before its rows have arrived. A worker that also serves JSON:API `GET`s from the replica (the façade, [ADR-0029](../../docs/adr/0029-replica-served-jsonapi-facade.md), `JsonApiFacade` in `@thomasfosterau/legation-jsonapi`) builds its coverage check from those registrations. The [`commerce` example](../../examples/commerce) runs this end to end, with its façade.
|
|
146
|
+
|
|
147
|
+
### BackgroundSync
|
|
148
|
+
|
|
149
|
+
A tab's journal flushes on reconnect and on return-to-visible, but only while a page is open. `BackgroundSync` covers the rest. The browser wakes the service worker with a `sync` event once connectivity returns, tab or no tab, and the host flushes the journal through `ClientEngine.push`.
|
|
150
|
+
|
|
151
|
+
- **Call `BackgroundSync.listen` at the top level of the worker script.** It holds a `sync` event open for `readyTimeout` (30 seconds by default) until a host attaches, so the host must boot without a tab. The service worker example does that.
|
|
152
|
+
- **`identity` is required.** It is an Effect that resolves who is signed in now, or `BackgroundSync.identityUnchecked` for an app with no sign-in, which accepts that a store still stamped for a signed-out user would be pushed. A flush pushes only if the store is stamped for that identity, and otherwise skips with `"IdentityChanged"` and leaves the journal for the next page to reset or upgrade. A tab that closed before `Client.reset` ran would otherwise leave the old identity's journal pushable.
|
|
153
|
+
- **Registration happens only when the journal cannot drain now:** a mutation journaled while the worker is offline, or a push that failed terminally. Online, the sync loop drains the journal as usual, and a registration would bypass `pushDebounce`.
|
|
154
|
+
- **A page can register too.** `BackgroundSync.register()` returns `"Registered"`, `"Unsupported"` or `"Refused"`, never fails, and is best-effort.
|
|
155
|
+
- **Two limits of registration from the worker.** Chromium refuses a registration made from a worker while no window of the origin is open, so a push that fails terminally after the last tab has closed registers nothing, and the journal waits for the next page. A page that wants to cover that case can call `BackgroundSync.register()` on `pagehide` (or `visibilitychange` to hidden) while it knows writes are pending. And on reconnect with a page open, both the sync loop and the `sync` flush push: the backend's ledger skips the duplicate, so the cost is one extra round trip, not a double write.
|
|
156
|
+
- **No retry ladder.** A flush that fails on the transport rejects the event's `waitUntil` promise, which is the browser's cue to reschedule it with its own backoff. The journal is untouched either way, and the next connectivity signal flushes it regardless.
|
|
157
|
+
- **Background Sync is Chromium-only.** On Safari and Firefox `register` returns `"Unsupported"`, and the journal flushes on the next connectivity signal when a page is open.
|
|
158
|
+
- **A foreign tag's event is left alone.** The listener answers only `BackgroundSync.defaultTag` (`"legation-flush"`) or the `tag` it was given.
|
|
159
|
+
|
|
160
|
+
Periodic Background Sync is not wrapped: it has its own permission story and battery cost. A worker that wants one can call `ClientEngine.pull` from its own `periodicsync` listener.
|
|
161
|
+
|
|
162
|
+
### PushPoke
|
|
163
|
+
|
|
164
|
+
Web Push is the one mechanism that wakes a service worker that is not running (#214). A push-poke wakes it so the replica is current when the user opens the app, not after a round trip.
|
|
165
|
+
|
|
166
|
+
**The server side** is `PushPoke.sink` in `@thomasfosterau/legation/internal`, an `InvalidationSink` run with `InvalidationSink.run` where the bus is:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const sink =
|
|
170
|
+
yield *
|
|
171
|
+
PushPoke.sink({
|
|
172
|
+
// The app's endpoint registry: which subscriptions a change should wake.
|
|
173
|
+
targetsFor: (touched) => subscriptionsOfOwnersOf(touched),
|
|
174
|
+
keyOf: (subscription) => subscription.endpoint,
|
|
175
|
+
// The app signs and sends with its own VAPID keys; the library holds none.
|
|
176
|
+
send: (subscription, payload) => sendWebPush(subscription, payload),
|
|
177
|
+
coalesce: "5 minutes",
|
|
178
|
+
budget: { pushes: 6, per: "1 hour" },
|
|
179
|
+
});
|
|
180
|
+
yield * InvalidationSink.run(sink);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- **The payload is contentless.** Every push is `PushPoke.payload`, a fixed marker: no topic, no collection, no row id. A payload crosses a third-party push service, and a collection name would tell it what a user subscribes to. The pull the poke prompts is the authenticated channel, and the only place the data travels.
|
|
184
|
+
- **State your budget.** `coalesce` and `budget` are required and have no default. Browsers penalise a push that shows no notification (Chromium subscriptions require `userVisibleOnly`, and a worker that shows nothing for its pushes can lose the permission), and push services rate-limit each subscription. So wake a device for a change worth waking it for, not for every write: within `coalesce` a target gets one push, plus one more at the window's end if anything else changed, and past `budget.pushes` per `budget.per` it gets none until the period moves on. A skipped push costs nothing but a delay: the next pull converges. If your subscriptions are `userVisibleOnly`, show a notification from your own `push` handler for the pokes you send, or send them only where a notification is wanted anyway.
|
|
185
|
+
|
|
186
|
+
**The worker side** is `PushPoke.listen`, installed at the top level of the worker script and passed to the host as `pushPoke`:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
const pushPoke = PushPoke.listen({ identity: currentFingerprint });
|
|
190
|
+
yield * ServiceWorkerHost.start({ backgroundSync, pushPoke, registry, nodes });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- **By default a push-poke only registers the Background Sync tag** (the flush path above), so the browser syncs when it judges the connection good: the kinder choice on a metered connection. `action: "pull"` pulls at once instead.
|
|
194
|
+
- **Every other push is left alone.** The listener acts only on the library's marker, with no `waitUntil` on anything else, so the app's own `push` handler keeps working.
|
|
195
|
+
- **`identity` is required**, as for `BackgroundSync.listen`. A push acts only if the replica is stamped for whoever is signed in now, and otherwise returns `"IdentityChanged"`, so a push for a superseded identity never pulls or registers under the new one.
|
|
196
|
+
|
|
197
|
+
### ElectedHost
|
|
198
|
+
|
|
199
|
+
For apps without a service worker. `ElectedHost.run` elects exactly one tab per `clientGroupId` to host the engine on its main thread, through the Web Locks API (`navigator.locks`). The other tabs mirror that tab over the same BroadcastChannel. The lock is held for the tab's lifetime once won, so there is no voluntary step-down. When the elected tab closes or crashes, the next tab boots the engine against the same durable store. Its journal, rows and cookie carry over, so the CVR conversation resumes.
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
// elected.ts
|
|
203
|
+
import { ClientStore, Transport } from "@thomasfosterau/legation";
|
|
204
|
+
import { RpcTransport } from "@thomasfosterau/legation/internal";
|
|
205
|
+
import {
|
|
206
|
+
BrowserConnectivity,
|
|
207
|
+
ElectedHost,
|
|
208
|
+
IndexedDbClientStore,
|
|
209
|
+
} from "@thomasfosterau/legation-browser";
|
|
210
|
+
import { Effect, Layer, Stream } from "effect";
|
|
211
|
+
import { mutations, nodes, openTodos } from "./domain.js";
|
|
212
|
+
|
|
213
|
+
const clientGroupId = "todo-group";
|
|
214
|
+
|
|
215
|
+
// Only built once this tab wins the election: nothing connects, and nothing
|
|
216
|
+
// opens IndexedDB, in a tab that is still mirroring.
|
|
217
|
+
const dependencies: Layer.Layer<ClientStore.ClientStore | Transport.ClientTransport> =
|
|
218
|
+
Layer.mergeAll(
|
|
219
|
+
IndexedDbClientStore.layerWindow({ database: "todo-app" }),
|
|
220
|
+
Layer.effect(Transport.ClientTransport)(
|
|
221
|
+
RpcTransport.fromOrigin({ clientGroupId, origin: "https://sync.example.com" }).pipe(
|
|
222
|
+
Effect.flatMap(BrowserConnectivity.withBrowserConnectivity)
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const program = Effect.gen(function* () {
|
|
228
|
+
const { client, role } = yield* ElectedHost.run({
|
|
229
|
+
clientGroupId,
|
|
230
|
+
nodes,
|
|
231
|
+
mutations,
|
|
232
|
+
dependencies,
|
|
233
|
+
});
|
|
234
|
+
// `role` is for devtools and debug UI only. Never branch app logic on it.
|
|
235
|
+
yield* Effect.forkScoped(
|
|
236
|
+
role.pipe(Stream.runForEach((current) => Effect.sync(() => console.debug("role", current))))
|
|
237
|
+
);
|
|
238
|
+
// `client` works the same whether this tab is hosting or mirroring.
|
|
239
|
+
yield* client.subscribe(openTodos);
|
|
240
|
+
yield* client
|
|
241
|
+
.observe(openTodos)
|
|
242
|
+
.pipe(Stream.runForEach((rows) => Effect.sync(() => console.log(`${rows.length} open`))));
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
Effect.runFork(Effect.scoped(program));
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`ElectedHost.run` returns one `Client` for the tab's lifetime: reads and writes go through the mirror until this tab is elected, and through the engine from then on. Live `observe` streams re-subscribe on the switch, and `subscribe` pins are replayed. The lock name is `clientGroupId`, and the host's sync-protocol identity defaults to `host-${clientGroupId}`, so whichever tab wins resumes the same journal. Without `navigator.locks` (some embedded webviews), `ElectedHost.run` logs a warning and becomes the host at once. That is correct only when a single tab is ever open.
|
|
249
|
+
|
|
250
|
+
### IndexedDbClientStore
|
|
251
|
+
|
|
252
|
+
A durable `ClientStore` over IndexedDB. Every engine state transition lands in IndexedDB, so a killed service worker, or a closed tab in main-thread hosting, rehydrates without loss. The options are `database` (the IndexedDB database name), `prefix` (the key namespace, default `"legation/"`) and `identity`.
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
// idb.ts
|
|
256
|
+
import type { Identity } from "@thomasfosterau/legation";
|
|
257
|
+
import { IndexedDbClientStore } from "@thomasfosterau/legation-browser";
|
|
258
|
+
|
|
259
|
+
// One replica for whoever is signed in. A sign-in change resets it, and the
|
|
260
|
+
// unpushed journal is quarantined under the old identity, never discarded.
|
|
261
|
+
export const sharedReplica = (fingerprint: Identity.Fingerprint) =>
|
|
262
|
+
IndexedDbClientStore.layerWindow({ database: "todo-app", identity: fingerprint });
|
|
263
|
+
|
|
264
|
+
// One replica per identity, keyed by fingerprint. Switching back finds the replica warm.
|
|
265
|
+
export const perUserReplica = (fingerprint: Identity.Fingerprint) =>
|
|
266
|
+
IndexedDbClientStore.layerNamespaced(fingerprint, { database: "todo-app" });
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Choose by how sign-ins behave on the device. Namespace when a few regular users switch back and forth, since each keeps a warm replica on disk. Reset when sign-ins are one-off or unbounded, such as a kiosk or a shared library machine, where a replica per visitor is the worse trade. Layers that take an `identity` (`layer`, `layerWindow`) turn the identity boundary on. `layerWindow` expects the page's own `indexedDB`. In a worker, provide `IndexedDb` yourself, as the service worker example does.
|
|
270
|
+
|
|
271
|
+
The store opens its database without a version, so a database a later app build already upgraded still opens. If another connection later upgrades the database, or deletes it, this store's connections close, since they would block that change. Every call after that fails with "the IndexedDB database was upgraded by another connection; reload to continue". The store does not reopen itself, so the app should reload to pick up the new schema. Writes that were committed before the upgrade are kept.
|
|
272
|
+
|
|
273
|
+
### HTTP cache
|
|
274
|
+
|
|
275
|
+
`CacheApiHttpCache` keeps responses in a named Cache API cache, and its `fetchHandler` serves them stale-while-revalidate under a per-request policy. A policy returning `undefined` bypasses the cache. `HttpCacheInvalidation` evicts entries by tag as the engine's collections change: a response tagged `collection:todos` is dropped when `todos` changes.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
// cache.ts
|
|
279
|
+
import type { ClientEngine, HttpCache } from "@thomasfosterau/legation/internal";
|
|
280
|
+
import { CacheApiHttpCache, HttpCacheInvalidation } from "@thomasfosterau/legation-browser";
|
|
281
|
+
import { Effect } from "effect";
|
|
282
|
+
|
|
283
|
+
const policy = (request: Request): HttpCache.HttpCachePolicy | undefined =>
|
|
284
|
+
request.mode === "navigate"
|
|
285
|
+
? { tags: ["pages"], staleTime: "5 seconds", timeToLive: "1 hour" }
|
|
286
|
+
: undefined;
|
|
287
|
+
|
|
288
|
+
// Answer a request from the cache, or from the network when nothing is cached.
|
|
289
|
+
export const respond = (request: Request) =>
|
|
290
|
+
Effect.gen(function* () {
|
|
291
|
+
const store = yield* CacheApiHttpCache.open("app-cache");
|
|
292
|
+
const handle = CacheApiHttpCache.fetchHandler({ store, policy });
|
|
293
|
+
const result = yield* handle(request);
|
|
294
|
+
return result.response;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Evict cached responses as the engine's collections change. Run it in a scope.
|
|
298
|
+
export const evictOnChanges = (engine: ClientEngine.ClientEngine) =>
|
|
299
|
+
Effect.gen(function* () {
|
|
300
|
+
const store = yield* CacheApiHttpCache.open("app-cache");
|
|
301
|
+
yield* HttpCacheInvalidation.invalidateOnChanges({
|
|
302
|
+
store,
|
|
303
|
+
changes: HttpCacheInvalidation.collectionChanges(engine.engineEvents),
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`fetchHandler` returns `revalidate` for a stale response. A service worker passes it to `event.waitUntil` so the refresh finishes after the response is sent.
|
|
309
|
+
|
|
310
|
+
### BrowserConnectivity
|
|
311
|
+
|
|
312
|
+
`BrowserConnectivity.withBrowserConnectivity(transport, options?)` wraps a `ClientTransport` so its status combines `navigator.onLine` with the transport's own signal. A return to the visible tab counts as a poke, so the sync loop catches up. The service worker and elected host examples above both use it. It is scoped: its listeners and probes are removed when the scope closes.
|
|
313
|
+
|
|
314
|
+
Where there is a `window`, the `online` and `offline` events drive the navigator signal. A service worker has no `window` and no such events, so there `navigator.onLine` is read every `pollInterval` (default `"5 seconds"`, set through `options`) and a change is emitted when it flips. In a worker or a window, when the navigator goes from offline back to online, `statusChanges` emits `connected` once, whatever the transport reports. The sync loop then runs its push and pull at once, instead of waiting on the push retry backoff. `status` still reads the worse of the two signals, so the two can disagree after a reconnect: `statusChanges` is an edge trigger for the sync loop, and a connection label should read `status`.
|
|
315
|
+
|
|
316
|
+
Auto-push does not consult this status. A push attempted while the status is `disconnected` goes out anyway. `navigator.onLine` is a hint and can read `false` while the transport still works, and a failed push already backs off on its own retry policy. The reconnect edge is what drains a journal that a failed push left behind.
|
|
317
|
+
|
|
318
|
+
### BroadcastMirrorChannel
|
|
319
|
+
|
|
320
|
+
`BroadcastMirrorChannel.make(name)` opens a `BroadcastChannel` as the mirror seam's `MirrorChannel`, and closes it with its scope. A tab passes it to `Client.connect`, as the tab example does. Its name must match the host's `channelName`, which defaults to `"legation"`.
|
|
321
|
+
|
|
322
|
+
## License
|
|
323
|
+
|
|
324
|
+
MIT
|