@c9up/aurora 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/live.d.ts +55 -0
- package/dist/live.js +96 -0
- package/dist/liveBroadcast.d.ts +51 -0
- package/dist/liveBroadcast.js +44 -0
- package/dist/liveClient.d.ts +74 -0
- package/dist/liveClient.js +77 -0
- package/dist/liveRegistry.d.ts +43 -0
- package/dist/liveRegistry.js +77 -0
- package/dist/liveRouter.d.ts +40 -0
- package/dist/liveRouter.js +46 -0
- package/dist/liveServer.d.ts +37 -0
- package/dist/liveServer.js +43 -0
- package/dist/relay.js +38 -8
- package/dist/rpc.d.ts +55 -0
- package/dist/rpc.js +97 -0
- package/package.json +1 -1
- package/src/index.ts +46 -0
- package/src/live.ts +132 -0
- package/src/liveBroadcast.ts +85 -0
- package/src/liveClient.ts +115 -0
- package/src/liveRegistry.ts +112 -0
- package/src/liveRouter.ts +70 -0
- package/src/liveServer.ts +75 -0
- package/src/relay.ts +39 -10
- package/src/rpc.ts +145 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live client runtime (Stage 5) — the thin browser side of live components.
|
|
3
|
+
*
|
|
4
|
+
* It HYDRATES the server-rendered HTML with mirror signals (aurora's real
|
|
5
|
+
* `hydrate`), then:
|
|
6
|
+
* - applies inbound patches by SETTING the mirror signal at the patched slot
|
|
7
|
+
* → aurora's fine-grained binding updates the exact DOM node (no bespoke
|
|
8
|
+
* DOM patcher; the isomorphic renderer IS the applier);
|
|
9
|
+
* - forwards interactions declared with `data-live-click="<event>"` to the
|
|
10
|
+
* server via the injected transport.
|
|
11
|
+
*
|
|
12
|
+
* Transport-agnostic: `subscribe` (relay SSE) and `post` (HTTP up) are injected,
|
|
13
|
+
* so aurora never imports `@c9up/relay` or an HTTP client here. In an app, wire
|
|
14
|
+
* `subscribe` to `@c9up/aurora/relay`'s `relay().subscribe` and `post` to an
|
|
15
|
+
* `HttpClient`. Browser-only (uses the DOM) — part of the client barrel.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { hydrate } from "./hydrate.js";
|
|
19
|
+
import { isSignal } from "./reactive.js";
|
|
20
|
+
import type { SlotPatch } from "./live.js";
|
|
21
|
+
import type { TemplateResult } from "./types.js";
|
|
22
|
+
|
|
23
|
+
/** The transport the live client needs: a patch subscription + an event POST. */
|
|
24
|
+
export interface LiveClientTransport {
|
|
25
|
+
/** Subscribe to a channel's patches (relay SSE). Returns an unsubscribe. */
|
|
26
|
+
subscribe(channel: string, handler: (patch: SlotPatch[]) => void): () => void;
|
|
27
|
+
/** Send a client event to the server (HTTP POST up). */
|
|
28
|
+
post(id: string, event: string, payload?: unknown): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LiveClientOptions {
|
|
32
|
+
/** The element holding the server-rendered HTML to adopt. */
|
|
33
|
+
container: Element;
|
|
34
|
+
/** The client view (same template as the server; its signals are mirrors). */
|
|
35
|
+
factory: () => TemplateResult;
|
|
36
|
+
/** Ids from the server's mount response. */
|
|
37
|
+
mount: { id: string; channel: string };
|
|
38
|
+
transport: LiveClientTransport;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Start the live client for one mounted component. Returns a disposer that
|
|
43
|
+
* unsubscribes, removes the event listener, and tears down the hydration.
|
|
44
|
+
*
|
|
45
|
+
* Patches set writable signal slots; derived slots recompute locally from the
|
|
46
|
+
* base signals they read (aurora re-evaluates them) — so the server's
|
|
47
|
+
* base-signal patch is enough. (Derived-only slots with no mirrored base are an
|
|
48
|
+
* étape-6 refinement.)
|
|
49
|
+
*
|
|
50
|
+
* Authoring rule (aurora hydration): a reactive text slot must be the SOLE
|
|
51
|
+
* content of its element — write `Count: <span>${count}</span>`, not
|
|
52
|
+
* `Count: ${count}`. SSR merges adjacent static+dynamic text into one node,
|
|
53
|
+
* which hydration cannot re-split; isolating the slot keeps adopt + patch exact.
|
|
54
|
+
*/
|
|
55
|
+
export function liveClient(opts: LiveClientOptions): () => void {
|
|
56
|
+
const view = opts.factory();
|
|
57
|
+
const disposeHydrate = hydrate(opts.container, () => view);
|
|
58
|
+
|
|
59
|
+
const off = opts.transport.subscribe(opts.mount.channel, (patch) => {
|
|
60
|
+
for (const { slot, value } of patch) {
|
|
61
|
+
const sig = view.values[slot];
|
|
62
|
+
if (isSignal(sig)) (sig as (v: string) => void)(value);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const onClick = (event: Event): void => {
|
|
67
|
+
const target = event.target;
|
|
68
|
+
if (!(target instanceof Element)) return;
|
|
69
|
+
const el = target.closest("[data-live-click]");
|
|
70
|
+
if (el) opts.transport.post(opts.mount.id, el.getAttribute("data-live-click") ?? "");
|
|
71
|
+
};
|
|
72
|
+
opts.container.addEventListener("click", onClick);
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
off();
|
|
76
|
+
opts.container.removeEventListener("click", onClick);
|
|
77
|
+
disposeHydrate();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The relay client slice the transport needs (`@c9up/aurora/relay` satisfies it). */
|
|
82
|
+
export interface RelaySubscribeClient {
|
|
83
|
+
subscribe<E>(channel: string, handler: (event: E) => void): () => void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The HTTP client slice the transport needs (aurora's `HttpClient` satisfies it). */
|
|
87
|
+
export interface LiveHttpPoster {
|
|
88
|
+
post(url: string, body: unknown): unknown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a {@link LiveClientTransport} from a relay client (SSE down) + an HTTP
|
|
93
|
+
* client (events up). `path` must match the server's `wireLiveEvents` route
|
|
94
|
+
* (default `/_live/event`). Keeps `liveClient` itself transport-agnostic.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* import { relay } from '@c9up/aurora/relay'
|
|
98
|
+
* import { HttpClient, buildLiveTransport, liveClient } from '@c9up/aurora'
|
|
99
|
+
* const transport = buildLiveTransport(relay(), new HttpClient())
|
|
100
|
+
* liveClient({ container, factory, mount, transport })
|
|
101
|
+
*/
|
|
102
|
+
export function buildLiveTransport(
|
|
103
|
+
relayClient: RelaySubscribeClient,
|
|
104
|
+
http: LiveHttpPoster,
|
|
105
|
+
options: { path?: string } = {},
|
|
106
|
+
): LiveClientTransport {
|
|
107
|
+
const path = options.path ?? "/_live/event";
|
|
108
|
+
return {
|
|
109
|
+
subscribe: (channel, handler) =>
|
|
110
|
+
relayClient.subscribe<SlotPatch[]>(channel, handler),
|
|
111
|
+
post: (id, event, payload) => {
|
|
112
|
+
void http.post(path, { id, event, payload });
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live session registry (Stage 2) — the lifecycle layer for live components.
|
|
3
|
+
*
|
|
4
|
+
* Holds component DEFINITIONS by name (like the Pages registry), mounts a fresh
|
|
5
|
+
* {@link LiveSession} per connected client, tracks ownership so every session a
|
|
6
|
+
* client opened can be torn down at once on disconnect, and exposes lookup so
|
|
7
|
+
* an inbound event reaches the right session.
|
|
8
|
+
*
|
|
9
|
+
* The transport stage drives it: connect → `mount(name, uid)`; event →
|
|
10
|
+
* `get(id)?.dispatch(...)`; disconnect → `disposeOwner(uid)`. Transport-agnostic
|
|
11
|
+
* and node-free (only `mountLiveSession` + `crypto.randomUUID`, both isomorphic).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type LiveComponentDefinition,
|
|
16
|
+
type LiveSession,
|
|
17
|
+
mountLiveSession,
|
|
18
|
+
} from "./live.js";
|
|
19
|
+
|
|
20
|
+
/** A mounted instance: its id, the owner (e.g. relay uid), and the session. */
|
|
21
|
+
export interface LiveSessionHandle {
|
|
22
|
+
id: string;
|
|
23
|
+
ownerId: string;
|
|
24
|
+
session: LiveSession;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LiveRegistry {
|
|
28
|
+
/** Register a live component definition under `name` (the "live class"). */
|
|
29
|
+
define(name: string, factory: () => LiveComponentDefinition): void;
|
|
30
|
+
/** True if `name` is registered. */
|
|
31
|
+
has(name: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Mount a fresh session of `name` owned by `ownerId`. Each call gets its own
|
|
34
|
+
* per-session signals. Throws if `name` is unknown — an unmountable component
|
|
35
|
+
* must fail loudly, never silently serve nothing.
|
|
36
|
+
*/
|
|
37
|
+
mount(name: string, ownerId: string): LiveSessionHandle;
|
|
38
|
+
/** Look up a live session by instance id. */
|
|
39
|
+
get(id: string): LiveSession | undefined;
|
|
40
|
+
/** Dispose one session instance (frees its effects). */
|
|
41
|
+
dispose(id: string): void;
|
|
42
|
+
/** Dispose EVERY session a given owner opened — call on disconnect. */
|
|
43
|
+
disposeOwner(ownerId: string): void;
|
|
44
|
+
/** Dispose all sessions (shutdown). */
|
|
45
|
+
disposeAll(): void;
|
|
46
|
+
/** Number of live sessions currently mounted (diagnostics / tests). */
|
|
47
|
+
size(): number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Create an isolated live-session registry (one per app / per relay instance). */
|
|
51
|
+
export function createLiveRegistry(): LiveRegistry {
|
|
52
|
+
const defs = new Map<string, () => LiveComponentDefinition>();
|
|
53
|
+
const sessions = new Map<string, LiveSessionHandle>();
|
|
54
|
+
const byOwner = new Map<string, Set<string>>();
|
|
55
|
+
|
|
56
|
+
const dispose = (id: string): void => {
|
|
57
|
+
const handle = sessions.get(id);
|
|
58
|
+
if (!handle) return;
|
|
59
|
+
handle.session.dispose();
|
|
60
|
+
sessions.delete(id);
|
|
61
|
+
const owned = byOwner.get(handle.ownerId);
|
|
62
|
+
if (owned) {
|
|
63
|
+
owned.delete(id);
|
|
64
|
+
if (owned.size === 0) byOwner.delete(handle.ownerId);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
define(name, factory) {
|
|
70
|
+
defs.set(name, factory);
|
|
71
|
+
},
|
|
72
|
+
has(name) {
|
|
73
|
+
return defs.has(name);
|
|
74
|
+
},
|
|
75
|
+
mount(name, ownerId) {
|
|
76
|
+
const factory = defs.get(name);
|
|
77
|
+
if (!factory) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[aurora:live] unknown live component "${name}" — register it with registry.define("${name}", …) before mounting.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const id = crypto.randomUUID();
|
|
83
|
+
const handle: LiveSessionHandle = {
|
|
84
|
+
id,
|
|
85
|
+
ownerId,
|
|
86
|
+
session: mountLiveSession(factory),
|
|
87
|
+
};
|
|
88
|
+
sessions.set(id, handle);
|
|
89
|
+
const owned = byOwner.get(ownerId) ?? new Set<string>();
|
|
90
|
+
owned.add(id);
|
|
91
|
+
byOwner.set(ownerId, owned);
|
|
92
|
+
return handle;
|
|
93
|
+
},
|
|
94
|
+
get(id) {
|
|
95
|
+
return sessions.get(id)?.session;
|
|
96
|
+
},
|
|
97
|
+
dispose,
|
|
98
|
+
disposeOwner(ownerId) {
|
|
99
|
+
const owned = byOwner.get(ownerId);
|
|
100
|
+
if (!owned) return;
|
|
101
|
+
// Copy ids first — `dispose` mutates the same set as it goes.
|
|
102
|
+
for (const id of [...owned]) dispose(id);
|
|
103
|
+
byOwner.delete(ownerId);
|
|
104
|
+
},
|
|
105
|
+
disposeAll() {
|
|
106
|
+
for (const id of [...sessions.keys()]) dispose(id);
|
|
107
|
+
},
|
|
108
|
+
size() {
|
|
109
|
+
return sessions.size;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live router (Stage 4) — the server-side orchestration that ties the session
|
|
3
|
+
* registry to the relay transport, transport-agnostically.
|
|
4
|
+
*
|
|
5
|
+
* - `mount(name, ownerId)` → mounts a session, wires its patches to a
|
|
6
|
+
* per-session channel, returns `{ id, channel, html }` for the HTTP
|
|
7
|
+
* response (the client renders `html` and subscribes to `channel`).
|
|
8
|
+
* - `event(id, event, payload)` → routes an inbound client event (a relay
|
|
9
|
+
* POST) to the session; its patches auto-broadcast on the channel.
|
|
10
|
+
* - `disconnect(ownerId)` → disposes every session that owner opened.
|
|
11
|
+
*
|
|
12
|
+
* Pure orchestration over the duck-typed registry + relay — node-free, no
|
|
13
|
+
* `@c9up/ream` / `@c9up/relay` import. The thin HTTP/relay wiring (register the
|
|
14
|
+
* POST route, hook relay's disconnect) is the provider/app's job and feeds
|
|
15
|
+
* these three methods.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { connectPatches, type RelayBroadcaster } from "./liveBroadcast.js";
|
|
19
|
+
import type { LiveRegistry } from "./liveRegistry.js";
|
|
20
|
+
|
|
21
|
+
/** What a client needs after mounting: render this `html`, subscribe to `channel`. */
|
|
22
|
+
export interface LiveMount {
|
|
23
|
+
id: string;
|
|
24
|
+
channel: string;
|
|
25
|
+
html: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface LiveRouter {
|
|
29
|
+
/** Mount a session, wire its channel, return the initial render + ids. */
|
|
30
|
+
mount(name: string, ownerId: string): LiveMount;
|
|
31
|
+
/** Route a client event to its session. Returns false if the id is unknown. */
|
|
32
|
+
event(id: string, event: string, payload?: unknown): boolean;
|
|
33
|
+
/** Tear down every session an owner opened (call on relay disconnect). */
|
|
34
|
+
disconnect(ownerId: string): void;
|
|
35
|
+
/** The relay channel a session id broadcasts on. */
|
|
36
|
+
channelFor(id: string): string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create the live router over a session registry + a relay broadcaster. One per
|
|
41
|
+
* app. The per-session channel is `live/<id>`; gate subscription with
|
|
42
|
+
* `relay.authorize("live/*", …)` if the components carry sensitive state.
|
|
43
|
+
*/
|
|
44
|
+
export function createLiveRouter(
|
|
45
|
+
registry: LiveRegistry,
|
|
46
|
+
relay: RelayBroadcaster,
|
|
47
|
+
): LiveRouter {
|
|
48
|
+
const channelFor = (id: string): string => `live/${id}`;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
mount(name, ownerId) {
|
|
52
|
+
const { id, session } = registry.mount(name, ownerId);
|
|
53
|
+
const channel = channelFor(id);
|
|
54
|
+
// Patches flow to the channel; `disconnect` → registry disposes the
|
|
55
|
+
// session, which clears its patch listener (stops broadcasting).
|
|
56
|
+
connectPatches(session, relay, channel);
|
|
57
|
+
return { id, channel, html: session.renderToString() };
|
|
58
|
+
},
|
|
59
|
+
event(id, event, payload) {
|
|
60
|
+
const session = registry.get(id);
|
|
61
|
+
if (!session) return false;
|
|
62
|
+
session.dispatch(event, payload);
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
disconnect(ownerId) {
|
|
66
|
+
registry.disposeOwner(ownerId);
|
|
67
|
+
},
|
|
68
|
+
channelFor,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live server wiring (Stage 6) — register the inbound-event HTTP route that
|
|
3
|
+
* feeds the {@link LiveRouter}. The client POSTs `{ id, event, payload }` here;
|
|
4
|
+
* the route dispatches it to the session, whose patches broadcast over relay.
|
|
5
|
+
*
|
|
6
|
+
* Agnostic: the host HTTP router + context are DUCK-TYPED (a `post(path,
|
|
7
|
+
* handler)` router; a `ctx.request.body()` / `ctx.response` context) — no
|
|
8
|
+
* `@c9up/ream` import. Mirrors how warden/blackhole middleware read the ctx.
|
|
9
|
+
* Mount (render + ids) is done by the page handler via `liveRouter.mount`;
|
|
10
|
+
* disconnect is wired by the app: relay's disconnect → `liveRouter.disconnect`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LiveRouter } from "./liveRouter.js";
|
|
14
|
+
|
|
15
|
+
/** The slice of the host HTTP router this needs. */
|
|
16
|
+
export interface LiveHttpRouter {
|
|
17
|
+
post(path: string, handler: (ctx: LiveHttpContext) => unknown): unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The slice of the host HTTP context this needs (Ream's HttpContext satisfies it). */
|
|
21
|
+
export interface LiveHttpContext {
|
|
22
|
+
request: { body(): unknown };
|
|
23
|
+
response: {
|
|
24
|
+
status(code: number): unknown;
|
|
25
|
+
json(data: unknown): void;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WireLiveEventsOptions {
|
|
30
|
+
/** Route path for inbound events (must match the client transport). */
|
|
31
|
+
path?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface LiveEventBody {
|
|
35
|
+
id: string;
|
|
36
|
+
event: string;
|
|
37
|
+
payload?: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Structural guard for the POST body — no casts (`in`-narrowing + typeof). */
|
|
41
|
+
function isLiveEventBody(value: unknown): value is LiveEventBody {
|
|
42
|
+
if (typeof value !== "object" || value === null) return false;
|
|
43
|
+
if (!("id" in value) || !("event" in value)) return false;
|
|
44
|
+
return typeof value.id === "string" && typeof value.event === "string";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Default inbound-event route — keep the client transport's `path` in sync. */
|
|
48
|
+
export const DEFAULT_LIVE_EVENT_PATH = "/_live/event";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register the inbound live-event route on the host router. Call once at boot
|
|
52
|
+
* (e.g. from a provider that resolved the router + relay from the container).
|
|
53
|
+
*/
|
|
54
|
+
export function wireLiveEvents(
|
|
55
|
+
router: LiveHttpRouter,
|
|
56
|
+
live: LiveRouter,
|
|
57
|
+
options: WireLiveEventsOptions = {},
|
|
58
|
+
): void {
|
|
59
|
+
const path = options.path ?? DEFAULT_LIVE_EVENT_PATH;
|
|
60
|
+
router.post(path, (ctx) => {
|
|
61
|
+
const body = ctx.request.body();
|
|
62
|
+
if (!isLiveEventBody(body)) {
|
|
63
|
+
ctx.response.status(400);
|
|
64
|
+
ctx.response.json({ error: "live event requires { id, event }" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const handled = live.event(body.id, body.event, body.payload);
|
|
68
|
+
if (!handled) {
|
|
69
|
+
ctx.response.status(404);
|
|
70
|
+
ctx.response.json({ error: "unknown live session" });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
ctx.response.json({ ok: true });
|
|
74
|
+
});
|
|
75
|
+
}
|
package/src/relay.ts
CHANGED
|
@@ -27,12 +27,15 @@ interface RelayState {
|
|
|
27
27
|
sse: EventSource | null;
|
|
28
28
|
uid: string | null;
|
|
29
29
|
channels: Map<string, Set<(event: unknown) => void>>;
|
|
30
|
+
/** Channels we've already wired an SSE listener for on the current sse. */
|
|
31
|
+
attached: Set<string>;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const STATE: RelayState = {
|
|
33
35
|
sse: null,
|
|
34
36
|
uid: null,
|
|
35
37
|
channels: new Map(),
|
|
38
|
+
attached: new Set(),
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
export interface RelayOptions {
|
|
@@ -85,6 +88,11 @@ const CLIENT: RelayClient = {
|
|
|
85
88
|
const adapted = handler as (event: unknown) => void;
|
|
86
89
|
handlers.add(adapted);
|
|
87
90
|
|
|
91
|
+
// Wire the SSE listener for this channel's NAMED events — the relay
|
|
92
|
+
// broadcasts `event: <channel>`, so a per-channel addEventListener (not
|
|
93
|
+
// the default `onmessage`) is what actually receives the payload.
|
|
94
|
+
if (STATE.sse) attachChannel(STATE.sse, channel);
|
|
95
|
+
|
|
88
96
|
// Subscribe over POST as soon as we have a uid. Before the first uid (or
|
|
89
97
|
// during an auto-reconnect) the channel already lives in STATE.channels
|
|
90
98
|
// and is (re-)subscribed by the `connected` handler — so the server,
|
|
@@ -110,15 +118,17 @@ const CLIENT: RelayClient = {
|
|
|
110
118
|
}
|
|
111
119
|
STATE.uid = null;
|
|
112
120
|
STATE.channels.clear();
|
|
121
|
+
STATE.attached.clear();
|
|
113
122
|
},
|
|
114
123
|
};
|
|
115
124
|
|
|
116
125
|
function open(): void {
|
|
117
126
|
const sse = new EventSource(CONFIG.sseUrl);
|
|
118
127
|
STATE.sse = sse;
|
|
128
|
+
STATE.attached = new Set();
|
|
119
129
|
|
|
120
130
|
sse.addEventListener("connected", (ev) => {
|
|
121
|
-
const data = safeJson<{ uid?: string }>((ev
|
|
131
|
+
const data = safeJson<{ uid?: string }>(messageData(ev));
|
|
122
132
|
if (data && typeof data.uid === "string") {
|
|
123
133
|
STATE.uid = data.uid;
|
|
124
134
|
// Re-apply EVERY active subscription on each (re)connect. The server
|
|
@@ -137,21 +147,40 @@ function open(): void {
|
|
|
137
147
|
}
|
|
138
148
|
});
|
|
139
149
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
// Re-attach channel listeners — a close()+reopen builds a fresh EventSource
|
|
151
|
+
// that has lost the listeners wired by earlier subscribe() calls.
|
|
152
|
+
for (const channel of STATE.channels.keys()) attachChannel(sse, channel);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Wire one SSE listener for a channel's named broadcast events. The relay sends
|
|
157
|
+
* `event: <channel>\ndata: <JSON payload>`, so each channel is its own named
|
|
158
|
+
* event — `onmessage` (default/unnamed only) never sees them. The handler
|
|
159
|
+
* receives the broadcast payload verbatim (the value passed to
|
|
160
|
+
* `relay.broadcast(channel, payload)`).
|
|
161
|
+
*/
|
|
162
|
+
function attachChannel(sse: EventSource, channel: string): void {
|
|
163
|
+
if (STATE.attached.has(channel)) return;
|
|
164
|
+
STATE.attached.add(channel);
|
|
165
|
+
sse.addEventListener(channel, (ev) => {
|
|
166
|
+
const payload = safeJson<unknown>(messageData(ev));
|
|
167
|
+
if (payload === null) return;
|
|
168
|
+
const handlers = STATE.channels.get(channel);
|
|
146
169
|
if (!handlers) return;
|
|
147
170
|
for (const handler of handlers) {
|
|
148
171
|
try {
|
|
149
|
-
handler(
|
|
172
|
+
handler(payload);
|
|
150
173
|
} catch (err) {
|
|
151
|
-
console.warn(`[aurora/relay] listener for ${
|
|
174
|
+
console.warn(`[aurora/relay] listener for ${channel} threw:`, err);
|
|
152
175
|
}
|
|
153
176
|
}
|
|
154
|
-
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read an SSE event's string `data` without an unsafe DOM cast. */
|
|
181
|
+
function messageData(ev: Event): string | null {
|
|
182
|
+
if ("data" in ev && typeof ev.data === "string") return ev.data;
|
|
183
|
+
return null;
|
|
155
184
|
}
|
|
156
185
|
|
|
157
186
|
async function postSubscribe(channel: string): Promise<void> {
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser JSON-RPC 2.0 client for Ream's RPC endpoint. `@c9up/ream`'s
|
|
3
|
+
* RpcProvider mounts `POST /rpc` and speaks JSON-RPC 2.0 (single + batch); this
|
|
4
|
+
* client builds on aurora's {@link HttpClient}, inheriting its base URL, auth
|
|
5
|
+
* headers, and timeouts.
|
|
6
|
+
*
|
|
7
|
+
* const rpc = createRpcClient() // POST /rpc, same-origin
|
|
8
|
+
* const result = await rpc.call('task.validate', { id }) // typed via call<T>()
|
|
9
|
+
* const user = await rpc.call('user.find', { id }, isUser) // validated, cast-free
|
|
10
|
+
*
|
|
11
|
+
* Pairs with aurora's `command()` for reactive calls:
|
|
12
|
+
* const validate = command((p) => rpc.call('task.validate', p))
|
|
13
|
+
*/
|
|
14
|
+
import { HttpClient } from "./http.js";
|
|
15
|
+
|
|
16
|
+
export interface RpcClientOptions {
|
|
17
|
+
/** Endpoint path. Default `/rpc` (matches RpcProvider's default). */
|
|
18
|
+
url?: string;
|
|
19
|
+
/** Reuse an existing HttpClient — its baseURL / headers / auth carry over. */
|
|
20
|
+
http?: HttpClient;
|
|
21
|
+
/** Default headers — only used when no `http` client is supplied. */
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A JSON-RPC 2.0 error returned by the server (code + message + optional data). */
|
|
26
|
+
export class RpcError extends Error {
|
|
27
|
+
readonly code: number;
|
|
28
|
+
readonly data?: unknown;
|
|
29
|
+
constructor(code: number, message: string, data?: unknown) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "RpcError";
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.data = data;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Type guard for {@link RpcError}. */
|
|
38
|
+
export function isRpcError(value: unknown): value is RpcError {
|
|
39
|
+
return value instanceof RpcError;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One call in a batch. `parse` optionally validates that call's result (cast-free). */
|
|
43
|
+
export interface RpcCall<T = unknown> {
|
|
44
|
+
method: string;
|
|
45
|
+
params?: unknown;
|
|
46
|
+
parse?: (data: unknown) => T;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A settled batch entry — the result, or the JSON-RPC error for that call. */
|
|
50
|
+
export type RpcResult<T = unknown> =
|
|
51
|
+
| { ok: true; value: T }
|
|
52
|
+
| { ok: false; error: RpcError };
|
|
53
|
+
|
|
54
|
+
export interface RpcClient {
|
|
55
|
+
/**
|
|
56
|
+
* Call one method. Returns the result, or throws {@link RpcError} on a
|
|
57
|
+
* JSON-RPC error. Pass `parse` to validate the result at runtime (and skip
|
|
58
|
+
* the unchecked `T` assertion).
|
|
59
|
+
*/
|
|
60
|
+
call<T = unknown>(
|
|
61
|
+
method: string,
|
|
62
|
+
params?: unknown,
|
|
63
|
+
parse?: (data: unknown) => T,
|
|
64
|
+
): Promise<T>;
|
|
65
|
+
/** Send a JSON-RPC batch. Returns one settled entry per call, in request order. */
|
|
66
|
+
batch(calls: RpcCall[]): Promise<RpcResult[]>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
70
|
+
return typeof value === "object" && value !== null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Turn a JSON-RPC `error` member into an {@link RpcError}. */
|
|
74
|
+
function toRpcError(error: unknown): RpcError {
|
|
75
|
+
if (
|
|
76
|
+
isObject(error) &&
|
|
77
|
+
typeof error.code === "number" &&
|
|
78
|
+
typeof error.message === "string"
|
|
79
|
+
) {
|
|
80
|
+
return new RpcError(error.code, error.message, error.data);
|
|
81
|
+
}
|
|
82
|
+
return new RpcError(-32603, "Malformed JSON-RPC error envelope", error);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createRpcClient(options: RpcClientOptions = {}): RpcClient {
|
|
86
|
+
const http = options.http ?? new HttpClient({ headers: options.headers });
|
|
87
|
+
const url = options.url ?? "/rpc";
|
|
88
|
+
let nextId = 0;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
async call<T>(
|
|
92
|
+
method: string,
|
|
93
|
+
params?: unknown,
|
|
94
|
+
parse?: (data: unknown) => T,
|
|
95
|
+
): Promise<T> {
|
|
96
|
+
const id = ++nextId;
|
|
97
|
+
const res = await http.post<unknown>(url, {
|
|
98
|
+
jsonrpc: "2.0",
|
|
99
|
+
method,
|
|
100
|
+
params,
|
|
101
|
+
id,
|
|
102
|
+
});
|
|
103
|
+
if (!isObject(res)) {
|
|
104
|
+
throw new RpcError(
|
|
105
|
+
-32603,
|
|
106
|
+
`Malformed JSON-RPC response for "${method}"`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (res.error !== undefined) throw toRpcError(res.error);
|
|
110
|
+
// Result boundary — the same unchecked `T` assertion HttpClient uses,
|
|
111
|
+
// with `parse` as the cast-free, runtime-validated escape hatch.
|
|
112
|
+
return parse ? parse(res.result) : (res.result as T);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async batch(calls: RpcCall[]): Promise<RpcResult[]> {
|
|
116
|
+
if (calls.length === 0) return [];
|
|
117
|
+
const requests = calls.map((c, index) => ({
|
|
118
|
+
jsonrpc: "2.0",
|
|
119
|
+
method: c.method,
|
|
120
|
+
params: c.params,
|
|
121
|
+
id: index, // index = request position; responses are matched back by id
|
|
122
|
+
}));
|
|
123
|
+
const res = await http.post<unknown>(url, requests);
|
|
124
|
+
if (!Array.isArray(res)) {
|
|
125
|
+
throw new RpcError(-32603, "Malformed JSON-RPC batch response");
|
|
126
|
+
}
|
|
127
|
+
const byId = new Map<unknown, Record<string, unknown>>();
|
|
128
|
+
for (const item of res) if (isObject(item)) byId.set(item.id, item);
|
|
129
|
+
return calls.map((c, index) => {
|
|
130
|
+
const envelope = byId.get(index);
|
|
131
|
+
if (!envelope) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: new RpcError(-32603, `No response for "${c.method}"`),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (envelope.error !== undefined) {
|
|
138
|
+
return { ok: false, error: toRpcError(envelope.error) };
|
|
139
|
+
}
|
|
140
|
+
const value = c.parse ? c.parse(envelope.result) : envelope.result;
|
|
141
|
+
return { ok: true, value };
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|