eraspace 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,34 @@
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.2.0",
4
+ "description": "eraspace: build, deploy and manage eraspace capsules",
5
+ "type": "module",
6
+ "keywords": [
7
+ "eraspace",
8
+ "cli",
9
+ "realtime",
10
+ "preact",
11
+ "v8"
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
+ "esbuild": "0.28.2",
28
+ "oxc-parser": "0.154.0",
29
+ "preact": "11.0.1",
30
+ "preact-iso": "2.12.2",
31
+ "tailwindcss": "4.3.3",
32
+ "typescript": "7.0.2"
33
+ }
34
+ }
@@ -0,0 +1,171 @@
1
+ import { computed, type ReadonlySignal } from "@preact/signals";
2
+ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
3
+ import { createStorageClient } from "./storage.ts";
4
+ import { useUpload, type UseUploadOptions } from "./files.ts";
5
+ import type { ClientOptions } from "./config.ts";
6
+ import { subKey, stableStringify } from "./sub-key.ts";
7
+ import type { Subscription } from "./subscription.ts";
8
+ import type { RoomConn } from "./room.ts";
9
+ import type { EraspaceClient, Room } from "./types.ts";
10
+ import { EraspaceError } from "./errors.ts";
11
+ import { ClientImpl } from "./connection.ts";
12
+ import { Pager } from "./pagination.ts";
13
+
14
+ export { readConfig, type EraspaceConfig, type ClientOptions } from "./config.ts";
15
+ export { subKey } from "./sub-key.ts";
16
+ export type { EraspaceClient, Room, PresenceEntry, MutationOptions, OptimisticStore, PaginatedQueryName, PaginatedQueryResult } from "./types.ts";
17
+
18
+ type Fn = (...args: any[]) => any;
19
+
20
+ /**
21
+ * Create the typed client. Import the server definition with `import type app from "../server/index"`, then
22
+ * `const client = createClient<typeof app>()`. Options default to `window.__ERASPACE__`.
23
+ */
24
+ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C> {
25
+ let impl: ClientImpl | undefined;
26
+ // Lazy so a module-level createClient() costs nothing until first use.
27
+ const get = () => (impl ??= new ClientImpl(options));
28
+
29
+ function useSubscription(name: string, args: unknown[]): Subscription {
30
+ const c = get();
31
+ const sub = c.acquire(name, args);
32
+ useEffect(() => {
33
+ c.retain(sub);
34
+ return () => c.release(sub);
35
+ }, [sub]);
36
+ return sub;
37
+ }
38
+
39
+ function useQuerySignal(name: string, ...args: unknown[]): ReadonlySignal<unknown> {
40
+ const sub = useSubscription(name, args);
41
+ return useMemo(() => computed(() => {
42
+ const err = sub.error.value;
43
+ const value: unknown = sub.value.value;
44
+ // Retried failures keep showing the last value; a gone app cannot recover.
45
+ if (err && (value === undefined || err.name === "AppGone")) throw err;
46
+ return value;
47
+ }), [sub]);
48
+ }
49
+
50
+ function useQuerySelector(name: string, args: unknown[], select: (value: unknown) => unknown): unknown {
51
+ const result = useQuerySignal(name, ...args);
52
+ const selected = useMemo(() => computed(() => select(result.value)), [result, select]);
53
+ return selected.value;
54
+ }
55
+
56
+ function useQuery(name: string, ...args: unknown[]): unknown {
57
+ return useQuerySignal(name, ...args).value;
58
+ }
59
+
60
+ function useMutation(name: string, opts?: { optimistic?: Fn }) {
61
+ const ref = useRef(opts);
62
+ ref.current = opts;
63
+ return useMemo(() => (...args: unknown[]) => get().mutate(name, args, ref.current?.optimistic), [name]);
64
+ }
65
+
66
+ function useAction(name: string) {
67
+ return useMemo(() => (...args: unknown[]) => get().call(name, args, "action"), [name]);
68
+ }
69
+
70
+ function usePaginatedQuery(name: string, args: unknown[], opts?: { initialNumItems?: number }) {
71
+ const c = get();
72
+ const n = opts?.initialNumItems ?? 20;
73
+ const key = subKey(name, args) + ":" + n;
74
+ const pager = useMemo(() => new Pager(c, name, args, n), [key]);
75
+ useEffect(() => {
76
+ pager.start();
77
+ return () => pager.stop();
78
+ }, [pager]);
79
+ const { results, status } = pager.result.value;
80
+ return {
81
+ results,
82
+ status,
83
+ isLoading: status === "LoadingFirstPage" || status === "LoadingMore",
84
+ loadMore: (k?: number) => pager.loadMore(k),
85
+ reset: () => pager.reset(),
86
+ };
87
+ }
88
+
89
+ function useRoomConn(room: string, meta: Record<string, unknown> | undefined): RoomConn {
90
+ const c = get();
91
+ const [generation, renew] = useState(0);
92
+ // Acquiring during render holds nothing: only a committed effect retains the room.
93
+ const r = useMemo(() => c.acquireRoom(room), [room, generation]);
94
+ useEffect(() => {
95
+ if (!c.retainRoom(r)) {
96
+ // Dropped between render and commit.
97
+ renew((n) => n + 1);
98
+ return;
99
+ }
100
+ return () => c.releaseRoom(r);
101
+ }, [r]);
102
+ const metaKey = meta === undefined ? undefined : stableStringify(meta);
103
+ useEffect(() => {
104
+ if (meta !== undefined) r.setMeta(meta);
105
+ }, [r, metaKey]);
106
+ return r;
107
+ }
108
+
109
+ function usePresence(room: string, meta?: Record<string, unknown>) {
110
+ return useRoomConn(room, meta).members.value;
111
+ }
112
+
113
+ function useRoom(room: string, meta?: Record<string, unknown>): Room<any, any> {
114
+ const r = useRoomConn(room, meta);
115
+ return {
116
+ presence: r.members.value,
117
+ self: r.self.value,
118
+ state: r.state.value,
119
+ error: r.error.value,
120
+ broadcast: (event: string, payload?: unknown) => {
121
+ void r.channel.push("broadcast", { event, payload: payload ?? null }).catch(error => { r.error.value = EraspaceError.from(error); });
122
+ },
123
+ on: (event: string, cb: (payload: any, from: string | undefined) => void) => {
124
+ let set = r.listeners.get(event);
125
+ if (!set) r.listeners.set(event, (set = new Set()));
126
+ set.add(cb);
127
+ return () => set!.delete(cb);
128
+ },
129
+ setState: (key: string, value: unknown) => {
130
+ r.setState(key, value);
131
+ },
132
+ };
133
+ }
134
+
135
+ const storage = createStorageClient(() => get().base);
136
+ const client = {
137
+ storage,
138
+ useUpload: (opts?: UseUploadOptions) => useUpload(storage, opts),
139
+ useQuery,
140
+ useQuerySignal,
141
+ useQuerySelector,
142
+ useMutation,
143
+ useAction,
144
+ usePaginatedQuery,
145
+ usePresence,
146
+ useRoom,
147
+ query: (name: string, ...args: unknown[]) => get().call(name, args),
148
+ mutation: (name: string, ...args: unknown[]) => get().mutate(name, args),
149
+ retryMutation: (name: string, args: unknown[], requestKey: string) => get().retryMutation(name, args, requestKey),
150
+ action: (name: string, ...args: unknown[]) => get().call(name, args, "action"),
151
+ subscribe: (name: string, args: unknown[], onValue: Fn, onError?: Fn) => get().subscribe(name, args, onValue, onError),
152
+ get app() {
153
+ return get().app;
154
+ },
155
+ get base() {
156
+ return get().base;
157
+ },
158
+ get guestId() {
159
+ return get().self.value;
160
+ },
161
+ get status() {
162
+ return get().status;
163
+ },
164
+ get version() {
165
+ return get().version;
166
+ },
167
+ onDeployed: (cb: (v: number) => void) => get().onDeployedCb(cb),
168
+ close: () => impl?.close(),
169
+ };
170
+ return client as unknown as EraspaceClient<C>;
171
+ }
@@ -0,0 +1,39 @@
1
+ import type { SocketOptions, WebSocketConstructor } from "./transport.ts";
2
+
3
+ /** `window.__ERASPACE__`, injected by the platform's HTML shell (SPEC §6). */
4
+ export interface EraspaceConfig {
5
+ app: string;
6
+ base: string;
7
+ version: number;
8
+ socket: string;
9
+ }
10
+
11
+ export interface ClientOptions {
12
+ app?: string;
13
+ /** Socket endpoint, e.g. `/socket` or `ws://localhost:4000/socket`. */
14
+ socket?: string;
15
+ base?: string;
16
+ version?: number;
17
+ /** Reload the page when the app is redeployed. Default `false`. */
18
+ reloadOnDeploy?: boolean;
19
+ /** Push timeout in ms. Default 15000. */
20
+ timeout?: number;
21
+ /** Delay before an unused subscription is dropped. Default 50 ms. */
22
+ unsubscribeDelayMs?: number;
23
+ /** Delay before refreshing a subscription still waiting for a mutation commit. Default 2000 ms. */
24
+ optimisticTimeoutMs?: number;
25
+ /** WebSocket constructor (tests, non-browser runtimes). */
26
+ transport?: WebSocketConstructor;
27
+ /** Reconnect and rejoin delay overrides. */
28
+ socketOptions?: Pick<SocketOptions, "reconnectAfterMs" | "rejoinAfterMs">;
29
+ }
30
+
31
+ declare global {
32
+ interface Window {
33
+ __ERASPACE__?: EraspaceConfig;
34
+ }
35
+ }
36
+
37
+ export function readConfig(): Partial<EraspaceConfig> {
38
+ return (typeof window !== "undefined" && window.__ERASPACE__) || {};
39
+ }
@@ -0,0 +1,450 @@
1
+ import { Socket, type Channel } from "./transport.ts";
2
+ import { signal, effect, batch, type Signal } from "@preact/signals";
3
+ import { applyPatch } from "./result-diff.ts";
4
+ import { EraspaceError, OutcomeUnknownError } from "./errors.ts";
5
+ import { readConfig, type ClientOptions } from "./config.ts";
6
+ import { requestKey } from "./request-key.ts";
7
+ import { subKey } from "./sub-key.ts";
8
+ import { Subscription, type FullResult, type DataMessage } from "./subscription.ts";
9
+ import { RoomConn } from "./room.ts";
10
+ import { retryDelay } from "./retry.ts";
11
+ import type { Status } from "./types.ts";
12
+
13
+ type Fn = (...args: any[]) => any;
14
+
15
+ export class ClientImpl {
16
+ readonly app: string;
17
+ readonly base: string;
18
+ readonly self = signal("");
19
+ readonly status = signal<Status>("connecting");
20
+ readonly version: Signal<number>;
21
+ private opts: ClientOptions;
22
+ private socketUrl: string;
23
+ private socket: Socket | undefined;
24
+ private channel: Channel | undefined;
25
+ private joinedOnce = false;
26
+ /** Counts app channel joins. Server subscription ids live only as long as the join that created them. */
27
+ private joins = 0;
28
+ /** Server time minus local time, from the latest join reply. */
29
+ private clockOffset = 0;
30
+ private gone: EraspaceError | undefined;
31
+ private nextSub = 1;
32
+ private nextMutation = 1;
33
+ private subs = new Map<string, Subscription>();
34
+ private subsById = new Map<string, Subscription>();
35
+ private rooms = new Map<string, RoomConn>();
36
+ private deployedCbs = new Set<(v: number) => void>();
37
+
38
+ constructor(opts: ClientOptions) {
39
+ const cfg = readConfig();
40
+ this.opts = opts;
41
+ this.app = opts.app ?? cfg.app ?? "";
42
+ if (!this.app) throw new Error("eraspace: createClient() needs an app (window.__ERASPACE__ is missing)");
43
+ this.base = opts.base ?? cfg.base ?? `/${this.app}/`;
44
+ this.socketUrl = opts.socket ?? cfg.socket ?? "/socket";
45
+ this.version = signal(opts.version ?? cfg.version ?? 0);
46
+ }
47
+
48
+ // -- connection -----------------------------------------------------------------------------------------------
49
+
50
+ private ensureSocket(): Socket {
51
+ if (this.socket) return this.socket;
52
+ const socket = new Socket(this.socketUrl, {
53
+ timeout: this.opts.timeout,
54
+ ...this.opts.socketOptions,
55
+ transport: this.opts.transport,
56
+ });
57
+ socket.onOpen(() => { this.status.value = "open"; });
58
+ socket.onClose(() => { this.status.value = "closed"; });
59
+ socket.connect();
60
+ this.socket = socket;
61
+ return socket;
62
+ }
63
+
64
+ private ensureChannel(): Channel {
65
+ if (this.channel) return this.channel;
66
+ const socket = this.ensureSocket();
67
+ const channel = socket.channel(`app:${this.app}`, {});
68
+ channel.on("data", (m: DataMessage) => this.onData(m));
69
+ channel.on("error", (m: { id?: string; name?: string; message?: string }) => {
70
+ const sub = m.id !== undefined ? this.subsById.get(String(m.id)) : undefined;
71
+ if (sub) {
72
+ sub.error.value = EraspaceError.from(m);
73
+ this.retrySub(sub);
74
+ }
75
+ });
76
+ channel.on("gone", () => this.onGone());
77
+ // Transport loss changes status; reconnect rejoins and resubscribes while cached values stay valid.
78
+ // A join the server rejects is reported to every subscription.
79
+ const failed = (payload: unknown, rejected: boolean) => {
80
+ if (this.gone) return;
81
+ if (payload instanceof EraspaceError && payload.name === "AppGone") { this.onGone(); return; }
82
+ this.status.value = "closed";
83
+ if (rejected) for (const sub of this.subs.values()) sub.error.value = EraspaceError.from(payload, "App channel join failed");
84
+ };
85
+ channel.onError((reason, rejected) => failed(reason, rejected));
86
+ channel.onClose(() => failed(undefined, false));
87
+ channel.on("deployed", (m: { version?: number }) => this.onDeployed(m?.version));
88
+ channel.onJoin((resp) => {
89
+ this.joins++;
90
+ if (this.gone) return;
91
+ this.status.value = "open";
92
+ if (resp && typeof resp.self === "string") this.self.value = resp.self;
93
+ if (resp && typeof resp.now === "number") this.clockOffset = resp.now - Date.now();
94
+ // The channel process is new after a reconnect, so every live subscription is sent again.
95
+ if (this.joinedOnce) this.resubscribeAll();
96
+ this.joinedOnce = true;
97
+ });
98
+ channel.join();
99
+ this.channel = channel;
100
+ return channel;
101
+ }
102
+
103
+ private push<T>(event: string, payload: object | (() => object | null)): Promise<T> {
104
+ if (this.gone) return Promise.reject(this.gone);
105
+ const channel = this.ensureChannel();
106
+ return channel.push<T>(event, payload, this.opts.timeout ?? 15000);
107
+ }
108
+
109
+ async call(fn: string, args: unknown[], kind: "query" | "action" = "query"): Promise<unknown> {
110
+ const key = this.requestKey();
111
+ try {
112
+ const resp = await this.push<{ value?: unknown }>("call", { fn, args, kind, key });
113
+ return resp?.value;
114
+ } catch (error) {
115
+ if (kind === "action" && error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError"].includes(error.name)) {
116
+ throw new OutcomeUnknownError(key, "The action may have performed effects; it cannot be replayed safely");
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /** Request keys carry server time, so a skewed client clock cannot place them outside the retry window. */
123
+ private requestKey(): string {
124
+ return requestKey(Date.now() + this.clockOffset);
125
+ }
126
+
127
+ private async mutationRequest(fn: string, args: unknown[], key = this.requestKey(), retry = false): Promise<{ value: unknown; commitSeq?: number }> {
128
+ let rekeyed = false;
129
+ for (let attempt = 0; ; attempt++) {
130
+ try {
131
+ return await this.push("call", { fn, args, kind: "mutation", key, retry });
132
+ } catch (error) {
133
+ // The server refused the key before running anything. The join reply has corrected the clock by now.
134
+ if (error instanceof EraspaceError && error.name === "RequestKeyError" && !retry && !rekeyed) {
135
+ rekeyed = true;
136
+ attempt--;
137
+ key = this.requestKey();
138
+ continue;
139
+ }
140
+ const uncertain = error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError", "RetryExpiredError"].includes(error.name);
141
+ // A rejected recovery attempt cannot resolve the original request's unknown outcome.
142
+ if (!uncertain && !retry) throw error;
143
+ if (uncertain && attempt === 0 && error.name !== "RetryExpiredError") {
144
+ retry = true;
145
+ continue;
146
+ }
147
+ const message = error instanceof Error ? error.message : "Recovery failed";
148
+ throw new OutcomeUnknownError(key, `${message}. Recover this mutation only with the same request key.`);
149
+ }
150
+ }
151
+ }
152
+
153
+ async retryMutation(fn: string, args: unknown[], key: string): Promise<unknown> {
154
+ return (await this.mutationRequest(fn, args, key, true)).value;
155
+ }
156
+
157
+ close(): void {
158
+ for (const sub of this.subs.values()) {
159
+ clearTimeout(sub.timer);
160
+ clearTimeout(sub.retryTimer);
161
+ sub.request++;
162
+ }
163
+ for (const r of this.rooms.values()) r.close();
164
+ this.rooms.clear();
165
+ this.channel?.leave();
166
+ this.socket?.disconnect();
167
+ this.channel = undefined;
168
+ this.socket = undefined;
169
+ this.subs.clear();
170
+ this.subsById.clear();
171
+ this.joinedOnce = false;
172
+ this.status.value = "closed";
173
+ }
174
+
175
+ private onGone(): void {
176
+ this.gone = new EraspaceError("AppGone", "This app is no longer serving");
177
+ for (const sub of this.subs.values()) {
178
+ clearTimeout(sub.retryTimer);
179
+ sub.retryTimer = undefined;
180
+ sub.request++;
181
+ sub.error.value = this.gone;
182
+ }
183
+ for (const r of this.rooms.values()) r.close();
184
+ this.channel?.leave();
185
+ this.socket?.disconnect();
186
+ this.status.value = "closed";
187
+ }
188
+
189
+ private retrySub(sub: Subscription): void {
190
+ if (this.gone || sub.retryTimer || this.subs.get(sub.key) !== sub) return;
191
+ sub.retryTimer = setTimeout(() => {
192
+ sub.retryTimer = undefined;
193
+ if (this.subs.get(sub.key) === sub) this.sendSub(sub);
194
+ }, retryDelay(++sub.retries));
195
+ }
196
+
197
+ // -- subscriptions --------------------------------------------------------------------------------------------
198
+
199
+ private sendSub(sub: Subscription): void {
200
+ if (this.gone) { sub.error.value = this.gone; return; }
201
+ clearTimeout(sub.retryTimer);
202
+ sub.retryTimer = undefined;
203
+ const oldId = sub.id;
204
+ const request = ++sub.request;
205
+ if (request > 1) {
206
+ this.subsById.delete(oldId);
207
+ sub.id = String(this.nextSub++);
208
+ this.subsById.set(sub.id, sub);
209
+ sub.revision = undefined;
210
+ this.unsubscribe(sub, oldId);
211
+ }
212
+ const id = sub.id;
213
+ this.push<FullResult>("sub", () => {
214
+ // A newer request or a drop replaced this one before it was sent: withdraw it.
215
+ if (sub.request !== request) return null;
216
+ sub.sent = { id, join: this.joins };
217
+ return { id, fn: sub.fn, args: sub.args, minCommitSeq: Math.max(0, ...sub.layers.map(layer => layer.commitSeq ?? 0)) };
218
+ }).then(
219
+ (resp) => {
220
+ if (this.subsById.get(sub.id) !== sub || sub.request !== request) return;
221
+ this.setServer(sub, resp.value, resp.commitSeq, resp.revision);
222
+ },
223
+ (err: EraspaceError) => {
224
+ if (this.subsById.get(sub.id) === sub && sub.request === request) {
225
+ sub.error.value = err;
226
+ this.retrySub(sub);
227
+ }
228
+ },
229
+ );
230
+ }
231
+
232
+ /** Tells the server to drop `id`, unless the server cannot have it: never sent, or sent before a reconnect. */
233
+ private unsubscribe(sub: Subscription, id: string): void {
234
+ if (sub.sent?.id !== id || sub.sent.join !== this.joins) return;
235
+ sub.sent = undefined;
236
+ this.push("unsub", { id }).catch(() => {});
237
+ }
238
+
239
+ // Messages for one subscription id arrive in order, so only commit watermarks are compared. Revisions
240
+ // belong to a server cache entry and may restart lower when an identity-dependent query splits.
241
+ private setServer(sub: Subscription, value: unknown, commitSeq: number, revision: number): void {
242
+ if (commitSeq < sub.commitSeq) return;
243
+ clearTimeout(sub.retryTimer);
244
+ sub.retryTimer = undefined;
245
+ sub.retries = 0;
246
+ batch(() => {
247
+ sub.server = value;
248
+ sub.hasServer = true;
249
+ sub.commitSeq = commitSeq;
250
+ sub.revision = revision;
251
+ sub.layers = sub.layers.filter((l) => l.commitSeq === undefined || l.commitSeq > commitSeq);
252
+ sub.error.value = undefined;
253
+ sub.recompute();
254
+ });
255
+ }
256
+
257
+ private onData(message: DataMessage): void {
258
+ if (this.gone) return;
259
+ const sub = this.subsById.get(String(message.id));
260
+ if (!sub || message.commitSeq < sub.commitSeq) return;
261
+ if ("patch" in message) {
262
+ if (!sub.hasServer || sub.revision !== message.base) {
263
+ this.sendSub(sub);
264
+ return;
265
+ }
266
+ try {
267
+ this.setServer(sub, applyPatch(sub.server, message.patch), message.commitSeq, message.revision);
268
+ } catch {
269
+ this.sendSub(sub);
270
+ }
271
+ } else {
272
+ this.setServer(sub, message.value, message.commitSeq, message.revision);
273
+ }
274
+ }
275
+
276
+ private resubscribeAll(): void {
277
+ for (const sub of this.subs.values()) this.sendSub(sub);
278
+ }
279
+
280
+ // Subscriptions survive a deploy: the server re-runs them with the new code and pushes changed values.
281
+ private onDeployed(version: number | undefined): void {
282
+ if (typeof version === "number") this.version.value = version;
283
+ for (const cb of [...this.deployedCbs]) cb(this.version.value);
284
+ if (this.opts.reloadOnDeploy && typeof location !== "undefined") location.reload();
285
+ }
286
+
287
+ onDeployedCb(cb: (v: number) => void): () => void {
288
+ this.deployedCbs.add(cb);
289
+ return () => this.deployedCbs.delete(cb);
290
+ }
291
+
292
+ /** Get or create the subscription for `(fn, args)`. A new sub with no retainers is dropped after a grace period. */
293
+ acquire(fn: string, args: unknown[]): Subscription {
294
+ const key = subKey(fn, args);
295
+ let sub = this.subs.get(key);
296
+ if (!sub) {
297
+ sub = new Subscription(String(this.nextSub++), fn, args, key);
298
+ this.subs.set(key, sub);
299
+ this.subsById.set(sub.id, sub);
300
+ this.sendSub(sub);
301
+ }
302
+ if (sub.refs === 0) this.scheduleDrop(sub, 1000);
303
+ return sub;
304
+ }
305
+
306
+ retain(sub: Subscription): void {
307
+ if (this.subs.get(sub.key) !== sub) {
308
+ // Dropped in the meantime: bring it back.
309
+ const existing = this.subs.get(sub.key);
310
+ if (existing) sub = existing;
311
+ else {
312
+ this.subs.set(sub.key, sub);
313
+ this.subsById.set(sub.id, sub);
314
+ this.sendSub(sub);
315
+ }
316
+ }
317
+ sub.refs++;
318
+ if (sub.timer) clearTimeout(sub.timer);
319
+ sub.timer = undefined;
320
+ }
321
+
322
+ release(sub: Subscription): void {
323
+ const live = this.subs.get(sub.key);
324
+ if (!live) return;
325
+ live.refs = Math.max(0, live.refs - 1);
326
+ if (live.refs === 0) this.scheduleDrop(live, this.opts.unsubscribeDelayMs ?? 50);
327
+ }
328
+
329
+ private scheduleDrop(sub: Subscription, ms: number): void {
330
+ if (sub.timer) clearTimeout(sub.timer);
331
+ sub.timer = setTimeout(() => {
332
+ sub.timer = undefined;
333
+ if (sub.refs > 0 || this.subs.get(sub.key) !== sub) return;
334
+ clearTimeout(sub.retryTimer);
335
+ sub.retryTimer = undefined;
336
+ sub.request++;
337
+ this.subs.delete(sub.key);
338
+ this.subsById.delete(sub.id);
339
+ this.unsubscribe(sub, sub.id);
340
+ }, ms);
341
+ }
342
+
343
+ subscribe(fn: string, args: unknown[], onValue: Fn, onError?: Fn): () => void {
344
+ const sub = this.acquire(fn, args);
345
+ this.retain(sub);
346
+ const stop = effect(() => {
347
+ const err = sub.error.value;
348
+ const v = sub.value.value;
349
+ if (err) onError?.(err);
350
+ else if (sub.hasServer) onValue(v);
351
+ });
352
+ return () => {
353
+ stop();
354
+ this.release(sub);
355
+ };
356
+ }
357
+
358
+ // -- mutations ------------------------------------------------------------------------------------------------
359
+
360
+ async mutate(fn: string, args: unknown[], optimistic?: Fn): Promise<any> {
361
+ if (!optimistic) return (await this.mutationRequest(fn, args)).value;
362
+ const mid = this.nextMutation++;
363
+ const touched = new Set<Subscription>();
364
+ const store = {
365
+ get: (name: string, qargs: unknown[]) => this.subs.get(subKey(name, qargs))?.value.peek(),
366
+ set: (name: string, qargs: unknown[], value: unknown) => this.addLayer(mid, name, qargs, () => value, touched),
367
+ update: (name: string, qargs: unknown[], updater: Fn) =>
368
+ this.addLayer(mid, name, qargs, (prev) => (prev === undefined ? prev : updater(prev)), touched),
369
+ };
370
+ try {
371
+ batch(() => optimistic(store, ...args));
372
+ const { value, commitSeq } = await this.mutationRequest(fn, args);
373
+ const timeout = this.opts.optimisticTimeoutMs ?? 2000;
374
+ for (const sub of touched) {
375
+ // Only mutations report a commit. Without one, no watermark can cover the layer.
376
+ if (typeof commitSeq !== "number") {
377
+ setTimeout(() => this.dropLayers(sub, mid), timeout);
378
+ continue;
379
+ }
380
+ for (const l of sub.layers) {
381
+ if (l.mid !== mid) continue;
382
+ l.commitSeq = commitSeq;
383
+ }
384
+ if (sub.commitSeq >= commitSeq) this.dropLayers(sub, mid);
385
+ else setTimeout(() => {
386
+ if (sub.layers.some((l) => l.mid === mid) && this.subs.get(sub.key) === sub) this.sendSub(sub);
387
+ }, timeout);
388
+ }
389
+ return value;
390
+ } catch (e) {
391
+ for (const sub of touched) this.dropLayers(sub, mid);
392
+ throw e;
393
+ }
394
+ }
395
+
396
+ private addLayer(mid: number, name: string, args: unknown[], apply: Fn, touched: Set<Subscription>): void {
397
+ const sub = this.subs.get(subKey(name, args));
398
+ if (!sub) return;
399
+ sub.layers.push({ mid, apply });
400
+ touched.add(sub);
401
+ sub.recompute();
402
+ }
403
+
404
+ private dropLayers(sub: Subscription, mid: number): void {
405
+ const before = sub.layers.length;
406
+ sub.layers = sub.layers.filter((l) => l.mid !== mid);
407
+ if (sub.layers.length !== before) sub.recompute();
408
+ }
409
+
410
+ // -- rooms ----------------------------------------------------------------------------------------------------
411
+
412
+ /**
413
+ * Get or create the room for `room`. Like a subscription, it is only held once retained: a render that never
414
+ * commits leaves an unretained room, which is dropped after a grace period.
415
+ */
416
+ acquireRoom(room: string): RoomConn {
417
+ const topic = `room:${this.app}:${room}`;
418
+ let r = this.rooms.get(topic);
419
+ if (!r) {
420
+ r = new RoomConn(this.ensureSocket(), topic, this.self);
421
+ this.rooms.set(topic, r);
422
+ }
423
+ if (r.refs === 0) this.scheduleRoomDrop(r, 1000);
424
+ return r;
425
+ }
426
+
427
+ /** False when the room was dropped after it was acquired; acquire it again. */
428
+ retainRoom(r: RoomConn): boolean {
429
+ if (this.rooms.get(r.topic) !== r) return false;
430
+ clearTimeout(r.timer);
431
+ r.timer = undefined;
432
+ r.refs++;
433
+ return true;
434
+ }
435
+
436
+ releaseRoom(r: RoomConn): void {
437
+ r.refs = Math.max(0, r.refs - 1);
438
+ if (r.refs === 0) this.scheduleRoomDrop(r, this.opts.unsubscribeDelayMs ?? 50);
439
+ }
440
+
441
+ private scheduleRoomDrop(r: RoomConn, ms: number): void {
442
+ clearTimeout(r.timer);
443
+ r.timer = setTimeout(() => {
444
+ r.timer = undefined;
445
+ if (r.refs > 0 || this.rooms.get(r.topic) !== r) return;
446
+ this.rooms.delete(r.topic);
447
+ r.close();
448
+ }, ms);
449
+ }
450
+ }