@directive-run/sources 0.2.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 +44 -0
- package/LICENSE +26 -0
- package/README.md +142 -0
- package/dist/cloudflare.cjs +2 -0
- package/dist/cloudflare.cjs.map +1 -0
- package/dist/cloudflare.d.cts +150 -0
- package/dist/cloudflare.d.ts +150 -0
- package/dist/cloudflare.js +2 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +67 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/supabase.cjs +2 -0
- package/dist/supabase.cjs.map +1 -0
- package/dist/supabase.d.cts +120 -0
- package/dist/supabase.d.ts +120 -0
- package/dist/supabase.js +2 -0
- package/dist/supabase.js.map +1 -0
- package/package.json +86 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @directive-run/sources
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#52](https://github.com/directive-run/directive/pull/52) [`c31d22d`](https://github.com/directive-run/directive/commit/c31d22d94671ee15cc943bc07946a96848ba64fc) Thanks [@jasoncomes](https://github.com/jasoncomes)! - `@directive-run/sources` — umbrella package with per-vendor subpath exports
|
|
8
|
+
|
|
9
|
+
New package wrapping the most common external event streams as typed
|
|
10
|
+
Directive `source` primitives. One install, optional peerDependencies
|
|
11
|
+
per vendor.
|
|
12
|
+
|
|
13
|
+
**Subpaths shipped in 0.1.0:**
|
|
14
|
+
|
|
15
|
+
- `@directive-run/sources/supabase` — `sourceFromSupabaseChannel({ client, channel, events, redactRow? })`.
|
|
16
|
+
Wraps Supabase realtime channels with declarative event bindings
|
|
17
|
+
(`{ event, table, filter?, map }`). Optional `redactRow` runs at the
|
|
18
|
+
payload boundary so PII can be stripped before the `map` callback
|
|
19
|
+
sees it — defense in depth alongside `createFactPIIGuardrail`.
|
|
20
|
+
- `@directive-run/sources/cloudflare` — `sourceFromDOAlarm({ storage, intervalMs, eventName, payload? })`
|
|
21
|
+
and `sourceFromWebSocketMessage({ socket, decode, closeEvent?, errorEvent? })`.
|
|
22
|
+
DO alarms replace the canonical `setInterval`-inside-`attach` recipe
|
|
23
|
+
that dies on hibernation; the alarm survives eviction via DO storage.
|
|
24
|
+
The WebSocket adapter wraps `addEventListener('message' | 'close' | 'error')`
|
|
25
|
+
with typed Directive events.
|
|
26
|
+
|
|
27
|
+
Vendor peerDependencies (`@supabase/supabase-js`, `@cloudflare/workers-types`)
|
|
28
|
+
are optional and only need to be installed when the consumer imports
|
|
29
|
+
the matching subpath.
|
|
30
|
+
|
|
31
|
+
Future subpaths (`/websocket` for raw browser WebSocket, `/sentry` for
|
|
32
|
+
production error streams, `/eventsource` for SSE) land additively as
|
|
33
|
+
single subpath additions.
|
|
34
|
+
|
|
35
|
+
Tests: 9 regression tests covering channel binding + redaction + cleanup
|
|
36
|
+
on stop (Supabase) and alarm scheduling + tick / clear + WebSocket
|
|
37
|
+
listener teardown (Cloudflare).
|
|
38
|
+
|
|
39
|
+
Docs:
|
|
40
|
+
|
|
41
|
+
- README in the package surfaces the install, subpath inventory, and
|
|
42
|
+
three quick-start examples.
|
|
43
|
+
- `packages/knowledge/ai/ai-sources.md` lists the subpath inventory
|
|
44
|
+
under "Adapter packages".
|
package/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sizls ∞ Jason Comes
|
|
4
|
+
|
|
5
|
+
This project is dual-licensed under MIT OR Apache-2.0. You may choose
|
|
6
|
+
either license. See LICENSE-APACHE for the Apache License, Version 2.0.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# @directive-run/sources
|
|
2
|
+
|
|
3
|
+
Source adapters for [Directive](https://directive.run) — wrap external
|
|
4
|
+
event streams (Supabase realtime, Cloudflare DO alarms, WebSocket,
|
|
5
|
+
Sentry, etc.) as typed `source` primitives.
|
|
6
|
+
|
|
7
|
+
**One package, one install, subpath exports per vendor.** Vendor
|
|
8
|
+
peerDependencies are optional — only the vendors you import need their
|
|
9
|
+
peer-dependency installed.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @directive-run/sources
|
|
15
|
+
# Plus whichever vendor SDKs you actually use:
|
|
16
|
+
pnpm add @supabase/supabase-js # if importing /supabase
|
|
17
|
+
pnpm add @cloudflare/workers-types # if importing /cloudflare (dev-time only)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Subpath inventory
|
|
21
|
+
|
|
22
|
+
| Subpath | Factory | Wraps |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `@directive-run/sources/supabase` | `sourceFromSupabaseChannel()` | Supabase realtime channel + per-row event mapping |
|
|
25
|
+
| `@directive-run/sources/cloudflare` | `sourceFromDOAlarm()` | Durable Object alarm as a periodic source |
|
|
26
|
+
| `@directive-run/sources/cloudflare` | `sourceFromWebSocketMessage()` | DO WebSocket message stream |
|
|
27
|
+
|
|
28
|
+
Future subpaths land additively (`/websocket` for raw browser WebSocket,
|
|
29
|
+
`/sentry` for production error stream, `/eventsource` for SSE, …).
|
|
30
|
+
|
|
31
|
+
## Quick examples
|
|
32
|
+
|
|
33
|
+
### Supabase realtime
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createClient } from '@supabase/supabase-js';
|
|
37
|
+
import { createModule, createSystem, t } from '@directive-run/core';
|
|
38
|
+
import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';
|
|
39
|
+
|
|
40
|
+
const supabase = createClient(url, key);
|
|
41
|
+
|
|
42
|
+
const gameUpdates = createModule('gameUpdates', {
|
|
43
|
+
schema: {
|
|
44
|
+
facts: { snapshot: t.object<GameSnapshot>().nullable() },
|
|
45
|
+
events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },
|
|
46
|
+
},
|
|
47
|
+
init: (f) => { f.snapshot = null; },
|
|
48
|
+
events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },
|
|
49
|
+
sources: {
|
|
50
|
+
gameChannel: sourceFromSupabaseChannel({
|
|
51
|
+
client: supabase,
|
|
52
|
+
channel: `game:${gameId}`,
|
|
53
|
+
events: [{
|
|
54
|
+
table: 'games',
|
|
55
|
+
filter: `id=eq.${gameId}`,
|
|
56
|
+
event: 'UPDATE',
|
|
57
|
+
map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row.new) } }),
|
|
58
|
+
}],
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const system = createSystem({ module: gameUpdates });
|
|
64
|
+
system.start();
|
|
65
|
+
// `system.facts.snapshot` updates automatically on every postgres UPDATE
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Cloudflare DO alarm
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';
|
|
72
|
+
|
|
73
|
+
const ticker = createModule('ticker', {
|
|
74
|
+
schema: {
|
|
75
|
+
facts: { lastTick: t.number() },
|
|
76
|
+
events: { TICK: { at: t.number() } },
|
|
77
|
+
},
|
|
78
|
+
init: (f) => { f.lastTick = 0; },
|
|
79
|
+
events: { TICK: (f, p) => { f.lastTick = p.at; } },
|
|
80
|
+
sources: {
|
|
81
|
+
alarm: sourceFromDOAlarm({
|
|
82
|
+
storage: this.state.storage,
|
|
83
|
+
intervalMs: 30_000,
|
|
84
|
+
eventName: 'TICK',
|
|
85
|
+
payload: () => ({ at: Date.now() }),
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Cloudflare DO WebSocket
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
import { sourceFromWebSocketMessage } from '@directive-run/sources/cloudflare';
|
|
95
|
+
|
|
96
|
+
const liveFeed = createModule('liveFeed', {
|
|
97
|
+
schema: {
|
|
98
|
+
facts: { lastMessage: t.string() },
|
|
99
|
+
events: {
|
|
100
|
+
MESSAGE: { content: t.string() },
|
|
101
|
+
WEBSOCKET_CLOSED: { code: t.number(), reason: t.string() },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
init: (f) => { f.lastMessage = ''; },
|
|
105
|
+
events: { MESSAGE: (f, p) => { f.lastMessage = p.content; } },
|
|
106
|
+
sources: {
|
|
107
|
+
socket: sourceFromWebSocketMessage({
|
|
108
|
+
socket: server, // from webSocketAccept pair
|
|
109
|
+
decode: (data) => {
|
|
110
|
+
if (typeof data !== 'string') return null;
|
|
111
|
+
return { name: 'MESSAGE', payload: { content: data } };
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Why an umbrella package
|
|
119
|
+
|
|
120
|
+
- **One install, one version, one changeset cadence.** No version skew
|
|
121
|
+
between adapters.
|
|
122
|
+
- **Optional peer-dependencies.** Only consumers that import a vendor
|
|
123
|
+
subpath need that vendor's peerDep installed.
|
|
124
|
+
- **One discovery surface.** "If I want a source adapter, I look in
|
|
125
|
+
`@directive-run/sources`."
|
|
126
|
+
- **Cheap to add new vendors.** Each new adapter is a single subpath
|
|
127
|
+
addition, not a whole new package + GitHub release + npm publish +
|
|
128
|
+
README boilerplate.
|
|
129
|
+
|
|
130
|
+
This matches how `@directive-run/core` already uses subpath exports for
|
|
131
|
+
`/internals`, `/plugins`, `/testing`, `/migration`, `/worker`,
|
|
132
|
+
`/adapter-utils`.
|
|
133
|
+
|
|
134
|
+
## Related
|
|
135
|
+
|
|
136
|
+
- [`@directive-run/core` source primitive](https://github.com/directive-run/directive/blob/main/packages/knowledge/core/sources.md) — the underlying primitive these adapters wrap.
|
|
137
|
+
- [`@directive-run/ai` AI × Sources recipes](https://github.com/directive-run/directive/blob/main/packages/knowledge/ai/ai-sources.md) — `runStream({ liveContext })`, MCP lifecycle as a source.
|
|
138
|
+
- [Tier 0 PII guardrail](https://github.com/directive-run/directive/blob/main/packages/knowledge/ai/ai-security.md#sources-pii--closing-the-fact-injection-bypass) — `createFactPIIGuardrail` (wire whenever sources feed facts the agent reads).
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT or Apache-2.0
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
'use strict';function u(i){let{storage:e,intervalMs:r,eventName:n,payload:c=()=>({}),onTickRegistered:a}=i;if(r<1)throw new Error(`[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${r}`);let o=null;function s(){o&&(o(n,c()),e.setAlarm(Date.now()+r));}return {attach:t=>(o=t,e.setAlarm(Date.now()+r),a?.(s),()=>{o=null,e.deleteAlarm();}),tick:s,meta:{label:`DO alarm: ${n} every ${r}ms`,tags:["source","cloudflare","alarm"]}}}function m(i){let{socket:e,decode:r,closeEvent:n="WEBSOCKET_CLOSED",errorEvent:c="WEBSOCKET_ERROR"}=i;return {attach:a=>{let o=t=>{let d=r(t.data);d!==null&&a(d.name,d.payload);},s=t=>{n!==null&&a(n,{code:t.code??1e3,reason:t.reason??""});},l=()=>{c!==null&&a(c,{});};return e.addEventListener("message",o),e.addEventListener("close",s),e.addEventListener("error",l),()=>{e.removeEventListener("message",o),e.removeEventListener("close",s),e.removeEventListener("error",l);}},meta:{label:"Cloudflare WebSocket message stream",tags:["source","cloudflare","websocket"]}}}exports.sourceFromDOAlarm=u;exports.sourceFromWebSocketMessage=m;//# sourceMappingURL=cloudflare.cjs.map
|
|
2
|
+
//# sourceMappingURL=cloudflare.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cloudflare.ts"],"names":["sourceFromDOAlarm","options","storage","intervalMs","eventName","payload","onTickRegistered","activePublish","tick","publish","sourceFromWebSocketMessage","socket","decode","closeEvent","errorEvent","onMessage","event","decoded","onClose","onError"],"mappings":"aA8GO,SAASA,CAAAA,CACdC,EAC8B,CAC9B,GAAM,CACJ,OAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CACA,QAAAC,CAAAA,CAAU,KAAO,EAAC,CAAA,CAClB,gBAAA,CAAAC,CACF,CAAA,CAAIL,CAAAA,CAEJ,GAAIE,CAAAA,CAAa,CAAA,CACf,MAAM,IAAI,KAAA,CACR,CAAA,4DAAA,EAA+DA,CAAU,CAAA,CAC3E,CAAA,CAGF,IAAII,CAAAA,CAAsC,IAAA,CAE1C,SAASC,CAAAA,EAAa,CACfD,CAAAA,GACLA,EAAcH,CAAAA,CAAWC,CAAAA,EAAS,CAAA,CAI7BH,CAAAA,CAAQ,QAAA,CAAS,KAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,EAC/C,CAuBA,OArB0C,CACxC,MAAA,CAASM,CAAAA,GACPF,EAAgBE,CAAAA,CAGXP,CAAAA,CAAQ,SAAS,IAAA,CAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,CAC7CG,CAAAA,GAAmBE,CAAI,CAAA,CAChB,IAAM,CACXD,CAAAA,CAAgB,IAAA,CAGXL,CAAAA,CAAQ,cACf,CAAA,CAAA,CAEF,IAAA,CAAAM,CAAAA,CACA,IAAA,CAAM,CACJ,MAAO,CAAA,UAAA,EAAaJ,CAAS,UAAUD,CAAU,CAAA,EAAA,CAAA,CACjD,KAAM,CAAC,QAAA,CAAU,YAAA,CAAc,OAAO,CACxC,CACF,CAGF,CAyDO,SAASO,CAAAA,CACdT,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAU,CAAAA,CACA,MAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CAAa,mBACb,UAAA,CAAAC,CAAAA,CAAa,iBACf,CAAA,CAAIb,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASQ,CAAAA,EAA2B,CAClC,IAAMM,CAAAA,CAAaC,GAA8B,CAC/C,IAAMC,CAAAA,CAAUL,CAAAA,CAAOI,CAAAA,CAAM,IAAI,EAC7BC,CAAAA,GAAY,IAAA,EAChBR,CAAAA,CAAQQ,CAAAA,CAAQ,IAAA,CAAMA,CAAAA,CAAQ,OAAO,EACvC,CAAA,CACMC,EAAWF,CAAAA,EAA8C,CACzDH,IAAe,IAAA,EACnBJ,CAAAA,CAAQI,CAAAA,CAAY,CAClB,IAAA,CAAMG,CAAAA,CAAM,MAAQ,GAAA,CACpB,MAAA,CAAQA,CAAAA,CAAM,MAAA,EAAU,EAC1B,CAAC,EACH,CAAA,CACMG,CAAAA,CAAU,IAAM,CAChBL,CAAAA,GAAe,IAAA,EACnBL,EAAQK,CAAAA,CAAY,EAAE,EACxB,CAAA,CAEA,OAAAH,CAAAA,CAAO,gBAAA,CAAiB,SAAA,CAAWI,CAAS,CAAA,CAC5CJ,CAAAA,CAAO,iBAAiB,OAAA,CAASO,CAAO,CAAA,CACxCP,CAAAA,CAAO,gBAAA,CAAiB,OAAA,CAASQ,CAAO,CAAA,CAEjC,IAAM,CACXR,CAAAA,CAAO,mBAAA,CAAoB,SAAA,CAAWI,CAAS,CAAA,CAC/CJ,CAAAA,CAAO,oBAAoB,OAAA,CAASO,CAAO,EAC3CP,CAAAA,CAAO,mBAAA,CAAoB,OAAA,CAASQ,CAAO,EAC7C,CACF,EACA,IAAA,CAAM,CACJ,KAAA,CAAO,qCAAA,CACP,IAAA,CAAM,CAAC,SAAU,YAAA,CAAc,WAAW,CAC5C,CACF,CACF","file":"cloudflare.cjs","sourcesContent":["/**\n * @directive-run/sources/cloudflare\n *\n * Bridges Cloudflare-specific runtime primitives into the Directive\n * `source` primitive:\n *\n * - **`sourceFromDOAlarm`** — Durable Object alarms as a typed\n * periodic source. Replaces hand-rolled `setInterval` inside `attach`\n * (which dies on hibernation) with a storage-backed alarm that\n * survives eviction.\n * - **`sourceFromWebSocketMessage`** — DO `WebSocket` connection\n * (Cloudflare's `webSocketAccept` flow) as a typed message source.\n *\n * Both adapters integrate with the source primitive's lifecycle, so\n * `system.stop()` cleans up via the storage / socket teardown paths.\n *\n * @example DO alarm as a 30-second tick source\n * ```ts\n * import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';\n *\n * export class TickerDO {\n * constructor(public state: DurableObjectState) {}\n *\n * async fetch(req: Request) {\n * const system = createSystem({\n * module: createModule('ticker', {\n * schema: {\n * facts: { lastTick: t.number() },\n * events: { TICK: { at: t.number() } },\n * },\n * init: (f) => { f.lastTick = 0; },\n * events: { TICK: (f, p) => { f.lastTick = p.at; } },\n * sources: {\n * alarm: sourceFromDOAlarm({\n * storage: this.state.storage,\n * intervalMs: 30_000,\n * eventName: 'TICK',\n * payload: () => ({ at: Date.now() }),\n * }),\n * },\n * }),\n * });\n * system.start();\n * return new Response('ok');\n * }\n *\n * // DO runtime calls alarm() on each scheduled tick; the source\n * // adapter's storage key triggers a publish on the active system.\n * async alarm() {\n * // The DO runtime calls this; the source publishes via the\n * // shared module-level event bus (the active system observes).\n * }\n * }\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Cloudflare Workers / DO API surface we touch\n// ============================================================================\n\ninterface DurableObjectStorage {\n setAlarm(scheduledTime: number | Date): Promise<void>;\n deleteAlarm(): Promise<void>;\n getAlarm(): Promise<number | null>;\n}\n\n// ============================================================================\n// sourceFromDOAlarm — Durable Object alarm as a periodic source\n// ============================================================================\n\nexport interface DOAlarmSourceOptions {\n /** The DO's `state.storage` handle. */\n storage: DurableObjectStorage;\n /** Tick interval in milliseconds. Minimum 1ms. */\n intervalMs: number;\n /**\n * Event name to publish on every tick. Must match an event declared\n * on the module's schema (otherwise the engine drops the publish with\n * `lastDropReason: 'invalid-event-name'` per the R6 telemetry).\n */\n eventName: string;\n /**\n * Payload factory. Called on every tick. Default: `() => ({})`.\n */\n payload?: () => Record<string, unknown>;\n /**\n * Optional hook for the consumer to wire the DO's `alarm()` callback\n * back into this source. The adapter cannot intercept the DO runtime's\n * `alarm()` call directly (it's a class method); the consumer's\n * `alarm()` handler should call `adapter.tick()` to drive the publish.\n *\n * Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).\n */\n onTickRegistered?: (tick: () => void) => void;\n}\n\n/**\n * Build a `SourceDef` that schedules a DO alarm every `intervalMs` and\n * publishes on every tick. The adapter manages alarm scheduling via\n * `state.storage.setAlarm()`; on `system.stop()` it clears the alarm.\n *\n * **Important wiring step:** the DO's `alarm()` instance method MUST\n * call the adapter's tick callback. Capture it via `onTickRegistered`\n * (or by stashing the source in a class field and invoking\n * `source.tick()` from your alarm method).\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromDOAlarm(\n options: DOAlarmSourceOptions,\n): SourceDef & { tick(): void } {\n const {\n storage,\n intervalMs,\n eventName,\n payload = () => ({}),\n onTickRegistered,\n } = options;\n\n if (intervalMs < 1) {\n throw new Error(\n `[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${intervalMs}`,\n );\n }\n\n let activePublish: SourcePublish | null = null;\n\n function tick(): void {\n if (!activePublish) return;\n activePublish(eventName, payload());\n // Schedule the next alarm. Fire-and-forget — the host runtime\n // handles delivery; failures land via the source primitive's\n // observation events.\n void storage.setAlarm(Date.now() + intervalMs);\n }\n\n const def: SourceDef & { tick(): void } = {\n attach: (publish: SourcePublish) => {\n activePublish = publish;\n // Schedule the first alarm. The DO's `alarm()` method must call\n // `tick()` to drive subsequent publishes.\n void storage.setAlarm(Date.now() + intervalMs);\n onTickRegistered?.(tick);\n return () => {\n activePublish = null;\n // Clear any pending alarm so the DO doesn't wake the dead\n // system after stop.\n void storage.deleteAlarm();\n };\n },\n tick,\n meta: {\n label: `DO alarm: ${eventName} every ${intervalMs}ms`,\n tags: [\"source\", \"cloudflare\", \"alarm\"],\n },\n };\n\n return def;\n}\n\n// ============================================================================\n// sourceFromWebSocketMessage — DO WebSocket message stream as a source\n// ============================================================================\n\ninterface CloudflareWebSocket {\n send(data: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n addEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n removeEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n}\n\nexport interface WebSocketMessageSourceOptions {\n /** The Cloudflare WebSocket (`server` half of the pair from `webSocketAccept`). */\n socket: CloudflareWebSocket;\n /**\n * Decode each `MessageEvent.data` into a typed Directive event.\n * Return `null` to drop the message (e.g. ping frames).\n */\n decode: (\n data: unknown,\n ) => { name: string; payload: Record<string, unknown> } | null;\n /**\n * Event name to publish when the socket closes. Default `\"WEBSOCKET_CLOSED\"`.\n * Set `null` to skip publishing on close.\n */\n closeEvent?: string | null;\n /**\n * Event name to publish on socket errors. Default `\"WEBSOCKET_ERROR\"`.\n * Set `null` to skip publishing on error.\n */\n errorEvent?: string | null;\n}\n\n/**\n * Build a `SourceDef` that listens on a Cloudflare WebSocket and\n * publishes each decoded message as a typed Directive event. Wraps\n * the standard `addEventListener('message' | 'close' | 'error')`\n * surface.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromWebSocketMessage(\n options: WebSocketMessageSourceOptions,\n): SourceDef {\n const {\n socket,\n decode,\n closeEvent = \"WEBSOCKET_CLOSED\",\n errorEvent = \"WEBSOCKET_ERROR\",\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n const onMessage = (event: { data?: unknown }) => {\n const decoded = decode(event.data);\n if (decoded === null) return;\n publish(decoded.name, decoded.payload);\n };\n const onClose = (event: { code?: number; reason?: string }) => {\n if (closeEvent === null) return;\n publish(closeEvent, {\n code: event.code ?? 1000,\n reason: event.reason ?? \"\",\n });\n };\n const onError = () => {\n if (errorEvent === null) return;\n publish(errorEvent, {});\n };\n\n socket.addEventListener(\"message\", onMessage);\n socket.addEventListener(\"close\", onClose);\n socket.addEventListener(\"error\", onError);\n\n return () => {\n socket.removeEventListener(\"message\", onMessage);\n socket.removeEventListener(\"close\", onClose);\n socket.removeEventListener(\"error\", onError);\n };\n },\n meta: {\n label: \"Cloudflare WebSocket message stream\",\n tags: [\"source\", \"cloudflare\", \"websocket\"],\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { SourceDef } from '@directive-run/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @directive-run/sources/cloudflare
|
|
5
|
+
*
|
|
6
|
+
* Bridges Cloudflare-specific runtime primitives into the Directive
|
|
7
|
+
* `source` primitive:
|
|
8
|
+
*
|
|
9
|
+
* - **`sourceFromDOAlarm`** — Durable Object alarms as a typed
|
|
10
|
+
* periodic source. Replaces hand-rolled `setInterval` inside `attach`
|
|
11
|
+
* (which dies on hibernation) with a storage-backed alarm that
|
|
12
|
+
* survives eviction.
|
|
13
|
+
* - **`sourceFromWebSocketMessage`** — DO `WebSocket` connection
|
|
14
|
+
* (Cloudflare's `webSocketAccept` flow) as a typed message source.
|
|
15
|
+
*
|
|
16
|
+
* Both adapters integrate with the source primitive's lifecycle, so
|
|
17
|
+
* `system.stop()` cleans up via the storage / socket teardown paths.
|
|
18
|
+
*
|
|
19
|
+
* @example DO alarm as a 30-second tick source
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';
|
|
22
|
+
*
|
|
23
|
+
* export class TickerDO {
|
|
24
|
+
* constructor(public state: DurableObjectState) {}
|
|
25
|
+
*
|
|
26
|
+
* async fetch(req: Request) {
|
|
27
|
+
* const system = createSystem({
|
|
28
|
+
* module: createModule('ticker', {
|
|
29
|
+
* schema: {
|
|
30
|
+
* facts: { lastTick: t.number() },
|
|
31
|
+
* events: { TICK: { at: t.number() } },
|
|
32
|
+
* },
|
|
33
|
+
* init: (f) => { f.lastTick = 0; },
|
|
34
|
+
* events: { TICK: (f, p) => { f.lastTick = p.at; } },
|
|
35
|
+
* sources: {
|
|
36
|
+
* alarm: sourceFromDOAlarm({
|
|
37
|
+
* storage: this.state.storage,
|
|
38
|
+
* intervalMs: 30_000,
|
|
39
|
+
* eventName: 'TICK',
|
|
40
|
+
* payload: () => ({ at: Date.now() }),
|
|
41
|
+
* }),
|
|
42
|
+
* },
|
|
43
|
+
* }),
|
|
44
|
+
* });
|
|
45
|
+
* system.start();
|
|
46
|
+
* return new Response('ok');
|
|
47
|
+
* }
|
|
48
|
+
*
|
|
49
|
+
* // DO runtime calls alarm() on each scheduled tick; the source
|
|
50
|
+
* // adapter's storage key triggers a publish on the active system.
|
|
51
|
+
* async alarm() {
|
|
52
|
+
* // The DO runtime calls this; the source publishes via the
|
|
53
|
+
* // shared module-level event bus (the active system observes).
|
|
54
|
+
* }
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
interface DurableObjectStorage {
|
|
60
|
+
setAlarm(scheduledTime: number | Date): Promise<void>;
|
|
61
|
+
deleteAlarm(): Promise<void>;
|
|
62
|
+
getAlarm(): Promise<number | null>;
|
|
63
|
+
}
|
|
64
|
+
interface DOAlarmSourceOptions {
|
|
65
|
+
/** The DO's `state.storage` handle. */
|
|
66
|
+
storage: DurableObjectStorage;
|
|
67
|
+
/** Tick interval in milliseconds. Minimum 1ms. */
|
|
68
|
+
intervalMs: number;
|
|
69
|
+
/**
|
|
70
|
+
* Event name to publish on every tick. Must match an event declared
|
|
71
|
+
* on the module's schema (otherwise the engine drops the publish with
|
|
72
|
+
* `lastDropReason: 'invalid-event-name'` per the R6 telemetry).
|
|
73
|
+
*/
|
|
74
|
+
eventName: string;
|
|
75
|
+
/**
|
|
76
|
+
* Payload factory. Called on every tick. Default: `() => ({})`.
|
|
77
|
+
*/
|
|
78
|
+
payload?: () => Record<string, unknown>;
|
|
79
|
+
/**
|
|
80
|
+
* Optional hook for the consumer to wire the DO's `alarm()` callback
|
|
81
|
+
* back into this source. The adapter cannot intercept the DO runtime's
|
|
82
|
+
* `alarm()` call directly (it's a class method); the consumer's
|
|
83
|
+
* `alarm()` handler should call `adapter.tick()` to drive the publish.
|
|
84
|
+
*
|
|
85
|
+
* Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).
|
|
86
|
+
*/
|
|
87
|
+
onTickRegistered?: (tick: () => void) => void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build a `SourceDef` that schedules a DO alarm every `intervalMs` and
|
|
91
|
+
* publishes on every tick. The adapter manages alarm scheduling via
|
|
92
|
+
* `state.storage.setAlarm()`; on `system.stop()` it clears the alarm.
|
|
93
|
+
*
|
|
94
|
+
* **Important wiring step:** the DO's `alarm()` instance method MUST
|
|
95
|
+
* call the adapter's tick callback. Capture it via `onTickRegistered`
|
|
96
|
+
* (or by stashing the source in a class field and invoking
|
|
97
|
+
* `source.tick()` from your alarm method).
|
|
98
|
+
*
|
|
99
|
+
* @returns a `SourceDef` to drop into a module's `sources:` map.
|
|
100
|
+
*/
|
|
101
|
+
declare function sourceFromDOAlarm(options: DOAlarmSourceOptions): SourceDef & {
|
|
102
|
+
tick(): void;
|
|
103
|
+
};
|
|
104
|
+
interface CloudflareWebSocket {
|
|
105
|
+
send(data: string | ArrayBuffer): void;
|
|
106
|
+
close(code?: number, reason?: string): void;
|
|
107
|
+
addEventListener(type: "message" | "close" | "error", handler: (event: {
|
|
108
|
+
data?: unknown;
|
|
109
|
+
code?: number;
|
|
110
|
+
reason?: string;
|
|
111
|
+
}) => void): void;
|
|
112
|
+
removeEventListener(type: "message" | "close" | "error", handler: (event: {
|
|
113
|
+
data?: unknown;
|
|
114
|
+
code?: number;
|
|
115
|
+
reason?: string;
|
|
116
|
+
}) => void): void;
|
|
117
|
+
}
|
|
118
|
+
interface WebSocketMessageSourceOptions {
|
|
119
|
+
/** The Cloudflare WebSocket (`server` half of the pair from `webSocketAccept`). */
|
|
120
|
+
socket: CloudflareWebSocket;
|
|
121
|
+
/**
|
|
122
|
+
* Decode each `MessageEvent.data` into a typed Directive event.
|
|
123
|
+
* Return `null` to drop the message (e.g. ping frames).
|
|
124
|
+
*/
|
|
125
|
+
decode: (data: unknown) => {
|
|
126
|
+
name: string;
|
|
127
|
+
payload: Record<string, unknown>;
|
|
128
|
+
} | null;
|
|
129
|
+
/**
|
|
130
|
+
* Event name to publish when the socket closes. Default `"WEBSOCKET_CLOSED"`.
|
|
131
|
+
* Set `null` to skip publishing on close.
|
|
132
|
+
*/
|
|
133
|
+
closeEvent?: string | null;
|
|
134
|
+
/**
|
|
135
|
+
* Event name to publish on socket errors. Default `"WEBSOCKET_ERROR"`.
|
|
136
|
+
* Set `null` to skip publishing on error.
|
|
137
|
+
*/
|
|
138
|
+
errorEvent?: string | null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build a `SourceDef` that listens on a Cloudflare WebSocket and
|
|
142
|
+
* publishes each decoded message as a typed Directive event. Wraps
|
|
143
|
+
* the standard `addEventListener('message' | 'close' | 'error')`
|
|
144
|
+
* surface.
|
|
145
|
+
*
|
|
146
|
+
* @returns a `SourceDef` to drop into a module's `sources:` map.
|
|
147
|
+
*/
|
|
148
|
+
declare function sourceFromWebSocketMessage(options: WebSocketMessageSourceOptions): SourceDef;
|
|
149
|
+
|
|
150
|
+
export { type DOAlarmSourceOptions, type WebSocketMessageSourceOptions, sourceFromDOAlarm, sourceFromWebSocketMessage };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { SourceDef } from '@directive-run/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @directive-run/sources/cloudflare
|
|
5
|
+
*
|
|
6
|
+
* Bridges Cloudflare-specific runtime primitives into the Directive
|
|
7
|
+
* `source` primitive:
|
|
8
|
+
*
|
|
9
|
+
* - **`sourceFromDOAlarm`** — Durable Object alarms as a typed
|
|
10
|
+
* periodic source. Replaces hand-rolled `setInterval` inside `attach`
|
|
11
|
+
* (which dies on hibernation) with a storage-backed alarm that
|
|
12
|
+
* survives eviction.
|
|
13
|
+
* - **`sourceFromWebSocketMessage`** — DO `WebSocket` connection
|
|
14
|
+
* (Cloudflare's `webSocketAccept` flow) as a typed message source.
|
|
15
|
+
*
|
|
16
|
+
* Both adapters integrate with the source primitive's lifecycle, so
|
|
17
|
+
* `system.stop()` cleans up via the storage / socket teardown paths.
|
|
18
|
+
*
|
|
19
|
+
* @example DO alarm as a 30-second tick source
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';
|
|
22
|
+
*
|
|
23
|
+
* export class TickerDO {
|
|
24
|
+
* constructor(public state: DurableObjectState) {}
|
|
25
|
+
*
|
|
26
|
+
* async fetch(req: Request) {
|
|
27
|
+
* const system = createSystem({
|
|
28
|
+
* module: createModule('ticker', {
|
|
29
|
+
* schema: {
|
|
30
|
+
* facts: { lastTick: t.number() },
|
|
31
|
+
* events: { TICK: { at: t.number() } },
|
|
32
|
+
* },
|
|
33
|
+
* init: (f) => { f.lastTick = 0; },
|
|
34
|
+
* events: { TICK: (f, p) => { f.lastTick = p.at; } },
|
|
35
|
+
* sources: {
|
|
36
|
+
* alarm: sourceFromDOAlarm({
|
|
37
|
+
* storage: this.state.storage,
|
|
38
|
+
* intervalMs: 30_000,
|
|
39
|
+
* eventName: 'TICK',
|
|
40
|
+
* payload: () => ({ at: Date.now() }),
|
|
41
|
+
* }),
|
|
42
|
+
* },
|
|
43
|
+
* }),
|
|
44
|
+
* });
|
|
45
|
+
* system.start();
|
|
46
|
+
* return new Response('ok');
|
|
47
|
+
* }
|
|
48
|
+
*
|
|
49
|
+
* // DO runtime calls alarm() on each scheduled tick; the source
|
|
50
|
+
* // adapter's storage key triggers a publish on the active system.
|
|
51
|
+
* async alarm() {
|
|
52
|
+
* // The DO runtime calls this; the source publishes via the
|
|
53
|
+
* // shared module-level event bus (the active system observes).
|
|
54
|
+
* }
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
interface DurableObjectStorage {
|
|
60
|
+
setAlarm(scheduledTime: number | Date): Promise<void>;
|
|
61
|
+
deleteAlarm(): Promise<void>;
|
|
62
|
+
getAlarm(): Promise<number | null>;
|
|
63
|
+
}
|
|
64
|
+
interface DOAlarmSourceOptions {
|
|
65
|
+
/** The DO's `state.storage` handle. */
|
|
66
|
+
storage: DurableObjectStorage;
|
|
67
|
+
/** Tick interval in milliseconds. Minimum 1ms. */
|
|
68
|
+
intervalMs: number;
|
|
69
|
+
/**
|
|
70
|
+
* Event name to publish on every tick. Must match an event declared
|
|
71
|
+
* on the module's schema (otherwise the engine drops the publish with
|
|
72
|
+
* `lastDropReason: 'invalid-event-name'` per the R6 telemetry).
|
|
73
|
+
*/
|
|
74
|
+
eventName: string;
|
|
75
|
+
/**
|
|
76
|
+
* Payload factory. Called on every tick. Default: `() => ({})`.
|
|
77
|
+
*/
|
|
78
|
+
payload?: () => Record<string, unknown>;
|
|
79
|
+
/**
|
|
80
|
+
* Optional hook for the consumer to wire the DO's `alarm()` callback
|
|
81
|
+
* back into this source. The adapter cannot intercept the DO runtime's
|
|
82
|
+
* `alarm()` call directly (it's a class method); the consumer's
|
|
83
|
+
* `alarm()` handler should call `adapter.tick()` to drive the publish.
|
|
84
|
+
*
|
|
85
|
+
* Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).
|
|
86
|
+
*/
|
|
87
|
+
onTickRegistered?: (tick: () => void) => void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Build a `SourceDef` that schedules a DO alarm every `intervalMs` and
|
|
91
|
+
* publishes on every tick. The adapter manages alarm scheduling via
|
|
92
|
+
* `state.storage.setAlarm()`; on `system.stop()` it clears the alarm.
|
|
93
|
+
*
|
|
94
|
+
* **Important wiring step:** the DO's `alarm()` instance method MUST
|
|
95
|
+
* call the adapter's tick callback. Capture it via `onTickRegistered`
|
|
96
|
+
* (or by stashing the source in a class field and invoking
|
|
97
|
+
* `source.tick()` from your alarm method).
|
|
98
|
+
*
|
|
99
|
+
* @returns a `SourceDef` to drop into a module's `sources:` map.
|
|
100
|
+
*/
|
|
101
|
+
declare function sourceFromDOAlarm(options: DOAlarmSourceOptions): SourceDef & {
|
|
102
|
+
tick(): void;
|
|
103
|
+
};
|
|
104
|
+
interface CloudflareWebSocket {
|
|
105
|
+
send(data: string | ArrayBuffer): void;
|
|
106
|
+
close(code?: number, reason?: string): void;
|
|
107
|
+
addEventListener(type: "message" | "close" | "error", handler: (event: {
|
|
108
|
+
data?: unknown;
|
|
109
|
+
code?: number;
|
|
110
|
+
reason?: string;
|
|
111
|
+
}) => void): void;
|
|
112
|
+
removeEventListener(type: "message" | "close" | "error", handler: (event: {
|
|
113
|
+
data?: unknown;
|
|
114
|
+
code?: number;
|
|
115
|
+
reason?: string;
|
|
116
|
+
}) => void): void;
|
|
117
|
+
}
|
|
118
|
+
interface WebSocketMessageSourceOptions {
|
|
119
|
+
/** The Cloudflare WebSocket (`server` half of the pair from `webSocketAccept`). */
|
|
120
|
+
socket: CloudflareWebSocket;
|
|
121
|
+
/**
|
|
122
|
+
* Decode each `MessageEvent.data` into a typed Directive event.
|
|
123
|
+
* Return `null` to drop the message (e.g. ping frames).
|
|
124
|
+
*/
|
|
125
|
+
decode: (data: unknown) => {
|
|
126
|
+
name: string;
|
|
127
|
+
payload: Record<string, unknown>;
|
|
128
|
+
} | null;
|
|
129
|
+
/**
|
|
130
|
+
* Event name to publish when the socket closes. Default `"WEBSOCKET_CLOSED"`.
|
|
131
|
+
* Set `null` to skip publishing on close.
|
|
132
|
+
*/
|
|
133
|
+
closeEvent?: string | null;
|
|
134
|
+
/**
|
|
135
|
+
* Event name to publish on socket errors. Default `"WEBSOCKET_ERROR"`.
|
|
136
|
+
* Set `null` to skip publishing on error.
|
|
137
|
+
*/
|
|
138
|
+
errorEvent?: string | null;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build a `SourceDef` that listens on a Cloudflare WebSocket and
|
|
142
|
+
* publishes each decoded message as a typed Directive event. Wraps
|
|
143
|
+
* the standard `addEventListener('message' | 'close' | 'error')`
|
|
144
|
+
* surface.
|
|
145
|
+
*
|
|
146
|
+
* @returns a `SourceDef` to drop into a module's `sources:` map.
|
|
147
|
+
*/
|
|
148
|
+
declare function sourceFromWebSocketMessage(options: WebSocketMessageSourceOptions): SourceDef;
|
|
149
|
+
|
|
150
|
+
export { type DOAlarmSourceOptions, type WebSocketMessageSourceOptions, sourceFromDOAlarm, sourceFromWebSocketMessage };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
function u(i){let{storage:e,intervalMs:r,eventName:n,payload:c=()=>({}),onTickRegistered:a}=i;if(r<1)throw new Error(`[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${r}`);let o=null;function s(){o&&(o(n,c()),e.setAlarm(Date.now()+r));}return {attach:t=>(o=t,e.setAlarm(Date.now()+r),a?.(s),()=>{o=null,e.deleteAlarm();}),tick:s,meta:{label:`DO alarm: ${n} every ${r}ms`,tags:["source","cloudflare","alarm"]}}}function m(i){let{socket:e,decode:r,closeEvent:n="WEBSOCKET_CLOSED",errorEvent:c="WEBSOCKET_ERROR"}=i;return {attach:a=>{let o=t=>{let d=r(t.data);d!==null&&a(d.name,d.payload);},s=t=>{n!==null&&a(n,{code:t.code??1e3,reason:t.reason??""});},l=()=>{c!==null&&a(c,{});};return e.addEventListener("message",o),e.addEventListener("close",s),e.addEventListener("error",l),()=>{e.removeEventListener("message",o),e.removeEventListener("close",s),e.removeEventListener("error",l);}},meta:{label:"Cloudflare WebSocket message stream",tags:["source","cloudflare","websocket"]}}}export{u as sourceFromDOAlarm,m as sourceFromWebSocketMessage};//# sourceMappingURL=cloudflare.js.map
|
|
2
|
+
//# sourceMappingURL=cloudflare.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cloudflare.ts"],"names":["sourceFromDOAlarm","options","storage","intervalMs","eventName","payload","onTickRegistered","activePublish","tick","publish","sourceFromWebSocketMessage","socket","decode","closeEvent","errorEvent","onMessage","event","decoded","onClose","onError"],"mappings":"AA8GO,SAASA,CAAAA,CACdC,EAC8B,CAC9B,GAAM,CACJ,OAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CACA,QAAAC,CAAAA,CAAU,KAAO,EAAC,CAAA,CAClB,gBAAA,CAAAC,CACF,CAAA,CAAIL,CAAAA,CAEJ,GAAIE,CAAAA,CAAa,CAAA,CACf,MAAM,IAAI,KAAA,CACR,CAAA,4DAAA,EAA+DA,CAAU,CAAA,CAC3E,CAAA,CAGF,IAAII,CAAAA,CAAsC,IAAA,CAE1C,SAASC,CAAAA,EAAa,CACfD,CAAAA,GACLA,EAAcH,CAAAA,CAAWC,CAAAA,EAAS,CAAA,CAI7BH,CAAAA,CAAQ,QAAA,CAAS,KAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,EAC/C,CAuBA,OArB0C,CACxC,MAAA,CAASM,CAAAA,GACPF,EAAgBE,CAAAA,CAGXP,CAAAA,CAAQ,SAAS,IAAA,CAAK,GAAA,EAAI,CAAIC,CAAU,CAAA,CAC7CG,CAAAA,GAAmBE,CAAI,CAAA,CAChB,IAAM,CACXD,CAAAA,CAAgB,IAAA,CAGXL,CAAAA,CAAQ,cACf,CAAA,CAAA,CAEF,IAAA,CAAAM,CAAAA,CACA,IAAA,CAAM,CACJ,MAAO,CAAA,UAAA,EAAaJ,CAAS,UAAUD,CAAU,CAAA,EAAA,CAAA,CACjD,KAAM,CAAC,QAAA,CAAU,YAAA,CAAc,OAAO,CACxC,CACF,CAGF,CAyDO,SAASO,CAAAA,CACdT,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAU,CAAAA,CACA,MAAA,CAAAC,CAAAA,CACA,UAAA,CAAAC,CAAAA,CAAa,mBACb,UAAA,CAAAC,CAAAA,CAAa,iBACf,CAAA,CAAIb,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASQ,CAAAA,EAA2B,CAClC,IAAMM,CAAAA,CAAaC,GAA8B,CAC/C,IAAMC,CAAAA,CAAUL,CAAAA,CAAOI,CAAAA,CAAM,IAAI,EAC7BC,CAAAA,GAAY,IAAA,EAChBR,CAAAA,CAAQQ,CAAAA,CAAQ,IAAA,CAAMA,CAAAA,CAAQ,OAAO,EACvC,CAAA,CACMC,EAAWF,CAAAA,EAA8C,CACzDH,IAAe,IAAA,EACnBJ,CAAAA,CAAQI,CAAAA,CAAY,CAClB,IAAA,CAAMG,CAAAA,CAAM,MAAQ,GAAA,CACpB,MAAA,CAAQA,CAAAA,CAAM,MAAA,EAAU,EAC1B,CAAC,EACH,CAAA,CACMG,CAAAA,CAAU,IAAM,CAChBL,CAAAA,GAAe,IAAA,EACnBL,EAAQK,CAAAA,CAAY,EAAE,EACxB,CAAA,CAEA,OAAAH,CAAAA,CAAO,gBAAA,CAAiB,SAAA,CAAWI,CAAS,CAAA,CAC5CJ,CAAAA,CAAO,iBAAiB,OAAA,CAASO,CAAO,CAAA,CACxCP,CAAAA,CAAO,gBAAA,CAAiB,OAAA,CAASQ,CAAO,CAAA,CAEjC,IAAM,CACXR,CAAAA,CAAO,mBAAA,CAAoB,SAAA,CAAWI,CAAS,CAAA,CAC/CJ,CAAAA,CAAO,oBAAoB,OAAA,CAASO,CAAO,EAC3CP,CAAAA,CAAO,mBAAA,CAAoB,OAAA,CAASQ,CAAO,EAC7C,CACF,EACA,IAAA,CAAM,CACJ,KAAA,CAAO,qCAAA,CACP,IAAA,CAAM,CAAC,SAAU,YAAA,CAAc,WAAW,CAC5C,CACF,CACF","file":"cloudflare.js","sourcesContent":["/**\n * @directive-run/sources/cloudflare\n *\n * Bridges Cloudflare-specific runtime primitives into the Directive\n * `source` primitive:\n *\n * - **`sourceFromDOAlarm`** — Durable Object alarms as a typed\n * periodic source. Replaces hand-rolled `setInterval` inside `attach`\n * (which dies on hibernation) with a storage-backed alarm that\n * survives eviction.\n * - **`sourceFromWebSocketMessage`** — DO `WebSocket` connection\n * (Cloudflare's `webSocketAccept` flow) as a typed message source.\n *\n * Both adapters integrate with the source primitive's lifecycle, so\n * `system.stop()` cleans up via the storage / socket teardown paths.\n *\n * @example DO alarm as a 30-second tick source\n * ```ts\n * import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';\n *\n * export class TickerDO {\n * constructor(public state: DurableObjectState) {}\n *\n * async fetch(req: Request) {\n * const system = createSystem({\n * module: createModule('ticker', {\n * schema: {\n * facts: { lastTick: t.number() },\n * events: { TICK: { at: t.number() } },\n * },\n * init: (f) => { f.lastTick = 0; },\n * events: { TICK: (f, p) => { f.lastTick = p.at; } },\n * sources: {\n * alarm: sourceFromDOAlarm({\n * storage: this.state.storage,\n * intervalMs: 30_000,\n * eventName: 'TICK',\n * payload: () => ({ at: Date.now() }),\n * }),\n * },\n * }),\n * });\n * system.start();\n * return new Response('ok');\n * }\n *\n * // DO runtime calls alarm() on each scheduled tick; the source\n * // adapter's storage key triggers a publish on the active system.\n * async alarm() {\n * // The DO runtime calls this; the source publishes via the\n * // shared module-level event bus (the active system observes).\n * }\n * }\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Cloudflare Workers / DO API surface we touch\n// ============================================================================\n\ninterface DurableObjectStorage {\n setAlarm(scheduledTime: number | Date): Promise<void>;\n deleteAlarm(): Promise<void>;\n getAlarm(): Promise<number | null>;\n}\n\n// ============================================================================\n// sourceFromDOAlarm — Durable Object alarm as a periodic source\n// ============================================================================\n\nexport interface DOAlarmSourceOptions {\n /** The DO's `state.storage` handle. */\n storage: DurableObjectStorage;\n /** Tick interval in milliseconds. Minimum 1ms. */\n intervalMs: number;\n /**\n * Event name to publish on every tick. Must match an event declared\n * on the module's schema (otherwise the engine drops the publish with\n * `lastDropReason: 'invalid-event-name'` per the R6 telemetry).\n */\n eventName: string;\n /**\n * Payload factory. Called on every tick. Default: `() => ({})`.\n */\n payload?: () => Record<string, unknown>;\n /**\n * Optional hook for the consumer to wire the DO's `alarm()` callback\n * back into this source. The adapter cannot intercept the DO runtime's\n * `alarm()` call directly (it's a class method); the consumer's\n * `alarm()` handler should call `adapter.tick()` to drive the publish.\n *\n * Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).\n */\n onTickRegistered?: (tick: () => void) => void;\n}\n\n/**\n * Build a `SourceDef` that schedules a DO alarm every `intervalMs` and\n * publishes on every tick. The adapter manages alarm scheduling via\n * `state.storage.setAlarm()`; on `system.stop()` it clears the alarm.\n *\n * **Important wiring step:** the DO's `alarm()` instance method MUST\n * call the adapter's tick callback. Capture it via `onTickRegistered`\n * (or by stashing the source in a class field and invoking\n * `source.tick()` from your alarm method).\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromDOAlarm(\n options: DOAlarmSourceOptions,\n): SourceDef & { tick(): void } {\n const {\n storage,\n intervalMs,\n eventName,\n payload = () => ({}),\n onTickRegistered,\n } = options;\n\n if (intervalMs < 1) {\n throw new Error(\n `[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${intervalMs}`,\n );\n }\n\n let activePublish: SourcePublish | null = null;\n\n function tick(): void {\n if (!activePublish) return;\n activePublish(eventName, payload());\n // Schedule the next alarm. Fire-and-forget — the host runtime\n // handles delivery; failures land via the source primitive's\n // observation events.\n void storage.setAlarm(Date.now() + intervalMs);\n }\n\n const def: SourceDef & { tick(): void } = {\n attach: (publish: SourcePublish) => {\n activePublish = publish;\n // Schedule the first alarm. The DO's `alarm()` method must call\n // `tick()` to drive subsequent publishes.\n void storage.setAlarm(Date.now() + intervalMs);\n onTickRegistered?.(tick);\n return () => {\n activePublish = null;\n // Clear any pending alarm so the DO doesn't wake the dead\n // system after stop.\n void storage.deleteAlarm();\n };\n },\n tick,\n meta: {\n label: `DO alarm: ${eventName} every ${intervalMs}ms`,\n tags: [\"source\", \"cloudflare\", \"alarm\"],\n },\n };\n\n return def;\n}\n\n// ============================================================================\n// sourceFromWebSocketMessage — DO WebSocket message stream as a source\n// ============================================================================\n\ninterface CloudflareWebSocket {\n send(data: string | ArrayBuffer): void;\n close(code?: number, reason?: string): void;\n addEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n removeEventListener(\n type: \"message\" | \"close\" | \"error\",\n handler: (event: {\n data?: unknown;\n code?: number;\n reason?: string;\n }) => void,\n ): void;\n}\n\nexport interface WebSocketMessageSourceOptions {\n /** The Cloudflare WebSocket (`server` half of the pair from `webSocketAccept`). */\n socket: CloudflareWebSocket;\n /**\n * Decode each `MessageEvent.data` into a typed Directive event.\n * Return `null` to drop the message (e.g. ping frames).\n */\n decode: (\n data: unknown,\n ) => { name: string; payload: Record<string, unknown> } | null;\n /**\n * Event name to publish when the socket closes. Default `\"WEBSOCKET_CLOSED\"`.\n * Set `null` to skip publishing on close.\n */\n closeEvent?: string | null;\n /**\n * Event name to publish on socket errors. Default `\"WEBSOCKET_ERROR\"`.\n * Set `null` to skip publishing on error.\n */\n errorEvent?: string | null;\n}\n\n/**\n * Build a `SourceDef` that listens on a Cloudflare WebSocket and\n * publishes each decoded message as a typed Directive event. Wraps\n * the standard `addEventListener('message' | 'close' | 'error')`\n * surface.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromWebSocketMessage(\n options: WebSocketMessageSourceOptions,\n): SourceDef {\n const {\n socket,\n decode,\n closeEvent = \"WEBSOCKET_CLOSED\",\n errorEvent = \"WEBSOCKET_ERROR\",\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n const onMessage = (event: { data?: unknown }) => {\n const decoded = decode(event.data);\n if (decoded === null) return;\n publish(decoded.name, decoded.payload);\n };\n const onClose = (event: { code?: number; reason?: string }) => {\n if (closeEvent === null) return;\n publish(closeEvent, {\n code: event.code ?? 1000,\n reason: event.reason ?? \"\",\n });\n };\n const onError = () => {\n if (errorEvent === null) return;\n publish(errorEvent, {});\n };\n\n socket.addEventListener(\"message\", onMessage);\n socket.addEventListener(\"close\", onClose);\n socket.addEventListener(\"error\", onError);\n\n return () => {\n socket.removeEventListener(\"message\", onMessage);\n socket.removeEventListener(\"close\", onClose);\n socket.removeEventListener(\"error\", onError);\n };\n },\n meta: {\n label: \"Cloudflare WebSocket message stream\",\n tags: [\"source\", \"cloudflare\", \"websocket\"],\n },\n };\n}\n"]}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["VERSION"],"mappings":"aA8EO,IAAMA,CAAAA,CAAU","file":"index.cjs","sourcesContent":["/**\n * @directive-run/sources\n *\n * Source adapters for Directive — wrap external event streams (Supabase\n * realtime, Cloudflare DO alarms, WebSocket, Sentry, etc.) as typed\n * `source` primitives. Import vendor adapters from the per-vendor\n * subpath; only the vendors you import need their peer-dependency\n * installed.\n *\n * @example Supabase realtime channel as a source\n * ```ts\n * import { createClient } from '@supabase/supabase-js';\n * import { createModule, t } from '@directive-run/core';\n * import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';\n *\n * const supabase = createClient(url, key);\n *\n * const gameUpdates = createModule('gameUpdates', {\n * schema: {\n * facts: { snapshot: t.object<GameSnapshot>().nullable() },\n * events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },\n * },\n * init: (f) => { f.snapshot = null; },\n * events: {\n * GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; },\n * },\n * sources: {\n * gameChannel: sourceFromSupabaseChannel({\n * client: supabase,\n * channel: `game:${gameId}`,\n * events: [\n * { table: 'games', filter: `id=eq.${gameId}`, event: 'UPDATE',\n * map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }) },\n * ],\n * }),\n * },\n * });\n * ```\n *\n * @example Cloudflare DO alarm as a source\n * ```ts\n * import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';\n *\n * // Inside your DurableObject class:\n * const ticker = createModule('ticker', {\n * schema: {\n * facts: { lastTick: t.number() },\n * events: { TICK: { at: t.number() } },\n * },\n * init: (f) => { f.lastTick = 0; },\n * events: { TICK: (f, p) => { f.lastTick = p.at; } },\n * sources: {\n * alarm: sourceFromDOAlarm({\n * storage: this.state.storage,\n * intervalMs: 30_000,\n * eventName: 'TICK',\n * payload: () => ({ at: Date.now() }),\n * }),\n * },\n * });\n * ```\n *\n * @packageDocumentation\n */\n\n// The umbrella re-exports nothing concrete — adapters live at the\n// per-vendor subpaths so the optional peerDependency isn't pulled\n// transitively into a consumer that only uses one adapter.\n//\n// Import from:\n// @directive-run/sources/supabase\n// @directive-run/sources/cloudflare\n//\n// The subpath import is intentional: tree-shakers cannot reliably\n// eliminate Supabase's runtime if it's re-exported from the umbrella,\n// but a direct subpath import lands only the bytes that subpath\n// actually uses.\n\nexport const VERSION = \"0.1.0\";\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @directive-run/sources
|
|
3
|
+
*
|
|
4
|
+
* Source adapters for Directive — wrap external event streams (Supabase
|
|
5
|
+
* realtime, Cloudflare DO alarms, WebSocket, Sentry, etc.) as typed
|
|
6
|
+
* `source` primitives. Import vendor adapters from the per-vendor
|
|
7
|
+
* subpath; only the vendors you import need their peer-dependency
|
|
8
|
+
* installed.
|
|
9
|
+
*
|
|
10
|
+
* @example Supabase realtime channel as a source
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createClient } from '@supabase/supabase-js';
|
|
13
|
+
* import { createModule, t } from '@directive-run/core';
|
|
14
|
+
* import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';
|
|
15
|
+
*
|
|
16
|
+
* const supabase = createClient(url, key);
|
|
17
|
+
*
|
|
18
|
+
* const gameUpdates = createModule('gameUpdates', {
|
|
19
|
+
* schema: {
|
|
20
|
+
* facts: { snapshot: t.object<GameSnapshot>().nullable() },
|
|
21
|
+
* events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },
|
|
22
|
+
* },
|
|
23
|
+
* init: (f) => { f.snapshot = null; },
|
|
24
|
+
* events: {
|
|
25
|
+
* GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; },
|
|
26
|
+
* },
|
|
27
|
+
* sources: {
|
|
28
|
+
* gameChannel: sourceFromSupabaseChannel({
|
|
29
|
+
* client: supabase,
|
|
30
|
+
* channel: `game:${gameId}`,
|
|
31
|
+
* events: [
|
|
32
|
+
* { table: 'games', filter: `id=eq.${gameId}`, event: 'UPDATE',
|
|
33
|
+
* map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }) },
|
|
34
|
+
* ],
|
|
35
|
+
* }),
|
|
36
|
+
* },
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @example Cloudflare DO alarm as a source
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';
|
|
43
|
+
*
|
|
44
|
+
* // Inside your DurableObject class:
|
|
45
|
+
* const ticker = createModule('ticker', {
|
|
46
|
+
* schema: {
|
|
47
|
+
* facts: { lastTick: t.number() },
|
|
48
|
+
* events: { TICK: { at: t.number() } },
|
|
49
|
+
* },
|
|
50
|
+
* init: (f) => { f.lastTick = 0; },
|
|
51
|
+
* events: { TICK: (f, p) => { f.lastTick = p.at; } },
|
|
52
|
+
* sources: {
|
|
53
|
+
* alarm: sourceFromDOAlarm({
|
|
54
|
+
* storage: this.state.storage,
|
|
55
|
+
* intervalMs: 30_000,
|
|
56
|
+
* eventName: 'TICK',
|
|
57
|
+
* payload: () => ({ at: Date.now() }),
|
|
58
|
+
* }),
|
|
59
|
+
* },
|
|
60
|
+
* });
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @packageDocumentation
|
|
64
|
+
*/
|
|
65
|
+
declare const VERSION = "0.1.0";
|
|
66
|
+
|
|
67
|
+
export { VERSION };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @directive-run/sources
|
|
3
|
+
*
|
|
4
|
+
* Source adapters for Directive — wrap external event streams (Supabase
|
|
5
|
+
* realtime, Cloudflare DO alarms, WebSocket, Sentry, etc.) as typed
|
|
6
|
+
* `source` primitives. Import vendor adapters from the per-vendor
|
|
7
|
+
* subpath; only the vendors you import need their peer-dependency
|
|
8
|
+
* installed.
|
|
9
|
+
*
|
|
10
|
+
* @example Supabase realtime channel as a source
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { createClient } from '@supabase/supabase-js';
|
|
13
|
+
* import { createModule, t } from '@directive-run/core';
|
|
14
|
+
* import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';
|
|
15
|
+
*
|
|
16
|
+
* const supabase = createClient(url, key);
|
|
17
|
+
*
|
|
18
|
+
* const gameUpdates = createModule('gameUpdates', {
|
|
19
|
+
* schema: {
|
|
20
|
+
* facts: { snapshot: t.object<GameSnapshot>().nullable() },
|
|
21
|
+
* events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },
|
|
22
|
+
* },
|
|
23
|
+
* init: (f) => { f.snapshot = null; },
|
|
24
|
+
* events: {
|
|
25
|
+
* GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; },
|
|
26
|
+
* },
|
|
27
|
+
* sources: {
|
|
28
|
+
* gameChannel: sourceFromSupabaseChannel({
|
|
29
|
+
* client: supabase,
|
|
30
|
+
* channel: `game:${gameId}`,
|
|
31
|
+
* events: [
|
|
32
|
+
* { table: 'games', filter: `id=eq.${gameId}`, event: 'UPDATE',
|
|
33
|
+
* map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }) },
|
|
34
|
+
* ],
|
|
35
|
+
* }),
|
|
36
|
+
* },
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @example Cloudflare DO alarm as a source
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';
|
|
43
|
+
*
|
|
44
|
+
* // Inside your DurableObject class:
|
|
45
|
+
* const ticker = createModule('ticker', {
|
|
46
|
+
* schema: {
|
|
47
|
+
* facts: { lastTick: t.number() },
|
|
48
|
+
* events: { TICK: { at: t.number() } },
|
|
49
|
+
* },
|
|
50
|
+
* init: (f) => { f.lastTick = 0; },
|
|
51
|
+
* events: { TICK: (f, p) => { f.lastTick = p.at; } },
|
|
52
|
+
* sources: {
|
|
53
|
+
* alarm: sourceFromDOAlarm({
|
|
54
|
+
* storage: this.state.storage,
|
|
55
|
+
* intervalMs: 30_000,
|
|
56
|
+
* eventName: 'TICK',
|
|
57
|
+
* payload: () => ({ at: Date.now() }),
|
|
58
|
+
* }),
|
|
59
|
+
* },
|
|
60
|
+
* });
|
|
61
|
+
* ```
|
|
62
|
+
*
|
|
63
|
+
* @packageDocumentation
|
|
64
|
+
*/
|
|
65
|
+
declare const VERSION = "0.1.0";
|
|
66
|
+
|
|
67
|
+
export { VERSION };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["VERSION"],"mappings":"AA8EO,IAAMA,CAAAA,CAAU","file":"index.js","sourcesContent":["/**\n * @directive-run/sources\n *\n * Source adapters for Directive — wrap external event streams (Supabase\n * realtime, Cloudflare DO alarms, WebSocket, Sentry, etc.) as typed\n * `source` primitives. Import vendor adapters from the per-vendor\n * subpath; only the vendors you import need their peer-dependency\n * installed.\n *\n * @example Supabase realtime channel as a source\n * ```ts\n * import { createClient } from '@supabase/supabase-js';\n * import { createModule, t } from '@directive-run/core';\n * import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';\n *\n * const supabase = createClient(url, key);\n *\n * const gameUpdates = createModule('gameUpdates', {\n * schema: {\n * facts: { snapshot: t.object<GameSnapshot>().nullable() },\n * events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },\n * },\n * init: (f) => { f.snapshot = null; },\n * events: {\n * GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; },\n * },\n * sources: {\n * gameChannel: sourceFromSupabaseChannel({\n * client: supabase,\n * channel: `game:${gameId}`,\n * events: [\n * { table: 'games', filter: `id=eq.${gameId}`, event: 'UPDATE',\n * map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }) },\n * ],\n * }),\n * },\n * });\n * ```\n *\n * @example Cloudflare DO alarm as a source\n * ```ts\n * import { sourceFromDOAlarm } from '@directive-run/sources/cloudflare';\n *\n * // Inside your DurableObject class:\n * const ticker = createModule('ticker', {\n * schema: {\n * facts: { lastTick: t.number() },\n * events: { TICK: { at: t.number() } },\n * },\n * init: (f) => { f.lastTick = 0; },\n * events: { TICK: (f, p) => { f.lastTick = p.at; } },\n * sources: {\n * alarm: sourceFromDOAlarm({\n * storage: this.state.storage,\n * intervalMs: 30_000,\n * eventName: 'TICK',\n * payload: () => ({ at: Date.now() }),\n * }),\n * },\n * });\n * ```\n *\n * @packageDocumentation\n */\n\n// The umbrella re-exports nothing concrete — adapters live at the\n// per-vendor subpaths so the optional peerDependency isn't pulled\n// transitively into a consumer that only uses one adapter.\n//\n// Import from:\n// @directive-run/sources/supabase\n// @directive-run/sources/cloudflare\n//\n// The subpath import is intentional: tree-shakers cannot reliably\n// eliminate Supabase's runtime if it's re-exported from the umbrella,\n// but a direct subpath import lands only the bytes that subpath\n// actually uses.\n\nexport const VERSION = \"0.1.0\";\n"]}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
'use strict';function b(l){let{client:s,channel:i,events:u,onStatus:c,redactRow:o=a=>a}=l;return {attach:a=>{let n=s.channel(i);for(let e of u)n=n.on("postgres_changes",{event:e.event,schema:e.schema??"public",table:e.table,...e.filter!==void 0?{filter:e.filter}:{}},t=>{let p={...t,new:o(t.new),old:o(t.old)},r=e.map(p);r!==null&&a(r.name,r.payload);});return n.subscribe(e=>c?.(e)),()=>{s.removeChannel(n);}},meta:{label:`Supabase realtime: ${i}`,tags:["source","supabase"]}}}exports.sourceFromSupabaseChannel=b;//# sourceMappingURL=supabase.cjs.map
|
|
2
|
+
//# sourceMappingURL=supabase.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/supabase.ts"],"names":["sourceFromSupabaseChannel","options","client","channelName","events","onStatus","redactRow","row","publish","chan","binding","payload","safe","result","status"],"mappings":"aA8IO,SAASA,CAAAA,CACdC,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAC,CAAAA,CACA,OAAA,CAASC,CAAAA,CACT,MAAA,CAAAC,CAAAA,CACA,QAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CAAaC,CAAAA,EAAQA,CACvB,CAAA,CAAIN,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASO,CAAAA,EAA2B,CAIlC,IAAIC,CAAAA,CAAOP,CAAAA,CAAO,OAAA,CAAQC,CAAW,EACrC,IAAA,IAAWO,CAAAA,IAAWN,CAAAA,CACpBK,CAAAA,CAAOA,CAAAA,CAAK,EAAA,CACV,kBAAA,CACA,CACE,KAAA,CAAOC,CAAAA,CAAQ,KAAA,CACf,MAAA,CAAQA,CAAAA,CAAQ,MAAA,EAAU,QAAA,CAC1B,KAAA,CAAOA,CAAAA,CAAQ,KAAA,CACf,GAAIA,CAAAA,CAAQ,MAAA,GAAW,MAAA,CAAY,CAAE,MAAA,CAAQA,CAAAA,CAAQ,MAAO,CAAA,CAAI,EAClE,CAAA,CACCC,GAAY,CAKX,IAAMC,CAAAA,CAAwB,CAC5B,GAAGD,CAAAA,CACH,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAAA,CAC1B,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAC5B,CAAA,CACME,CAAAA,CAASH,CAAAA,CAAQ,GAAA,CAAIE,CAAI,CAAA,CAC3BC,CAAAA,GAAW,IAAA,EACfL,CAAAA,CAAQK,CAAAA,CAAO,IAAA,CAAMA,CAAAA,CAAO,OAAO,EACrC,CACF,CAAA,CAOF,OAAAJ,CAAAA,CAAK,SAAA,CAAWK,CAAAA,EAAWT,CAAAA,GAAWS,CAAM,CAAC,CAAA,CAOtC,IAAM,CACNZ,CAAAA,CAAO,aAAA,CAAcO,CAAI,EAChC,CACF,CAAA,CACA,IAAA,CAAM,CACJ,KAAA,CAAO,CAAA,mBAAA,EAAsBN,CAAW,CAAA,CAAA,CACxC,IAAA,CAAM,CAAC,QAAA,CAAU,UAAU,CAC7B,CACF,CACF","file":"supabase.cjs","sourcesContent":["/**\n * @directive-run/sources/supabase\n *\n * Bridges Supabase realtime channels into the Directive `source` primitive.\n * One factory call → one declared source on your module → the engine\n * owns the subscription lifecycle. No `useEffect` bridges, no manual\n * cleanup, no leaked channels.\n *\n * @example Wire a Supabase realtime channel for a single game row\n * ```ts\n * import { createClient } from '@supabase/supabase-js';\n * import { createModule, t } from '@directive-run/core';\n * import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';\n *\n * const supabase = createClient(url, key);\n *\n * const gameUpdates = createModule('gameUpdates', {\n * schema: {\n * facts: { snapshot: t.object<GameSnapshot>().nullable() },\n * events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },\n * },\n * init: (f) => { f.snapshot = null; },\n * events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },\n * sources: {\n * gameChannel: sourceFromSupabaseChannel({\n * client: supabase,\n * channel: `game:${gameId}`,\n * events: [{\n * table: 'games',\n * filter: `id=eq.${gameId}`,\n * event: 'UPDATE',\n * map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }),\n * }],\n * }),\n * },\n * });\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Supabase API surface we touch\n// ============================================================================\n\n// We deliberately do NOT import from `@supabase/supabase-js` so this file\n// type-checks without the optional peerDependency installed. The runtime\n// shape matches Supabase's RealtimeChannel + SupabaseClient surfaces;\n// consumers who install the package get full typing through their own\n// `createClient(...)` instance.\n\ninterface SupabasePayload {\n schema: string;\n table: string;\n commit_timestamp?: string;\n eventType: \"INSERT\" | \"UPDATE\" | \"DELETE\";\n new: Record<string, unknown>;\n old: Record<string, unknown>;\n}\n\ntype SupabaseHandler = (payload: SupabasePayload) => void;\n\ninterface SupabaseRealtimeChannel {\n on(\n type: \"postgres_changes\",\n filter: {\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n schema?: string;\n table?: string;\n filter?: string;\n },\n handler: SupabaseHandler,\n ): SupabaseRealtimeChannel;\n subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;\n unsubscribe(): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\ninterface SupabaseRealtimeClient {\n channel(name: string): SupabaseRealtimeChannel;\n removeChannel(\n channel: SupabaseRealtimeChannel,\n ): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Declarative mapping from a Supabase `postgres_changes` event to a typed\n * Directive event publish. `map` runs on every matching payload; return\n * `null` to skip publishing for that row (useful for soft-filters).\n */\nexport interface SupabaseEventBinding {\n /** Postgres event type. Use `'*'` for any. */\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n /** Table to listen on. */\n table: string;\n /** Postgres schema. Default `'public'`. */\n schema?: string;\n /** Server-side filter expression (e.g. `id=eq.123`). */\n filter?: string;\n /**\n * Map the Supabase payload to a typed Directive event. Return `null` to\n * skip this publish (e.g. filter out updates that don't affect the\n * tracked fields).\n */\n map: (\n payload: SupabasePayload,\n ) => { name: string; payload: Record<string, unknown> } | null;\n}\n\nexport interface SupabaseChannelOptions {\n /** The Supabase client (`createClient(url, key)`). */\n client: SupabaseRealtimeClient;\n /** Channel name. Must be unique per system instance. */\n channel: string;\n /** One or more event bindings on this channel. */\n events: readonly SupabaseEventBinding[];\n /**\n * Optional status hook. Fires with Supabase's connection status\n * (`'SUBSCRIBED'`, `'CHANNEL_ERROR'`, `'TIMED_OUT'`, `'CLOSED'`).\n * Wire to your logging / health-check telemetry.\n */\n onStatus?: (status: string) => void;\n /**\n * Optional pii-redaction hook applied to every payload row BEFORE the\n * `map` callback runs. Useful for blanking columns the schema author\n * marked as PII when the consumer's `createFactPIIGuardrail` cannot\n * reach the field shape (e.g. nested Supabase JSON column).\n *\n * Default: identity (no redaction).\n */\n redactRow?: (row: Record<string, unknown>) => Record<string, unknown>;\n}\n\n/**\n * Build a `SourceDef` that subscribes to a Supabase realtime channel\n * and publishes typed Directive events for each matching row change.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromSupabaseChannel(\n options: SupabaseChannelOptions,\n): SourceDef {\n const {\n client,\n channel: channelName,\n events,\n onStatus,\n redactRow = (row) => row,\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n // Build the channel + register every binding. Each binding's\n // `map` runs on every matching row; the source publishes the\n // returned Directive event.\n let chan = client.channel(channelName);\n for (const binding of events) {\n chan = chan.on(\n \"postgres_changes\",\n {\n event: binding.event,\n schema: binding.schema ?? \"public\",\n table: binding.table,\n ...(binding.filter !== undefined ? { filter: binding.filter } : {}),\n },\n (payload) => {\n // Redact at the row boundary so the `map` callback (which\n // typically embeds row fields into the event payload) sees\n // only the redacted version. The fact-level\n // createFactPIIGuardrail is the second line of defense.\n const safe: SupabasePayload = {\n ...payload,\n new: redactRow(payload.new),\n old: redactRow(payload.old),\n };\n const result = binding.map(safe);\n if (result === null) return;\n publish(result.name, result.payload);\n },\n );\n }\n\n // Open the subscription. `subscribe()` is async on the wire but\n // returns the channel synchronously — the status callback fires\n // once the realtime server acknowledges. Our `attach` contract is\n // sync; we capture the status callback for the consumer.\n chan.subscribe((status) => onStatus?.(status));\n\n // Cleanup: unsubscribe via the client so internal channel registry\n // is cleared. `removeChannel` returns a Promise; the engine's\n // current `SourceUnsubscribeFn` is sync — we kick off the cleanup\n // and let it complete in the background. RFC 0009 widens this to\n // an awaitable signature.\n return () => {\n void client.removeChannel(chan);\n };\n },\n meta: {\n label: `Supabase realtime: ${channelName}`,\n tags: [\"source\", \"supabase\"],\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { SourceDef } from '@directive-run/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @directive-run/sources/supabase
|
|
5
|
+
*
|
|
6
|
+
* Bridges Supabase realtime channels into the Directive `source` primitive.
|
|
7
|
+
* One factory call → one declared source on your module → the engine
|
|
8
|
+
* owns the subscription lifecycle. No `useEffect` bridges, no manual
|
|
9
|
+
* cleanup, no leaked channels.
|
|
10
|
+
*
|
|
11
|
+
* @example Wire a Supabase realtime channel for a single game row
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { createClient } from '@supabase/supabase-js';
|
|
14
|
+
* import { createModule, t } from '@directive-run/core';
|
|
15
|
+
* import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';
|
|
16
|
+
*
|
|
17
|
+
* const supabase = createClient(url, key);
|
|
18
|
+
*
|
|
19
|
+
* const gameUpdates = createModule('gameUpdates', {
|
|
20
|
+
* schema: {
|
|
21
|
+
* facts: { snapshot: t.object<GameSnapshot>().nullable() },
|
|
22
|
+
* events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },
|
|
23
|
+
* },
|
|
24
|
+
* init: (f) => { f.snapshot = null; },
|
|
25
|
+
* events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },
|
|
26
|
+
* sources: {
|
|
27
|
+
* gameChannel: sourceFromSupabaseChannel({
|
|
28
|
+
* client: supabase,
|
|
29
|
+
* channel: `game:${gameId}`,
|
|
30
|
+
* events: [{
|
|
31
|
+
* table: 'games',
|
|
32
|
+
* filter: `id=eq.${gameId}`,
|
|
33
|
+
* event: 'UPDATE',
|
|
34
|
+
* map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }),
|
|
35
|
+
* }],
|
|
36
|
+
* }),
|
|
37
|
+
* },
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
interface SupabasePayload {
|
|
43
|
+
schema: string;
|
|
44
|
+
table: string;
|
|
45
|
+
commit_timestamp?: string;
|
|
46
|
+
eventType: "INSERT" | "UPDATE" | "DELETE";
|
|
47
|
+
new: Record<string, unknown>;
|
|
48
|
+
old: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
type SupabaseHandler = (payload: SupabasePayload) => void;
|
|
51
|
+
interface SupabaseRealtimeChannel {
|
|
52
|
+
on(type: "postgres_changes", filter: {
|
|
53
|
+
event: "INSERT" | "UPDATE" | "DELETE" | "*";
|
|
54
|
+
schema?: string;
|
|
55
|
+
table?: string;
|
|
56
|
+
filter?: string;
|
|
57
|
+
}, handler: SupabaseHandler): SupabaseRealtimeChannel;
|
|
58
|
+
subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;
|
|
59
|
+
unsubscribe(): Promise<"ok" | "timed out" | "error">;
|
|
60
|
+
}
|
|
61
|
+
interface SupabaseRealtimeClient {
|
|
62
|
+
channel(name: string): SupabaseRealtimeChannel;
|
|
63
|
+
removeChannel(channel: SupabaseRealtimeChannel): Promise<"ok" | "timed out" | "error">;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Declarative mapping from a Supabase `postgres_changes` event to a typed
|
|
67
|
+
* Directive event publish. `map` runs on every matching payload; return
|
|
68
|
+
* `null` to skip publishing for that row (useful for soft-filters).
|
|
69
|
+
*/
|
|
70
|
+
interface SupabaseEventBinding {
|
|
71
|
+
/** Postgres event type. Use `'*'` for any. */
|
|
72
|
+
event: "INSERT" | "UPDATE" | "DELETE" | "*";
|
|
73
|
+
/** Table to listen on. */
|
|
74
|
+
table: string;
|
|
75
|
+
/** Postgres schema. Default `'public'`. */
|
|
76
|
+
schema?: string;
|
|
77
|
+
/** Server-side filter expression (e.g. `id=eq.123`). */
|
|
78
|
+
filter?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Map the Supabase payload to a typed Directive event. Return `null` to
|
|
81
|
+
* skip this publish (e.g. filter out updates that don't affect the
|
|
82
|
+
* tracked fields).
|
|
83
|
+
*/
|
|
84
|
+
map: (payload: SupabasePayload) => {
|
|
85
|
+
name: string;
|
|
86
|
+
payload: Record<string, unknown>;
|
|
87
|
+
} | null;
|
|
88
|
+
}
|
|
89
|
+
interface SupabaseChannelOptions {
|
|
90
|
+
/** The Supabase client (`createClient(url, key)`). */
|
|
91
|
+
client: SupabaseRealtimeClient;
|
|
92
|
+
/** Channel name. Must be unique per system instance. */
|
|
93
|
+
channel: string;
|
|
94
|
+
/** One or more event bindings on this channel. */
|
|
95
|
+
events: readonly SupabaseEventBinding[];
|
|
96
|
+
/**
|
|
97
|
+
* Optional status hook. Fires with Supabase's connection status
|
|
98
|
+
* (`'SUBSCRIBED'`, `'CHANNEL_ERROR'`, `'TIMED_OUT'`, `'CLOSED'`).
|
|
99
|
+
* Wire to your logging / health-check telemetry.
|
|
100
|
+
*/
|
|
101
|
+
onStatus?: (status: string) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Optional pii-redaction hook applied to every payload row BEFORE the
|
|
104
|
+
* `map` callback runs. Useful for blanking columns the schema author
|
|
105
|
+
* marked as PII when the consumer's `createFactPIIGuardrail` cannot
|
|
106
|
+
* reach the field shape (e.g. nested Supabase JSON column).
|
|
107
|
+
*
|
|
108
|
+
* Default: identity (no redaction).
|
|
109
|
+
*/
|
|
110
|
+
redactRow?: (row: Record<string, unknown>) => Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Build a `SourceDef` that subscribes to a Supabase realtime channel
|
|
114
|
+
* and publishes typed Directive events for each matching row change.
|
|
115
|
+
*
|
|
116
|
+
* @returns a `SourceDef` to drop into a module's `sources:` map.
|
|
117
|
+
*/
|
|
118
|
+
declare function sourceFromSupabaseChannel(options: SupabaseChannelOptions): SourceDef;
|
|
119
|
+
|
|
120
|
+
export { type SupabaseChannelOptions, type SupabaseEventBinding, sourceFromSupabaseChannel };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { SourceDef } from '@directive-run/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @directive-run/sources/supabase
|
|
5
|
+
*
|
|
6
|
+
* Bridges Supabase realtime channels into the Directive `source` primitive.
|
|
7
|
+
* One factory call → one declared source on your module → the engine
|
|
8
|
+
* owns the subscription lifecycle. No `useEffect` bridges, no manual
|
|
9
|
+
* cleanup, no leaked channels.
|
|
10
|
+
*
|
|
11
|
+
* @example Wire a Supabase realtime channel for a single game row
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { createClient } from '@supabase/supabase-js';
|
|
14
|
+
* import { createModule, t } from '@directive-run/core';
|
|
15
|
+
* import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';
|
|
16
|
+
*
|
|
17
|
+
* const supabase = createClient(url, key);
|
|
18
|
+
*
|
|
19
|
+
* const gameUpdates = createModule('gameUpdates', {
|
|
20
|
+
* schema: {
|
|
21
|
+
* facts: { snapshot: t.object<GameSnapshot>().nullable() },
|
|
22
|
+
* events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },
|
|
23
|
+
* },
|
|
24
|
+
* init: (f) => { f.snapshot = null; },
|
|
25
|
+
* events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },
|
|
26
|
+
* sources: {
|
|
27
|
+
* gameChannel: sourceFromSupabaseChannel({
|
|
28
|
+
* client: supabase,
|
|
29
|
+
* channel: `game:${gameId}`,
|
|
30
|
+
* events: [{
|
|
31
|
+
* table: 'games',
|
|
32
|
+
* filter: `id=eq.${gameId}`,
|
|
33
|
+
* event: 'UPDATE',
|
|
34
|
+
* map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }),
|
|
35
|
+
* }],
|
|
36
|
+
* }),
|
|
37
|
+
* },
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
interface SupabasePayload {
|
|
43
|
+
schema: string;
|
|
44
|
+
table: string;
|
|
45
|
+
commit_timestamp?: string;
|
|
46
|
+
eventType: "INSERT" | "UPDATE" | "DELETE";
|
|
47
|
+
new: Record<string, unknown>;
|
|
48
|
+
old: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
type SupabaseHandler = (payload: SupabasePayload) => void;
|
|
51
|
+
interface SupabaseRealtimeChannel {
|
|
52
|
+
on(type: "postgres_changes", filter: {
|
|
53
|
+
event: "INSERT" | "UPDATE" | "DELETE" | "*";
|
|
54
|
+
schema?: string;
|
|
55
|
+
table?: string;
|
|
56
|
+
filter?: string;
|
|
57
|
+
}, handler: SupabaseHandler): SupabaseRealtimeChannel;
|
|
58
|
+
subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;
|
|
59
|
+
unsubscribe(): Promise<"ok" | "timed out" | "error">;
|
|
60
|
+
}
|
|
61
|
+
interface SupabaseRealtimeClient {
|
|
62
|
+
channel(name: string): SupabaseRealtimeChannel;
|
|
63
|
+
removeChannel(channel: SupabaseRealtimeChannel): Promise<"ok" | "timed out" | "error">;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Declarative mapping from a Supabase `postgres_changes` event to a typed
|
|
67
|
+
* Directive event publish. `map` runs on every matching payload; return
|
|
68
|
+
* `null` to skip publishing for that row (useful for soft-filters).
|
|
69
|
+
*/
|
|
70
|
+
interface SupabaseEventBinding {
|
|
71
|
+
/** Postgres event type. Use `'*'` for any. */
|
|
72
|
+
event: "INSERT" | "UPDATE" | "DELETE" | "*";
|
|
73
|
+
/** Table to listen on. */
|
|
74
|
+
table: string;
|
|
75
|
+
/** Postgres schema. Default `'public'`. */
|
|
76
|
+
schema?: string;
|
|
77
|
+
/** Server-side filter expression (e.g. `id=eq.123`). */
|
|
78
|
+
filter?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Map the Supabase payload to a typed Directive event. Return `null` to
|
|
81
|
+
* skip this publish (e.g. filter out updates that don't affect the
|
|
82
|
+
* tracked fields).
|
|
83
|
+
*/
|
|
84
|
+
map: (payload: SupabasePayload) => {
|
|
85
|
+
name: string;
|
|
86
|
+
payload: Record<string, unknown>;
|
|
87
|
+
} | null;
|
|
88
|
+
}
|
|
89
|
+
interface SupabaseChannelOptions {
|
|
90
|
+
/** The Supabase client (`createClient(url, key)`). */
|
|
91
|
+
client: SupabaseRealtimeClient;
|
|
92
|
+
/** Channel name. Must be unique per system instance. */
|
|
93
|
+
channel: string;
|
|
94
|
+
/** One or more event bindings on this channel. */
|
|
95
|
+
events: readonly SupabaseEventBinding[];
|
|
96
|
+
/**
|
|
97
|
+
* Optional status hook. Fires with Supabase's connection status
|
|
98
|
+
* (`'SUBSCRIBED'`, `'CHANNEL_ERROR'`, `'TIMED_OUT'`, `'CLOSED'`).
|
|
99
|
+
* Wire to your logging / health-check telemetry.
|
|
100
|
+
*/
|
|
101
|
+
onStatus?: (status: string) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Optional pii-redaction hook applied to every payload row BEFORE the
|
|
104
|
+
* `map` callback runs. Useful for blanking columns the schema author
|
|
105
|
+
* marked as PII when the consumer's `createFactPIIGuardrail` cannot
|
|
106
|
+
* reach the field shape (e.g. nested Supabase JSON column).
|
|
107
|
+
*
|
|
108
|
+
* Default: identity (no redaction).
|
|
109
|
+
*/
|
|
110
|
+
redactRow?: (row: Record<string, unknown>) => Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Build a `SourceDef` that subscribes to a Supabase realtime channel
|
|
114
|
+
* and publishes typed Directive events for each matching row change.
|
|
115
|
+
*
|
|
116
|
+
* @returns a `SourceDef` to drop into a module's `sources:` map.
|
|
117
|
+
*/
|
|
118
|
+
declare function sourceFromSupabaseChannel(options: SupabaseChannelOptions): SourceDef;
|
|
119
|
+
|
|
120
|
+
export { type SupabaseChannelOptions, type SupabaseEventBinding, sourceFromSupabaseChannel };
|
package/dist/supabase.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
function b(l){let{client:s,channel:i,events:u,onStatus:c,redactRow:o=a=>a}=l;return {attach:a=>{let n=s.channel(i);for(let e of u)n=n.on("postgres_changes",{event:e.event,schema:e.schema??"public",table:e.table,...e.filter!==void 0?{filter:e.filter}:{}},t=>{let p={...t,new:o(t.new),old:o(t.old)},r=e.map(p);r!==null&&a(r.name,r.payload);});return n.subscribe(e=>c?.(e)),()=>{s.removeChannel(n);}},meta:{label:`Supabase realtime: ${i}`,tags:["source","supabase"]}}}export{b as sourceFromSupabaseChannel};//# sourceMappingURL=supabase.js.map
|
|
2
|
+
//# sourceMappingURL=supabase.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/supabase.ts"],"names":["sourceFromSupabaseChannel","options","client","channelName","events","onStatus","redactRow","row","publish","chan","binding","payload","safe","result","status"],"mappings":"AA8IO,SAASA,CAAAA,CACdC,CAAAA,CACW,CACX,GAAM,CACJ,MAAA,CAAAC,CAAAA,CACA,OAAA,CAASC,CAAAA,CACT,MAAA,CAAAC,CAAAA,CACA,QAAA,CAAAC,CAAAA,CACA,SAAA,CAAAC,CAAAA,CAAaC,CAAAA,EAAQA,CACvB,CAAA,CAAIN,CAAAA,CAEJ,OAAO,CACL,MAAA,CAASO,CAAAA,EAA2B,CAIlC,IAAIC,CAAAA,CAAOP,CAAAA,CAAO,OAAA,CAAQC,CAAW,EACrC,IAAA,IAAWO,CAAAA,IAAWN,CAAAA,CACpBK,CAAAA,CAAOA,CAAAA,CAAK,EAAA,CACV,kBAAA,CACA,CACE,KAAA,CAAOC,CAAAA,CAAQ,KAAA,CACf,MAAA,CAAQA,CAAAA,CAAQ,MAAA,EAAU,QAAA,CAC1B,KAAA,CAAOA,CAAAA,CAAQ,KAAA,CACf,GAAIA,CAAAA,CAAQ,MAAA,GAAW,MAAA,CAAY,CAAE,MAAA,CAAQA,CAAAA,CAAQ,MAAO,CAAA,CAAI,EAClE,CAAA,CACCC,GAAY,CAKX,IAAMC,CAAAA,CAAwB,CAC5B,GAAGD,CAAAA,CACH,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAAA,CAC1B,GAAA,CAAKL,CAAAA,CAAUK,CAAAA,CAAQ,GAAG,CAC5B,CAAA,CACME,CAAAA,CAASH,CAAAA,CAAQ,GAAA,CAAIE,CAAI,CAAA,CAC3BC,CAAAA,GAAW,IAAA,EACfL,CAAAA,CAAQK,CAAAA,CAAO,IAAA,CAAMA,CAAAA,CAAO,OAAO,EACrC,CACF,CAAA,CAOF,OAAAJ,CAAAA,CAAK,SAAA,CAAWK,CAAAA,EAAWT,CAAAA,GAAWS,CAAM,CAAC,CAAA,CAOtC,IAAM,CACNZ,CAAAA,CAAO,aAAA,CAAcO,CAAI,EAChC,CACF,CAAA,CACA,IAAA,CAAM,CACJ,KAAA,CAAO,CAAA,mBAAA,EAAsBN,CAAW,CAAA,CAAA,CACxC,IAAA,CAAM,CAAC,QAAA,CAAU,UAAU,CAC7B,CACF,CACF","file":"supabase.js","sourcesContent":["/**\n * @directive-run/sources/supabase\n *\n * Bridges Supabase realtime channels into the Directive `source` primitive.\n * One factory call → one declared source on your module → the engine\n * owns the subscription lifecycle. No `useEffect` bridges, no manual\n * cleanup, no leaked channels.\n *\n * @example Wire a Supabase realtime channel for a single game row\n * ```ts\n * import { createClient } from '@supabase/supabase-js';\n * import { createModule, t } from '@directive-run/core';\n * import { sourceFromSupabaseChannel } from '@directive-run/sources/supabase';\n *\n * const supabase = createClient(url, key);\n *\n * const gameUpdates = createModule('gameUpdates', {\n * schema: {\n * facts: { snapshot: t.object<GameSnapshot>().nullable() },\n * events: { GAME_UPDATED: { snapshot: t.object<GameSnapshot>() } },\n * },\n * init: (f) => { f.snapshot = null; },\n * events: { GAME_UPDATED: (f, p) => { f.snapshot = p.snapshot; } },\n * sources: {\n * gameChannel: sourceFromSupabaseChannel({\n * client: supabase,\n * channel: `game:${gameId}`,\n * events: [{\n * table: 'games',\n * filter: `id=eq.${gameId}`,\n * event: 'UPDATE',\n * map: (row) => ({ name: 'GAME_UPDATED', payload: { snapshot: mapRow(row) } }),\n * }],\n * }),\n * },\n * });\n * ```\n */\n\nimport type { SourceDef, SourcePublish } from \"@directive-run/core\";\n\n// ============================================================================\n// Type stubs for the Supabase API surface we touch\n// ============================================================================\n\n// We deliberately do NOT import from `@supabase/supabase-js` so this file\n// type-checks without the optional peerDependency installed. The runtime\n// shape matches Supabase's RealtimeChannel + SupabaseClient surfaces;\n// consumers who install the package get full typing through their own\n// `createClient(...)` instance.\n\ninterface SupabasePayload {\n schema: string;\n table: string;\n commit_timestamp?: string;\n eventType: \"INSERT\" | \"UPDATE\" | \"DELETE\";\n new: Record<string, unknown>;\n old: Record<string, unknown>;\n}\n\ntype SupabaseHandler = (payload: SupabasePayload) => void;\n\ninterface SupabaseRealtimeChannel {\n on(\n type: \"postgres_changes\",\n filter: {\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n schema?: string;\n table?: string;\n filter?: string;\n },\n handler: SupabaseHandler,\n ): SupabaseRealtimeChannel;\n subscribe(callback?: (status: string) => void): SupabaseRealtimeChannel;\n unsubscribe(): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\ninterface SupabaseRealtimeClient {\n channel(name: string): SupabaseRealtimeChannel;\n removeChannel(\n channel: SupabaseRealtimeChannel,\n ): Promise<\"ok\" | \"timed out\" | \"error\">;\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\n/**\n * Declarative mapping from a Supabase `postgres_changes` event to a typed\n * Directive event publish. `map` runs on every matching payload; return\n * `null` to skip publishing for that row (useful for soft-filters).\n */\nexport interface SupabaseEventBinding {\n /** Postgres event type. Use `'*'` for any. */\n event: \"INSERT\" | \"UPDATE\" | \"DELETE\" | \"*\";\n /** Table to listen on. */\n table: string;\n /** Postgres schema. Default `'public'`. */\n schema?: string;\n /** Server-side filter expression (e.g. `id=eq.123`). */\n filter?: string;\n /**\n * Map the Supabase payload to a typed Directive event. Return `null` to\n * skip this publish (e.g. filter out updates that don't affect the\n * tracked fields).\n */\n map: (\n payload: SupabasePayload,\n ) => { name: string; payload: Record<string, unknown> } | null;\n}\n\nexport interface SupabaseChannelOptions {\n /** The Supabase client (`createClient(url, key)`). */\n client: SupabaseRealtimeClient;\n /** Channel name. Must be unique per system instance. */\n channel: string;\n /** One or more event bindings on this channel. */\n events: readonly SupabaseEventBinding[];\n /**\n * Optional status hook. Fires with Supabase's connection status\n * (`'SUBSCRIBED'`, `'CHANNEL_ERROR'`, `'TIMED_OUT'`, `'CLOSED'`).\n * Wire to your logging / health-check telemetry.\n */\n onStatus?: (status: string) => void;\n /**\n * Optional pii-redaction hook applied to every payload row BEFORE the\n * `map` callback runs. Useful for blanking columns the schema author\n * marked as PII when the consumer's `createFactPIIGuardrail` cannot\n * reach the field shape (e.g. nested Supabase JSON column).\n *\n * Default: identity (no redaction).\n */\n redactRow?: (row: Record<string, unknown>) => Record<string, unknown>;\n}\n\n/**\n * Build a `SourceDef` that subscribes to a Supabase realtime channel\n * and publishes typed Directive events for each matching row change.\n *\n * @returns a `SourceDef` to drop into a module's `sources:` map.\n */\nexport function sourceFromSupabaseChannel(\n options: SupabaseChannelOptions,\n): SourceDef {\n const {\n client,\n channel: channelName,\n events,\n onStatus,\n redactRow = (row) => row,\n } = options;\n\n return {\n attach: (publish: SourcePublish) => {\n // Build the channel + register every binding. Each binding's\n // `map` runs on every matching row; the source publishes the\n // returned Directive event.\n let chan = client.channel(channelName);\n for (const binding of events) {\n chan = chan.on(\n \"postgres_changes\",\n {\n event: binding.event,\n schema: binding.schema ?? \"public\",\n table: binding.table,\n ...(binding.filter !== undefined ? { filter: binding.filter } : {}),\n },\n (payload) => {\n // Redact at the row boundary so the `map` callback (which\n // typically embeds row fields into the event payload) sees\n // only the redacted version. The fact-level\n // createFactPIIGuardrail is the second line of defense.\n const safe: SupabasePayload = {\n ...payload,\n new: redactRow(payload.new),\n old: redactRow(payload.old),\n };\n const result = binding.map(safe);\n if (result === null) return;\n publish(result.name, result.payload);\n },\n );\n }\n\n // Open the subscription. `subscribe()` is async on the wire but\n // returns the channel synchronously — the status callback fires\n // once the realtime server acknowledges. Our `attach` contract is\n // sync; we capture the status callback for the consumer.\n chan.subscribe((status) => onStatus?.(status));\n\n // Cleanup: unsubscribe via the client so internal channel registry\n // is cleared. `removeChannel` returns a Promise; the engine's\n // current `SourceUnsubscribeFn` is sync — we kick off the cleanup\n // and let it complete in the background. RFC 0009 widens this to\n // an awaitable signature.\n return () => {\n void client.removeChannel(chan);\n };\n },\n meta: {\n label: `Supabase realtime: ${channelName}`,\n tags: [\"source\", \"supabase\"],\n },\n };\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@directive-run/sources",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Source adapters for Directive — wrap Supabase realtime, Cloudflare DO alarms, WebSocket, Sentry, etc. as typed `source` primitives. One package, one install, subpath exports per vendor.",
|
|
5
|
+
"license": "(MIT OR Apache-2.0)",
|
|
6
|
+
"author": "Jason Comes",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/directive-run/directive",
|
|
10
|
+
"directory": "packages/sources"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://directive.run",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/directive-run/directive/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"directive",
|
|
25
|
+
"source",
|
|
26
|
+
"supabase",
|
|
27
|
+
"cloudflare",
|
|
28
|
+
"realtime",
|
|
29
|
+
"websocket",
|
|
30
|
+
"state-management"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"type": "module",
|
|
34
|
+
"main": "./dist/index.cjs",
|
|
35
|
+
"module": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./dist/index.d.ts",
|
|
40
|
+
"require": "./dist/index.cjs",
|
|
41
|
+
"import": "./dist/index.js"
|
|
42
|
+
},
|
|
43
|
+
"./supabase": {
|
|
44
|
+
"types": "./dist/supabase.d.ts",
|
|
45
|
+
"require": "./dist/supabase.cjs",
|
|
46
|
+
"import": "./dist/supabase.js"
|
|
47
|
+
},
|
|
48
|
+
"./cloudflare": {
|
|
49
|
+
"types": "./dist/cloudflare.d.ts",
|
|
50
|
+
"require": "./dist/cloudflare.cjs",
|
|
51
|
+
"import": "./dist/cloudflare.js"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"README.md",
|
|
57
|
+
"CHANGELOG.md"
|
|
58
|
+
],
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@directive-run/core": "^1.2.0",
|
|
61
|
+
"@supabase/supabase-js": "^2.0.0",
|
|
62
|
+
"@cloudflare/workers-types": "^4.0.0"
|
|
63
|
+
},
|
|
64
|
+
"peerDependenciesMeta": {
|
|
65
|
+
"@supabase/supabase-js": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"@cloudflare/workers-types": {
|
|
69
|
+
"optional": true
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@types/node": "^25.2.0",
|
|
74
|
+
"tsup": "^8.3.5",
|
|
75
|
+
"typescript": "^5.7.2",
|
|
76
|
+
"vitest": "^2.1.9",
|
|
77
|
+
"@directive-run/core": "1.18.0"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"build": "tsup",
|
|
81
|
+
"dev": "tsup --watch",
|
|
82
|
+
"test": "vitest run",
|
|
83
|
+
"typecheck": "tsc --noEmit",
|
|
84
|
+
"clean": "rm -rf dist"
|
|
85
|
+
}
|
|
86
|
+
}
|