eraspace 0.1.0 → 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.
@@ -0,0 +1,364 @@
1
+ // File upload and preview for app UIs, over the storage client.
2
+ import { Fragment, h, type ComponentChildren, type TargetedDragEvent, type TargetedEvent } from "preact";
3
+ import { useEffect, useRef, useState } from "preact/hooks";
4
+ import { EraspaceError } from "./errors.ts";
5
+ import { isStorageKey, type StorageClient, type UploadedObject } from "./storage.ts";
6
+
7
+ /**
8
+ * What to save in a row to show a file later. Declare it in the schema as
9
+ * `object({ key: string(), name: string(), contentType: string(), size: number() })`.
10
+ */
11
+ export interface FileRef {
12
+ key: string;
13
+ name: string;
14
+ contentType: string;
15
+ size: number;
16
+ }
17
+
18
+ export interface UseUploadOptions {
19
+ /** Accepted types, as for `<input accept>`: `"image/*"`, `".pdf,.txt"`, `"image/png,application/pdf"`. */
20
+ accept?: string;
21
+ /** Refuse larger files before uploading. The server also enforces its own limit (5 MiB by default). */
22
+ maxBytes?: number;
23
+ /**
24
+ * Called after each file is stored, typically to save it with a mutation. If it throws, the stored file is
25
+ * deleted again and the error is reported.
26
+ */
27
+ onUpload?: (file: FileRef) => unknown;
28
+ }
29
+
30
+ export interface Upload {
31
+ /**
32
+ * Upload files one after another, after any batch already running. Resolves with the stored files; failures are
33
+ * reported in `error`. Type and size checks run only in the browser: the server stores any type up to its limit.
34
+ */
35
+ upload(files: Blob | Iterable<Blob> | ArrayLike<Blob> | null | undefined): Promise<FileRef[]>;
36
+ uploading: boolean;
37
+ /** Fraction of the current batch's bytes uploaded, from 0 to 1. */
38
+ progress: number;
39
+ /** The latest failure, cleared when the next batch starts. */
40
+ error: EraspaceError | null;
41
+ reset(): void;
42
+ }
43
+
44
+ type StorageHolder = { readonly storage: StorageClient };
45
+
46
+ export function formatBytes(bytes: number): string {
47
+ if (!Number.isFinite(bytes) || bytes < 1024) return `${Math.max(0, Math.round(bytes || 0))} B`;
48
+ const units = ["KB", "MB", "GB"];
49
+ let value = bytes / 1024;
50
+ let unit = 0;
51
+ while (value >= 1024 && unit < units.length - 1) {
52
+ value /= 1024;
53
+ unit++;
54
+ }
55
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
56
+ }
57
+
58
+ function nameOf(blob: Blob): string {
59
+ return typeof File !== "undefined" && blob instanceof File && blob.name ? blob.name : "file";
60
+ }
61
+
62
+ /** Whether `blob` matches an `<input accept>` list. */
63
+ export function acceptsFile(accept: string | undefined, blob: Blob): boolean {
64
+ const tokens = (accept ?? "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
65
+ if (!tokens.length) return true;
66
+ const type = blob.type.toLowerCase();
67
+ const name = nameOf(blob).toLowerCase();
68
+ return tokens.some((t) => t === "*/*" || (t.startsWith(".") ? name.endsWith(t) : t.endsWith("/*") ? type.startsWith(t.slice(0, -1)) : type === t));
69
+ }
70
+
71
+ function toList(files: Blob | Iterable<Blob> | ArrayLike<Blob> | null | undefined): Blob[] {
72
+ if (!files) return [];
73
+ if (files instanceof Blob) return [files];
74
+ return Array.from(files as ArrayLike<Blob>);
75
+ }
76
+
77
+ function asError(e: unknown): EraspaceError {
78
+ if (e instanceof EraspaceError) return e;
79
+ if (e instanceof Error) return new EraspaceError(e.name, e.message);
80
+ return EraspaceError.from(e, "Upload failed");
81
+ }
82
+
83
+ /**
84
+ * Upload state for a component. Calls made while a batch is uploading wait their turn. `reset()` and unmounting
85
+ * cancel what has not finished; a file whose bytes already reached the server may still be stored.
86
+ */
87
+ export function useUpload(storage: StorageClient, options: UseUploadOptions = {}): Upload {
88
+ const [state, setState] = useState({ uploading: false, progress: 0, error: null as EraspaceError | null });
89
+ const opts = useRef(options);
90
+ opts.current = options;
91
+ // Replaced by reset(), so batches queued before it never start.
92
+ const controller = useRef<AbortController | null>(null);
93
+ const queue = useRef<Promise<unknown>>(Promise.resolve());
94
+ useEffect(() => () => controller.current?.abort(), []);
95
+
96
+ async function run(files: Blob[], abort: AbortController): Promise<FileRef[]> {
97
+ if (abort.signal.aborted) return [];
98
+ // Progress is weighted by size; a batch of empty files counts each file once.
99
+ const bytes = files.reduce((n, f) => n + f.size, 0);
100
+ const total = bytes || files.length;
101
+ let done = 0;
102
+ let error: EraspaceError | null = null;
103
+ const stored: FileRef[] = [];
104
+ setState({ uploading: true, progress: 0, error: null });
105
+ for (const file of files) {
106
+ const weight = bytes ? file.size : 1;
107
+ try {
108
+ const object: UploadedObject = await storage.upload(file, {
109
+ signal: abort.signal,
110
+ onProgress: (fraction) => {
111
+ if (!abort.signal.aborted) setState((s) => ({ ...s, progress: Math.min(1, (done + fraction * weight) / total) }));
112
+ },
113
+ });
114
+ const ref: FileRef = { key: object.key, name: object.name ?? nameOf(file), contentType: object.contentType, size: object.size };
115
+ try {
116
+ await opts.current.onUpload?.(ref);
117
+ } catch (e) {
118
+ await storage.delete(ref.key).catch(() => {});
119
+ throw e;
120
+ }
121
+ stored.push(ref);
122
+ } catch (e) {
123
+ if (abort.signal.aborted) return stored;
124
+ error = asError(e);
125
+ }
126
+ done += weight;
127
+ }
128
+ // Keep a validation error reported while this batch was running.
129
+ if (!abort.signal.aborted) setState((s) => ({ uploading: false, progress: 1, error: error ?? s.error }));
130
+ return stored;
131
+ }
132
+
133
+ function upload(input: Parameters<Upload["upload"]>[0]): Promise<FileRef[]> {
134
+ const { accept, maxBytes } = opts.current;
135
+ const files = toList(input);
136
+ if (!files.length) return Promise.resolve([]);
137
+ const rejected = files.find((f) => !acceptsFile(accept, f)) ?? files.find((f) => maxBytes !== undefined && f.size > maxBytes);
138
+ if (rejected) {
139
+ const message = acceptsFile(accept, rejected)
140
+ ? `${nameOf(rejected)} is larger than ${formatBytes(maxBytes!)}.`
141
+ : `${nameOf(rejected)} is not an accepted file type.`;
142
+ setState((s) => ({ ...s, error: new EraspaceError("StorageError", message) }));
143
+ return Promise.resolve([]);
144
+ }
145
+ const abort = (controller.current ??= new AbortController());
146
+ const batch = queue.current.then(() => run(files, abort));
147
+ queue.current = batch.catch(() => {});
148
+ return batch;
149
+ }
150
+
151
+ return {
152
+ upload,
153
+ uploading: state.uploading,
154
+ progress: state.progress,
155
+ error: state.error,
156
+ reset() {
157
+ controller.current?.abort();
158
+ controller.current = null;
159
+ setState({ uploading: false, progress: 0, error: null });
160
+ },
161
+ };
162
+ }
163
+
164
+ export interface FileUploadProps extends UseUploadOptions {
165
+ /** The app client, from `createClient()`. */
166
+ client: StorageHolder;
167
+ multiple?: boolean;
168
+ disabled?: boolean;
169
+ /** Classes for the drop zone. Without them it has a small default style. It has `data-dragging` during a drag. */
170
+ class?: string;
171
+ /** The drop zone's label. Defaults to "Drop a file here or click to upload". */
172
+ children?: ComponentChildren;
173
+ }
174
+
175
+ const VISUALLY_HIDDEN = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap";
176
+
177
+ /** A drop zone and file picker that uploads to app storage and reports each stored file through `onUpload`. */
178
+ export function FileUpload(props: FileUploadProps) {
179
+ const { client, multiple, disabled, children } = props;
180
+ const state = useUpload(client.storage, props);
181
+ const [dragging, setDragging] = useState(false);
182
+ const off = disabled || state.uploading;
183
+ const style = props.class
184
+ ? undefined
185
+ : `position:relative;display:flex;flex-direction:column;align-items:center;gap:.5rem;padding:1.25rem;border:2px dashed ${dragging ? "#64748b" : "#cbd5e1"};border-radius:.75rem;background:${dragging ? "#f1f5f9" : "transparent"};color:#475569;text-align:center;cursor:${off ? "default" : "pointer"};opacity:${disabled ? ".6" : "1"}`;
186
+ const drop = (e: TargetedDragEvent<HTMLLabelElement>) => {
187
+ e.preventDefault();
188
+ setDragging(false);
189
+ if (off) return;
190
+ const files: Blob[] = Array.from(e.dataTransfer?.files ?? []);
191
+ void state.upload(multiple ? files : files.slice(0, 1));
192
+ };
193
+ return h(
194
+ Fragment,
195
+ null,
196
+ h(
197
+ "label",
198
+ {
199
+ class: props.class,
200
+ style,
201
+ "data-dragging": dragging ? "" : undefined,
202
+ "aria-busy": state.uploading,
203
+ onDragOver: (e: TargetedDragEvent<HTMLLabelElement>) => {
204
+ e.preventDefault();
205
+ if (!off) setDragging(true);
206
+ },
207
+ onDragLeave: () => setDragging(false),
208
+ onDrop: drop,
209
+ },
210
+ h("input", {
211
+ type: "file",
212
+ accept: props.accept,
213
+ multiple,
214
+ disabled: off,
215
+ style: VISUALLY_HIDDEN,
216
+ onChange: (e: TargetedEvent<HTMLInputElement>) => {
217
+ const input = e.currentTarget;
218
+ const files: Blob[] = Array.from(input.files ?? []);
219
+ input.value = "";
220
+ void state.upload(files);
221
+ },
222
+ }),
223
+ state.uploading
224
+ ? h(
225
+ Fragment,
226
+ null,
227
+ h("span", null, `Uploading… ${Math.round(state.progress * 100)}%`),
228
+ h("progress", { value: state.progress, max: 1, style: props.class ? undefined : "width:100%;max-width:16rem" }),
229
+ )
230
+ : (children ?? h("span", null, multiple ? "Drop files here or click to upload" : "Drop a file here or click to upload")),
231
+ ),
232
+ state.error && h("p", { role: "alert", style: props.class ? undefined : "color:#b91c1c;margin:.5rem 0 0" }, state.error.message),
233
+ );
234
+ }
235
+
236
+ // Metadata lookups for previews given only a key, shared across components. Only found files stay cached, so a
237
+ // missing file or a failed request is asked again on the next attempt.
238
+ const infoCache = new Map<string, Promise<UploadedObject | null>>();
239
+
240
+ function lookup(storage: StorageClient, key: string): Promise<UploadedObject | null> {
241
+ const id = storage.url(key);
242
+ let pending = infoCache.get(id);
243
+ if (!pending) {
244
+ if (infoCache.size >= 500) infoCache.delete(infoCache.keys().next().value!);
245
+ pending = storage.info(key);
246
+ infoCache.set(id, pending);
247
+ pending.then((info) => info || infoCache.delete(id), () => infoCache.delete(id));
248
+ }
249
+ return pending;
250
+ }
251
+
252
+ export interface FilePreviewProps {
253
+ /** The app client, from `createClient()`. */
254
+ client: StorageHolder;
255
+ /** A storage key, or a saved file. Without `contentType`, the preview looks the file up first. */
256
+ file: string | { key: string; name?: string | null; contentType?: string | null; size?: number | null };
257
+ class?: string;
258
+ /** Image alt text. Defaults to the file name. */
259
+ alt?: string;
260
+ /** Show the name, size and a download link under images, video and audio (default true). */
261
+ caption?: boolean;
262
+ }
263
+
264
+ type Kind = "image" | "video" | "audio" | "text" | "file";
265
+
266
+ function kindOf(contentType: string): Kind {
267
+ const type = contentType.split(";")[0]!.trim().toLowerCase();
268
+ if (type.startsWith("image/")) return "image";
269
+ if (type.startsWith("video/")) return "video";
270
+ if (type.startsWith("audio/")) return "audio";
271
+ if (type === "text/plain") return "text";
272
+ return "file";
273
+ }
274
+
275
+ const MEDIA = "display:block;max-width:100%;border-radius:.5rem";
276
+
277
+ /**
278
+ * Shows a stored file by type: images, video and audio inline, plain text in a sandboxed frame, and anything else
279
+ * as a download card. The URL always comes from the key, never from saved data.
280
+ */
281
+ export function FilePreview(props: FilePreviewProps) {
282
+ const { client, file } = props;
283
+ const given = typeof file === "string" ? { key: file } : (file ?? { key: "" });
284
+ const key = isStorageKey(given.key) ? given.key : null;
285
+ const known = typeof given.contentType === "string" && given.contentType ? given : null;
286
+ // `failed`: the lookup request failed (offline, rate limited); null means the server has no such file.
287
+ const [fetched, setFetched] = useState<{ key: string; info: UploadedObject | null | "failed" } | undefined>(undefined);
288
+ const [broken, setBroken] = useState<string | null>(null);
289
+ const [attempt, setAttempt] = useState(0);
290
+ useEffect(() => {
291
+ if (!key || known) return;
292
+ let live = true;
293
+ lookup(client.storage, key).then(
294
+ (info) => live && setFetched({ key, info }),
295
+ () => live && setFetched({ key, info: "failed" }),
296
+ );
297
+ return () => {
298
+ live = false;
299
+ };
300
+ }, [client.storage, key, !!known, attempt]);
301
+
302
+ const plain = !props.class;
303
+ const box = (body: ComponentChildren, extra?: Record<string, unknown>) =>
304
+ h("figure", { class: props.class, style: plain ? "margin:0;max-width:100%" : undefined, ...extra }, body);
305
+ const card = (label: ComponentChildren, href?: string, name?: string) =>
306
+ box(
307
+ h(
308
+ href ? "a" : "div",
309
+ {
310
+ href,
311
+ download: href ? name : undefined,
312
+ style: "display:inline-flex;align-items:center;gap:.5rem;padding:.625rem .875rem;border:1px solid #e2e8f0;border-radius:.5rem;color:inherit;text-decoration:none;max-width:100%",
313
+ },
314
+ label,
315
+ ),
316
+ );
317
+
318
+ if (!key) return card(h("span", { style: "color:#64748b" }, "File unavailable"));
319
+ const meta = known ?? (fetched?.key === key ? fetched.info : undefined);
320
+ if (meta === undefined && broken !== key) return box(h("div", { style: "height:3rem;border-radius:.5rem;background:#f1f5f9" }), { "aria-busy": true });
321
+ if (meta === undefined || meta === null || meta === "failed" || broken === key) {
322
+ // A media error cannot tell a missing file from a failed request, so every failure offers a retry.
323
+ const retry = () => {
324
+ setBroken(null);
325
+ setFetched(undefined);
326
+ setAttempt((n) => n + 1);
327
+ };
328
+ return card([
329
+ h("span", { style: "color:#64748b" }, meta === null ? "File unavailable" : "Could not load the file"),
330
+ h("button", { type: "button", onClick: retry, style: plain ? "font:inherit;padding:.125rem .5rem;border:1px solid #cbd5e1;border-radius:.375rem;background:transparent;color:inherit;cursor:pointer" : undefined }, "Retry"),
331
+ ]);
332
+ }
333
+
334
+ const url = client.storage.url(key);
335
+ const name = meta.name || "file";
336
+ const size = typeof meta.size === "number" ? formatBytes(meta.size) : "";
337
+ const fail = () => setBroken(key);
338
+ const caption = props.caption === false
339
+ ? null
340
+ : h(
341
+ "figcaption",
342
+ { style: plain ? "display:flex;gap:.5rem;align-items:baseline;margin-top:.375rem;font-size:.875rem;color:#64748b" : undefined },
343
+ h("a", { href: url, download: name, style: plain ? "color:inherit;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" : undefined }, name),
344
+ size && h("span", null, size),
345
+ );
346
+ switch (kindOf(meta.contentType!)) {
347
+ case "image":
348
+ return box([
349
+ h("a", { href: url, target: "_blank", rel: "noopener" }, h("img", { src: url, alt: props.alt ?? name, loading: "lazy", onError: fail, style: plain ? `${MEDIA};max-height:20rem` : undefined })),
350
+ caption,
351
+ ]);
352
+ case "video":
353
+ return box([h("video", { src: url, controls: true, preload: "metadata", onError: fail, style: plain ? `${MEDIA};max-height:20rem` : undefined }), caption]);
354
+ case "audio":
355
+ return box([h("audio", { src: url, controls: true, preload: "metadata", onError: fail, style: plain ? "display:block;width:100%" : undefined }), caption]);
356
+ case "text":
357
+ return box([
358
+ h("iframe", { src: url, title: name, sandbox: "", loading: "lazy", style: plain ? "display:block;width:100%;height:12rem;border:1px solid #e2e8f0;border-radius:.5rem;background:#fff" : undefined }),
359
+ caption,
360
+ ]);
361
+ default:
362
+ return card([h("span", { "aria-hidden": true }, "📄"), h("span", { style: "overflow:hidden;text-overflow:ellipsis;white-space:nowrap" }, name), size && h("span", { style: "color:#64748b;font-size:.875rem" }, size)], url, name);
363
+ }
364
+ }
@@ -1,4 +1,4 @@
1
- // eraspace/client: typed hooks over Phoenix Channels, routing, and mount().
1
+ // eraspace/client: typed hooks over native WebSockets, routing, and mount().
2
2
  export {
3
3
  createClient,
4
4
  readConfig,
@@ -13,8 +13,9 @@ export {
13
13
  type EraspaceClient,
14
14
  type EraspaceConfig,
15
15
  } from "./client.ts";
16
- export { EraspaceError, isEraspaceError } from "./errors.ts";
17
- export type { StorageClient, UploadedObject } from "./storage.ts";
16
+ export { EraspaceError, OutcomeUnknownError, isEraspaceError } from "./errors.ts";
17
+ export { isStorageKey, type StorageClient, type StorageUploadOptions, type UploadedObject } from "./storage.ts";
18
+ export { FileUpload, FilePreview, formatBytes, type FileRef, type FilePreviewProps, type FileUploadProps, type Upload, type UseUploadOptions } from "./files.ts";
18
19
  export { mount, ErrorBoundary, type ErrorBoundaryProps, type MountOptions } from "./mount.ts";
19
20
  export {
20
21
  LocationProvider,
@@ -22,12 +22,15 @@ export class Pager {
22
22
  this.result = computed(() => {
23
23
  const pages = this.pages.value;
24
24
  const results: any[] = [];
25
- let loading = false;
25
+ // Before start() subscribes the first page, nothing has loaded yet.
26
+ let loading = pages.length === 0;
26
27
  let done = false;
27
- for (const p of pages) {
28
+ for (const [index, p] of pages.entries()) {
28
29
  const err = p.error.value;
29
- if (err) throw err;
30
30
  const v = p.value.value as PaginationResult<any> | undefined;
31
+ // As with useQuery, a failing page keeps its last result while it retries. A first page with no result
32
+ // reports the error; a later one counts as still loading. A gone app cannot recover.
33
+ if (err && (err.name === "AppGone" || (v === undefined && index === 0))) throw err;
31
34
  if (v === undefined) {
32
35
  loading = true;
33
36
  break;
@@ -0,0 +1,13 @@
1
+ /** A random UUIDv7 whose timestamp lets the server reject retries after result retention expires. */
2
+ export function requestKey(now = Date.now()): string {
3
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
4
+ let timestamp = Math.max(0, Math.floor(now));
5
+ for (let i = 5; i >= 0; i--) {
6
+ bytes[i] = timestamp % 256;
7
+ timestamp = Math.floor(timestamp / 256);
8
+ }
9
+ bytes[6] = (bytes[6]! & 0x0f) | 0x70;
10
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
11
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
12
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
13
+ }
@@ -0,0 +1,5 @@
1
+ /** Bounded exponential backoff with equal jitter keeps reconnects spread across clients. */
2
+ export function retryDelay(attempt: number): number {
3
+ const ceiling = Math.min(30_000, 1_000 * 2 ** Math.min(Math.max(0, attempt - 1), 5));
4
+ return ceiling * (0.5 + Math.random() * 0.5);
5
+ }
@@ -1,14 +1,20 @@
1
- import { Presence, type Socket, type Channel } from "phoenix";
1
+ import type { Socket, Channel } from "./transport.ts";
2
2
  import { signal, type Signal } from "@preact/signals";
3
3
  import type { PresenceEntry } from "./types.ts";
4
+ import { EraspaceError } from "./errors.ts";
4
5
  import { stableStringify } from "./sub-key.ts";
5
6
 
6
7
  export class RoomConn {
7
8
  channel: Channel;
8
- presence: Presence;
9
- readonly members: Signal<PresenceEntry<any>[]> = signal([]);
9
+ readonly members: Signal<PresenceEntry<Record<string, unknown>>[]> = signal([]);
10
10
  readonly state: Signal<Record<string, unknown>> = signal({});
11
11
  readonly self: Signal<string>;
12
+ readonly error = signal<EraspaceError | undefined>(undefined);
13
+ private serverState: Record<string, unknown> = {};
14
+ private pending = new Map<number, { key: string; value: unknown }>();
15
+ private revisions = new Map<string, number>();
16
+ private nextChange = 0;
17
+ private closed = false;
12
18
  refs = 0;
13
19
  meta: Record<string, unknown> = {};
14
20
  listeners = new Map<string, Set<(payload: any, from: string | undefined) => void>>();
@@ -20,12 +26,8 @@ export class RoomConn {
20
26
  // The join reply carries the server-derived id; until then this is the id from the app channel, if known.
21
27
  this.self = signal(clientSelf.value);
22
28
  this.channel = socket.channel(topic, () => ({ meta: this.meta }));
23
- this.presence = new Presence(this.channel);
24
- this.presence.onSync(() => {
25
- this.members.value = this.presence.list((id: string, entry: any) => {
26
- const metas = (entry.metas ?? []).map(stripPhxRef);
27
- return { id, meta: metas[0] ?? {}, metas };
28
- });
29
+ this.channel.on<Record<string, Record<string, unknown>[]>>("presence", (members) => {
30
+ this.members.value = Object.entries(members).map(([id, metas]) => ({ id, meta: metas[0] ?? {}, metas }));
29
31
  });
30
32
  this.channel.on("msg", (m: { event: string; payload: unknown; from?: string }) => {
31
33
  const set = this.listeners.get(m.event);
@@ -34,25 +36,75 @@ export class RoomConn {
34
36
  if (all) for (const cb of [...all]) cb(m, m.from);
35
37
  });
36
38
  this.channel.on("state:full", (m: { state?: Record<string, unknown> }) => {
37
- this.state.value = { ...(m.state ?? {}) };
39
+ this.serverState = { ...(m.state ?? {}) };
40
+ this.revisions.clear();
41
+ // Writes buffered while joining still need their replies to settle or report an error.
42
+ this.recompute();
38
43
  });
39
44
  this.channel.on("state", (m: { key: string; value: unknown }) => {
40
- this.state.value = { ...this.state.value, [m.key]: m.value };
45
+ this.serverState = { ...this.serverState, [m.key]: m.value };
46
+ this.revisions.set(m.key, (this.revisions.get(m.key) ?? 0) + 1);
47
+ this.recompute();
41
48
  });
42
- this.channel.join().receive("ok", (resp: { self?: string } | undefined) => {
49
+ const failed = (payload: unknown) => {
50
+ this.pending.clear();
51
+ this.recompute();
52
+ this.error.value = EraspaceError.from(payload, "Room channel disconnected");
53
+ if (payload && typeof payload === "object" && "name" in payload && payload.name === "AppGone") this.close();
54
+ };
55
+ this.channel.on("gone", () => failed({ name: "AppGone", message: "This app is no longer serving", reason: "gone" }));
56
+ this.channel.onError(failed);
57
+ this.channel.onClose(() => { if (!this.closed) failed({ name: "UnavailableError", message: "Room channel closed" }); });
58
+ this.channel.onJoin((resp) => {
59
+ this.error.value = undefined;
43
60
  if (resp && typeof resp.self === "string") this.self.value = clientSelf.value = resp.self;
44
61
  });
62
+ this.channel.join();
45
63
  }
46
64
 
47
65
  setMeta(meta: Record<string, unknown>): void {
48
66
  if (stableStringify(meta) === stableStringify(this.meta)) return;
67
+ const previous = this.meta;
49
68
  this.meta = meta;
50
- this.channel.push("meta", { meta });
69
+ const failed = (payload: unknown) => {
70
+ if (this.meta === meta) this.meta = previous;
71
+ this.error.value = EraspaceError.from(payload, "Room metadata update failed");
72
+ };
73
+ void this.channel.push("meta", { meta }).catch(failed);
74
+ }
75
+
76
+ private recompute(): void {
77
+ const state = { ...this.serverState };
78
+ for (const { key, value } of this.pending.values()) state[key] = value;
79
+ this.state.value = state;
80
+ }
81
+
82
+ setState(key: string, value: unknown): void {
83
+ if (this.closed) return;
84
+ const id = ++this.nextChange;
85
+ const revision = this.revisions.get(key) ?? 0;
86
+ this.pending.set(id, { key, value: value ?? null });
87
+ this.recompute();
88
+ const failed = (payload: unknown) => {
89
+ if (!this.pending.delete(id)) return;
90
+ this.recompute();
91
+ this.error.value = EraspaceError.from(payload, "Room state update failed");
92
+ };
93
+ void this.channel.push("state:set", { key, value: value ?? null })
94
+ .then(() => {
95
+ if (!this.pending.delete(id)) return;
96
+ if ((this.revisions.get(key) ?? 0) === revision) this.serverState = { ...this.serverState, [key]: value ?? null };
97
+ this.error.value = undefined;
98
+ this.recompute();
99
+ }, failed);
100
+ }
101
+
102
+ close(): void {
103
+ this.closed = true;
104
+ clearTimeout(this.timer);
105
+ this.pending.clear();
106
+ this.recompute();
107
+ this.channel.leave();
51
108
  }
52
- }
53
109
 
54
- function stripPhxRef(meta: any): any {
55
- if (!meta || typeof meta !== "object") return meta;
56
- const { phx_ref: _a, phx_ref_prev: _b, ...rest } = meta;
57
- return rest;
58
110
  }
@@ -118,17 +118,26 @@ export function Router(props: {
118
118
  }) {
119
119
  const hasProvider = !!useContext(ctx)?.url;
120
120
  const routes = toChildArray(props.children).some(isRouteChild);
121
- // preact-iso serializes route props; rendered VNodes contain circular parent links.
121
+ // preact-iso serializes route props, and rendered VNodes contain circular parent links, so elements travel in
122
+ // a function. It re-renders a matched route only when the URL or those serialized props change, and functions
123
+ // do not serialize: the revision marks every new element as a change. ElementRoute stays the same component,
124
+ // so the route updates in place instead of remounting.
122
125
  const children = useMemo(() => toChildArray(props.children).filter(isValidElement).map((child) => {
123
126
  if (child.type !== Route) return child;
124
127
  const { element, ...rest } = (child as VNode<RouteProps>).props;
125
128
  if (element === undefined) return child;
126
- return h(Route, { ...rest, key: child.key, component: () => element });
129
+ return h(Route, { ...rest, key: child.key, component: ElementRoute, render: () => element, revision: ++elementRevision });
127
130
  }), [props.children]);
128
131
  const content = routes ? h(IsoRouter as ComponentType<typeof props>, { ...props, children }) : props.children;
129
132
  return hasProvider ? (content as any) : h(LocationProvider, null, content);
130
133
  }
131
134
 
135
+ let elementRevision = 0;
136
+
137
+ function ElementRoute(props: { render: () => JSX.Element | null }) {
138
+ return props.render();
139
+ }
140
+
132
141
  /** Alias of `<Router>` (react-router style naming). */
133
142
  export const Routes = Router;
134
143