eraspace 0.2.0 → 0.3.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.3.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";
@@ -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();
@@ -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;
@@ -332,9 +354,12 @@ export function FilePreview(props: FilePreviewProps) {
332
354
  }
333
355
 
334
356
  const url = client.storage.url(key);
357
+ // A file uploaded from this page shows from the browser's copy instead of being downloaded again.
358
+ const local = remote === key ? undefined : client.storage.localUrl?.(key);
359
+ const src = local ?? url;
335
360
  const name = meta.name || "file";
336
361
  const size = typeof meta.size === "number" ? formatBytes(meta.size) : "";
337
- const fail = () => setBroken(key);
362
+ const fail = () => (local ? setRemote(key) : setBroken(key));
338
363
  const caption = props.caption === false
339
364
  ? null
340
365
  : h(
@@ -346,13 +371,13 @@ export function FilePreview(props: FilePreviewProps) {
346
371
  switch (kindOf(meta.contentType!)) {
347
372
  case "image":
348
373
  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 })),
374
+ 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
375
  caption,
351
376
  ]);
352
377
  case "video":
353
- return box([h("video", { src: url, controls: true, preload: "metadata", onError: fail, style: plain ? `${MEDIA};max-height:20rem` : undefined }), caption]);
378
+ return box([h("video", { src, controls: true, preload: "metadata", onError: fail, style: plain ? `${MEDIA};max-height:20rem` : undefined }), caption]);
354
379
  case "audio":
355
- return box([h("audio", { src: url, controls: true, preload: "metadata", onError: fail, style: plain ? "display:block;width:100%" : undefined }), caption]);
380
+ return box([h("audio", { src, controls: true, preload: "metadata", onError: fail, style: plain ? "display:block;width:100%" : undefined }), caption]);
356
381
  case "text":
357
382
  return box([
358
383
  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 }),
