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/README.md +7 -1
- package/bin.js +562 -208
- package/package.json +1 -1
- package/sdk/src/client/client.ts +14 -8
- package/sdk/src/client/config.ts +17 -1
- package/sdk/src/client/connection.ts +67 -12
- package/sdk/src/client/files.ts +36 -9
- package/sdk/src/client/index.ts +1 -0
- package/sdk/src/client/router.ts +1 -1
- package/sdk/src/client/storage.ts +31 -0
- package/sdk/src/client/sub-key.ts +19 -0
- package/sdk/src/client/subscription.ts +12 -1
- package/sdk/src/client/transport.ts +82 -15
- package/sdk/src/server/index.ts +41 -2
- package/templates/chat/client/index.tsx +12 -4
- package/templates/poll/client/index.tsx +1 -1
- package/templates/todo/client/index.tsx +1 -1
package/package.json
CHANGED
package/sdk/src/client/client.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|
package/sdk/src/client/config.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
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;
|
package/sdk/src/client/files.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 }),
|
package/sdk/src/client/index.ts
CHANGED
package/sdk/src/client/router.ts
CHANGED
|
@@ -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
|
|