@directive-run/sources 0.2.0 → 0.3.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 +131 -0
- package/dist/cloudflare.cjs +1 -1
- package/dist/cloudflare.cjs.map +1 -1
- package/dist/cloudflare.d.cts +19 -0
- package/dist/cloudflare.d.ts +19 -0
- package/dist/cloudflare.js +1 -1
- package/dist/cloudflare.js.map +1 -1
- package/dist/supabase.cjs +1 -1
- package/dist/supabase.cjs.map +1 -1
- package/dist/supabase.js +1 -1
- package/dist/supabase.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,136 @@
|
|
|
1
1
|
# @directive-run/sources
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#55](https://github.com/directive-run/directive/pull/55) [`5c7a2d6`](https://github.com/directive-run/directive/commit/5c7a2d60f71f527e9afd85a67afa36f61fc0bdfc) Thanks [@jasoncomes](https://github.com/jasoncomes)! - R13 audit — 5 remaining Critical fixes to documented surfaces of the source primitive
|
|
8
|
+
|
|
9
|
+
This patch closes the 5 R13 CRITs that affect documented but unreachable
|
|
10
|
+
or misleading public APIs of v1.18.0. With Tier 1 (already merged) +
|
|
11
|
+
this Tier 2, all 10 R13 ship-blocking CRITs are resolved.
|
|
12
|
+
|
|
13
|
+
### Critical fixes
|
|
14
|
+
|
|
15
|
+
**`System.stopAsync` / `destroyAsync` / `evict` wired through
|
|
16
|
+
`createSystem` wrappers** (R13-C1). Engine implemented these per RFC
|
|
17
|
+
0009 but neither the single-module wrapper at `system.ts:1178+` nor the
|
|
18
|
+
namespaced wrapper at `system.ts:527+` assigned them, and the
|
|
19
|
+
`SingleModuleSystem` / `NamespacedSystem` / system-config types omitted
|
|
20
|
+
the declarations. Calling `createSystem({...}).stopAsync()` failed at
|
|
21
|
+
TypeScript (`Property 'stopAsync' does not exist`) AND at runtime
|
|
22
|
+
(undefined method). The entire RFC 0009 DO-eviction recipe documented
|
|
23
|
+
in `core/sources.md` was unreachable from the public API. All three
|
|
24
|
+
methods now delegate to the engine; both wrappers participate in the
|
|
25
|
+
`tickInterval` cleanup; added a 6-case regression test
|
|
26
|
+
(`system-async-lifecycle.test.ts`) that exercises the public boundary
|
|
27
|
+
including an async source unsubscribe await.
|
|
28
|
+
|
|
29
|
+
**Cloudflare DO adapters accept `onEvict`** (R13-C5). `sourceFromDOAlarm`
|
|
30
|
+
and `sourceFromWebSocketMessage` are the literal target runtime for RFC
|
|
31
|
+
0009, yet neither adapter accepted or forwarded an `onEvict` option.
|
|
32
|
+
With this change both adapters expose `onEvict?: () => void | Promise<void>`
|
|
33
|
+
on their options interface. Defaults: `DOAlarm` clears the pending
|
|
34
|
+
alarm via `storage.deleteAlarm()`; `WebSocketMessage` closes the socket
|
|
35
|
+
with code 1001 `"going-away"`. Consumers can override to skip the
|
|
36
|
+
default (e.g. when the runtime hibernates WebSockets natively) or to
|
|
37
|
+
add pre-hibernation work (flush audit log, signal broker). 4 new
|
|
38
|
+
regression tests covering default + custom `onEvict` for both adapters.
|
|
39
|
+
|
|
40
|
+
**`createFactPIIGuardrail` walker recurses into arrays** (R13-C6). The
|
|
41
|
+
walker previously short-circuited on `Array.isArray(value)`, so the
|
|
42
|
+
dominant real-world Supabase realtime shape
|
|
43
|
+
(`payload.new = [{ email, ... }]`) and MCP resource-list notifications
|
|
44
|
+
silently bypassed the Tier 0 guard. The walker now inspects array
|
|
45
|
+
elements at the same depth budget, rebuilding the array if any element
|
|
46
|
+
matched. Maps and Sets remain out of scope by design (consumers must
|
|
47
|
+
wire a `customDetector` for those). 2 new regression tests covering
|
|
48
|
+
both "array of PII objects" and "array of PII strings" shapes.
|
|
49
|
+
|
|
50
|
+
**RFC 0005 `mode` field removed** (R13-C7). The field
|
|
51
|
+
`liveContext.mode: "inject-system-message" | "restart"` shipped on the
|
|
52
|
+
public API but was never read by the impl. The name
|
|
53
|
+
`"inject-system-message"` falsely implied mid-stream injection; the
|
|
54
|
+
actual behavior is abort-and-emit. Since v1.18.0 has not yet shipped,
|
|
55
|
+
the field is removed cleanly (no deprecation tail to maintain). The
|
|
56
|
+
auto-re-prompt semantics will ship in a follow-up RFC + field together
|
|
57
|
+
once their design is settled. RFC 0005 + `ai-sources.md` updated.
|
|
58
|
+
|
|
59
|
+
### Documentation fixes
|
|
60
|
+
|
|
61
|
+
**MCP source recipe rewritten against the real adapter API** (R13-C8).
|
|
62
|
+
The previous recipe in `ai-sources.md` called `adapter.onConnect(cb) →
|
|
63
|
+
unsubscribe` — a method that doesn't exist on `MCPAdapter`. The actual
|
|
64
|
+
adapter exposes `MCPAdapterConfig.events` as a single callback bag at
|
|
65
|
+
construction time. The rewritten recipe documents the canonical
|
|
66
|
+
"holder + closure" bridge pattern: a `publishRef` variable that the
|
|
67
|
+
source's `attach` populates, with the adapter's `events.onConnect` /
|
|
68
|
+
`onDisconnect` forwarding through it. This is the general pattern for
|
|
69
|
+
bridging any single-callback-bag third-party SDK into a Directive
|
|
70
|
+
source. Recipe also adds the missing `derivations` schema declaration.
|
|
71
|
+
|
|
72
|
+
### Patch Changes
|
|
73
|
+
|
|
74
|
+
- [#55](https://github.com/directive-run/directive/pull/55) [`9ffd758`](https://github.com/directive-run/directive/commit/9ffd7584914b93ca840ae84372fe3e83c75f29e8) Thanks [@jasoncomes](https://github.com/jasoncomes)! - R13 audit — 5 Critical fixes to documented surfaces of the source primitive
|
|
75
|
+
|
|
76
|
+
The post-merge R13 audit (full 13-lens panel against the merged
|
|
77
|
+
`feat/source-primitive` work) found five Critical issues affecting
|
|
78
|
+
consumer-facing documented APIs of v1.18.0. All five close in this patch.
|
|
79
|
+
|
|
80
|
+
### Critical fixes
|
|
81
|
+
|
|
82
|
+
**`createFactPIIGuardrail` not exported from `@directive-run/ai/guardrails`
|
|
83
|
+
subpath** (R13-C2). The Tier 0 Mandatory Companion to `liveContext` was
|
|
84
|
+
declared in `guardrails/index.ts` but the actual tsup entry for the
|
|
85
|
+
subpath (`src/guardrails-export.ts`) didn't re-export it. Every recipe in
|
|
86
|
+
`packages/knowledge/ai/ai-sources.md` (Sources × Security section) failed
|
|
87
|
+
at import time: `Module '@directive-run/ai/guardrails' has no exported
|
|
88
|
+
member 'createFactPIIGuardrail'`. Now exported (function + the four
|
|
89
|
+
public types: `FactPIIGuardrailMode`, `FactPIIGuardrailOptions`,
|
|
90
|
+
`FactPIICategory`, `FactPIIMatch`). The internal JSDoc example in
|
|
91
|
+
`fact-pii.ts` also referenced the wrong import path (`@directive-run/ai`
|
|
92
|
+
instead of `@directive-run/ai/guardrails`) — corrected.
|
|
93
|
+
|
|
94
|
+
**`@directive-run/sources` rejected by `@directive-run/sandbox`
|
|
95
|
+
validator** (R13-C3). The sandbox validator's `ALLOWED_DIRECTIVE_PACKAGES`
|
|
96
|
+
set didn't include `sources`, so every playground snippet, MCP
|
|
97
|
+
`run_in_sandbox` call, and docs live runner that imported the umbrella
|
|
98
|
+
package or either subpath (`@directive-run/sources`,
|
|
99
|
+
`@directive-run/sources/supabase`, `@directive-run/sources/cloudflare`)
|
|
100
|
+
hard-failed with `is not allowed in the sandbox` despite the umbrella
|
|
101
|
+
shipping as part of v1.18.0. Added `sources` to the allowlist and added
|
|
102
|
+
two-segment-subpath coverage to the validator test grid.
|
|
103
|
+
|
|
104
|
+
**`sourceFromSupabaseChannel` unsubscribe fires-and-forgets
|
|
105
|
+
`removeChannel`** (R13-C4). The original R5-CR1 issue RFC 0009 was
|
|
106
|
+
designed to close: the adapter returned a sync unsubscribe that did
|
|
107
|
+
`void client.removeChannel(chan)`, so `system.stopAsync()` resolved
|
|
108
|
+
before the Supabase broker dropped the subscription. A subsequent
|
|
109
|
+
`start → stopAsync → start` cycle double-subscribed because the broker
|
|
110
|
+
still held the old channel when the new attach raced in. Per RFC 0009's
|
|
111
|
+
`SourceUnsubscribe = () => void | Promise<void>` widening, the adapter
|
|
112
|
+
now returns `async () => { await client.removeChannel(chan); }`. Engines
|
|
113
|
+
using legacy sync `cleanupAll` still ignore the returned promise — same
|
|
114
|
+
fire-and-forget behavior as before — but the broker drop is now
|
|
115
|
+
observable to consumers using `stopAsync`.
|
|
116
|
+
|
|
117
|
+
### Documentation fixes
|
|
118
|
+
|
|
119
|
+
**Broken cross-ref anchor** (R13-C9): `packages/knowledge/core/sources.md`
|
|
120
|
+
linked to `ai-security.md#sources-pii--closing-the-fact-injection-bypass`
|
|
121
|
+
with a single hyphen between "sources" and "pii". The actual GFM anchor
|
|
122
|
+
generated from the heading `## Sources × PII — closing the fact-injection
|
|
123
|
+
bypass` has a double hyphen (`×` strips to a kept space). The
|
|
124
|
+
highest-traffic cross-ref in the source primitive doc was landing on a
|
|
125
|
+
404 anchor. Corrected to `#sources--pii--closing-the-fact-injection-bypass`.
|
|
126
|
+
|
|
127
|
+
**RFCs 0005–0009 status flipped from Draft → Accepted** (R13-C10): all
|
|
128
|
+
five RFCs still carried `Status: Draft (2026-06-07)` even though
|
|
129
|
+
`sources.md` and `ai-sources.md` already cite them as shipped. Readers
|
|
130
|
+
following the link saw Draft headers and concluded the feature was
|
|
131
|
+
design-only. Status now reads: `Accepted — shipped 2026-06-07 in
|
|
132
|
+
feat/source-primitive (PR #52, merge ab97b028); pending v1.18.0 release`.
|
|
133
|
+
|
|
3
134
|
## 0.2.0
|
|
4
135
|
|
|
5
136
|
### Minor Changes
|
package/dist/cloudflare.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
'use strict';function
|
|
1
|
+
'use strict';function m(i){let{storage:e,intervalMs:o,eventName:n,payload:s=()=>({}),onTickRegistered:c,onEvict:v}=i;if(o<1)throw new Error(`[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${o}`);let r=null;function a(){r&&(r(n,s()),e.setAlarm(Date.now()+o));}return {attach:t=>(r=t,e.setAlarm(Date.now()+o),c?.(a),()=>{r=null,e.deleteAlarm();}),onEvict:v??(async()=>{await e.deleteAlarm();}),tick:a,meta:{label:`DO alarm: ${n} every ${o}ms`,tags:["source","cloudflare","alarm"]}}}function g(i){let{socket:e,decode:o,closeEvent:n="WEBSOCKET_CLOSED",errorEvent:s="WEBSOCKET_ERROR",onEvict:c}=i;return {attach:r=>{let a=t=>{let u=o(t.data);u!==null&&r(u.name,u.payload);},l=t=>{n!==null&&r(n,{code:t.code??1e3,reason:t.reason??""});},d=()=>{s!==null&&r(s,{});};return e.addEventListener("message",a),e.addEventListener("close",l),e.addEventListener("error",d),()=>{e.removeEventListener("message",a),e.removeEventListener("close",l),e.removeEventListener("error",d);}},onEvict:c??(()=>{try{e.close(1001,"going-away");}catch{}}),meta:{label:"Cloudflare WebSocket message stream",tags:["source","cloudflare","websocket"]}}}exports.sourceFromDOAlarm=m;exports.sourceFromWebSocketMessage=g;//# sourceMappingURL=cloudflare.cjs.map
|
|
2
2
|
//# sourceMappingURL=cloudflare.cjs.map
|
package/dist/cloudflare.cjs.map
CHANGED
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"sources":["../src/cloudflare.ts"],"names":["sourceFromDOAlarm","options","storage","intervalMs","eventName","payload","onTickRegistered","onEvict","activePublish","tick","publish","sourceFromWebSocketMessage","socket","decode","closeEvent","errorEvent","onMessage","event","decoded","onClose","onError"],"mappings":"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","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 * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the\n * DO runtime signals an upcoming hibernation. Clear the alarm here so\n * the DO doesn't wake just to fire a publish into a torn-down system.\n * The default `onEvict` (used when this option is omitted) deletes the\n * alarm via `storage.deleteAlarm()`; supply your own to add custom\n * pre-hibernation work (flush an audit log, signal the broker, etc.).\n */\n onEvict?: () => void | Promise<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 onEvict,\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 // Default onEvict: drop the pending alarm so the DO doesn't wake\n // post-hibernation just to fire into a torn-down system. RFC 0009\n // motivates the hook; this adapter is the literal target runtime.\n const evictHandler =\n onEvict ??\n (async () => {\n await storage.deleteAlarm();\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 onEvict: evictHandler,\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 * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the\n * DO runtime signals an upcoming hibernation. The default `onEvict`\n * closes the socket with code 1001 (`\"going-away\"`) so the broker sees\n * a clean disconnect and a re-attach after hibernation negotiates a\n * fresh session. Supply your own to skip the close (e.g., when the\n * runtime is hibernating WebSockets natively and the broker holds the\n * connection for resumption) or to add custom pre-hibernation work.\n */\n onEvict?: () => void | Promise<void>;\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 onEvict,\n } = options;\n\n // Default onEvict: close the socket so the broker sees a clean\n // disconnect ahead of the DO hibernating. Consumers who let the\n // runtime hibernate the WebSocket natively can opt out by supplying\n // their own no-op `onEvict`.\n const evictHandler =\n onEvict ??\n (() => {\n try {\n socket.close(1001, \"going-away\");\n } catch {\n // Already closed; ignore.\n }\n });\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 onEvict: evictHandler,\n meta: {\n label: \"Cloudflare WebSocket message stream\",\n tags: [\"source\", \"cloudflare\", \"websocket\"],\n },\n };\n}\n"]}
|
package/dist/cloudflare.d.cts
CHANGED
|
@@ -85,6 +85,15 @@ interface DOAlarmSourceOptions {
|
|
|
85
85
|
* Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).
|
|
86
86
|
*/
|
|
87
87
|
onTickRegistered?: (tick: () => void) => void;
|
|
88
|
+
/**
|
|
89
|
+
* RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the
|
|
90
|
+
* DO runtime signals an upcoming hibernation. Clear the alarm here so
|
|
91
|
+
* the DO doesn't wake just to fire a publish into a torn-down system.
|
|
92
|
+
* The default `onEvict` (used when this option is omitted) deletes the
|
|
93
|
+
* alarm via `storage.deleteAlarm()`; supply your own to add custom
|
|
94
|
+
* pre-hibernation work (flush an audit log, signal the broker, etc.).
|
|
95
|
+
*/
|
|
96
|
+
onEvict?: () => void | Promise<void>;
|
|
88
97
|
}
|
|
89
98
|
/**
|
|
90
99
|
* Build a `SourceDef` that schedules a DO alarm every `intervalMs` and
|
|
@@ -136,6 +145,16 @@ interface WebSocketMessageSourceOptions {
|
|
|
136
145
|
* Set `null` to skip publishing on error.
|
|
137
146
|
*/
|
|
138
147
|
errorEvent?: string | null;
|
|
148
|
+
/**
|
|
149
|
+
* RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the
|
|
150
|
+
* DO runtime signals an upcoming hibernation. The default `onEvict`
|
|
151
|
+
* closes the socket with code 1001 (`"going-away"`) so the broker sees
|
|
152
|
+
* a clean disconnect and a re-attach after hibernation negotiates a
|
|
153
|
+
* fresh session. Supply your own to skip the close (e.g., when the
|
|
154
|
+
* runtime is hibernating WebSockets natively and the broker holds the
|
|
155
|
+
* connection for resumption) or to add custom pre-hibernation work.
|
|
156
|
+
*/
|
|
157
|
+
onEvict?: () => void | Promise<void>;
|
|
139
158
|
}
|
|
140
159
|
/**
|
|
141
160
|
* Build a `SourceDef` that listens on a Cloudflare WebSocket and
|
package/dist/cloudflare.d.ts
CHANGED
|
@@ -85,6 +85,15 @@ interface DOAlarmSourceOptions {
|
|
|
85
85
|
* Returned from `sourceFromDOAlarm.exposeTick(source)` (see below).
|
|
86
86
|
*/
|
|
87
87
|
onTickRegistered?: (tick: () => void) => void;
|
|
88
|
+
/**
|
|
89
|
+
* RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the
|
|
90
|
+
* DO runtime signals an upcoming hibernation. Clear the alarm here so
|
|
91
|
+
* the DO doesn't wake just to fire a publish into a torn-down system.
|
|
92
|
+
* The default `onEvict` (used when this option is omitted) deletes the
|
|
93
|
+
* alarm via `storage.deleteAlarm()`; supply your own to add custom
|
|
94
|
+
* pre-hibernation work (flush an audit log, signal the broker, etc.).
|
|
95
|
+
*/
|
|
96
|
+
onEvict?: () => void | Promise<void>;
|
|
88
97
|
}
|
|
89
98
|
/**
|
|
90
99
|
* Build a `SourceDef` that schedules a DO alarm every `intervalMs` and
|
|
@@ -136,6 +145,16 @@ interface WebSocketMessageSourceOptions {
|
|
|
136
145
|
* Set `null` to skip publishing on error.
|
|
137
146
|
*/
|
|
138
147
|
errorEvent?: string | null;
|
|
148
|
+
/**
|
|
149
|
+
* RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the
|
|
150
|
+
* DO runtime signals an upcoming hibernation. The default `onEvict`
|
|
151
|
+
* closes the socket with code 1001 (`"going-away"`) so the broker sees
|
|
152
|
+
* a clean disconnect and a re-attach after hibernation negotiates a
|
|
153
|
+
* fresh session. Supply your own to skip the close (e.g., when the
|
|
154
|
+
* runtime is hibernating WebSockets natively and the broker holds the
|
|
155
|
+
* connection for resumption) or to add custom pre-hibernation work.
|
|
156
|
+
*/
|
|
157
|
+
onEvict?: () => void | Promise<void>;
|
|
139
158
|
}
|
|
140
159
|
/**
|
|
141
160
|
* Build a `SourceDef` that listens on a Cloudflare WebSocket and
|
package/dist/cloudflare.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
function
|
|
1
|
+
function m(i){let{storage:e,intervalMs:o,eventName:n,payload:s=()=>({}),onTickRegistered:c,onEvict:v}=i;if(o<1)throw new Error(`[Directive] sourceFromDOAlarm: intervalMs must be >= 1, got ${o}`);let r=null;function a(){r&&(r(n,s()),e.setAlarm(Date.now()+o));}return {attach:t=>(r=t,e.setAlarm(Date.now()+o),c?.(a),()=>{r=null,e.deleteAlarm();}),onEvict:v??(async()=>{await e.deleteAlarm();}),tick:a,meta:{label:`DO alarm: ${n} every ${o}ms`,tags:["source","cloudflare","alarm"]}}}function g(i){let{socket:e,decode:o,closeEvent:n="WEBSOCKET_CLOSED",errorEvent:s="WEBSOCKET_ERROR",onEvict:c}=i;return {attach:r=>{let a=t=>{let u=o(t.data);u!==null&&r(u.name,u.payload);},l=t=>{n!==null&&r(n,{code:t.code??1e3,reason:t.reason??""});},d=()=>{s!==null&&r(s,{});};return e.addEventListener("message",a),e.addEventListener("close",l),e.addEventListener("error",d),()=>{e.removeEventListener("message",a),e.removeEventListener("close",l),e.removeEventListener("error",d);}},onEvict:c??(()=>{try{e.close(1001,"going-away");}catch{}}),meta:{label:"Cloudflare WebSocket message stream",tags:["source","cloudflare","websocket"]}}}export{m as sourceFromDOAlarm,g as sourceFromWebSocketMessage};//# sourceMappingURL=cloudflare.js.map
|
|
2
2
|
//# sourceMappingURL=cloudflare.js.map
|
package/dist/cloudflare.js.map
CHANGED
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"sources":["../src/cloudflare.ts"],"names":["sourceFromDOAlarm","options","storage","intervalMs","eventName","payload","onTickRegistered","onEvict","activePublish","tick","publish","sourceFromWebSocketMessage","socket","decode","closeEvent","errorEvent","onMessage","event","decoded","onClose","onError"],"mappings":"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","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 * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the\n * DO runtime signals an upcoming hibernation. Clear the alarm here so\n * the DO doesn't wake just to fire a publish into a torn-down system.\n * The default `onEvict` (used when this option is omitted) deletes the\n * alarm via `storage.deleteAlarm()`; supply your own to add custom\n * pre-hibernation work (flush an audit log, signal the broker, etc.).\n */\n onEvict?: () => void | Promise<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 onEvict,\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 // Default onEvict: drop the pending alarm so the DO doesn't wake\n // post-hibernation just to fire into a torn-down system. RFC 0009\n // motivates the hook; this adapter is the literal target runtime.\n const evictHandler =\n onEvict ??\n (async () => {\n await storage.deleteAlarm();\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 onEvict: evictHandler,\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 * RFC 0009 eviction hook. Called by `system.evict(deadline?)` when the\n * DO runtime signals an upcoming hibernation. The default `onEvict`\n * closes the socket with code 1001 (`\"going-away\"`) so the broker sees\n * a clean disconnect and a re-attach after hibernation negotiates a\n * fresh session. Supply your own to skip the close (e.g., when the\n * runtime is hibernating WebSockets natively and the broker holds the\n * connection for resumption) or to add custom pre-hibernation work.\n */\n onEvict?: () => void | Promise<void>;\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 onEvict,\n } = options;\n\n // Default onEvict: close the socket so the broker sees a clean\n // disconnect ahead of the DO hibernating. Consumers who let the\n // runtime hibernate the WebSocket natively can opt out by supplying\n // their own no-op `onEvict`.\n const evictHandler =\n onEvict ??\n (() => {\n try {\n socket.close(1001, \"going-away\");\n } catch {\n // Already closed; ignore.\n }\n });\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 onEvict: evictHandler,\n meta: {\n label: \"Cloudflare WebSocket message stream\",\n tags: [\"source\", \"cloudflare\", \"websocket\"],\n },\n };\n}\n"]}
|
package/dist/supabase.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
'use strict';function b(
|
|
1
|
+
'use strict';function b(o){let{client:r,channel:i,events:u,onStatus:c,redactRow:l=a=>a}=o;return {attach:a=>{let n=r.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:l(t.new),old:l(t.old)},s=e.map(p);s!==null&&a(s.name,s.payload);});return n.subscribe(e=>c?.(e)),async()=>{await r.removeChannel(n);}},meta:{label:`Supabase realtime: ${i}`,tags:["source","supabase"]}}}exports.sourceFromSupabaseChannel=b;//# sourceMappingURL=supabase.cjs.map
|
|
2
2
|
//# sourceMappingURL=supabase.cjs.map
|
package/dist/supabase.cjs.map
CHANGED
|
@@ -1 +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,
|
|
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,CAAA,CACrC,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,CAAAA,EAAY,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,CAYtC,SAAY,CACjB,MAAMZ,CAAAA,CAAO,aAAA,CAAcO,CAAI,EACjC,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 the internal channel\n // registry is cleared. `removeChannel` returns a Promise; per RFC\n // 0009 we await it so `system.stopAsync()` does not resolve\n // before the broker has dropped the subscription. Without this\n // await, a `start → stopAsync → start` cycle double-subscribes\n // (the broker still holds the old channel when the new attach\n // races in). Engines pre-RFC-0009 that call the legacy sync\n // `cleanupAll` ignore the returned promise — that's the same\n // fire-and-forget behavior as before, just with the broker drop\n // observable to consumers who DO use `stopAsync`.\n return async () => {\n await client.removeChannel(chan);\n };\n },\n meta: {\n label: `Supabase realtime: ${channelName}`,\n tags: [\"source\", \"supabase\"],\n },\n };\n}\n"]}
|
package/dist/supabase.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
function b(
|
|
1
|
+
function b(o){let{client:r,channel:i,events:u,onStatus:c,redactRow:l=a=>a}=o;return {attach:a=>{let n=r.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:l(t.new),old:l(t.old)},s=e.map(p);s!==null&&a(s.name,s.payload);});return n.subscribe(e=>c?.(e)),async()=>{await r.removeChannel(n);}},meta:{label:`Supabase realtime: ${i}`,tags:["source","supabase"]}}}export{b as sourceFromSupabaseChannel};//# sourceMappingURL=supabase.js.map
|
|
2
2
|
//# sourceMappingURL=supabase.js.map
|
package/dist/supabase.js.map
CHANGED
|
@@ -1 +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,
|
|
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,CAAA,CACrC,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,CAAAA,EAAY,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,CAYtC,SAAY,CACjB,MAAMZ,CAAAA,CAAO,aAAA,CAAcO,CAAI,EACjC,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 the internal channel\n // registry is cleared. `removeChannel` returns a Promise; per RFC\n // 0009 we await it so `system.stopAsync()` does not resolve\n // before the broker has dropped the subscription. Without this\n // await, a `start → stopAsync → start` cycle double-subscribes\n // (the broker still holds the old channel when the new attach\n // races in). Engines pre-RFC-0009 that call the legacy sync\n // `cleanupAll` ignore the returned promise — that's the same\n // fire-and-forget behavior as before, just with the broker drop\n // observable to consumers who DO use `stopAsync`.\n return async () => {\n await client.removeChannel(chan);\n };\n },\n meta: {\n label: `Supabase realtime: ${channelName}`,\n tags: [\"source\", \"supabase\"],\n },\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directive-run/sources",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"license": "(MIT OR Apache-2.0)",
|
|
6
6
|
"author": "Jason Comes",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"tsup": "^8.3.5",
|
|
75
75
|
"typescript": "^5.7.2",
|
|
76
76
|
"vitest": "^2.1.9",
|
|
77
|
-
"@directive-run/core": "1.
|
|
77
|
+
"@directive-run/core": "1.19.0"
|
|
78
78
|
},
|
|
79
79
|
"scripts": {
|
|
80
80
|
"build": "tsup",
|