eraspace 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +55 -2
- package/bin.js +40620 -0
- package/package.json +32 -4
- package/sdk/src/client/client.ts +171 -0
- package/sdk/src/client/config.ts +39 -0
- package/sdk/src/client/connection.ts +450 -0
- package/sdk/src/client/errors.ts +37 -0
- package/sdk/src/client/files.ts +364 -0
- package/sdk/src/client/index.ts +36 -0
- package/sdk/src/client/mount.ts +45 -0
- package/sdk/src/client/pagination.ts +104 -0
- package/sdk/src/client/request-key.ts +13 -0
- package/sdk/src/client/result-diff.ts +41 -0
- package/sdk/src/client/retry.ts +5 -0
- package/sdk/src/client/room.ts +110 -0
- package/sdk/src/client/router.ts +177 -0
- package/sdk/src/client/storage.ts +132 -0
- package/sdk/src/client/sub-key.ts +12 -0
- package/sdk/src/client/subscription.ts +55 -0
- package/sdk/src/client/transport.ts +356 -0
- package/sdk/src/client/types.ts +132 -0
- package/sdk/src/server/index.ts +635 -0
- package/templates/chat/client/index.tsx +41 -0
- package/templates/chat/client/styles.css +43 -0
- package/templates/chat/server/index.ts +27 -0
- package/templates/poll/client/index.tsx +24 -0
- package/templates/poll/client/styles.css +28 -0
- package/templates/poll/server/index.ts +17 -0
- package/templates/todo/client/index.tsx +26 -0
- package/templates/todo/client/styles.css +28 -0
- package/templates/todo/server/index.ts +18 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An error raised by a server handler (or the transport). `name` is the server error name, for example `Error`,
|
|
3
|
+
* `TypeError`, or `OutcomeUnknownError` when a mutation or action could have applied.
|
|
4
|
+
*/
|
|
5
|
+
export class EraspaceError extends Error {
|
|
6
|
+
readonly data?: unknown;
|
|
7
|
+
constructor(name: string, message: string, data?: unknown) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = name;
|
|
10
|
+
if (data !== undefined) this.data = data;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static from(payload: unknown, fallback = "Request failed"): EraspaceError {
|
|
14
|
+
if (payload && typeof payload === "object") {
|
|
15
|
+
const p = payload as { name?: unknown; message?: unknown; data?: unknown };
|
|
16
|
+
return new EraspaceError(
|
|
17
|
+
typeof p.name === "string" ? p.name : "Error",
|
|
18
|
+
typeof p.message === "string" ? p.message : fallback,
|
|
19
|
+
p.data,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return new EraspaceError("Error", typeof payload === "string" ? payload : fallback);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isEraspaceError(e: unknown): e is EraspaceError {
|
|
27
|
+
return e instanceof EraspaceError;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The mutation may have committed. Recover with `client.retryMutation(name, args, error.requestKey)`. */
|
|
31
|
+
export class OutcomeUnknownError extends EraspaceError {
|
|
32
|
+
readonly requestKey: string;
|
|
33
|
+
constructor(requestKey: string, message = "The request's outcome is unknown") {
|
|
34
|
+
super("OutcomeUnknownError", message);
|
|
35
|
+
this.requestKey = requestKey;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// eraspace/client: typed hooks over native WebSockets, routing, and mount().
|
|
2
|
+
export {
|
|
3
|
+
createClient,
|
|
4
|
+
readConfig,
|
|
5
|
+
subKey,
|
|
6
|
+
type ClientOptions,
|
|
7
|
+
type MutationOptions,
|
|
8
|
+
type OptimisticStore,
|
|
9
|
+
type PaginatedQueryName,
|
|
10
|
+
type PaginatedQueryResult,
|
|
11
|
+
type PresenceEntry,
|
|
12
|
+
type Room,
|
|
13
|
+
type EraspaceClient,
|
|
14
|
+
type EraspaceConfig,
|
|
15
|
+
} from "./client.ts";
|
|
16
|
+
export { EraspaceError, OutcomeUnknownError, isEraspaceError } from "./errors.ts";
|
|
17
|
+
export { isStorageKey, type StorageClient, type StorageUploadOptions, type UploadedObject } from "./storage.ts";
|
|
18
|
+
export { FileUpload, FilePreview, formatBytes, type FileRef, type FilePreviewProps, type FileUploadProps, type Upload, type UseUploadOptions } from "./files.ts";
|
|
19
|
+
export { mount, ErrorBoundary, type ErrorBoundaryProps, type MountOptions } from "./mount.ts";
|
|
20
|
+
export {
|
|
21
|
+
LocationProvider,
|
|
22
|
+
Router,
|
|
23
|
+
Routes,
|
|
24
|
+
Route,
|
|
25
|
+
Link,
|
|
26
|
+
useLocation,
|
|
27
|
+
useNavigate,
|
|
28
|
+
useParams,
|
|
29
|
+
useRoute,
|
|
30
|
+
stripBase,
|
|
31
|
+
withBase,
|
|
32
|
+
type LocationValue,
|
|
33
|
+
type RouteProps,
|
|
34
|
+
} from "./router.ts";
|
|
35
|
+
export { signal, computed, effect, batch, useSignal, useComputed, useSignalEffect, type Signal, type ReadonlySignal } from "@preact/signals";
|
|
36
|
+
export type { Id, Doc, PaginationOptions, PaginationResult } from "../server/index.ts";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Component, h, render, type ComponentChildren, type ComponentType } from "preact";
|
|
2
|
+
import { LocationProvider } from "./router.ts";
|
|
3
|
+
|
|
4
|
+
export interface ErrorBoundaryProps {
|
|
5
|
+
children?: ComponentChildren;
|
|
6
|
+
/** Render a fallback for `error`. `retry` re-renders the subtree. */
|
|
7
|
+
fallback?: (error: Error, retry: () => void) => ComponentChildren;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Catches render errors (including query errors thrown by `useQuery`) in its subtree. */
|
|
11
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, { error: Error | null }> {
|
|
12
|
+
state = { error: null as Error | null };
|
|
13
|
+
static getDerivedStateFromError(error: unknown) {
|
|
14
|
+
return { error: error instanceof Error ? error : new Error(String(error)) };
|
|
15
|
+
}
|
|
16
|
+
componentDidCatch(error: unknown) {
|
|
17
|
+
console.error(error);
|
|
18
|
+
}
|
|
19
|
+
retry = () => this.setState({ error: null });
|
|
20
|
+
render() {
|
|
21
|
+
const { error } = this.state;
|
|
22
|
+
if (!error) return this.props.children;
|
|
23
|
+
if (this.props.fallback) return this.props.fallback(error, this.retry);
|
|
24
|
+
return h(
|
|
25
|
+
"div",
|
|
26
|
+
{ role: "alert", style: "font-family:system-ui,sans-serif;padding:1.5rem;max-width:40rem;margin:2rem auto" },
|
|
27
|
+
h("p", { style: "font-weight:600;margin:0 0 .5rem" }, "Something went wrong"),
|
|
28
|
+
h("pre", { style: "white-space:pre-wrap;color:#b91c1c;margin:0 0 1rem" }, `${error.name}: ${error.message}`),
|
|
29
|
+
h("button", { type: "button", onClick: this.retry, style: "padding:.375rem .75rem;border:1px solid #ccc;border-radius:.375rem" }, "Retry"),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MountOptions {
|
|
35
|
+
/** Provide the app-relative location for `useLocation`, `<Link>` and `<Router>` (default true). */
|
|
36
|
+
router?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Render `App` into `#app` (or `target`) inside a LocationProvider and an ErrorBoundary. */
|
|
40
|
+
export function mount(App: ComponentType<{}>, target?: Element | null, options: MountOptions = {}): void {
|
|
41
|
+
const el = target ?? document.getElementById("app");
|
|
42
|
+
if (!el) throw new Error("eraspace: mount() could not find #app");
|
|
43
|
+
const app = h(ErrorBoundary, null, h(App, null));
|
|
44
|
+
render(options.router === false ? app : h(LocationProvider, null, app), el);
|
|
45
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { signal, computed, effect, type Signal, type ReadonlySignal } from "@preact/signals";
|
|
2
|
+
import type { PaginationResult } from "../server/index.ts";
|
|
3
|
+
import type { PaginatedQueryResult } from "./types.ts";
|
|
4
|
+
import type { ClientImpl } from "./connection.ts";
|
|
5
|
+
import type { Subscription } from "./subscription.ts";
|
|
6
|
+
|
|
7
|
+
export class Pager {
|
|
8
|
+
readonly pages: Signal<Subscription[]> = signal([]);
|
|
9
|
+
private stops: (() => void)[] = [];
|
|
10
|
+
private sizes: number[] = [];
|
|
11
|
+
readonly result: ReadonlySignal<{ results: any[]; status: PaginatedQueryResult<any>["status"] }>;
|
|
12
|
+
private client: ClientImpl;
|
|
13
|
+
private fn: string;
|
|
14
|
+
private prefix: unknown[];
|
|
15
|
+
private initial: number;
|
|
16
|
+
|
|
17
|
+
constructor(client: ClientImpl, fn: string, prefix: unknown[], initial: number) {
|
|
18
|
+
this.client = client;
|
|
19
|
+
this.fn = fn;
|
|
20
|
+
this.prefix = prefix;
|
|
21
|
+
this.initial = initial;
|
|
22
|
+
this.result = computed(() => {
|
|
23
|
+
const pages = this.pages.value;
|
|
24
|
+
const results: any[] = [];
|
|
25
|
+
// Before start() subscribes the first page, nothing has loaded yet.
|
|
26
|
+
let loading = pages.length === 0;
|
|
27
|
+
let done = false;
|
|
28
|
+
for (const [index, p] of pages.entries()) {
|
|
29
|
+
const err = p.error.value;
|
|
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;
|
|
34
|
+
if (v === undefined) {
|
|
35
|
+
loading = true;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
results.push(...v.page);
|
|
39
|
+
done = v.isDone;
|
|
40
|
+
}
|
|
41
|
+
const status = loading
|
|
42
|
+
? pages.length <= 1
|
|
43
|
+
? "LoadingFirstPage"
|
|
44
|
+
: "LoadingMore"
|
|
45
|
+
: done
|
|
46
|
+
? "Exhausted"
|
|
47
|
+
: "CanLoadMore";
|
|
48
|
+
return { results, status } as const;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
start(): void {
|
|
53
|
+
if (this.pages.peek().length === 0) this.addPage(null, this.initial);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stop(): void {
|
|
57
|
+
for (const s of this.stops) s();
|
|
58
|
+
this.stops = [];
|
|
59
|
+
this.sizes = [];
|
|
60
|
+
for (const p of this.pages.peek()) this.client.release(p);
|
|
61
|
+
this.pages.value = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
reset(): void {
|
|
65
|
+
this.stop();
|
|
66
|
+
this.start();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
loadMore(n?: number): void {
|
|
70
|
+
const pages = this.pages.peek();
|
|
71
|
+
const last = pages[pages.length - 1];
|
|
72
|
+
const v = last?.value.peek() as PaginationResult<any> | undefined;
|
|
73
|
+
if (!v || v.isDone) return;
|
|
74
|
+
this.addPage(v.continueCursor, n ?? this.initial);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private addPage(cursor: string | null, numItems: number): void {
|
|
78
|
+
const sub = this.client.acquire(this.fn, [...this.prefix, { cursor, numItems }]);
|
|
79
|
+
this.client.retain(sub);
|
|
80
|
+
const index = this.pages.peek().length;
|
|
81
|
+
this.sizes.push(numItems);
|
|
82
|
+
this.pages.value = [...this.pages.peek(), sub];
|
|
83
|
+
// If this page's end cursor moves (rows inserted/deleted), re-chain the pages after it.
|
|
84
|
+
let lastCursor: string | undefined;
|
|
85
|
+
const stop = effect(() => {
|
|
86
|
+
const v = sub.value.value as PaginationResult<any> | undefined;
|
|
87
|
+
if (!v) return;
|
|
88
|
+
if (lastCursor !== undefined && v.continueCursor !== lastCursor) this.truncateAfter(index, v);
|
|
89
|
+
lastCursor = v.continueCursor;
|
|
90
|
+
});
|
|
91
|
+
this.stops.push(stop);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private truncateAfter(index: number, v: PaginationResult<any>): void {
|
|
95
|
+
const pages = this.pages.peek();
|
|
96
|
+
if (pages.length <= index + 1) return;
|
|
97
|
+
const sizes = this.sizes.slice(index + 1);
|
|
98
|
+
for (const p of pages.slice(index + 1)) this.client.release(p);
|
|
99
|
+
for (const s of this.stops.splice(index + 1)) s();
|
|
100
|
+
this.sizes = this.sizes.slice(0, index + 1);
|
|
101
|
+
this.pages.value = pages.slice(0, index + 1);
|
|
102
|
+
if (!v.isDone) this.addPage(v.continueCursor, sizes.reduce((a, b) => a + b, 0));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -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,41 @@
|
|
|
1
|
+
export type Patch =
|
|
2
|
+
| { path: (string | number)[]; value: unknown }
|
|
3
|
+
| { path: (string | number)[]; remove: true }
|
|
4
|
+
| { path: (string | number)[]; index: number; deleteCount: number; items: unknown[] };
|
|
5
|
+
|
|
6
|
+
/** Apply only to the server value. Optimistic layers are replayed afterwards. */
|
|
7
|
+
export function applyPatch(value: unknown, patches: Patch[]): unknown {
|
|
8
|
+
return patches.reduce((current, patch) => edit(current, patch.path, patch), value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function edit(value: unknown, path: (string | number)[], patch: Patch): unknown {
|
|
12
|
+
if (path.length === 0) {
|
|
13
|
+
if ("index" in patch) {
|
|
14
|
+
if (!Array.isArray(value) || patch.index < 0 || patch.index + patch.deleteCount > value.length) {
|
|
15
|
+
throw new Error("Invalid array patch");
|
|
16
|
+
}
|
|
17
|
+
const next: unknown[] = [...value];
|
|
18
|
+
next.splice(patch.index, patch.deleteCount, ...patch.items);
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
21
|
+
if ("value" in patch) return patch.value;
|
|
22
|
+
throw new Error("Cannot remove the root value");
|
|
23
|
+
}
|
|
24
|
+
const [key, ...rest] = path;
|
|
25
|
+
if (Array.isArray(value) && typeof key === "number" && key >= 0 && key < value.length) {
|
|
26
|
+
const next: unknown[] = [...value];
|
|
27
|
+
if (rest.length === 0 && "remove" in patch) next.splice(key, 1);
|
|
28
|
+
else next[key] = edit(value[key], rest, patch);
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value) && typeof key === "string") {
|
|
32
|
+
const next: Record<string, unknown> = { ...value };
|
|
33
|
+
if (rest.length === 0 && "remove" in patch) delete next[key];
|
|
34
|
+
else {
|
|
35
|
+
const previous = Object.hasOwn(next, key) ? next[key] : undefined;
|
|
36
|
+
Object.defineProperty(next, key, { value: edit(previous, rest, patch), enumerable: true, writable: true, configurable: true });
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
throw new Error("Invalid patch path");
|
|
41
|
+
}
|
|
@@ -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
|
+
}
|