eraspace 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "eraspace",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "eraspace: build, deploy and manage eraspace capsules",
5
+ "type": "module",
6
+ "keywords": [
7
+ "eraspace",
8
+ "cli",
9
+ "realtime",
10
+ "preact",
11
+ "beam"
12
+ ],
13
+ "license": "MIT",
14
+ "bin": {
15
+ "eraspace": "bin.js"
16
+ },
17
+ "eraspace": {
18
+ "command": "npx eraspace"
19
+ },
20
+ "engines": {
21
+ "node": ">=24"
22
+ },
23
+ "dependencies": {
24
+ "@preact/signals": "2.11.3",
25
+ "@tailwindcss/node": "4.3.3",
26
+ "@tailwindcss/oxide": "4.3.3",
27
+ "@types/phoenix": "1.6.7",
28
+ "esbuild": "0.28.2",
29
+ "oxc-parser": "0.154.0",
30
+ "phoenix": "1.8.15",
31
+ "preact": "11.0.1",
32
+ "preact-iso": "2.12.2",
33
+ "tailwindcss": "4.3.3",
34
+ "typescript": "7.0.2"
35
+ }
36
+ }
@@ -0,0 +1,156 @@
1
+ import { computed, type ReadonlySignal } from "@preact/signals";
2
+ import { useEffect, useMemo, useRef } from "preact/hooks";
3
+ import { createStorageClient } from "./storage.ts";
4
+ import type { ClientOptions } from "./config.ts";
5
+ import { subKey, stableStringify } from "./sub-key.ts";
6
+ import type { Subscription } from "./subscription.ts";
7
+ import type { RoomConn } from "./room.ts";
8
+ import type { EraspaceClient, Room } from "./types.ts";
9
+ import { ClientImpl } from "./connection.ts";
10
+ import { Pager } from "./pagination.ts";
11
+
12
+ export { readConfig, type EraspaceConfig, type ClientOptions } from "./config.ts";
13
+ export { subKey } from "./sub-key.ts";
14
+ export type { EraspaceClient, Room, PresenceEntry, MutationOptions, OptimisticStore, PaginatedQueryName, PaginatedQueryResult } from "./types.ts";
15
+
16
+ type Fn = (...args: any[]) => any;
17
+
18
+ /**
19
+ * Create the typed client. Import the server definition with `import type app from "../server/index"`, then
20
+ * `const client = createClient<typeof app>()`. Options default to `window.__ERASPACE__`.
21
+ */
22
+ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C> {
23
+ let impl: ClientImpl | undefined;
24
+ // Lazy so a module-level createClient() costs nothing until first use.
25
+ const get = () => (impl ??= new ClientImpl(options));
26
+
27
+ function useSubscription(name: string, args: unknown[]): Subscription {
28
+ const c = get();
29
+ const sub = c.acquire(name, args);
30
+ useEffect(() => {
31
+ c.retain(sub);
32
+ return () => c.release(sub);
33
+ }, [sub]);
34
+ return sub;
35
+ }
36
+
37
+ function useQuerySignal(name: string, ...args: unknown[]): ReadonlySignal<unknown> {
38
+ const sub = useSubscription(name, args);
39
+ return useMemo(() => computed(() => {
40
+ const err = sub.error.value;
41
+ const value: unknown = sub.value.value;
42
+ if (err && value === undefined) throw err;
43
+ return value;
44
+ }), [sub]);
45
+ }
46
+
47
+ function useQuerySelector(name: string, args: unknown[], select: (value: unknown) => unknown): unknown {
48
+ const result = useQuerySignal(name, ...args);
49
+ const selected = useMemo(() => computed(() => select(result.value)), [result, select]);
50
+ return selected.value;
51
+ }
52
+
53
+ function useQuery(name: string, ...args: unknown[]): unknown {
54
+ return useQuerySignal(name, ...args).value;
55
+ }
56
+
57
+ function useMutation(name: string, opts?: { optimistic?: Fn }) {
58
+ const ref = useRef(opts);
59
+ ref.current = opts;
60
+ return useMemo(() => (...args: unknown[]) => get().mutate(name, args, ref.current?.optimistic), [name]);
61
+ }
62
+
63
+ function useAction(name: string) {
64
+ return useMemo(() => (...args: unknown[]) => get().call(name, args), [name]);
65
+ }
66
+
67
+ function usePaginatedQuery(name: string, args: unknown[], opts?: { initialNumItems?: number }) {
68
+ const c = get();
69
+ const n = opts?.initialNumItems ?? 20;
70
+ const key = subKey(name, args) + ":" + n;
71
+ const pager = useMemo(() => new Pager(c, name, args, n), [key]);
72
+ useEffect(() => {
73
+ pager.start();
74
+ return () => pager.stop();
75
+ }, [pager]);
76
+ const { results, status } = pager.result.value;
77
+ return {
78
+ results,
79
+ status,
80
+ isLoading: status === "LoadingFirstPage" || status === "LoadingMore",
81
+ loadMore: (k?: number) => pager.loadMore(k),
82
+ reset: () => pager.reset(),
83
+ };
84
+ }
85
+
86
+ function useRoomConn(room: string, meta: Record<string, unknown> | undefined): RoomConn {
87
+ const c = get();
88
+ const r = useMemo(() => c.acquireRoom(room), [room]);
89
+ useEffect(() => () => c.releaseRoom(r), [r]);
90
+ const metaKey = meta === undefined ? undefined : stableStringify(meta);
91
+ useEffect(() => {
92
+ if (meta !== undefined) r.setMeta(meta);
93
+ }, [r, metaKey]);
94
+ return r;
95
+ }
96
+
97
+ function usePresence(room: string, meta?: Record<string, unknown>) {
98
+ return useRoomConn(room, meta).members.value;
99
+ }
100
+
101
+ function useRoom(room: string, meta?: Record<string, unknown>): Room<any, any> {
102
+ const r = useRoomConn(room, meta);
103
+ return {
104
+ presence: r.members.value,
105
+ self: r.self.value,
106
+ state: r.state.value,
107
+ broadcast: (event: string, payload?: unknown) => {
108
+ r.channel.push("broadcast", { event, payload: payload ?? null });
109
+ },
110
+ on: (event: string, cb: (payload: any, from: string | undefined) => void) => {
111
+ let set = r.listeners.get(event);
112
+ if (!set) r.listeners.set(event, (set = new Set()));
113
+ set.add(cb);
114
+ return () => set!.delete(cb);
115
+ },
116
+ setState: (key: string, value: unknown) => {
117
+ r.state.value = { ...r.state.peek(), [key]: value };
118
+ r.channel.push("state:set", { key, value: value ?? null });
119
+ },
120
+ };
121
+ }
122
+
123
+ const client = {
124
+ storage: createStorageClient(() => get().base),
125
+ useQuery,
126
+ useQuerySignal,
127
+ useQuerySelector,
128
+ useMutation,
129
+ useAction,
130
+ usePaginatedQuery,
131
+ usePresence,
132
+ useRoom,
133
+ query: (name: string, ...args: unknown[]) => get().call(name, args),
134
+ mutation: (name: string, ...args: unknown[]) => get().call(name, args),
135
+ action: (name: string, ...args: unknown[]) => get().call(name, args),
136
+ subscribe: (name: string, args: unknown[], onValue: Fn, onError?: Fn) => get().subscribe(name, args, onValue, onError),
137
+ get app() {
138
+ return get().app;
139
+ },
140
+ get base() {
141
+ return get().base;
142
+ },
143
+ get guestId() {
144
+ return get().self.value;
145
+ },
146
+ get status() {
147
+ return get().status;
148
+ },
149
+ get version() {
150
+ return get().version;
151
+ },
152
+ onDeployed: (cb: (v: number) => void) => get().onDeployedCb(cb),
153
+ close: () => impl?.close(),
154
+ };
155
+ return client as unknown as EraspaceClient<C>;
156
+ }
@@ -0,0 +1,37 @@
1
+ /** `window.__ERASPACE__`, injected by the platform's HTML shell (SPEC §6). */
2
+ export interface EraspaceConfig {
3
+ app: string;
4
+ base: string;
5
+ version: number;
6
+ socket: string;
7
+ }
8
+
9
+ export interface ClientOptions {
10
+ app?: string;
11
+ /** Socket endpoint, e.g. `/socket` or `ws://localhost:4000/socket`. */
12
+ socket?: string;
13
+ base?: string;
14
+ version?: number;
15
+ /** Reload the page when the app is redeployed (after re-subscribing). Default `false`. */
16
+ reloadOnDeploy?: boolean;
17
+ /** Push timeout in ms. Default 15000. */
18
+ timeout?: number;
19
+ /** Delay before an unused subscription is dropped. Default 50 ms. */
20
+ unsubscribeDelayMs?: number;
21
+ /** Delay before refreshing a subscription still waiting for a mutation commit. Default 2000 ms. */
22
+ optimisticTimeoutMs?: number;
23
+ /** WebSocket constructor (tests, non-browser runtimes). */
24
+ transport?: unknown;
25
+ /** Extra Phoenix socket options. */
26
+ socketOptions?: Record<string, unknown>;
27
+ }
28
+
29
+ declare global {
30
+ interface Window {
31
+ __ERASPACE__?: EraspaceConfig;
32
+ }
33
+ }
34
+
35
+ export function readConfig(): Partial<EraspaceConfig> {
36
+ return (typeof window !== "undefined" && window.__ERASPACE__) || {};
37
+ }
@@ -0,0 +1,322 @@
1
+ import { Socket, type Channel } from "phoenix";
2
+ import { signal, effect, batch, type Signal } from "@preact/signals";
3
+ import { applyPatch } from "./result-diff.ts";
4
+ import { EraspaceError } from "./errors.ts";
5
+ import { readConfig, type ClientOptions } from "./config.ts";
6
+ import { subKey } from "./sub-key.ts";
7
+ import { Subscription, type FullResult, type DataMessage } from "./subscription.ts";
8
+ import { RoomConn } from "./room.ts";
9
+ import type { Status } from "./types.ts";
10
+
11
+ type Fn = (...args: any[]) => any;
12
+
13
+ export class ClientImpl {
14
+ readonly app: string;
15
+ readonly base: string;
16
+ readonly self = signal("");
17
+ readonly status = signal<Status>("connecting");
18
+ readonly version: Signal<number>;
19
+ private opts: ClientOptions;
20
+ private socketUrl: string;
21
+ private socket: Socket | undefined;
22
+ private channel: Channel | undefined;
23
+ private joinedOnce = false;
24
+ private nextSub = 1;
25
+ private nextMutation = 1;
26
+ private subs = new Map<string, Subscription>();
27
+ private subsById = new Map<string, Subscription>();
28
+ private rooms = new Map<string, RoomConn>();
29
+ private deployedCbs = new Set<(v: number) => void>();
30
+
31
+ constructor(opts: ClientOptions) {
32
+ const cfg = readConfig();
33
+ this.opts = opts;
34
+ this.app = opts.app ?? cfg.app ?? "";
35
+ if (!this.app) throw new Error("eraspace: createClient() needs an app (window.__ERASPACE__ is missing)");
36
+ this.base = opts.base ?? cfg.base ?? `/${this.app}/`;
37
+ this.socketUrl = opts.socket ?? cfg.socket ?? "/socket";
38
+ this.version = signal(opts.version ?? cfg.version ?? 0);
39
+ }
40
+
41
+ // -- connection -----------------------------------------------------------------------------------------------
42
+
43
+ private ensureSocket(): Socket {
44
+ if (this.socket) return this.socket;
45
+ const socketOpts: Record<string, unknown> = {
46
+ params: () => ({ app: this.app }),
47
+ timeout: this.opts.timeout ?? 15000,
48
+ ...this.opts.socketOptions,
49
+ };
50
+ if (this.opts.transport) socketOpts.transport = this.opts.transport;
51
+ const socket = new Socket(this.socketUrl, socketOpts as any);
52
+ socket.onOpen(() => { this.status.value = "open"; });
53
+ socket.onClose(() => { this.status.value = "closed"; });
54
+ socket.onError(() => { this.status.value = "closed"; });
55
+ socket.connect();
56
+ this.socket = socket;
57
+ return socket;
58
+ }
59
+
60
+ private ensureChannel(): Channel {
61
+ if (this.channel) return this.channel;
62
+ const socket = this.ensureSocket();
63
+ const channel = socket.channel(`app:${this.app}`, { patches: true, watermarks: true });
64
+ channel.on("data", (m: DataMessage) => this.onData(m));
65
+ channel.on("error", (m: { id?: string; name?: string; message?: string }) => {
66
+ const sub = m.id !== undefined ? this.subsById.get(String(m.id)) : undefined;
67
+ if (sub) sub.error.value = EraspaceError.from(m);
68
+ });
69
+ channel.on("deployed", (m: { version?: number }) => this.onDeployed(m?.version));
70
+ channel.join().receive("ok", (resp: { self?: string } | undefined) => {
71
+ if (resp && typeof resp.self === "string") this.self.value = resp.self;
72
+ // The channel process is new after a reconnect, so every live subscription is sent again.
73
+ if (this.joinedOnce) this.resubscribeAll();
74
+ this.joinedOnce = true;
75
+ });
76
+ this.channel = channel;
77
+ return channel;
78
+ }
79
+
80
+ private push<T>(event: string, payload: object): Promise<T> {
81
+ const channel = this.ensureChannel();
82
+ return new Promise<T>((resolve, reject) => {
83
+ channel
84
+ .push(event, payload, this.opts.timeout ?? 15000)
85
+ .receive("ok", (resp: T) => resolve(resp))
86
+ .receive("error", (resp: unknown) => reject(EraspaceError.from(resp)))
87
+ .receive("timeout", () => reject(new EraspaceError("TimeoutError", `${event} timed out`)));
88
+ });
89
+ }
90
+
91
+ async call(fn: string, args: unknown[]): Promise<any> {
92
+ const resp = await this.push<{ value?: unknown }>("call", { fn, args });
93
+ return resp?.value;
94
+ }
95
+
96
+ close(): void {
97
+ for (const r of this.rooms.values()) r.channel.leave();
98
+ this.rooms.clear();
99
+ this.channel?.leave();
100
+ this.socket?.disconnect();
101
+ this.channel = undefined;
102
+ this.socket = undefined;
103
+ this.subs.clear();
104
+ this.subsById.clear();
105
+ this.status.value = "closed";
106
+ }
107
+
108
+ // -- subscriptions --------------------------------------------------------------------------------------------
109
+
110
+ private sendSub(sub: Subscription): void {
111
+ const oldId = sub.id;
112
+ const request = ++sub.request;
113
+ if (request > 1) {
114
+ this.subsById.delete(oldId);
115
+ sub.id = String(this.nextSub++);
116
+ this.subsById.set(sub.id, sub);
117
+ sub.revision = undefined;
118
+ this.push("unsub", { id: oldId }).catch(() => {});
119
+ }
120
+ this.push<FullResult>("sub", { id: sub.id, fn: sub.fn, args: sub.args,
121
+ minCommitSeq: Math.max(0, ...sub.layers.map(layer => layer.commitSeq ?? 0)),
122
+ }).then(
123
+ (resp) => {
124
+ if (this.subsById.get(sub.id) !== sub || sub.request !== request) return;
125
+ this.setServer(sub, resp.value, resp.commitSeq, resp.revision);
126
+ },
127
+ (err: EraspaceError) => {
128
+ if (this.subsById.get(sub.id) === sub && sub.request === request) sub.error.value = err;
129
+ },
130
+ );
131
+ }
132
+
133
+ // Messages for one subscription id arrive in order, so only commit watermarks are compared. Revisions
134
+ // belong to a server cache entry and may restart lower when an identity-dependent query splits.
135
+ private setServer(sub: Subscription, value: unknown, commitSeq: number, revision: number): void {
136
+ if (commitSeq < sub.commitSeq) return;
137
+ batch(() => {
138
+ sub.server = value;
139
+ sub.hasServer = true;
140
+ sub.commitSeq = commitSeq;
141
+ sub.revision = revision;
142
+ sub.layers = sub.layers.filter((l) => l.commitSeq === undefined || l.commitSeq > commitSeq);
143
+ sub.error.value = undefined;
144
+ sub.recompute();
145
+ });
146
+ }
147
+
148
+ private onData(message: DataMessage): void {
149
+ const sub = this.subsById.get(String(message.id));
150
+ if (!sub || message.commitSeq < sub.commitSeq) return;
151
+ if ("patch" in message) {
152
+ if (!sub.hasServer || sub.revision !== message.base) {
153
+ this.sendSub(sub);
154
+ return;
155
+ }
156
+ try {
157
+ this.setServer(sub, applyPatch(sub.server, message.patch), message.commitSeq, message.revision);
158
+ } catch {
159
+ this.sendSub(sub);
160
+ }
161
+ } else {
162
+ this.setServer(sub, message.value, message.commitSeq, message.revision);
163
+ }
164
+ }
165
+
166
+ private resubscribeAll(): void {
167
+ for (const sub of this.subs.values()) this.sendSub(sub);
168
+ }
169
+
170
+ private onDeployed(version: number | undefined): void {
171
+ if (typeof version === "number") this.version.value = version;
172
+ this.resubscribeAll();
173
+ for (const cb of [...this.deployedCbs]) cb(this.version.value);
174
+ if (this.opts.reloadOnDeploy && typeof location !== "undefined") location.reload();
175
+ }
176
+
177
+ onDeployedCb(cb: (v: number) => void): () => void {
178
+ this.deployedCbs.add(cb);
179
+ return () => this.deployedCbs.delete(cb);
180
+ }
181
+
182
+ /** Get or create the subscription for `(fn, args)`. A new sub with no retainers is dropped after a grace period. */
183
+ acquire(fn: string, args: unknown[]): Subscription {
184
+ const key = subKey(fn, args);
185
+ let sub = this.subs.get(key);
186
+ if (!sub) {
187
+ sub = new Subscription(String(this.nextSub++), fn, args, key);
188
+ this.subs.set(key, sub);
189
+ this.subsById.set(sub.id, sub);
190
+ this.sendSub(sub);
191
+ }
192
+ if (sub.refs === 0) this.scheduleDrop(sub, 1000);
193
+ return sub;
194
+ }
195
+
196
+ retain(sub: Subscription): void {
197
+ if (this.subs.get(sub.key) !== sub) {
198
+ // Dropped in the meantime: bring it back.
199
+ const existing = this.subs.get(sub.key);
200
+ if (existing) sub = existing;
201
+ else {
202
+ this.subs.set(sub.key, sub);
203
+ this.subsById.set(sub.id, sub);
204
+ this.sendSub(sub);
205
+ }
206
+ }
207
+ sub.refs++;
208
+ if (sub.timer) clearTimeout(sub.timer);
209
+ sub.timer = undefined;
210
+ }
211
+
212
+ release(sub: Subscription): void {
213
+ const live = this.subs.get(sub.key);
214
+ if (!live) return;
215
+ live.refs = Math.max(0, live.refs - 1);
216
+ if (live.refs === 0) this.scheduleDrop(live, this.opts.unsubscribeDelayMs ?? 50);
217
+ }
218
+
219
+ private scheduleDrop(sub: Subscription, ms: number): void {
220
+ if (sub.timer) clearTimeout(sub.timer);
221
+ sub.timer = setTimeout(() => {
222
+ sub.timer = undefined;
223
+ if (sub.refs > 0 || this.subs.get(sub.key) !== sub) return;
224
+ this.subs.delete(sub.key);
225
+ this.subsById.delete(sub.id);
226
+ this.push("unsub", { id: sub.id }).catch(() => {});
227
+ }, ms);
228
+ }
229
+
230
+ subscribe(fn: string, args: unknown[], onValue: Fn, onError?: Fn): () => void {
231
+ const sub = this.acquire(fn, args);
232
+ this.retain(sub);
233
+ const stop = effect(() => {
234
+ const err = sub.error.value;
235
+ const v = sub.value.value;
236
+ if (err) onError?.(err);
237
+ else if (sub.hasServer) onValue(v);
238
+ });
239
+ return () => {
240
+ stop();
241
+ this.release(sub);
242
+ };
243
+ }
244
+
245
+ // -- mutations ------------------------------------------------------------------------------------------------
246
+
247
+ async mutate(fn: string, args: unknown[], optimistic?: Fn): Promise<any> {
248
+ if (!optimistic) return this.call(fn, args);
249
+ const mid = this.nextMutation++;
250
+ const touched = new Set<Subscription>();
251
+ const store = {
252
+ get: (name: string, qargs: unknown[]) => this.subs.get(subKey(name, qargs))?.value.peek(),
253
+ set: (name: string, qargs: unknown[], value: unknown) => this.addLayer(mid, name, qargs, () => value, touched),
254
+ update: (name: string, qargs: unknown[], updater: Fn) =>
255
+ this.addLayer(mid, name, qargs, (prev) => (prev === undefined ? prev : updater(prev)), touched),
256
+ };
257
+ try {
258
+ batch(() => optimistic(store, ...args));
259
+ const { value, commitSeq } = await this.push<{ value: unknown; commitSeq?: number }>("call", { fn, args });
260
+ const timeout = this.opts.optimisticTimeoutMs ?? 2000;
261
+ for (const sub of touched) {
262
+ // Only mutations report a commit. Without one, no watermark can cover the layer.
263
+ if (typeof commitSeq !== "number") {
264
+ setTimeout(() => this.dropLayers(sub, mid), timeout);
265
+ continue;
266
+ }
267
+ for (const l of sub.layers) {
268
+ if (l.mid !== mid) continue;
269
+ l.commitSeq = commitSeq;
270
+ }
271
+ if (sub.commitSeq >= commitSeq) this.dropLayers(sub, mid);
272
+ else setTimeout(() => {
273
+ if (sub.layers.some((l) => l.mid === mid) && this.subs.get(sub.key) === sub) this.sendSub(sub);
274
+ }, timeout);
275
+ }
276
+ return value;
277
+ } catch (e) {
278
+ for (const sub of touched) this.dropLayers(sub, mid);
279
+ throw e;
280
+ }
281
+ }
282
+
283
+ private addLayer(mid: number, name: string, args: unknown[], apply: Fn, touched: Set<Subscription>): void {
284
+ const sub = this.subs.get(subKey(name, args));
285
+ if (!sub) return;
286
+ sub.layers.push({ mid, apply });
287
+ touched.add(sub);
288
+ sub.recompute();
289
+ }
290
+
291
+ private dropLayers(sub: Subscription, mid: number): void {
292
+ const before = sub.layers.length;
293
+ sub.layers = sub.layers.filter((l) => l.mid !== mid);
294
+ if (sub.layers.length !== before) sub.recompute();
295
+ }
296
+
297
+ // -- rooms ----------------------------------------------------------------------------------------------------
298
+
299
+ acquireRoom(room: string): RoomConn {
300
+ const topic = `room:${this.app}:${room}`;
301
+ let r = this.rooms.get(topic);
302
+ if (!r) {
303
+ r = new RoomConn(this.ensureSocket(), topic, this.self);
304
+ this.rooms.set(topic, r);
305
+ }
306
+ if (r.timer) clearTimeout(r.timer);
307
+ r.timer = undefined;
308
+ r.refs++;
309
+ return r;
310
+ }
311
+
312
+ releaseRoom(r: RoomConn): void {
313
+ r.refs = Math.max(0, r.refs - 1);
314
+ if (r.refs > 0) return;
315
+ if (r.timer) clearTimeout(r.timer);
316
+ r.timer = setTimeout(() => {
317
+ if (r.refs > 0 || this.rooms.get(r.topic) !== r) return;
318
+ this.rooms.delete(r.topic);
319
+ r.channel.leave();
320
+ }, this.opts.unsubscribeDelayMs ?? 50);
321
+ }
322
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * An error raised by a server handler (or the transport). `name` is the server error name, for example `Error`,
3
+ * `TypeError`, or `TimeoutError` for a call that got no reply in time.
4
+ */
5
+ export class EraspaceError extends Error {
6
+ readonly data?: unknown;
7
+ constructor(name: string, message: string, data?: unknown) {
8
+ super(message);
9
+ this.name = name;
10
+ if (data !== undefined) this.data = data;
11
+ }
12
+
13
+ static from(payload: unknown, fallback = "Request failed"): EraspaceError {
14
+ if (payload && typeof payload === "object") {
15
+ const p = payload as { name?: unknown; message?: unknown; data?: unknown };
16
+ return new EraspaceError(
17
+ typeof p.name === "string" ? p.name : "Error",
18
+ typeof p.message === "string" ? p.message : fallback,
19
+ p.data,
20
+ );
21
+ }
22
+ return new EraspaceError("Error", typeof payload === "string" ? payload : fallback);
23
+ }
24
+ }
25
+
26
+ export function isEraspaceError(e: unknown): e is EraspaceError {
27
+ return e instanceof EraspaceError;
28
+ }
@@ -0,0 +1,35 @@
1
+ // eraspace/client: typed hooks over Phoenix Channels, routing, and mount().
2
+ export {
3
+ createClient,
4
+ readConfig,
5
+ subKey,
6
+ type ClientOptions,
7
+ type MutationOptions,
8
+ type OptimisticStore,
9
+ type PaginatedQueryName,
10
+ type PaginatedQueryResult,
11
+ type PresenceEntry,
12
+ type Room,
13
+ type EraspaceClient,
14
+ type EraspaceConfig,
15
+ } from "./client.ts";
16
+ export { EraspaceError, isEraspaceError } from "./errors.ts";
17
+ export type { StorageClient, UploadedObject } from "./storage.ts";
18
+ export { mount, ErrorBoundary, type ErrorBoundaryProps, type MountOptions } from "./mount.ts";
19
+ export {
20
+ LocationProvider,
21
+ Router,
22
+ Routes,
23
+ Route,
24
+ Link,
25
+ useLocation,
26
+ useNavigate,
27
+ useParams,
28
+ useRoute,
29
+ stripBase,
30
+ withBase,
31
+ type LocationValue,
32
+ type RouteProps,
33
+ } from "./router.ts";
34
+ export { signal, computed, effect, batch, useSignal, useComputed, useSignalEffect, type Signal, type ReadonlySignal } from "@preact/signals";
35
+ export type { Id, Doc, PaginationOptions, PaginationResult } from "../server/index.ts";
@@ -0,0 +1,45 @@
1
+ import { Component, h, render, type ComponentChildren, type ComponentType } from "preact";
2
+ import { LocationProvider } from "./router.ts";
3
+
4
+ export interface ErrorBoundaryProps {
5
+ children?: ComponentChildren;
6
+ /** Render a fallback for `error`. `retry` re-renders the subtree. */
7
+ fallback?: (error: Error, retry: () => void) => ComponentChildren;
8
+ }
9
+
10
+ /** Catches render errors (including query errors thrown by `useQuery`) in its subtree. */
11
+ export class ErrorBoundary extends Component<ErrorBoundaryProps, { error: Error | null }> {
12
+ state = { error: null as Error | null };
13
+ static getDerivedStateFromError(error: unknown) {
14
+ return { error: error instanceof Error ? error : new Error(String(error)) };
15
+ }
16
+ componentDidCatch(error: unknown) {
17
+ console.error(error);
18
+ }
19
+ retry = () => this.setState({ error: null });
20
+ render() {
21
+ const { error } = this.state;
22
+ if (!error) return this.props.children;
23
+ if (this.props.fallback) return this.props.fallback(error, this.retry);
24
+ return h(
25
+ "div",
26
+ { role: "alert", style: "font-family:system-ui,sans-serif;padding:1.5rem;max-width:40rem;margin:2rem auto" },
27
+ h("p", { style: "font-weight:600;margin:0 0 .5rem" }, "Something went wrong"),
28
+ h("pre", { style: "white-space:pre-wrap;color:#b91c1c;margin:0 0 1rem" }, `${error.name}: ${error.message}`),
29
+ h("button", { type: "button", onClick: this.retry, style: "padding:.375rem .75rem;border:1px solid #ccc;border-radius:.375rem" }, "Retry"),
30
+ );
31
+ }
32
+ }
33
+
34
+ export interface MountOptions {
35
+ /** Provide the app-relative location for `useLocation`, `<Link>` and `<Router>` (default true). */
36
+ router?: boolean;
37
+ }
38
+
39
+ /** Render `App` into `#app` (or `target`) inside a LocationProvider and an ErrorBoundary. */
40
+ export function mount(App: ComponentType<{}>, target?: Element | null, options: MountOptions = {}): void {
41
+ const el = target ?? document.getElementById("app");
42
+ if (!el) throw new Error("eraspace: mount() could not find #app");
43
+ const app = h(ErrorBoundary, null, h(App, null));
44
+ render(options.router === false ? app : h(LocationProvider, null, app), el);
45
+ }