eraspace 0.2.0 → 0.4.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,6 @@
1
1
  {
2
2
  "name": "eraspace",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "eraspace: build, deploy and manage eraspace capsules",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -2,7 +2,7 @@ import { computed, type ReadonlySignal } from "@preact/signals";
2
2
  import { useEffect, useMemo, useRef, useState } from "preact/hooks";
3
3
  import { createStorageClient } from "./storage.ts";
4
4
  import { useUpload, type UseUploadOptions } from "./files.ts";
5
- import type { ClientOptions } from "./config.ts";
5
+ import { readConfig, type ClientOptions } from "./config.ts";
6
6
  import { subKey, stableStringify } from "./sub-key.ts";
7
7
  import type { Subscription } from "./subscription.ts";
8
8
  import type { RoomConn } from "./room.ts";
@@ -13,7 +13,7 @@ import { Pager } from "./pagination.ts";
13
13
 
14
14
  export { readConfig, type EraspaceConfig, type ClientOptions } from "./config.ts";
15
15
  export { subKey } from "./sub-key.ts";
16
- export type { EraspaceClient, Room, PresenceEntry, MutationOptions, OptimisticStore, PaginatedQueryName, PaginatedQueryResult } from "./types.ts";
16
+ export type { EraspaceClient, Room, PresenceEntry, MutationOptions, OptimisticStore, PaginatedQueryName, PaginatedQueryResult, Status } from "./types.ts";
17
17
 
18
18
  type Fn = (...args: any[]) => any;
19
19
 
@@ -23,8 +23,10 @@ type Fn = (...args: any[]) => any;
23
23
  */
24
24
  export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C> {
25
25
  let impl: ClientImpl | undefined;
26
- // Lazy so a module-level createClient() costs nothing until first use.
26
+ // Lazy so a module-level createClient() costs nothing until first use, except in a browser page that reloads on
27
+ // deploy (the platform shell's default), where the client connects at once to hear deploys.
27
28
  const get = () => (impl ??= new ClientImpl(options));
29
+ if (typeof location !== "undefined" && (options.reloadOnDeploy ?? readConfig().app)) get().watchDeploys();
28
30
 
29
31
  function useSubscription(name: string, args: unknown[]): Subscription {
30
32
  const c = get();
@@ -112,11 +114,8 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
112
114
 
113
115
  function useRoom(room: string, meta?: Record<string, unknown>): Room<any, any> {
114
116
  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,
117
+ // Stable per room, so effects and memoized children that depend on them do not rerun every render.
118
+ const methods = useMemo(() => ({
120
119
  broadcast: (event: string, payload?: unknown) => {
121
120
  void r.channel.push("broadcast", { event, payload: payload ?? null }).catch(error => { r.error.value = EraspaceError.from(error); });
122
121
  },
@@ -129,6 +128,13 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
129
128
  setState: (key: string, value: unknown) => {
130
129
  r.setState(key, value);
131
130
  },
131
+ }), [r]);
132
+ return {
133
+ presence: r.members.value,
134
+ self: r.self.value,
135
+ state: r.state.value,
136
+ error: r.error.value,
137
+ ...methods,
132
138
  };
133
139
  }
134
140
 
@@ -1,11 +1,23 @@
1
1
  import type { SocketOptions, WebSocketConstructor } from "./transport.ts";
2
2
 
3
+ /** A query result the shell embedded, as a `sub` reply would carry it. */
4
+ export interface InitialResult {
5
+ fn: string;
6
+ args: unknown[];
7
+ value: unknown;
8
+ commitSeq: number;
9
+ revision: number;
10
+ entry: string;
11
+ }
12
+
3
13
  /** `window.__ERASPACE__`, injected by the platform's HTML shell (SPEC §6). */
4
14
  export interface EraspaceConfig {
5
15
  app: string;
6
16
  base: string;
7
17
  version: number;
8
18
  socket: string;
19
+ /** Current results of the queries the app's pages usually open first. */
20
+ initial?: InitialResult[];
9
21
  }
10
22
 
11
23
  export interface ClientOptions {
@@ -14,7 +26,11 @@ export interface ClientOptions {
14
26
  socket?: string;
15
27
  base?: string;
16
28
  version?: number;
17
- /** Reload the page when the app is redeployed. Default `false`. */
29
+ /**
30
+ * Reload the page when the app is redeployed, so a deploy reaches open tabs without a manual refresh. Default
31
+ * `true` for the shell's own app on pages served by the platform shell (`window.__ERASPACE__` is set), otherwise
32
+ * `false`. When enabled in a browser, the client connects at creation rather than on first use.
33
+ */
18
34
  reloadOnDeploy?: boolean;
19
35
  /** Push timeout in ms. Default 15000. */
20
36
  timeout?: number;
@@ -2,10 +2,10 @@ import { Socket, type Channel } from "./transport.ts";
2
2
  import { signal, effect, batch, type Signal } from "@preact/signals";
3
3
  import { applyPatch } from "./result-diff.ts";
4
4
  import { EraspaceError, OutcomeUnknownError } from "./errors.ts";
5
- import { readConfig, type ClientOptions } from "./config.ts";
5
+ import { readConfig, type ClientOptions, type InitialResult } from "./config.ts";
6
6
  import { requestKey } from "./request-key.ts";
7
- import { subKey } from "./sub-key.ts";
8
- import { Subscription, type FullResult, type DataMessage } from "./subscription.ts";
7
+ import { canonicalArgs, subKey } from "./sub-key.ts";
8
+ import { Subscription, type FullResult, type UnchangedResult, type DataMessage } from "./subscription.ts";
9
9
  import { RoomConn } from "./room.ts";
10
10
  import { retryDelay } from "./retry.ts";
11
11
  import type { Status } from "./types.ts";
@@ -18,6 +18,7 @@ export class ClientImpl {
18
18
  readonly self = signal("");
19
19
  readonly status = signal<Status>("connecting");
20
20
  readonly version: Signal<number>;
21
+ readonly reloadOnDeploy: boolean;
21
22
  private opts: ClientOptions;
22
23
  private socketUrl: string;
23
24
  private socket: Socket | undefined;
@@ -34,6 +35,8 @@ export class ClientImpl {
34
35
  private subsById = new Map<string, Subscription>();
35
36
  private rooms = new Map<string, RoomConn>();
36
37
  private deployedCbs = new Set<(v: number) => void>();
38
+ /** Results the shell embedded, by subscription key, each used by the first subscription to it. */
39
+ private initial = new Map<string, InitialResult>();
37
40
 
38
41
  constructor(opts: ClientOptions) {
39
42
  const cfg = readConfig();
@@ -42,7 +45,18 @@ export class ClientImpl {
42
45
  if (!this.app) throw new Error("eraspace: createClient() needs an app (window.__ERASPACE__ is missing)");
43
46
  this.base = opts.base ?? cfg.base ?? `/${this.app}/`;
44
47
  this.socketUrl = opts.socket ?? cfg.socket ?? "/socket";
45
- this.version = signal(opts.version ?? cfg.version ?? 0);
48
+ // The shell's version and reload default describe the page's own app, not another app it talks to.
49
+ const ownApp = !opts.app || opts.app === cfg.app;
50
+ this.version = signal(opts.version ?? (ownApp ? cfg.version : undefined) ?? 0);
51
+ this.reloadOnDeploy = opts.reloadOnDeploy ?? (ownApp && Boolean(cfg.app));
52
+ if (ownApp && Array.isArray(cfg.initial)) {
53
+ for (const r of cfg.initial) if (typeof r?.fn === "string" && Array.isArray(r.args)) this.initial.set(subKey(r.fn, r.args), r);
54
+ }
55
+ }
56
+
57
+ /** Join the app topic now when deploys reload the page, so a page that has not queried yet still hears them. */
58
+ watchDeploys(): void {
59
+ if (this.reloadOnDeploy && !this.gone) this.ensureChannel();
46
60
  }
47
61
 
48
62
  // -- connection -----------------------------------------------------------------------------------------------
@@ -91,6 +105,7 @@ export class ClientImpl {
91
105
  this.status.value = "open";
92
106
  if (resp && typeof resp.self === "string") this.self.value = resp.self;
93
107
  if (resp && typeof resp.now === "number") this.clockOffset = resp.now - Date.now();
108
+ if (resp && typeof resp.version === "number") this.onJoinVersion(resp.version);
94
109
  // The channel process is new after a reconnect, so every live subscription is sent again.
95
110
  if (this.joinedOnce) this.resubscribeAll();
96
111
  this.joinedOnce = true;
@@ -109,7 +124,8 @@ export class ClientImpl {
109
124
  async call(fn: string, args: unknown[], kind: "query" | "action" = "query"): Promise<unknown> {
110
125
  const key = this.requestKey();
111
126
  try {
112
- const resp = await this.push<{ value?: unknown }>("call", { fn, args, kind, key });
127
+ // Sorted object keys let equal query arguments share the server's cached result.
128
+ const resp = await this.push<{ value?: unknown }>("call", { fn, args: kind === "query" ? canonicalArgs(args) : args, kind, key });
113
129
  return resp?.value;
114
130
  } catch (error) {
115
131
  if (kind === "action" && error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError"].includes(error.name)) {
@@ -202,6 +218,8 @@ export class ClientImpl {
202
218
  sub.retryTimer = undefined;
203
219
  const oldId = sub.id;
204
220
  const request = ++sub.request;
221
+ // The server replies without the value when its cache entry still holds the revision we have.
222
+ const known = sub.hasServer && sub.entry !== undefined && sub.revision !== undefined ? { entry: sub.entry, revision: sub.revision } : undefined;
205
223
  if (request > 1) {
206
224
  this.subsById.delete(oldId);
207
225
  sub.id = String(this.nextSub++);
@@ -210,15 +228,24 @@ export class ClientImpl {
210
228
  this.unsubscribe(sub, oldId);
211
229
  }
212
230
  const id = sub.id;
213
- this.push<FullResult>("sub", () => {
231
+ this.push<FullResult | UnchangedResult>("sub", () => {
214
232
  // A newer request or a drop replaced this one before it was sent: withdraw it.
215
233
  if (sub.request !== request) return null;
216
234
  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)) };
235
+ const minCommitSeq = Math.max(0, ...sub.layers.map(layer => layer.commitSeq ?? 0));
236
+ return known ? { id, fn: sub.fn, args: sub.args, minCommitSeq, known } : { id, fn: sub.fn, args: sub.args, minCommitSeq };
218
237
  }).then(
219
238
  (resp) => {
220
239
  if (this.subsById.get(sub.id) !== sub || sub.request !== request) return;
221
- this.setServer(sub, resp.value, resp.commitSeq, resp.revision);
240
+ if ("unchanged" in resp && resp.unchanged === true) {
241
+ // Only a known revision is answered this way, and the value cannot have changed since it was sent.
242
+ if (known && sub.hasServer) this.setServer(sub, sub.server, resp.commitSeq, resp.revision);
243
+ else this.sendSub(sub);
244
+ return;
245
+ }
246
+ const full = resp as FullResult;
247
+ sub.entry = full.entry;
248
+ this.setServer(sub, full.value, full.commitSeq, full.revision);
222
249
  },
223
250
  (err: EraspaceError) => {
224
251
  if (this.subsById.get(sub.id) === sub && sub.request === request) {
@@ -269,6 +296,8 @@ export class ClientImpl {
269
296
  this.sendSub(sub);
270
297
  }
271
298
  } else {
299
+ // Recovered subscriptions arrive as full pushes that name their new cache entry.
300
+ if (typeof message.entry === "string") sub.entry = message.entry;
272
301
  this.setServer(sub, message.value, message.commitSeq, message.revision);
273
302
  }
274
303
  }
@@ -281,7 +310,14 @@ export class ClientImpl {
281
310
  private onDeployed(version: number | undefined): void {
282
311
  if (typeof version === "number") this.version.value = version;
283
312
  for (const cb of [...this.deployedCbs]) cb(this.version.value);
284
- if (this.opts.reloadOnDeploy && typeof location !== "undefined") location.reload();
313
+ if (this.reloadOnDeploy && typeof location !== "undefined") location.reload();
314
+ }
315
+
316
+ // A deploy announced while the socket was down, or before the first join, shows up as a newer join version.
317
+ // Without a known page version there is nothing to compare, so the first one seen becomes the baseline.
318
+ private onJoinVersion(version: number): void {
319
+ if (this.version.value === 0) this.version.value = version;
320
+ else if (version > this.version.value) this.onDeployed(version);
285
321
  }
286
322
 
287
323
  onDeployedCb(cb: (v: number) => void): () => void {
@@ -297,6 +333,14 @@ export class ClientImpl {
297
333
  sub = new Subscription(String(this.nextSub++), fn, args, key);
298
334
  this.subs.set(key, sub);
299
335
  this.subsById.set(sub.id, sub);
336
+ // A result embedded in the page shows at once; the server confirms it with `unchanged` when the first
337
+ // `sub` sends it as `known`, or replies with the current value.
338
+ const seed = this.initial.get(key);
339
+ if (seed) {
340
+ this.initial.delete(key);
341
+ sub.entry = seed.entry;
342
+ this.setServer(sub, seed.value, seed.commitSeq, seed.revision);
343
+ }
300
344
  this.sendSub(sub);
301
345
  }
302
346
  if (sub.refs === 0) this.scheduleDrop(sub, 1000);
@@ -382,9 +426,13 @@ export class ClientImpl {
382
426
  l.commitSeq = commitSeq;
383
427
  }
384
428
  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);
429
+ else {
430
+ // The server sends unchanged values' newer watermarks only on request.
431
+ this.watch(sub, commitSeq);
432
+ setTimeout(() => {
433
+ if (sub.layers.some((l) => l.mid === mid) && this.subs.get(sub.key) === sub) this.sendSub(sub);
434
+ }, timeout);
435
+ }
388
436
  }
389
437
  return value;
390
438
  } catch (e) {
@@ -393,6 +441,13 @@ export class ClientImpl {
393
441
  }
394
442
  }
395
443
 
444
+ /** Asks for a watermark covering `commitSeq` on the subscription's current server id. */
445
+ private watch(sub: Subscription, commitSeq: number): void {
446
+ const id = sub.id;
447
+ if (sub.sent?.id !== id || sub.sent.join !== this.joins) return;
448
+ this.push("watch", { id, commitSeq }).catch(() => {});
449
+ }
450
+
396
451
  private addLayer(mid: number, name: string, args: unknown[], apply: Fn, touched: Set<Subscription>): void {
397
452
  const sub = this.subs.get(subKey(name, args));
398
453
  if (!sub) return;
@@ -1,6 +1,6 @@
1
1
  // File upload and preview for app UIs, over the storage client.
2
2
  import { Fragment, h, type ComponentChildren, type TargetedDragEvent, type TargetedEvent } from "preact";
3
- import { useEffect, useRef, useState } from "preact/hooks";
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
4
4
  import { EraspaceError } from "./errors.ts";
5
5
  import { isStorageKey, type StorageClient, type UploadedObject } from "./storage.ts";
6
6
 
@@ -36,6 +36,8 @@ export interface Upload {
36
36
  uploading: boolean;
37
37
  /** Fraction of the current batch's bytes uploaded, from 0 to 1. */
38
38
  progress: number;
39
+ /** The file being uploaded now, for an immediate local preview. */
40
+ file: Blob | null;
39
41
  /** The latest failure, cleared when the next batch starts. */
40
42
  error: EraspaceError | null;
41
43
  reset(): void;
@@ -85,7 +87,7 @@ function asError(e: unknown): EraspaceError {
85
87
  * cancel what has not finished; a file whose bytes already reached the server may still be stored.
86
88
  */
87
89
  export function useUpload(storage: StorageClient, options: UseUploadOptions = {}): Upload {
88
- const [state, setState] = useState({ uploading: false, progress: 0, error: null as EraspaceError | null });
90
+ const [state, setState] = useState({ uploading: false, progress: 0, error: null as EraspaceError | null, file: null as Blob | null });
89
91
  const opts = useRef(options);
90
92
  opts.current = options;
91
93
  // Replaced by reset(), so batches queued before it never start.
@@ -101,9 +103,10 @@ export function useUpload(storage: StorageClient, options: UseUploadOptions = {}
101
103
  let done = 0;
102
104
  let error: EraspaceError | null = null;
103
105
  const stored: FileRef[] = [];
104
- setState({ uploading: true, progress: 0, error: null });
106
+ setState({ uploading: true, progress: 0, error: null, file: null });
105
107
  for (const file of files) {
106
108
  const weight = bytes ? file.size : 1;
109
+ if (!abort.signal.aborted) setState((s) => ({ ...s, file }));
107
110
  try {
108
111
  const object: UploadedObject = await storage.upload(file, {
109
112
  signal: abort.signal,
@@ -126,7 +129,7 @@ export function useUpload(storage: StorageClient, options: UseUploadOptions = {}
126
129
  done += weight;
127
130
  }
128
131
  // 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 }));
132
+ if (!abort.signal.aborted) setState((s) => ({ uploading: false, progress: 1, error: error ?? s.error, file: null }));
130
133
  return stored;
131
134
  }
132
135
 
@@ -153,10 +156,11 @@ export function useUpload(storage: StorageClient, options: UseUploadOptions = {}
153
156
  uploading: state.uploading,
154
157
  progress: state.progress,
155
158
  error: state.error,
159
+ file: state.file,
156
160
  reset() {
157
161
  controller.current?.abort();
158
162
  controller.current = null;
159
- setState({ uploading: false, progress: 0, error: null });
163
+ setState({ uploading: false, progress: 0, error: null, file: null });
160
164
  },
161
165
  };
162
166
  }
@@ -174,6 +178,21 @@ export interface FileUploadProps extends UseUploadOptions {
174
178
 
175
179
  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
180
 
181
+ /** The image being uploaded, read from the browser's copy so it shows before any bytes reach the server. */
182
+ function UploadingImage({ file }: { file: Blob | null }) {
183
+ const [shown, setShown] = useState<{ file: Blob; url: string } | null>(null);
184
+ // Created inside the effect that revokes it, so a fast upload or unmount cannot skip the revocation.
185
+ useLayoutEffect(() => {
186
+ if (!file || !/^image\//i.test(file.type) || typeof URL.createObjectURL !== "function") return;
187
+ const url = URL.createObjectURL(file);
188
+ setShown({ file, url });
189
+ return () => URL.revokeObjectURL(url);
190
+ }, [file]);
191
+ return shown && shown.file === file
192
+ ? h("img", { src: shown.url, alt: "", style: "display:block;max-width:100%;max-height:6rem;border-radius:.375rem;opacity:.7" })
193
+ : null;
194
+ }
195
+
177
196
  /** A drop zone and file picker that uploads to app storage and reports each stored file through `onUpload`. */
178
197
  export function FileUpload(props: FileUploadProps) {
179
198
  const { client, multiple, disabled, children } = props;
@@ -224,6 +243,7 @@ export function FileUpload(props: FileUploadProps) {
224
243
  ? h(
225
244
  Fragment,
226
245
  null,
246
+ h(UploadingImage, { file: state.file }),
227
247
  h("span", null, `Uploading… ${Math.round(state.progress * 100)}%`),
228
248
  h("progress", { value: state.progress, max: 1, style: props.class ? undefined : "width:100%;max-width:16rem" }),
229
249
  )
@@ -286,6 +306,8 @@ export function FilePreview(props: FilePreviewProps) {
286
306
  // `failed`: the lookup request failed (offline, rate limited); null means the server has no such file.
287
307
  const [fetched, setFetched] = useState<{ key: string; info: UploadedObject | null | "failed" } | undefined>(undefined);
288
308
  const [broken, setBroken] = useState<string | null>(null);
309
+ // The key whose page-local copy failed to load, for example after it was evicted; it loads from the server.
310
+ const [remote, setRemote] = useState<string | null>(null);
289
311
  const [attempt, setAttempt] = useState(0);
290
312
  useEffect(() => {
291
313
  if (!key || known) return;
@@ -321,6 +343,8 @@ export function FilePreview(props: FilePreviewProps) {
321
343
  if (meta === undefined || meta === null || meta === "failed" || broken === key) {
322
344
  // A media error cannot tell a missing file from a failed request, so every failure offers a retry.
323
345
  const retry = () => {
346
+ // Found metadata stays cached, so a file deleted since would show as found again and fail the same way.
347
+ infoCache.delete(client.storage.url(key));
324
348
  setBroken(null);
325
349
  setFetched(undefined);
326
350
  setAttempt((n) => n + 1);
@@ -332,9 +356,12 @@ export function FilePreview(props: FilePreviewProps) {
332
356
  }
333
357
 
334
358
  const url = client.storage.url(key);
359
+ // A file uploaded from this page shows from the browser's copy instead of being downloaded again.
360
+ const local = remote === key ? undefined : client.storage.localUrl?.(key);
361
+ const src = local ?? url;
335
362
  const name = meta.name || "file";
336
363
  const size = typeof meta.size === "number" ? formatBytes(meta.size) : "";
337
- const fail = () => setBroken(key);
364
+ const fail = () => (local ? setRemote(key) : setBroken(key));
338
365
  const caption = props.caption === false
339
366
  ? null
340
367
  : h(
@@ -346,13 +373,13 @@ export function FilePreview(props: FilePreviewProps) {
346
373
  switch (kindOf(meta.contentType!)) {
347
374
  case "image":
348
375
  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 })),
376
+ h("a", { href: url, target: "_blank", rel: "noopener" }, h("img", { src, alt: props.alt ?? name, loading: local ? undefined : "lazy", decoding: "async", onError: fail, style: plain ? `${MEDIA};max-height:20rem` : undefined })),
350
377
  caption,
351
378
  ]);
352
379
  case "video":
353
- return box([h("video", { src: url, controls: true, preload: "metadata", onError: fail, style: plain ? `${MEDIA};max-height:20rem` : undefined }), caption]);
380
+ return box([h("video", { src, controls: true, preload: "metadata", onError: fail, style: plain ? `${MEDIA};max-height:20rem` : undefined }), caption]);
354
381
  case "audio":
355
- return box([h("audio", { src: url, controls: true, preload: "metadata", onError: fail, style: plain ? "display:block;width:100%" : undefined }), caption]);
382
+ return box([h("audio", { src, controls: true, preload: "metadata", onError: fail, style: plain ? "display:block;width:100%" : undefined }), caption]);
356
383
  case "text":
357
384
  return box([
358
385
  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 }),
@@ -10,6 +10,7 @@ export {
10
10
  type PaginatedQueryResult,
11
11
  type PresenceEntry,
12
12
  type Room,
13
+ type Status,
13
14
  type EraspaceClient,
14
15
  type EraspaceConfig,
15
16
  } from "./client.ts";
@@ -81,7 +81,7 @@ export function LocationProvider(props: { base?: string; url?: string; children?
81
81
  const onClick = (e: MouseEvent) => {
82
82
  if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
83
83
  const a = (e.composedPath() as Element[]).find((el) => el.nodeName === "A") as HTMLAnchorElement | undefined;
84
- if (!a || !a.href || a.origin !== location.origin || a.download) return;
84
+ if (!a || !a.href || a.origin !== location.origin || a.hasAttribute("download")) return;
85
85
  if (a.target && !/^_?self$/i.test(a.target)) return;
86
86
  const href = a.getAttribute("href") ?? "";
87
87
  if (href.startsWith("#")) return;
@@ -24,6 +24,19 @@ export interface StorageClient {
24
24
  /** A stored file's metadata, or null when there is no such file. */
25
25
  info(key: string, options?: { signal?: AbortSignal }): Promise<UploadedObject | null>;
26
26
  delete(key: string, options?: { signal?: AbortSignal; deleteToken?: string }): Promise<void>;
27
+ /**
28
+ * A page-local URL for an image, video or audio file this client uploaded, so previews show it without
29
+ * downloading it again. Undefined for any other file, or after the file is deleted or evicted. Optional, so other
30
+ * implementations keep working; previews then use the server URL.
31
+ */
32
+ localUrl?(key: string): string | undefined;
33
+ }
34
+
35
+ /** Uploaded media kept as object URLs. Each keeps its Blob alive, so only the most recent are retained. */
36
+ const LOCAL_FILES = 50;
37
+
38
+ function isMedia(contentType: string): boolean {
39
+ return /^(image|video|audio)\//i.test(contentType.trim());
27
40
  }
28
41
 
29
42
  /** Storage keys are UUIDs. */
@@ -75,6 +88,19 @@ function sendWithProgress(target: string, headers: Headers, body: Blob, signal:
75
88
 
76
89
  export function createStorageClient(base: () => string): StorageClient {
77
90
  const deleteTokens = new Map<string, string>();
91
+ const localFiles = new Map<string, string>();
92
+ function forgetLocal(key: string) {
93
+ const local = localFiles.get(key);
94
+ if (local === undefined) return;
95
+ localFiles.delete(key);
96
+ URL.revokeObjectURL(local);
97
+ }
98
+ function keepLocal(key: string, body: Blob, contentType: string) {
99
+ if (!isMedia(contentType) || typeof URL.createObjectURL !== "function") return;
100
+ forgetLocal(key);
101
+ localFiles.set(key, URL.createObjectURL(body));
102
+ while (localFiles.size > LOCAL_FILES) forgetLocal(localFiles.keys().next().value!);
103
+ }
78
104
  function url(key?: string): string {
79
105
  const path = `${base().replace(/\/+$/, "")}/_sb/storage`;
80
106
  return key === undefined ? path : `${path}/${encodeURIComponent(key)}`;
@@ -105,6 +131,7 @@ export function createStorageClient(base: () => string): StorageClient {
105
131
  }
106
132
  const deleteToken = typeof result.deleteToken === "string" ? result.deleteToken : undefined;
107
133
  if (deleteToken) deleteTokens.set(result.key, deleteToken);
134
+ keepLocal(result.key, body, result.contentType);
108
135
  return { key: result.key, url: result.url, size: result.size, contentType: result.contentType, name: result.name, ...(deleteToken ? { deleteToken } : {}) };
109
136
  },
110
137
  async info(key, options = {}) {
@@ -127,6 +154,10 @@ export function createStorageClient(base: () => string): StorageClient {
127
154
  const response = await fetch(url(key), { method: "DELETE", headers, signal: options.signal });
128
155
  check(response.status, await response.text());
129
156
  deleteTokens.delete(key);
157
+ forgetLocal(key);
158
+ },
159
+ localUrl(key) {
160
+ return localFiles.get(key);
130
161
  },
131
162
  };
132
163
  }
@@ -2,9 +2,28 @@
2
2
  export function subKey(fn: string, args: unknown[]): string {
3
3
  return fn + ":" + stableStringify(args);
4
4
  }
5
+ /**
6
+ * Arguments as sent to the server: object keys in sorted order, so equal arguments written in any order share
7
+ * one server cache entry. Serialization is unchanged otherwise.
8
+ */
9
+ export function canonicalArgs(args: unknown[]): unknown[] {
10
+ return args.map(canonical);
11
+ }
12
+ function canonical(v: unknown): unknown {
13
+ if (v === null || typeof v !== "object") return v;
14
+ const json = (v as { toJSON?: unknown }).toJSON;
15
+ if (typeof json === "function") return canonical(json.call(v));
16
+ if (Array.isArray(v)) return v.map(canonical);
17
+ const o = v as Record<string, unknown>;
18
+ // Own data properties, so a `__proto__` key stays a key rather than setting the prototype.
19
+ return Object.fromEntries(Object.keys(o).sort().map((k) => [k, canonical(o[k])]));
20
+ }
21
+ /** Sorted-key JSON. Like `JSON.stringify` and `canonicalArgs`, it serializes what `toJSON` returns, so two Dates differ. */
5
22
  export function stableStringify(v: unknown): string {
6
23
  if (v === undefined) return "null";
7
24
  if (v === null || typeof v !== "object") return JSON.stringify(v);
25
+ const json = (v as { toJSON?: unknown }).toJSON;
26
+ if (typeof json === "function") return stableStringify(json.call(v));
8
27
  if (Array.isArray(v)) return "[" + v.map(stableStringify).join(",") + "]";
9
28
  const o = v as Record<string, unknown>;
10
29
  const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
@@ -1,11 +1,20 @@
1
1
  import { signal, type Signal } from "@preact/signals";
2
2
  import { EraspaceError } from "./errors.ts";
3
3
  import type { Patch } from "./result-diff.ts";
4
+ import { canonicalArgs } from "./sub-key.ts";
4
5
 
5
6
  export interface FullResult {
6
7
  value: unknown;
7
8
  commitSeq: number;
8
9
  revision: number;
10
+ /** The server cache entry holding the value, echoed as `known` on a resubscription. */
11
+ entry?: string;
12
+ }
13
+ /** A resubscription whose `known` entry and revision still hold: the client keeps its value. */
14
+ export interface UnchangedResult {
15
+ unchanged: true;
16
+ commitSeq: number;
17
+ revision: number;
9
18
  }
10
19
  export type DataMessage = { id: string } & (FullResult | {
11
20
  patch: Patch[];
@@ -27,6 +36,8 @@ export class Subscription {
27
36
  hasServer = false;
28
37
  commitSeq = -1;
29
38
  revision: number | undefined;
39
+ /** The server cache entry of `revision`. */
40
+ entry: string | undefined;
30
41
  request = 0;
31
42
  retries = 0;
32
43
  retryTimer: ReturnType<typeof setTimeout> | undefined;
@@ -43,7 +54,7 @@ export class Subscription {
43
54
  constructor(id: string, fn: string, args: unknown[], key: string) {
44
55
  this.id = id;
45
56
  this.fn = fn;
46
- this.args = args;
57
+ this.args = canonicalArgs(args);
47
58
  this.key = key;
48
59
  }
49
60