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