@@ -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,6 +2,22 @@
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
+ }
5
21
  export function stableStringify(v: unknown): string {
6
22
  if (v === undefined) return "null";
7
23
  if (v === null || typeof v !== "object") return JSON.stringify(v);
@@ -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
 
@@ -22,6 +22,8 @@ export interface SocketOptions {
22
22
  rejoinAfterMs?: (attempt: number) => number;
23
23
  /** How long a socket must stay open before reconnect backoff starts again from the first attempt. */
24
24
  stableAfterMs?: number;
25
+ /** Accept large payloads as deflated binary frames. Defaults to on where `DecompressionStream` inflates raw deflate. */
26
+ compression?: boolean;
25
27
  }
26
28
 
27
29
  interface Frame {
@@ -42,6 +44,58 @@ interface Pending {
42
44
  }
43
45
  export interface JoinReply { self?: string; now?: number; version?: number }
44
46
 
47
+ function asFrame(value: unknown): Frame | undefined {
48
+ if (!value || typeof value !== "object" || !("topic" in value) || typeof value.topic !== "string"
49
+ || !("event" in value) || typeof value.event !== "string" || !("session" in value)
50
+ || !(value.session === null || typeof value.session === "string") || !("ref" in value)
51
+ || !(value.ref === null || typeof value.ref === "string") || !("payload" in value)) return undefined;
52
+ return value as Frame;
53
+ }
54
+
55
+ /**
56
+ * Reports an exception thrown by app code that consumes frames, as an uncaught event handler exception would be.
57
+ * It must not look like a transport failure, which would fail every in-flight request with an unknown outcome.
58
+ */
59
+ function report(error: unknown): void {
60
+ if (typeof reportError === "function") reportError(error);
61
+ else setTimeout(() => { throw error; });
62
+ }
63
+
64
+ function inflates(): boolean {
65
+ try { return typeof DecompressionStream === "function" && Boolean(new DecompressionStream("deflate-raw")); }
66
+ catch { return false; }
67
+ }
68
+
69
+ async function bytesOf(data: unknown): Promise<Uint8Array<ArrayBuffer> | undefined> {
70
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
71
+ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer);
72
+ if (typeof Blob !== "undefined" && data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
73
+ return undefined;
74
+ }
75
+
76
+ /**
77
+ * A binary frame is a four-byte big-endian header length, the frame's JSON header and a raw-deflated JSON
78
+ * object. The header's `z` places the object: `"response"` is the reply's response, `"members"` adds its
79
+ * members to the header payload.
80
+ */
81
+ async function inflateFrame(data: unknown): Promise<Frame | undefined> {
82
+ const bytes = await bytesOf(data);
83
+ if (!bytes || bytes.length < 4) return undefined;
84
+ const length = new DataView(bytes.buffer, bytes.byteOffset).getUint32(0);
85
+ if (4 + length > bytes.length) return undefined;
86
+ const header: unknown = JSON.parse(new TextDecoder().decode(bytes.subarray(4, 4 + length)));
87
+ const frame = asFrame(header);
88
+ const z = header && typeof header === "object" && "z" in header ? header.z : undefined;
89
+ if (!frame || !frame.payload || typeof frame.payload !== "object") return undefined;
90
+ const inflated = new Blob([bytes.subarray(4 + length)]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
91
+ const body: unknown = JSON.parse(await new Response(inflated).text());
92
+ const payload = frame.payload as Record<string, unknown>;
93
+ if (z === "response") payload.response = body;
94
+ else if (z === "members" && body && typeof body === "object") frame.payload = { ...body, ...payload };
95
+ else return undefined;
96
+ return { session: frame.session, ref: frame.ref, topic: frame.topic, event: frame.event, payload: frame.payload };
97
+ }
98
+
45
99
  /** Multiplexes topics over one native WebSocket. Sent requests are never replayed on reconnect. */
46
100
  export class Socket {
47
101
  private ws: WebSocketTransport | undefined;
@@ -65,6 +119,7 @@ export class Socket {
65
119
  const url = new URL(endpoint, typeof location === "undefined" ? undefined : location.href);
66
120
  url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:";
67
121
  url.searchParams.set("v", "1");
122
+ if (options.compression ?? inflates()) url.searchParams.set("z", "1");
68
123
  this.url = url.href;
69
124
  this.options = options;
70
125
  }
@@ -88,6 +143,7 @@ export class Socket {
88
143
  try { ws = new Transport(this.url); }
89
144
  catch { this.scheduleReconnect(); return; }
90
145
  this.ws = ws;
146
+ (ws as { binaryType?: string }).binaryType = "arraybuffer";
91
147
  this.opening = setTimeout(() => this.lost(ws), this.options.timeout ?? 15000);
92
148
  ws.onopen = () => {
93
149
  if (this.ws !== ws) return;
@@ -103,25 +159,33 @@ export class Socket {
103
159
  this.heartbeat = setInterval(() => this.sendHeartbeat(ws), 25000);
104
160
  };
105
161
  ws.onmessage = (event) => {
106
- if (this.ws !== ws || typeof event.data !== "string") return;
107
- let frame: Frame;
108
- try {
109
- const value: unknown = JSON.parse(event.data);
110
- if (!value || typeof value !== "object" || !("topic" in value) || typeof value.topic !== "string"
111
- || !("event" in value) || typeof value.event !== "string" || !("session" in value)
112
- || !(value.session === null || typeof value.session === "string") || !("ref" in value)
113
- || !(value.ref === null || typeof value.ref === "string") || !("payload" in value)) return;
114
- frame = value as Frame;
115
- } catch { return; }
162
+ if (this.ws !== ws) return;
163
+ if (typeof event.data !== "string") {
164
+ // Deflated frames inflate asynchronously, in arrival order with every other frame.
165
+ const data = event.data;
166
+ // A frame that does not inflate is a transport failure; an exception while delivering one is not.
167
+ this.incoming = this.incoming.then(() => inflateFrame(data)).then(
168
+ frame => { if (frame) this.deliver(ws, frame); },
169
+ () => this.lost(ws),
170
+ );
171
+ return;
172
+ }
173
+ let frame: Frame | undefined;
174
+ try { frame = asFrame(JSON.parse(event.data)); } catch { return; }
175
+ if (!frame) return;
176
+ const received = frame;
116
177
  // ws can deliver several frames in one task. Let reply consumers settle before the next push.
117
- this.incoming = this.incoming.then(() => {
118
- if (this.ws === ws) this.receive(frame);
119
- }).catch(() => this.lost(ws));
178
+ this.incoming = this.incoming.then(() => this.deliver(ws, received));
120
179
  };
121
180
  ws.onclose = () => this.lost(ws);
122
181
  ws.onerror = () => this.lost(ws);
123
182
  }
124
183
 
184
+ private deliver(ws: WebSocketTransport, frame: Frame): void {
185
+ if (this.ws !== ws) return;
186
+ try { this.receive(frame); } catch (error) { report(error); }
187
+ }
188
+
125
189
  private receive(frame: Frame): void {
126
190
  if (frame.event === "reply" && frame.ref) {
127
191
  const body = frame.payload;
@@ -333,8 +397,11 @@ export class Channel {
333
397
  receive(frame: Frame): void {
334
398
  if (this.closed || frame.session !== this.session) return;
335
399
  if (frame.event === "closed") { this.leave(); return; }
336
- this.onMessage?.(frame.event, frame.payload);
337
- for (const cb of this.events.get(frame.event) ?? []) cb(frame.payload);
400
+ // One throwing handler must not keep the frame from the others.
401
+ try { this.onMessage?.(frame.event, frame.payload); } catch (error) { report(error); }
402
+ for (const cb of this.events.get(frame.event) ?? []) {
403
+ try { cb(frame.payload); } catch (error) { report(error); }
404
+ }
338
405
  }
339
406
 
340
407
  disconnected(): void {