@demlik/tea 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/dist/cache/index.d.ts +151 -0
- package/dist/cache/index.js +53 -0
- package/dist/cache/index.js.map +1 -0
- package/dist/chunk-2YFLOCWX.js +15 -0
- package/dist/chunk-2YFLOCWX.js.map +1 -0
- package/dist/chunk-CMG2KMWX.js +458 -0
- package/dist/chunk-CMG2KMWX.js.map +1 -0
- package/dist/chunk-PYBQREFU.js +95 -0
- package/dist/chunk-PYBQREFU.js.map +1 -0
- package/dist/chunk-PYOCUBQJ.js +196 -0
- package/dist/chunk-PYOCUBQJ.js.map +1 -0
- package/dist/chunk-Y5JM3UUH.js +126 -0
- package/dist/chunk-Y5JM3UUH.js.map +1 -0
- package/dist/chunk-ZRUST6BK.js +62 -0
- package/dist/chunk-ZRUST6BK.js.map +1 -0
- package/dist/circuit-breaker/index.d.ts +196 -0
- package/dist/circuit-breaker/index.js +53 -0
- package/dist/circuit-breaker/index.js.map +1 -0
- package/dist/deadline/index.d.ts +113 -0
- package/dist/deadline/index.js +20 -0
- package/dist/deadline/index.js.map +1 -0
- package/dist/debounce/index.d.ts +114 -0
- package/dist/debounce/index.js +56 -0
- package/dist/debounce/index.js.map +1 -0
- package/dist/devtools/index.d.ts +137 -0
- package/dist/devtools/index.js +217 -0
- package/dist/devtools/index.js.map +1 -0
- package/dist/devtools/styles.css +218 -0
- package/dist/do/index.d.ts +135 -0
- package/dist/do/index.js +54 -0
- package/dist/do/index.js.map +1 -0
- package/dist/extension/index.d.ts +207 -0
- package/dist/extension/index.js +26 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/react.d.ts +121 -0
- package/dist/extension/react.js +69 -0
- package/dist/extension/react.js.map +1 -0
- package/dist/extension/subs/index.d.ts +44 -0
- package/dist/extension/subs/index.js +83 -0
- package/dist/extension/subs/index.js.map +1 -0
- package/dist/extension/test-utils.d.ts +128 -0
- package/dist/extension/test-utils.js +269 -0
- package/dist/extension/test-utils.js.map +1 -0
- package/dist/idempotency/index.d.ts +131 -0
- package/dist/idempotency/index.js +58 -0
- package/dist/idempotency/index.js.map +1 -0
- package/dist/index.d.ts +364 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/machine-viz/index.d.ts +78 -0
- package/dist/machine-viz/index.js +139 -0
- package/dist/machine-viz/index.js.map +1 -0
- package/dist/mem/index.d.ts +45 -0
- package/dist/mem/index.js +19 -0
- package/dist/mem/index.js.map +1 -0
- package/dist/node/index.d.ts +134 -0
- package/dist/node/index.js +89 -0
- package/dist/node/index.js.map +1 -0
- package/dist/pbt/arbitraries/index.d.ts +135 -0
- package/dist/pbt/arbitraries/index.js +3 -0
- package/dist/pbt/arbitraries/index.js.map +1 -0
- package/dist/pbt/index.d.ts +28 -0
- package/dist/pbt/index.js +18 -0
- package/dist/pbt/index.js.map +1 -0
- package/dist/pbt/runners/index.d.ts +117 -0
- package/dist/pbt/runners/index.js +4 -0
- package/dist/pbt/runners/index.js.map +1 -0
- package/dist/rate-limit/index.d.ts +115 -0
- package/dist/rate-limit/index.js +45 -0
- package/dist/rate-limit/index.js.map +1 -0
- package/dist/react/index.d.ts +72 -0
- package/dist/react/index.js +33 -0
- package/dist/react/index.js.map +1 -0
- package/dist/recorder/index.d.ts +287 -0
- package/dist/recorder/index.js +157 -0
- package/dist/recorder/index.js.map +1 -0
- package/dist/retry-backoff/index.d.ts +131 -0
- package/dist/retry-backoff/index.js +37 -0
- package/dist/retry-backoff/index.js.map +1 -0
- package/dist/subs/index.d.ts +57 -0
- package/dist/subs/index.js +4 -0
- package/dist/subs/index.js.map +1 -0
- package/dist/testing/index.d.ts +220 -0
- package/dist/testing/index.js +105 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/throttle/index.d.ts +125 -0
- package/dist/throttle/index.js +54 -0
- package/dist/throttle/index.js.map +1 -0
- package/dist/trace-replay/index.d.ts +52 -0
- package/dist/trace-replay/index.js +57 -0
- package/dist/trace-replay/index.js.map +1 -0
- package/dist/types-CwBC5wMn.d.ts +5 -0
- package/dist/work-queue/index.d.ts +56 -0
- package/dist/work-queue/index.js +143 -0
- package/dist/work-queue/index.js.map +1 -0
- package/package.json +184 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Store, SubId, Sub } from '../index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @demlik/tea/do — Durable Object adapter for `@demlik/tea`.
|
|
5
|
+
*
|
|
6
|
+
* Two exports drive the integration:
|
|
7
|
+
*
|
|
8
|
+
* 1. `doStore<S>(storage, parse, key?)` — `Store<S>` impl backed by
|
|
9
|
+
* `DurableObjectStorage`. JSON-stringifies on save, JSON-parses on load
|
|
10
|
+
* and returns `unknown` (invariant-8 boundary); the substrate then calls
|
|
11
|
+
* `migrate(raw)` (which forwards to `parse`) to turn it into `S | null`.
|
|
12
|
+
* Returns `null` (typed as `unknown`) when the key is absent. Structural
|
|
13
|
+
* JSON malformation throws at load — `run()`'s boot path surfaces that
|
|
14
|
+
* (PRD throw-semantics table: "store.load throw at boot → run() throws
|
|
15
|
+
* synchronously"). Shape mismatch is NOT a throw — `parse` returns
|
|
16
|
+
* `null` and the substrate boots fresh.
|
|
17
|
+
*
|
|
18
|
+
* 2. `doSubscribe<M, Ctx>()` — handler registry for the two DO-native sub
|
|
19
|
+
* types this package owns:
|
|
20
|
+
* - `do_alarm` — schedules a real DO alarm via `state.storage.setAlarm`;
|
|
21
|
+
* the cooperating DO's `alarm()` lifecycle method dispatches the sub's
|
|
22
|
+
* msg. Cleanup clears the alarm.
|
|
23
|
+
* - `do_ws` — registers a WebSocket-id'd listener in a per-DO registry
|
|
24
|
+
* that the cooperating DO's `webSocketMessage()` consults. Cleanup
|
|
25
|
+
* deregisters.
|
|
26
|
+
*
|
|
27
|
+
* BOTH `do_alarm` and `do_ws` rely on the host DO cooperating: alarm and
|
|
28
|
+
* webSocketMessage are DO lifecycle methods, not JS events the handler can
|
|
29
|
+
* subscribe to from the outside. The cooperating DO must:
|
|
30
|
+
*
|
|
31
|
+
* - Maintain a `Ctx` that exposes `state: DurableObjectState` AND the
|
|
32
|
+
* subscriber registries (`wsRegistry`, `alarmRegistry`). The handlers read
|
|
33
|
+
* from `ctx`.
|
|
34
|
+
* - Forward `alarm()` callbacks into the alarm registry by id, dispatching
|
|
35
|
+
* the registered msg.
|
|
36
|
+
* - Forward `webSocketMessage(ws, data)` callbacks into the ws registry,
|
|
37
|
+
* dispatching `sub.msg(data)` for matching `socketId`s.
|
|
38
|
+
*
|
|
39
|
+
* `src/test-worker.ts` is the canonical example of this cooperation. See
|
|
40
|
+
* `test/README.md` for the convention.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `Store<S>` over `DurableObjectStorage`.
|
|
45
|
+
*
|
|
46
|
+
* `parse` is REQUIRED because DO storage is a real serialization boundary —
|
|
47
|
+
* the bytes that come back through `JSON.parse` are structurally `unknown`,
|
|
48
|
+
* and the caller (the DO that owns `S`) is the only party with enough
|
|
49
|
+
* information to validate the shape. Returning `null` from `parse` means
|
|
50
|
+
* "no usable persisted state" — the substrate boots `init` with `loaded =
|
|
51
|
+
* null`, a known-good fresh-boot path. `parse` must NOT throw per the
|
|
52
|
+
* `Store<S>.migrate` contract; shape mismatch is a value (null), not a
|
|
53
|
+
* panic.
|
|
54
|
+
*
|
|
55
|
+
* Structural malformations (the stored cell isn't even JSON) still throw at
|
|
56
|
+
* `load()` — that's an infrastructure error, distinct from a schema
|
|
57
|
+
* mismatch. The substrate surfaces it via `runtime.ready` rejection.
|
|
58
|
+
*
|
|
59
|
+
* Concrete bite this closes: when a `State` variant is renamed between
|
|
60
|
+
* deploys, the DO's new code previously received a value typed as the new
|
|
61
|
+
* `S` that held a runtime string from the old `S` — silent undefined
|
|
62
|
+
* behavior in `init`/`update`. After this, the boundary parse rejects the
|
|
63
|
+
* old shape (returns null) and the DO boots fresh, picking up its new
|
|
64
|
+
* schema from the next dispatch onward.
|
|
65
|
+
*/
|
|
66
|
+
declare function doStore<S>(storage: DurableObjectStorage, parse: (raw: unknown) => S | null, key?: string): Store<S>;
|
|
67
|
+
/**
|
|
68
|
+
* A DO alarm sub: fires `msg` at `firesAt` (epoch ms). The substrate diffs
|
|
69
|
+
* subs by `id`; same id across transitions = same alarm. To reschedule, emit
|
|
70
|
+
* a DIFFERENT id (e.g., `audit-timeout-v2`).
|
|
71
|
+
*/
|
|
72
|
+
type DoAlarmSub<M> = {
|
|
73
|
+
id: SubId;
|
|
74
|
+
type: "do_alarm";
|
|
75
|
+
firesAt: number;
|
|
76
|
+
msg: M;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* A DO WebSocket sub: routes incoming messages on `socketId` through
|
|
80
|
+
* `sub.msg(data)` to dispatch.
|
|
81
|
+
*/
|
|
82
|
+
type DoWsSub<M> = {
|
|
83
|
+
id: SubId;
|
|
84
|
+
type: "do_ws";
|
|
85
|
+
socketId: string;
|
|
86
|
+
msg: (data: string) => M;
|
|
87
|
+
};
|
|
88
|
+
type DoSub<M> = DoAlarmSub<M> | DoWsSub<M>;
|
|
89
|
+
type AssertDoSubIsSub<T extends Sub = DoSub<unknown>> = T;
|
|
90
|
+
/**
|
|
91
|
+
* Registry of pending alarm dispatches keyed by sub `id`. The cooperating DO's
|
|
92
|
+
* `alarm()` lifecycle method drains this and dispatches each msg.
|
|
93
|
+
*
|
|
94
|
+
* One alarm-queue cell at a time per DO (DO has exactly one alarm slot). We
|
|
95
|
+
* still key by `id` so multiple alarm subs scheduled in the same reconcile
|
|
96
|
+
* pass don't clobber each other — the earliest `firesAt` wins (DO native
|
|
97
|
+
* behavior) and `alarm()` drains everything due by the fire time.
|
|
98
|
+
*/
|
|
99
|
+
type DoAlarmRegistry<M> = Map<string, {
|
|
100
|
+
firesAt: number;
|
|
101
|
+
msg: M;
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* Registry of WebSocket message subscribers keyed by sub `id`. The cooperating
|
|
105
|
+
* DO's `webSocketMessage(ws, data)` consults this and dispatches
|
|
106
|
+
* `sub.msg(data)` for entries matching the incoming `socketId`.
|
|
107
|
+
*/
|
|
108
|
+
type DoWsRegistry<M> = Map<string, {
|
|
109
|
+
socketId: string;
|
|
110
|
+
msg: (data: string) => M;
|
|
111
|
+
}>;
|
|
112
|
+
/**
|
|
113
|
+
* Minimum shape a cooperating DO must put on its `Ctx` for `doSubscribe`
|
|
114
|
+
* handlers to wire up. Users may extend with anything else they like.
|
|
115
|
+
*/
|
|
116
|
+
interface DoSubscribeCtx<M> {
|
|
117
|
+
state: DurableObjectState;
|
|
118
|
+
alarmRegistry: DoAlarmRegistry<M>;
|
|
119
|
+
wsRegistry: DoWsRegistry<M>;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build the `subscribe` handler registry for `do_alarm` and `do_ws` subs.
|
|
123
|
+
*
|
|
124
|
+
* Returned object shape matches `Machine#subscribe`'s `[K in U['type']]:
|
|
125
|
+
* (sub, ctx, dispatch) => () => void` contract for `U = DoSub<M>`.
|
|
126
|
+
*
|
|
127
|
+
* Generic `Ctx` is user-defined and must extend `DoSubscribeCtx<M>` so handlers
|
|
128
|
+
* can reach `state.storage`, `alarmRegistry`, and `wsRegistry`.
|
|
129
|
+
*/
|
|
130
|
+
declare function doSubscribe<M, Ctx extends DoSubscribeCtx<M>>(): {
|
|
131
|
+
do_alarm: (sub: DoAlarmSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;
|
|
132
|
+
do_ws: (sub: DoWsSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export { type AssertDoSubIsSub, type DoAlarmRegistry, type DoAlarmSub, type DoSub, type DoSubscribeCtx, type DoWsRegistry, type DoWsSub, doStore, doSubscribe };
|
package/dist/do/index.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/do/index.ts
|
|
2
|
+
var DEFAULT_STATE_KEY = "@@state";
|
|
3
|
+
function doStore(storage, parse, key = DEFAULT_STATE_KEY) {
|
|
4
|
+
return {
|
|
5
|
+
async load() {
|
|
6
|
+
const raw = await storage.get(key);
|
|
7
|
+
if (raw === void 0 || raw === null) return null;
|
|
8
|
+
return JSON.parse(raw);
|
|
9
|
+
},
|
|
10
|
+
async save(state) {
|
|
11
|
+
await storage.put(key, JSON.stringify(state));
|
|
12
|
+
},
|
|
13
|
+
migrate(raw) {
|
|
14
|
+
return parse(raw);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function doSubscribe() {
|
|
19
|
+
return {
|
|
20
|
+
do_alarm: (sub, ctx, _dispatch) => {
|
|
21
|
+
ctx.alarmRegistry.set(sub.id, { firesAt: sub.firesAt, msg: sub.msg });
|
|
22
|
+
const earliest = earliestFiresAt(ctx.alarmRegistry);
|
|
23
|
+
if (earliest !== null) {
|
|
24
|
+
ctx.state.storage.setAlarm(earliest).catch((err) => console.error("[tea-do] setAlarm failed", err));
|
|
25
|
+
}
|
|
26
|
+
return () => {
|
|
27
|
+
ctx.alarmRegistry.delete(sub.id);
|
|
28
|
+
const nextEarliest = earliestFiresAt(ctx.alarmRegistry);
|
|
29
|
+
if (nextEarliest === null) {
|
|
30
|
+
ctx.state.storage.deleteAlarm().catch((err) => console.error("[tea-do] deleteAlarm failed", err));
|
|
31
|
+
} else {
|
|
32
|
+
ctx.state.storage.setAlarm(nextEarliest).catch((err) => console.error("[tea-do] setAlarm failed", err));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
do_ws: (sub, ctx, _dispatch) => {
|
|
37
|
+
ctx.wsRegistry.set(sub.id, { socketId: sub.socketId, msg: sub.msg });
|
|
38
|
+
return () => {
|
|
39
|
+
ctx.wsRegistry.delete(sub.id);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function earliestFiresAt(registry) {
|
|
45
|
+
let earliest = null;
|
|
46
|
+
for (const { firesAt } of registry.values()) {
|
|
47
|
+
if (earliest === null || firesAt < earliest) earliest = firesAt;
|
|
48
|
+
}
|
|
49
|
+
return earliest;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { doStore, doSubscribe };
|
|
53
|
+
//# sourceMappingURL=index.js.map
|
|
54
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/do/index.ts"],"names":[],"mappings":";AA+CA,IAAM,iBAAA,GAAoB,SAAA;AAyBnB,SAAS,OAAA,CACd,OAAA,EACA,KAAA,EACA,GAAA,GAAc,iBAAA,EACJ;AACV,EAAA,OAAO;AAAA,IACL,MAAM,IAAA,GAAyB;AAC7B,MAAA,MAAM,GAAA,GAAM,MAAM,OAAA,CAAQ,GAAA,CAAY,GAAG,CAAA;AACzC,MAAA,IAAI,GAAA,KAAQ,MAAA,IAAa,GAAA,KAAQ,IAAA,EAAM,OAAO,IAAA;AAK9C,MAAA,OAAO,IAAA,CAAK,MAAM,GAAG,CAAA;AAAA,IACvB,CAAA;AAAA,IACA,MAAM,KAAK,KAAA,EAAyB;AAClC,MAAA,MAAM,QAAQ,GAAA,CAAI,GAAA,EAAK,IAAA,CAAK,SAAA,CAAU,KAAK,CAAC,CAAA;AAAA,IAC9C,CAAA;AAAA,IACA,QAAQ,GAAA,EAAwB;AAC9B,MAAA,OAAO,MAAM,GAAG,CAAA;AAAA,IAClB;AAAA,GACF;AACF;AA4EO,SAAS,WAAA,GAGd;AACA,EAAA,OAAO;AAAA,IACL,QAAA,EAAU,CAAC,GAAA,EAAK,GAAA,EAAK,SAAA,KAAc;AAMjC,MAAA,GAAA,CAAI,aAAA,CAAc,GAAA,CAAI,GAAA,CAAI,EAAA,EAAI,EAAE,OAAA,EAAS,GAAA,CAAI,OAAA,EAAS,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,CAAA;AAIpE,MAAA,MAAM,QAAA,GAAW,eAAA,CAAgB,GAAA,CAAI,aAAa,CAAA;AAGlD,MAAA,IAAI,aAAa,IAAA,EAAM;AAMrB,QAAA,GAAA,CAAI,KAAA,CAAM,OAAA,CACP,QAAA,CAAS,QAAQ,CAAA,CACjB,KAAA,CAAM,CAAC,GAAA,KAAQ,OAAA,CAAQ,KAAA,CAAM,0BAAA,EAA4B,GAAG,CAAC,CAAA;AAAA,MAClE;AAEA,MAAA,OAAO,MAAM;AACX,QAAA,GAAA,CAAI,aAAA,CAAc,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAC/B,QAAA,MAAM,YAAA,GAAe,eAAA,CAAgB,GAAA,CAAI,aAAa,CAAA;AACtD,QAAA,IAAI,iBAAiB,IAAA,EAAM;AAIzB,UAAA,GAAA,CAAI,KAAA,CAAM,OAAA,CACP,WAAA,EAAY,CACZ,KAAA,CAAM,CAAC,GAAA,KAAQ,OAAA,CAAQ,KAAA,CAAM,6BAAA,EAA+B,GAAG,CAAC,CAAA;AAAA,QACrE,CAAA,MAAO;AACL,UAAA,GAAA,CAAI,KAAA,CAAM,OAAA,CACP,QAAA,CAAS,YAAY,CAAA,CACrB,KAAA,CAAM,CAAC,GAAA,KAAQ,OAAA,CAAQ,KAAA,CAAM,0BAAA,EAA4B,GAAG,CAAC,CAAA;AAAA,QAClE;AAAA,MACF,CAAA;AAAA,IACF,CAAA;AAAA,IAEA,KAAA,EAAO,CAAC,GAAA,EAAK,GAAA,EAAK,SAAA,KAAc;AAC9B,MAAA,GAAA,CAAI,UAAA,CAAW,GAAA,CAAI,GAAA,CAAI,EAAA,EAAI,EAAE,QAAA,EAAU,GAAA,CAAI,QAAA,EAAU,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,CAAA;AACnE,MAAA,OAAO,MAAM;AACX,QAAA,GAAA,CAAI,UAAA,CAAW,MAAA,CAAO,GAAA,CAAI,EAAE,CAAA;AAAA,MAC9B,CAAA;AAAA,IACF;AAAA,GACF;AACF;AAGA,SAAS,gBAAmB,QAAA,EAA6C;AACvE,EAAA,IAAI,QAAA,GAA0B,IAAA;AAC9B,EAAA,KAAA,MAAW,EAAE,OAAA,EAAQ,IAAK,QAAA,CAAS,QAAO,EAAG;AAC3C,IAAA,IAAI,QAAA,KAAa,IAAA,IAAQ,OAAA,GAAU,QAAA,EAAU,QAAA,GAAW,OAAA;AAAA,EAC1D;AACA,EAAA,OAAO,QAAA;AACT","file":"index.js","sourcesContent":["/// <reference types=\"@cloudflare/workers-types\" />\n/**\n * @demlik/tea/do — Durable Object adapter for `@demlik/tea`.\n *\n * Two exports drive the integration:\n *\n * 1. `doStore<S>(storage, parse, key?)` — `Store<S>` impl backed by\n * `DurableObjectStorage`. JSON-stringifies on save, JSON-parses on load\n * and returns `unknown` (invariant-8 boundary); the substrate then calls\n * `migrate(raw)` (which forwards to `parse`) to turn it into `S | null`.\n * Returns `null` (typed as `unknown`) when the key is absent. Structural\n * JSON malformation throws at load — `run()`'s boot path surfaces that\n * (PRD throw-semantics table: \"store.load throw at boot → run() throws\n * synchronously\"). Shape mismatch is NOT a throw — `parse` returns\n * `null` and the substrate boots fresh.\n *\n * 2. `doSubscribe<M, Ctx>()` — handler registry for the two DO-native sub\n * types this package owns:\n * - `do_alarm` — schedules a real DO alarm via `state.storage.setAlarm`;\n * the cooperating DO's `alarm()` lifecycle method dispatches the sub's\n * msg. Cleanup clears the alarm.\n * - `do_ws` — registers a WebSocket-id'd listener in a per-DO registry\n * that the cooperating DO's `webSocketMessage()` consults. Cleanup\n * deregisters.\n *\n * BOTH `do_alarm` and `do_ws` rely on the host DO cooperating: alarm and\n * webSocketMessage are DO lifecycle methods, not JS events the handler can\n * subscribe to from the outside. The cooperating DO must:\n *\n * - Maintain a `Ctx` that exposes `state: DurableObjectState` AND the\n * subscriber registries (`wsRegistry`, `alarmRegistry`). The handlers read\n * from `ctx`.\n * - Forward `alarm()` callbacks into the alarm registry by id, dispatching\n * the registered msg.\n * - Forward `webSocketMessage(ws, data)` callbacks into the ws registry,\n * dispatching `sub.msg(data)` for matching `socketId`s.\n *\n * `src/test-worker.ts` is the canonical example of this cooperation. See\n * `test/README.md` for the convention.\n */\n\nimport type { Store, Sub, SubId } from \"../index\";\n\n// ─────────────────────────────────────────────────────────────────────────────\n// doStore — `Store<S>` over DurableObjectStorage.\n// ─────────────────────────────────────────────────────────────────────────────\n\nconst DEFAULT_STATE_KEY = \"@@state\";\n\n/**\n * `Store<S>` over `DurableObjectStorage`.\n *\n * `parse` is REQUIRED because DO storage is a real serialization boundary —\n * the bytes that come back through `JSON.parse` are structurally `unknown`,\n * and the caller (the DO that owns `S`) is the only party with enough\n * information to validate the shape. Returning `null` from `parse` means\n * \"no usable persisted state\" — the substrate boots `init` with `loaded =\n * null`, a known-good fresh-boot path. `parse` must NOT throw per the\n * `Store<S>.migrate` contract; shape mismatch is a value (null), not a\n * panic.\n *\n * Structural malformations (the stored cell isn't even JSON) still throw at\n * `load()` — that's an infrastructure error, distinct from a schema\n * mismatch. The substrate surfaces it via `runtime.ready` rejection.\n *\n * Concrete bite this closes: when a `State` variant is renamed between\n * deploys, the DO's new code previously received a value typed as the new\n * `S` that held a runtime string from the old `S` — silent undefined\n * behavior in `init`/`update`. After this, the boundary parse rejects the\n * old shape (returns null) and the DO boots fresh, picking up its new\n * schema from the next dispatch onward.\n */\nexport function doStore<S>(\n storage: DurableObjectStorage,\n parse: (raw: unknown) => S | null,\n key: string = DEFAULT_STATE_KEY,\n): Store<S> {\n return {\n async load(): Promise<unknown> {\n const raw = await storage.get<string>(key);\n if (raw === undefined || raw === null) return null;\n // JSON.parse throws on malformed — propagate per PRD throw-semantics.\n // The decoded value is intentionally returned as `unknown`; the\n // substrate's `migrate` callback (forwarded from `parse`) is the\n // boundary parse that turns it into `S | null`.\n return JSON.parse(raw);\n },\n async save(state: S): Promise<void> {\n await storage.put(key, JSON.stringify(state));\n },\n migrate(raw: unknown): S | null {\n return parse(raw);\n },\n };\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// DoSub — the two DO-native subscription variants, locked in the PRD.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * A DO alarm sub: fires `msg` at `firesAt` (epoch ms). The substrate diffs\n * subs by `id`; same id across transitions = same alarm. To reschedule, emit\n * a DIFFERENT id (e.g., `audit-timeout-v2`).\n */\nexport type DoAlarmSub<M> = { id: SubId; type: \"do_alarm\"; firesAt: number; msg: M };\n\n/**\n * A DO WebSocket sub: routes incoming messages on `socketId` through\n * `sub.msg(data)` to dispatch.\n */\nexport type DoWsSub<M> = {\n id: SubId;\n type: \"do_ws\";\n socketId: string;\n msg: (data: string) => M;\n};\n\nexport type DoSub<M> = DoAlarmSub<M> | DoWsSub<M>;\n\n// Compile-time guarantee: `DoSub<M>` is structurally a `Sub` (has `id`+`type`).\n// Erased at runtime — the `extends Sub` constraint on the alias's type\n// parameter fails to compile if `DoSub` ever drifts off the `Sub` shape.\nexport type AssertDoSubIsSub<T extends Sub = DoSub<unknown>> = T;\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Ctx contract — what the cooperating DO must expose to `doSubscribe`.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Registry of pending alarm dispatches keyed by sub `id`. The cooperating DO's\n * `alarm()` lifecycle method drains this and dispatches each msg.\n *\n * One alarm-queue cell at a time per DO (DO has exactly one alarm slot). We\n * still key by `id` so multiple alarm subs scheduled in the same reconcile\n * pass don't clobber each other — the earliest `firesAt` wins (DO native\n * behavior) and `alarm()` drains everything due by the fire time.\n */\nexport type DoAlarmRegistry<M> = Map<string, { firesAt: number; msg: M }>;\n\n/**\n * Registry of WebSocket message subscribers keyed by sub `id`. The cooperating\n * DO's `webSocketMessage(ws, data)` consults this and dispatches\n * `sub.msg(data)` for entries matching the incoming `socketId`.\n */\nexport type DoWsRegistry<M> = Map<string, { socketId: string; msg: (data: string) => M }>;\n\n/**\n * Minimum shape a cooperating DO must put on its `Ctx` for `doSubscribe`\n * handlers to wire up. Users may extend with anything else they like.\n */\nexport interface DoSubscribeCtx<M> {\n state: DurableObjectState;\n alarmRegistry: DoAlarmRegistry<M>;\n wsRegistry: DoWsRegistry<M>;\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// doSubscribe — the handler registry.\n// ─────────────────────────────────────────────────────────────────────────────\n\n/**\n * Build the `subscribe` handler registry for `do_alarm` and `do_ws` subs.\n *\n * Returned object shape matches `Machine#subscribe`'s `[K in U['type']]:\n * (sub, ctx, dispatch) => () => void` contract for `U = DoSub<M>`.\n *\n * Generic `Ctx` is user-defined and must extend `DoSubscribeCtx<M>` so handlers\n * can reach `state.storage`, `alarmRegistry`, and `wsRegistry`.\n */\nexport function doSubscribe<M, Ctx extends DoSubscribeCtx<M>>(): {\n do_alarm: (sub: DoAlarmSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;\n do_ws: (sub: DoWsSub<M>, ctx: Ctx, dispatch: (msg: M) => void) => () => void;\n} {\n return {\n do_alarm: (sub, ctx, _dispatch) => {\n // Register the msg so the DO's `alarm()` lifecycle method can find it.\n // `dispatch` itself is not captured here — the DO's alarm() drains the\n // registry and calls dispatch via the runtime it owns. That keeps the\n // cleanup race-free: clearing the registry entry is the only thing\n // cleanup needs to do.\n ctx.alarmRegistry.set(sub.id, { firesAt: sub.firesAt, msg: sub.msg });\n\n // Recompute the next alarm wall-clock target. DO supports exactly one\n // alarm slot; the earliest firesAt across all registered subs wins.\n const earliest = earliestFiresAt(ctx.alarmRegistry);\n // `earliestFiresAt` returns null only when the registry is empty, which\n // cannot happen here (we just inserted). The null branch is for cleanup.\n if (earliest !== null) {\n // `setAlarm` is a Promise; we don't await — the substrate's subscribe\n // signature is sync. Attach `.catch` so an unhandled rejection doesn't\n // tear down the DO isolate silently (DO isolates terminate on\n // unhandled rejections — silent failure in an alarm scheduler would\n // vanish without trace otherwise).\n ctx.state.storage\n .setAlarm(earliest)\n .catch((err) => console.error(\"[tea-do] setAlarm failed\", err));\n }\n\n return () => {\n ctx.alarmRegistry.delete(sub.id);\n const nextEarliest = earliestFiresAt(ctx.alarmRegistry);\n if (nextEarliest === null) {\n // No registered alarms left — clear the slot. `deleteAlarm` returns\n // a Promise we don't await for the same reason as above; `.catch`\n // for the same isolate-safety reason.\n ctx.state.storage\n .deleteAlarm()\n .catch((err) => console.error(\"[tea-do] deleteAlarm failed\", err));\n } else {\n ctx.state.storage\n .setAlarm(nextEarliest)\n .catch((err) => console.error(\"[tea-do] setAlarm failed\", err));\n }\n };\n },\n\n do_ws: (sub, ctx, _dispatch) => {\n ctx.wsRegistry.set(sub.id, { socketId: sub.socketId, msg: sub.msg });\n return () => {\n ctx.wsRegistry.delete(sub.id);\n };\n },\n };\n}\n\n// Pure utility: lowest `firesAt` in a non-empty alarm registry, or `null`.\nfunction earliestFiresAt<M>(registry: DoAlarmRegistry<M>): number | null {\n let earliest: number | null = null;\n for (const { firesAt } of registry.values()) {\n if (earliest === null || firesAt < earliest) earliest = firesAt;\n }\n return earliest;\n}\n"]}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Runtime, Store } from '../index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runtime bridge — expose a `@demlik/tea` runtime running in one chrome context
|
|
5
|
+
* (typically the background service worker) to consumers in other contexts
|
|
6
|
+
* (sidepanel, popup, devtools panel) over `chrome.runtime.sendMessage` /
|
|
7
|
+
* `chrome.runtime.onMessage`.
|
|
8
|
+
*
|
|
9
|
+
* Two halves, one channel name string.
|
|
10
|
+
*
|
|
11
|
+
* ┌──── background (host) ────┐ ┌──── react surface ────┐
|
|
12
|
+
* │ bridgeRuntime(rt, {ch}) │ chrome.* │ useBackgroundRuntime │
|
|
13
|
+
* │ ├ observe → broadcast │ ──────────▶ │ ├ hydrate (initial) │
|
|
14
|
+
* │ └ onMessage(":dispatch") │ ◀────────── │ └ dispatch (msgs) │
|
|
15
|
+
* │ └ onMessage(":hydrate") │ │ │
|
|
16
|
+
* └────────────────────────────┘ └───────────────────────┘
|
|
17
|
+
*
|
|
18
|
+
* Wire-shape (single `channel` string, three messages):
|
|
19
|
+
*
|
|
20
|
+
* { type: channel, msg, state } // background → surfaces (broadcast)
|
|
21
|
+
* { type: `${channel}:hydrate` } // surface → background (request)
|
|
22
|
+
* { type: `${channel}:dispatch`, msg } // surface → background (input)
|
|
23
|
+
*
|
|
24
|
+
* The `:hydrate` reply is the current `runtime.getState()` snapshot —
|
|
25
|
+
* lets a newly-opened surface render the right view BEFORE the next
|
|
26
|
+
* transition broadcast arrives.
|
|
27
|
+
*
|
|
28
|
+
* The bridge is intentionally agnostic to `chrome.runtime.sendMessage`
|
|
29
|
+
* failure modes (no receiver, sender gone, etc.) — every cross-context send
|
|
30
|
+
* is fire-and-forget with `.catch(() => {})`. Surfaces that need
|
|
31
|
+
* delivery guarantees should layer their own retry, not bake it here.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface BridgeRuntimeOpts<S, V = S> {
|
|
35
|
+
channel: string;
|
|
36
|
+
/**
|
|
37
|
+
* Optional projection from the runtime's live state `S` to a JSON-safe
|
|
38
|
+
* view `V` that goes over the wire (broadcast + hydrate). Required when
|
|
39
|
+
* `S` carries non-serializable values (DOM refs, Map, Date, …).
|
|
40
|
+
*
|
|
41
|
+
* Identity passthrough by default — existing callers with POJO state pass
|
|
42
|
+
* `serialize` unset and `V = S`.
|
|
43
|
+
*
|
|
44
|
+
* Wire shape with `serialize`:
|
|
45
|
+
* { type: channel, msg, state: serialize(currentState) } // broadcast
|
|
46
|
+
* { type: hydrate } → { state: serialize(currentState) } // hydrate
|
|
47
|
+
*
|
|
48
|
+
* Dispatch is NOT affected — it carries `M` end-to-end. Surfaces parse the
|
|
49
|
+
* `V` they receive (they should pick a type parameter matching the
|
|
50
|
+
* projection).
|
|
51
|
+
*/
|
|
52
|
+
serialize?: (state: S) => V;
|
|
53
|
+
/**
|
|
54
|
+
* If > 0, the bridge composes a `historyTracker(runtime, size)` over the
|
|
55
|
+
* runtime and returns the captured `(msg, state)` backlog in every
|
|
56
|
+
* `:hydrate` reply. Late subscribers receive recent traffic on connect —
|
|
57
|
+
* the inspector then sees prior tool calls / phase transitions instead
|
|
58
|
+
* of just the current state snapshot.
|
|
59
|
+
*
|
|
60
|
+
* Defaults to 0 (no tracker, empty backlog). Composition stays opt-in:
|
|
61
|
+
* the substrate is untouched, the dispatch loop is untouched, and the
|
|
62
|
+
* bridge attaches the tracker only when this opt is set. Cleanup is
|
|
63
|
+
* the bridge's responsibility — the disposer returned by `bridgeRuntime`
|
|
64
|
+
* stops the tracker along with the observer.
|
|
65
|
+
*
|
|
66
|
+
* Memory cost: ~size × (msg + state) bytes, in-memory only, dies with
|
|
67
|
+
* the bridge. Privacy: backlog entries flow to every `:hydrate` reply —
|
|
68
|
+
* any surface that can hydrate sees them. Do not enable on bridges
|
|
69
|
+
* carrying user secrets.
|
|
70
|
+
*/
|
|
71
|
+
historySize?: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Install the bridge for a runtime. Returns a cleanup function that removes
|
|
75
|
+
* the observer and the chrome listener.
|
|
76
|
+
*
|
|
77
|
+
* - **Broadcast:** every (msg, state) transition emits
|
|
78
|
+
* `{ type: channel, msg, state }` via `chrome.runtime.sendMessage`. The
|
|
79
|
+
* send is fire-and-forget — when no surface is listening, chrome.runtime
|
|
80
|
+
* rejects with "Could not establish connection. Receiving end does not
|
|
81
|
+
* exist." which we swallow. If `serialize` is provided, `state` carries
|
|
82
|
+
* `serialize(state)` instead of the raw state.
|
|
83
|
+
* - **Hydrate:** `{ type: ":hydrate" }` is answered via `sendResponse` with
|
|
84
|
+
* the current (optionally projected) `runtime.getState()`. If the runtime
|
|
85
|
+
* is mid-boot (store load not yet resolved), `getState()` throws — we
|
|
86
|
+
* swallow and reply with `state: null`; surfaces should treat null as
|
|
87
|
+
* "not ready yet" and wait for the first broadcast.
|
|
88
|
+
* - **Dispatch:** `{ type: ":dispatch", msg }` routes to `runtime.dispatch`.
|
|
89
|
+
* Returns `true` from the listener so chrome keeps the message channel
|
|
90
|
+
* open until dispatch's promise settles (we ack with `{ ok: true }`).
|
|
91
|
+
*/
|
|
92
|
+
declare function bridgeRuntime<S, M extends {
|
|
93
|
+
type: string;
|
|
94
|
+
}, V = S>(runtime: Runtime<S, M>, { channel, serialize, historySize }: BridgeRuntimeOpts<S, V>): () => void;
|
|
95
|
+
interface BridgeClient<S, M> {
|
|
96
|
+
/** Send the hydrate request; resolves with the initial snapshot (or null
|
|
97
|
+
* if the host is still booting OR the response failed to parse). Also
|
|
98
|
+
* primes `getSnapshot()` so React's `useSyncExternalStore` sees the
|
|
99
|
+
* hydrated value on the next render. */
|
|
100
|
+
hydrate(): Promise<S | null>;
|
|
101
|
+
/** Send a dispatch envelope; resolves once the host's dispatch settles. */
|
|
102
|
+
dispatch(msg: M): Promise<void>;
|
|
103
|
+
/** Subscribe to broadcasts. Returns a cleanup function. Broadcasts whose
|
|
104
|
+
* payload fails `parseState` (or, if provided, `parseMsg`) are dropped
|
|
105
|
+
* silently — the listener is not called. Each successful broadcast
|
|
106
|
+
* updates `getSnapshot()`. */
|
|
107
|
+
subscribe(listener: (msg: M | null, state: S) => void): () => void;
|
|
108
|
+
/** Synchronous accessor for the latest known state, or `null` until the
|
|
109
|
+
* first successful hydrate or broadcast lands. Required reference stability
|
|
110
|
+
* for `useSyncExternalStore`: returns the SAME object reference when the
|
|
111
|
+
* underlying state hasn't changed since the prior call. */
|
|
112
|
+
getSnapshot(): S | null;
|
|
113
|
+
}
|
|
114
|
+
interface BridgeClientOpts<S, M> {
|
|
115
|
+
channel: string;
|
|
116
|
+
/**
|
|
117
|
+
* REQUIRED parse for inbound `state` payloads (from `:hydrate` reply AND
|
|
118
|
+
* every broadcast). Chrome runtime messages are an `unknown` boundary
|
|
119
|
+
* (Inv 8) — the surface is the only party that knows the domain type `S`,
|
|
120
|
+
* so it owns the boundary parse. Returning `null` drops the payload
|
|
121
|
+
* silently (subscriber not called; hydrate resolves to `null`). The
|
|
122
|
+
* parser MUST NOT throw — model rejection as `null`.
|
|
123
|
+
*/
|
|
124
|
+
parseState: (raw: unknown) => S | null;
|
|
125
|
+
/**
|
|
126
|
+
* Optional parse for inbound `msg` payloads on broadcasts. Defaults to a
|
|
127
|
+
* pass-through cast — most surfaces only need to gate on state and treat
|
|
128
|
+
* the msg union as advisory. Provide this when malformed msgs must be
|
|
129
|
+
* dropped (e.g. when the host's M is a real wire enum, not a local type).
|
|
130
|
+
* Returning `null` drops the broadcast silently.
|
|
131
|
+
*/
|
|
132
|
+
parseMsg?: (raw: unknown) => M | null;
|
|
133
|
+
}
|
|
134
|
+
declare function bridgeClient<S, M extends {
|
|
135
|
+
type: string;
|
|
136
|
+
}>({ channel, parseState, parseMsg, }: BridgeClientOpts<S, M>): BridgeClient<S, M>;
|
|
137
|
+
interface BridgeTabClientOpts<S, M> {
|
|
138
|
+
tabId: number;
|
|
139
|
+
channel: string;
|
|
140
|
+
/**
|
|
141
|
+
* REQUIRED parse for inbound `state` payloads. See `BridgeClientOpts.parseState`
|
|
142
|
+
* — same contract, same rationale. Chrome runtime messages from a tab are
|
|
143
|
+
* an `unknown` boundary; the SW client owns the parse.
|
|
144
|
+
*/
|
|
145
|
+
parseState: (raw: unknown) => S | null;
|
|
146
|
+
/**
|
|
147
|
+
* Optional parse for inbound `msg` payloads. See
|
|
148
|
+
* `BridgeClientOpts.parseMsg`.
|
|
149
|
+
*/
|
|
150
|
+
parseMsg?: (raw: unknown) => M | null;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* SW-side client of a `bridgeRuntime` hosted in a content script.
|
|
154
|
+
*
|
|
155
|
+
* Identical contract to `bridgeClient` (same `BridgeClient<S, M>` shape).
|
|
156
|
+
* Differences are confined to transport:
|
|
157
|
+
*
|
|
158
|
+
* - `hydrate()` / `dispatch(msg)` send via `chrome.tabs.sendMessage(tabId, …)`.
|
|
159
|
+
* If the tab is gone, `chrome.tabs.sendMessage` rejects (same shape as
|
|
160
|
+
* `chrome.runtime.sendMessage` with no receiver) — the promise propagates
|
|
161
|
+
* the rejection. Callers decide whether to retry, drop, or surface; the
|
|
162
|
+
* bridge has no opinion. Fire-and-forget posture matches `bridgeRuntime`.
|
|
163
|
+
* - `subscribe(listener)` registers a `chrome.runtime.onMessage` listener
|
|
164
|
+
* filtered by both `envelope.type === channel` AND `sender.tab?.id === tabId`,
|
|
165
|
+
* so broadcasts from other tabs are ignored (no cross-contamination across
|
|
166
|
+
* per-tab content-script runtimes).
|
|
167
|
+
*/
|
|
168
|
+
declare function bridgeTabClient<S, M extends {
|
|
169
|
+
type: string;
|
|
170
|
+
}>({ tabId, channel, parseState, parseMsg, }: BridgeTabClientOpts<S, M>): BridgeClient<S, M>;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @demlik/tea/extension — Chrome service-worker host adapter for @demlik/tea.
|
|
174
|
+
*
|
|
175
|
+
* Substrate-level companion to @demlik/tea/do (Durable Objects) and
|
|
176
|
+
* @demlik/tea/react (React). Lets a chrome service worker run a @demlik/tea
|
|
177
|
+
* machine with persistence backed by chrome.storage.
|
|
178
|
+
*
|
|
179
|
+
* Single export: chromeStorageStore<S>(key, area?). Apps register chrome.*
|
|
180
|
+
* listeners themselves and call runtime.dispatch from inside the handler —
|
|
181
|
+
* those wiring patterns are app-specific (different extensions register
|
|
182
|
+
* different listener sets, with different Msg adapters), so we don't
|
|
183
|
+
* abstract them here until the shape stabilizes across consumers.
|
|
184
|
+
*/
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `Store<S>` backed by `chrome.storage.local` (or any compatible
|
|
188
|
+
* `StorageArea`). JSON.stringify on save, JSON.parse on load. Returns `null`
|
|
189
|
+
* (typed as `unknown` per `Store<S>.load` contract) when the key is absent.
|
|
190
|
+
*
|
|
191
|
+
* Throws on the structural layer if the stored value is malformed (non-string
|
|
192
|
+
* at the key, or unparseable JSON) — matches @demlik/tea/do's "throw at boot via
|
|
193
|
+
* store.load" contract so tea's `run()` surfaces it predictably. The
|
|
194
|
+
* `parse` callback handles the semantic layer (shape mismatch → return
|
|
195
|
+
* `null`); it must NOT throw, per the `Store<S>.migrate` contract.
|
|
196
|
+
*
|
|
197
|
+
* `parse` is REQUIRED because chrome.storage is a real serialization boundary
|
|
198
|
+
* — JSON to disk, then back — and the structural type of what comes back is
|
|
199
|
+
* `unknown`. The caller (extension code that knows `S`) is the only party
|
|
200
|
+
* with enough information to write the parse. There is no honest default.
|
|
201
|
+
*
|
|
202
|
+
* `area` defaults to `chrome.storage.local` for production. Tests pass a
|
|
203
|
+
* `fakeChrome().storage.local` in.
|
|
204
|
+
*/
|
|
205
|
+
declare function chromeStorageStore<S>(key: string, parse: (raw: unknown) => S | null, area?: chrome.storage.StorageArea): Store<S>;
|
|
206
|
+
|
|
207
|
+
export { type BridgeClient, type BridgeClientOpts, type BridgeRuntimeOpts, type BridgeTabClientOpts, bridgeClient, bridgeRuntime, bridgeTabClient, chromeStorageStore };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export { bridgeClient, bridgeRuntime, bridgeTabClient } from '../chunk-PYOCUBQJ.js';
|
|
2
|
+
import '../chunk-CMG2KMWX.js';
|
|
3
|
+
|
|
4
|
+
// src/extension/index.ts
|
|
5
|
+
var MALFORMED = "chromeStorageStore: stored value is not a string";
|
|
6
|
+
function chromeStorageStore(key, parse, area = chrome.storage.local) {
|
|
7
|
+
return {
|
|
8
|
+
async load() {
|
|
9
|
+
const result = await area.get(key);
|
|
10
|
+
const raw = result[key];
|
|
11
|
+
if (raw === void 0 || raw === null) return null;
|
|
12
|
+
if (typeof raw !== "string") throw new Error(`${MALFORMED} at key "${key}"`);
|
|
13
|
+
return JSON.parse(raw);
|
|
14
|
+
},
|
|
15
|
+
async save(state) {
|
|
16
|
+
await area.set({ [key]: JSON.stringify(state) });
|
|
17
|
+
},
|
|
18
|
+
migrate(raw) {
|
|
19
|
+
return parse(raw);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { chromeStorageStore };
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
26
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/extension/index.ts"],"names":[],"mappings":";;;;AA6BA,IAAM,SAAA,GAAY,kDAAA;AAqBX,SAAS,mBACd,GAAA,EACA,KAAA,EACA,IAAA,GAAmC,MAAA,CAAO,QAAQ,KAAA,EACxC;AACV,EAAA,OAAO;AAAA,IACL,MAAM,IAAA,GAAyB;AAC7B,MAAA,MAAM,MAAA,GAAS,MAAM,IAAA,CAAK,GAAA,CAAI,GAAG,CAAA;AACjC,MAAA,MAAM,GAAA,GAAO,OAAmC,GAAG,CAAA;AACnD,MAAA,IAAI,GAAA,KAAQ,MAAA,IAAa,GAAA,KAAQ,IAAA,EAAM,OAAO,IAAA;AAC9C,MAAA,IAAI,OAAO,GAAA,KAAQ,QAAA,EAAU,MAAM,IAAI,MAAM,CAAA,EAAG,SAAS,CAAA,SAAA,EAAY,GAAG,CAAA,CAAA,CAAG,CAAA;AAK3E,MAAA,OAAO,IAAA,CAAK,MAAM,GAAG,CAAA;AAAA,IACvB,CAAA;AAAA,IACA,MAAM,KAAK,KAAA,EAAyB;AAClC,MAAA,MAAM,IAAA,CAAK,GAAA,CAAI,EAAE,CAAC,GAAG,GAAG,IAAA,CAAK,SAAA,CAAU,KAAK,CAAA,EAAG,CAAA;AAAA,IACjD,CAAA;AAAA,IACA,QAAQ,GAAA,EAAwB;AAC9B,MAAA,OAAO,MAAM,GAAG,CAAA;AAAA,IAClB;AAAA,GACF;AACF","file":"index.js","sourcesContent":["/**\n * @demlik/tea/extension — Chrome service-worker host adapter for @demlik/tea.\n *\n * Substrate-level companion to @demlik/tea/do (Durable Objects) and\n * @demlik/tea/react (React). Lets a chrome service worker run a @demlik/tea\n * machine with persistence backed by chrome.storage.\n *\n * Single export: chromeStorageStore<S>(key, area?). Apps register chrome.*\n * listeners themselves and call runtime.dispatch from inside the handler —\n * those wiring patterns are app-specific (different extensions register\n * different listener sets, with different Msg adapters), so we don't\n * abstract them here until the shape stabilizes across consumers.\n */\n\nimport type { Store } from \"../index\";\n\nexport type {\n BridgeClient,\n BridgeClientOpts,\n BridgeRuntimeOpts,\n BridgeTabClientOpts,\n} from \"./bridge\";\n// Re-export the wire-envelope types — surfaces that test against the\n// bridge can pin the shapes without round-tripping through the runtime.\n// Re-export the runtime bridge primitives. The React adapter lives at\n// `@demlik/tea/extension/react` so the main entry stays react-free for the\n// background service-worker side.\nexport { bridgeClient, bridgeRuntime, bridgeTabClient } from \"./bridge\";\n\nconst MALFORMED = \"chromeStorageStore: stored value is not a string\";\n\n/**\n * `Store<S>` backed by `chrome.storage.local` (or any compatible\n * `StorageArea`). JSON.stringify on save, JSON.parse on load. Returns `null`\n * (typed as `unknown` per `Store<S>.load` contract) when the key is absent.\n *\n * Throws on the structural layer if the stored value is malformed (non-string\n * at the key, or unparseable JSON) — matches @demlik/tea/do's \"throw at boot via\n * store.load\" contract so tea's `run()` surfaces it predictably. The\n * `parse` callback handles the semantic layer (shape mismatch → return\n * `null`); it must NOT throw, per the `Store<S>.migrate` contract.\n *\n * `parse` is REQUIRED because chrome.storage is a real serialization boundary\n * — JSON to disk, then back — and the structural type of what comes back is\n * `unknown`. The caller (extension code that knows `S`) is the only party\n * with enough information to write the parse. There is no honest default.\n *\n * `area` defaults to `chrome.storage.local` for production. Tests pass a\n * `fakeChrome().storage.local` in.\n */\nexport function chromeStorageStore<S>(\n key: string,\n parse: (raw: unknown) => S | null,\n area: chrome.storage.StorageArea = chrome.storage.local,\n): Store<S> {\n return {\n async load(): Promise<unknown> {\n const result = await area.get(key);\n const raw = (result as Record<string, unknown>)[key];\n if (raw === undefined || raw === null) return null;\n if (typeof raw !== \"string\") throw new Error(`${MALFORMED} at key \"${key}\"`);\n // JSON.parse throws on malformed — propagate per @demlik/tea/do parity.\n // The decoded value is intentionally returned as `unknown`; the\n // substrate's `migrate` callback (forwarded from `parse`) is the\n // boundary parse that turns it into `S | null`.\n return JSON.parse(raw);\n },\n async save(state: S): Promise<void> {\n await area.set({ [key]: JSON.stringify(state) });\n },\n migrate(raw: unknown): S | null {\n return parse(raw);\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* React adapters for the background TEA runtime.
|
|
5
|
+
*
|
|
6
|
+
* Two layers, both built on the same `bridgeClient`:
|
|
7
|
+
*
|
|
8
|
+
* 1. `useBackgroundRuntime(opts)` — direct hook. One bridge client per
|
|
9
|
+
* call. Surfaces that only have a single consumer mount it inline.
|
|
10
|
+
*
|
|
11
|
+
* 2. `createBackgroundRuntimeContext<S, M>()` → `{ Provider, useDispatch,
|
|
12
|
+
* useState, useRuntime }` — factory-style React context. Mounts the
|
|
13
|
+
* bridge client ONCE inside the Provider; child hooks read `state` /
|
|
14
|
+
* `dispatch` from context. Surfaces that drill `dispatch` through
|
|
15
|
+
* multiple component layers, or mount `useBackgroundRuntime` more
|
|
16
|
+
* than once for the same channel, should use this instead.
|
|
17
|
+
*
|
|
18
|
+
* Why a factory (not a single global context):
|
|
19
|
+
* - Type safety: each app types it against its own `S` / `M`.
|
|
20
|
+
* - Tests can mount fresh contexts per test (no module-level state to
|
|
21
|
+
* reset).
|
|
22
|
+
* - Multiple-runtimes case: an app COULD theoretically have two
|
|
23
|
+
* background-shaped runtimes (e.g. one per channel); the factory
|
|
24
|
+
* pattern doesn't preclude it.
|
|
25
|
+
*
|
|
26
|
+
* Hook lifecycle (`useBackgroundRuntime`) — preserved from the prior
|
|
27
|
+
* single-export module:
|
|
28
|
+
* 1. `useSyncExternalStore` subscribes to broadcasts via
|
|
29
|
+
* `bridgeClient.subscribe`, reading the latest state via
|
|
30
|
+
* `bridgeClient.getSnapshot`. Reference stability is owned by the
|
|
31
|
+
* bridge — `getSnapshot` returns the SAME `S` until a new broadcast
|
|
32
|
+
* overwrites it, so React doesn't loop.
|
|
33
|
+
* 2. A one-shot `hydrate()` call seeds `latestState` before the first
|
|
34
|
+
* broadcast arrives. The hydrate side-effect runs in an effect so the
|
|
35
|
+
* first render shows `null` and the subsequent render flips to the
|
|
36
|
+
* hydrated snapshot (or stays null if the host is still booting).
|
|
37
|
+
* 3. `dispatch(msg)` sends `:dispatch` envelopes.
|
|
38
|
+
*
|
|
39
|
+
* On unmount: `useSyncExternalStore` removes the listener; the host's
|
|
40
|
+
* bridge stays installed.
|
|
41
|
+
*
|
|
42
|
+
* Canon §3.1: React adapters use `useSyncExternalStore`, not
|
|
43
|
+
* `useState + useEffect`. `useMachine` and `useRuntime` already follow this
|
|
44
|
+
* — `useBackgroundRuntime` (and by extension the Provider) does too. One
|
|
45
|
+
* Way to Do Each Thing.
|
|
46
|
+
*
|
|
47
|
+
* NOTE: This is a separate entry from the main `index.ts` so apps that
|
|
48
|
+
* never need React don't drag react into their type graph. The React
|
|
49
|
+
* adapter peer-depends on react (you must have it installed in the app).
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
interface UseBackgroundRuntimeOpts<S> {
|
|
53
|
+
channel: string;
|
|
54
|
+
/**
|
|
55
|
+
* REQUIRED parse for inbound `state` payloads. Forwarded to
|
|
56
|
+
* `bridgeClient` — see `BridgeClientOpts.parseState`. Surfaces own this
|
|
57
|
+
* because the chrome.runtime boundary is `unknown` (Inv 8). Returning
|
|
58
|
+
* `null` drops the payload silently.
|
|
59
|
+
*/
|
|
60
|
+
parseState: (raw: unknown) => S | null;
|
|
61
|
+
}
|
|
62
|
+
interface UseBackgroundRuntimeResult<S, M> {
|
|
63
|
+
/** Latest snapshot from the host. `null` until the first hydrate response
|
|
64
|
+
* or the first broadcast arrives. Surfaces SHOULD render a placeholder
|
|
65
|
+
* for the null window. */
|
|
66
|
+
state: S | null;
|
|
67
|
+
/** Send a msg to the host runtime. Resolves once the host's dispatch
|
|
68
|
+
* settles. */
|
|
69
|
+
dispatch(msg: M): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
declare function useBackgroundRuntime<S, M extends {
|
|
72
|
+
type: string;
|
|
73
|
+
}>({ channel, parseState, }: UseBackgroundRuntimeOpts<S>): UseBackgroundRuntimeResult<S, M>;
|
|
74
|
+
interface BackgroundRuntimeContext<S, M> {
|
|
75
|
+
/**
|
|
76
|
+
* Wrap a subtree to share a single background-runtime bridge client.
|
|
77
|
+
* `opts` is forwarded verbatim to `useBackgroundRuntime` — same channel,
|
|
78
|
+
* same parse contract.
|
|
79
|
+
*/
|
|
80
|
+
Provider: (props: {
|
|
81
|
+
opts: UseBackgroundRuntimeOpts<S>;
|
|
82
|
+
children: ReactNode;
|
|
83
|
+
}) => ReactNode;
|
|
84
|
+
/**
|
|
85
|
+
* Returns the dispatch function bound to this Provider's bridge client.
|
|
86
|
+
* Reference is stable across renders (per `useBackgroundRuntime`'s
|
|
87
|
+
* `useCallback`), so passing it to memoized children won't churn them.
|
|
88
|
+
*/
|
|
89
|
+
useDispatch: () => (msg: M) => Promise<void>;
|
|
90
|
+
/**
|
|
91
|
+
* Returns a slice of the latest state. `selector` defaults to identity
|
|
92
|
+
* (returns the whole `S | null` snapshot). Selectors run on every state
|
|
93
|
+
* change — surfaces with expensive transforms should memoize the
|
|
94
|
+
* selector at call site.
|
|
95
|
+
*
|
|
96
|
+
* Returns `null` until the first hydrate / broadcast lands when the
|
|
97
|
+
* selector is identity; for non-identity selectors the return type is
|
|
98
|
+
* `T` and the caller's selector decides how to handle the pre-hydrate
|
|
99
|
+
* `null` snapshot.
|
|
100
|
+
*/
|
|
101
|
+
useState: {
|
|
102
|
+
(): S | null;
|
|
103
|
+
<T>(selector: (state: S | null) => T): T;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Escape hatch — returns the full `{ state, dispatch }` pair as-is.
|
|
107
|
+
* Useful for consumers that want both the snapshot and the dispatch
|
|
108
|
+
* fn in a single hook call (e.g. effects that read state THEN
|
|
109
|
+
* dispatch in response).
|
|
110
|
+
*
|
|
111
|
+
* Not a `@demlik/tea` `Runtime<S, M>` — the bridge surface is a thinner
|
|
112
|
+
* client. This hook returns the same shape as `useBackgroundRuntime`,
|
|
113
|
+
* just sourced from context instead of mounting a fresh client.
|
|
114
|
+
*/
|
|
115
|
+
useRuntime: () => UseBackgroundRuntimeResult<S, M>;
|
|
116
|
+
}
|
|
117
|
+
declare function createBackgroundRuntimeContext<S, M extends {
|
|
118
|
+
type: string;
|
|
119
|
+
}>(): BackgroundRuntimeContext<S, M>;
|
|
120
|
+
|
|
121
|
+
export { type BackgroundRuntimeContext, type UseBackgroundRuntimeOpts, type UseBackgroundRuntimeResult, createBackgroundRuntimeContext, useBackgroundRuntime };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { bridgeClient } from '../chunk-PYOCUBQJ.js';
|
|
2
|
+
import '../chunk-CMG2KMWX.js';
|
|
3
|
+
import { useRef, useSyncExternalStore, useEffect, useCallback, createContext, useMemo, useContext } from 'react';
|
|
4
|
+
import { jsx } from 'react/jsx-runtime';
|
|
5
|
+
|
|
6
|
+
function useBackgroundRuntime({
|
|
7
|
+
channel,
|
|
8
|
+
parseState
|
|
9
|
+
}) {
|
|
10
|
+
const clientRef = useRef(null);
|
|
11
|
+
if (clientRef.current === null) {
|
|
12
|
+
clientRef.current = bridgeClient({ channel, parseState });
|
|
13
|
+
}
|
|
14
|
+
const client = clientRef.current;
|
|
15
|
+
const state = useSyncExternalStore(
|
|
16
|
+
client.subscribe,
|
|
17
|
+
client.getSnapshot,
|
|
18
|
+
client.getSnapshot
|
|
19
|
+
);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
client.hydrate().catch(() => {
|
|
22
|
+
});
|
|
23
|
+
}, [client]);
|
|
24
|
+
const dispatch = useCallback(
|
|
25
|
+
async (msg) => {
|
|
26
|
+
await client.dispatch(msg);
|
|
27
|
+
},
|
|
28
|
+
[client]
|
|
29
|
+
);
|
|
30
|
+
return { state, dispatch };
|
|
31
|
+
}
|
|
32
|
+
var PROVIDER_ERROR = "createBackgroundRuntimeContext: hook used outside its Provider. Wrap the consumer in <BackgroundRuntime.Provider opts={...}>.";
|
|
33
|
+
function createBackgroundRuntimeContext() {
|
|
34
|
+
const Ctx = createContext(null);
|
|
35
|
+
function Provider({
|
|
36
|
+
opts,
|
|
37
|
+
children
|
|
38
|
+
}) {
|
|
39
|
+
const result = useBackgroundRuntime(opts);
|
|
40
|
+
const value = useMemo(
|
|
41
|
+
() => ({ state: result.state, dispatch: result.dispatch }),
|
|
42
|
+
[result.state, result.dispatch]
|
|
43
|
+
);
|
|
44
|
+
return /* @__PURE__ */ jsx(Ctx.Provider, { value, children });
|
|
45
|
+
}
|
|
46
|
+
function useRuntime() {
|
|
47
|
+
const ctx = useContext(Ctx);
|
|
48
|
+
if (ctx === null) throw new Error(PROVIDER_ERROR);
|
|
49
|
+
return ctx;
|
|
50
|
+
}
|
|
51
|
+
function useDispatch() {
|
|
52
|
+
return useRuntime().dispatch;
|
|
53
|
+
}
|
|
54
|
+
function useStateHook(selector) {
|
|
55
|
+
const { state } = useRuntime();
|
|
56
|
+
if (selector === void 0) return state;
|
|
57
|
+
return selector(state);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
Provider,
|
|
61
|
+
useDispatch,
|
|
62
|
+
useState: useStateHook,
|
|
63
|
+
useRuntime
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { createBackgroundRuntimeContext, useBackgroundRuntime };
|
|
68
|
+
//# sourceMappingURL=react.js.map
|
|
69
|
+
//# sourceMappingURL=react.js.map
|