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
|
@@ -6,25 +6,87 @@ export interface UploadedObject {
|
|
|
6
6
|
size: number;
|
|
7
7
|
contentType: string;
|
|
8
8
|
name: string | null;
|
|
9
|
+
/** Private upload capability. Keep it out of public rows and file references. */
|
|
10
|
+
deleteToken?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StorageUploadOptions {
|
|
14
|
+
name?: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
/** Called with the uploaded fraction, from 0 to 1, as the bytes leave the browser. */
|
|
17
|
+
onProgress?: (fraction: number) => void;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
export interface StorageClient {
|
|
12
|
-
/** Store a file in this app's
|
|
13
|
-
upload(body: Blob, options?:
|
|
21
|
+
/** Store a file in this app's bucket. Apps and their files are public. */
|
|
22
|
+
upload(body: Blob, options?: StorageUploadOptions): Promise<UploadedObject>;
|
|
14
23
|
url(key: string): string;
|
|
15
|
-
|
|
24
|
+
/** A stored file's metadata, or null when there is no such file. */
|
|
25
|
+
info(key: string, options?: { signal?: AbortSignal }): Promise<UploadedObject | null>;
|
|
26
|
+
delete(key: string, options?: { signal?: AbortSignal; deleteToken?: string }): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Storage keys are UUIDs. */
|
|
30
|
+
export function isStorageKey(key: unknown): key is string {
|
|
31
|
+
return typeof key === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The filename in a `filename*=UTF-8''...` Content-Disposition. */
|
|
35
|
+
function dispositionName(value: string | null): string | null {
|
|
36
|
+
const encoded = value?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
37
|
+
if (!encoded) return null;
|
|
38
|
+
try {
|
|
39
|
+
return decodeURIComponent(encoded);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** POST `body` with XMLHttpRequest, which reports upload progress where fetch cannot. */
|
|
46
|
+
function sendWithProgress(target: string, headers: Headers, body: Blob, signal: AbortSignal | undefined, onProgress: (fraction: number) => void) {
|
|
47
|
+
return new Promise<{ status: number; text: string }>((resolve, reject) => {
|
|
48
|
+
const abortError = () => signal?.reason ?? new DOMException("The upload was aborted", "AbortError");
|
|
49
|
+
if (signal?.aborted) return reject(abortError());
|
|
50
|
+
const xhr = new XMLHttpRequest();
|
|
51
|
+
const abort = () => xhr.abort();
|
|
52
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
53
|
+
const done = () => signal?.removeEventListener("abort", abort);
|
|
54
|
+
xhr.open("POST", target);
|
|
55
|
+
headers.forEach((value, name) => xhr.setRequestHeader(name, value));
|
|
56
|
+
xhr.upload.onprogress = (e) => {
|
|
57
|
+
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
|
|
58
|
+
};
|
|
59
|
+
xhr.onload = () => {
|
|
60
|
+
done();
|
|
61
|
+
if (xhr.status >= 200 && xhr.status < 300) onProgress(1);
|
|
62
|
+
resolve({ status: xhr.status, text: xhr.responseText });
|
|
63
|
+
};
|
|
64
|
+
xhr.onerror = () => {
|
|
65
|
+
done();
|
|
66
|
+
reject(new EraspaceError("StorageError", "The upload failed. Check your connection and try again."));
|
|
67
|
+
};
|
|
68
|
+
xhr.onabort = () => {
|
|
69
|
+
done();
|
|
70
|
+
reject(abortError());
|
|
71
|
+
};
|
|
72
|
+
xhr.send(body);
|
|
73
|
+
});
|
|
16
74
|
}
|
|
17
75
|
|
|
18
76
|
export function createStorageClient(base: () => string): StorageClient {
|
|
77
|
+
const deleteTokens = new Map<string, string>();
|
|
19
78
|
function url(key?: string): string {
|
|
20
79
|
const path = `${base().replace(/\/+$/, "")}/_sb/storage`;
|
|
21
80
|
return key === undefined ? path : `${path}/${encodeURIComponent(key)}`;
|
|
22
81
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
82
|
+
function check(status: number, text: string): Record<string, unknown> {
|
|
83
|
+
let payload: unknown;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(text);
|
|
86
|
+
} catch {}
|
|
87
|
+
if (!payload || typeof payload !== "object") throw new EraspaceError("StorageError", `HTTP ${status}`);
|
|
26
88
|
const result = payload as Record<string, unknown>;
|
|
27
|
-
if (
|
|
89
|
+
if (status < 200 || status > 299 || result.ok !== true) throw EraspaceError.from(result.error, `Storage request failed: HTTP ${status}`);
|
|
28
90
|
return result;
|
|
29
91
|
}
|
|
30
92
|
return {
|
|
@@ -33,14 +95,38 @@ export function createStorageClient(base: () => string): StorageClient {
|
|
|
33
95
|
const headers = new Headers({ "content-type": body.type || "application/octet-stream" });
|
|
34
96
|
const name = options.name ?? (typeof File !== "undefined" && body instanceof File ? body.name : undefined);
|
|
35
97
|
if (name) headers.set("x-filename", encodeURIComponent(name));
|
|
36
|
-
const
|
|
98
|
+
const { onProgress, signal } = options;
|
|
99
|
+
const response = onProgress && typeof XMLHttpRequest !== "undefined"
|
|
100
|
+
? await sendWithProgress(url(), headers, body, signal, onProgress)
|
|
101
|
+
: await fetch(url(), { method: "POST", headers, body, signal }).then(async (r) => ({ status: r.status, text: await r.text() }));
|
|
102
|
+
const result = check(response.status, response.text);
|
|
37
103
|
if (typeof result.key !== "string" || typeof result.url !== "string" || typeof result.size !== "number" || typeof result.contentType !== "string" || (result.name !== null && typeof result.name !== "string")) {
|
|
38
104
|
throw new EraspaceError("StorageError", "Invalid upload response");
|
|
39
105
|
}
|
|
40
|
-
|
|
106
|
+
const deleteToken = typeof result.deleteToken === "string" ? result.deleteToken : undefined;
|
|
107
|
+
if (deleteToken) deleteTokens.set(result.key, deleteToken);
|
|
108
|
+
return { key: result.key, url: result.url, size: result.size, contentType: result.contentType, name: result.name, ...(deleteToken ? { deleteToken } : {}) };
|
|
109
|
+
},
|
|
110
|
+
async info(key, options = {}) {
|
|
111
|
+
if (!isStorageKey(key)) return null;
|
|
112
|
+
const response = await fetch(url(key), { method: "HEAD", signal: options.signal });
|
|
113
|
+
if (response.status === 404) return null;
|
|
114
|
+
if (!response.ok) throw new EraspaceError("StorageError", `Storage request failed: HTTP ${response.status}`);
|
|
115
|
+
return {
|
|
116
|
+
key,
|
|
117
|
+
url: url(key),
|
|
118
|
+
size: Number(response.headers.get("content-length") ?? 0),
|
|
119
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
120
|
+
name: dispositionName(response.headers.get("content-disposition")),
|
|
121
|
+
};
|
|
41
122
|
},
|
|
42
123
|
async delete(key, options = {}) {
|
|
43
|
-
|
|
124
|
+
const deleteToken = options.deleteToken ?? deleteTokens.get(key);
|
|
125
|
+
const headers = new Headers();
|
|
126
|
+
if (deleteToken) headers.set("x-eraspace-file-token", deleteToken);
|
|
127
|
+
const response = await fetch(url(key), { method: "DELETE", headers, signal: options.signal });
|
|
128
|
+
check(response.status, await response.text());
|
|
129
|
+
deleteTokens.delete(key);
|
|
44
130
|
},
|
|
45
131
|
};
|
|
46
132
|
}
|
|
@@ -28,9 +28,13 @@ export class Subscription {
|
|
|
28
28
|
commitSeq = -1;
|
|
29
29
|
revision: number | undefined;
|
|
30
30
|
request = 0;
|
|
31
|
+
retries = 0;
|
|
32
|
+
retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
31
33
|
refs = 0;
|
|
32
34
|
layers: Layer[] = [];
|
|
33
35
|
timer: ReturnType<typeof setTimeout> | undefined;
|
|
36
|
+
/** The id last sent to the server, and the app channel join it was sent on. */
|
|
37
|
+
sent: { id: string; join: number } | undefined;
|
|
34
38
|
// Resubscriptions take a new id so messages for the previous one are ignored.
|
|
35
39
|
id: string;
|
|
36
40
|
readonly fn: string;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { EraspaceError } from "./errors.ts";
|
|
2
|
+
import { retryDelay } from "./retry.ts";
|
|
3
|
+
|
|
4
|
+
// Event classes differ across runtimes. Handlers only read message data.
|
|
5
|
+
type TransportHandler<T> = { handle(event: T): void }["handle"];
|
|
6
|
+
|
|
7
|
+
/** The browser WebSocket API subset also implemented by Node and ws. */
|
|
8
|
+
export interface WebSocketTransport {
|
|
9
|
+
readonly readyState: number;
|
|
10
|
+
onopen: TransportHandler<unknown> | null;
|
|
11
|
+
onclose: TransportHandler<unknown> | null;
|
|
12
|
+
onerror: TransportHandler<unknown> | null;
|
|
13
|
+
onmessage: TransportHandler<{ data: unknown }> | null;
|
|
14
|
+
send(data: string): void;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
export type WebSocketConstructor = new (url: string) => WebSocketTransport;
|
|
18
|
+
export interface SocketOptions {
|
|
19
|
+
timeout?: number;
|
|
20
|
+
transport?: WebSocketConstructor;
|
|
21
|
+
reconnectAfterMs?: (attempt: number) => number;
|
|
22
|
+
rejoinAfterMs?: (attempt: number) => number;
|
|
23
|
+
/** How long a socket must stay open before reconnect backoff starts again from the first attempt. */
|
|
24
|
+
stableAfterMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Frame {
|
|
28
|
+
session: string | null;
|
|
29
|
+
ref: string | null;
|
|
30
|
+
topic: string;
|
|
31
|
+
event: string;
|
|
32
|
+
payload: unknown;
|
|
33
|
+
}
|
|
34
|
+
interface Pending {
|
|
35
|
+
/** The frame to send: undefined while not ready, null to withdraw a request that was never sent. */
|
|
36
|
+
frame: () => Frame | undefined | null;
|
|
37
|
+
sent?: Frame;
|
|
38
|
+
resolve: (value: unknown) => void;
|
|
39
|
+
reject: (error: EraspaceError) => void;
|
|
40
|
+
timer: ReturnType<typeof setTimeout>;
|
|
41
|
+
topic: string;
|
|
42
|
+
}
|
|
43
|
+
export interface JoinReply { self?: string; now?: number; version?: number }
|
|
44
|
+
|
|
45
|
+
/** Multiplexes topics over one native WebSocket. Sent requests are never replayed on reconnect. */
|
|
46
|
+
export class Socket {
|
|
47
|
+
private ws: WebSocketTransport | undefined;
|
|
48
|
+
private channels = new Map<string, Channel>();
|
|
49
|
+
private pending = new Map<string, Pending>();
|
|
50
|
+
private nextRef = 0;
|
|
51
|
+
private incoming = Promise.resolve();
|
|
52
|
+
private stopped = true;
|
|
53
|
+
private attempts = 0;
|
|
54
|
+
private reconnect: ReturnType<typeof setTimeout> | undefined;
|
|
55
|
+
private opening: ReturnType<typeof setTimeout> | undefined;
|
|
56
|
+
private stable: ReturnType<typeof setTimeout> | undefined;
|
|
57
|
+
private heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
58
|
+
private awaitingHeartbeat: { ref: string; timer: ReturnType<typeof setTimeout> } | undefined;
|
|
59
|
+
private opens = new Set<() => void>();
|
|
60
|
+
private closes = new Set<() => void>();
|
|
61
|
+
readonly options: SocketOptions;
|
|
62
|
+
private url: string;
|
|
63
|
+
|
|
64
|
+
constructor(endpoint: string, options: SocketOptions = {}) {
|
|
65
|
+
const url = new URL(endpoint, typeof location === "undefined" ? undefined : location.href);
|
|
66
|
+
url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:";
|
|
67
|
+
url.searchParams.set("v", "1");
|
|
68
|
+
this.url = url.href;
|
|
69
|
+
this.options = options;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onOpen(cb: () => void): void { this.opens.add(cb); }
|
|
73
|
+
onClose(cb: () => void): void { this.closes.add(cb); }
|
|
74
|
+
ref(): string { return String(++this.nextRef); }
|
|
75
|
+
get connected(): boolean { return this.ws?.readyState === 1; }
|
|
76
|
+
|
|
77
|
+
connect(): void {
|
|
78
|
+
if (!this.stopped) return;
|
|
79
|
+
this.stopped = false;
|
|
80
|
+
this.attempts = 0;
|
|
81
|
+
this.open();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private open(): void {
|
|
85
|
+
if (this.stopped) return;
|
|
86
|
+
const Transport = this.options.transport ?? WebSocket;
|
|
87
|
+
let ws: WebSocketTransport;
|
|
88
|
+
try { ws = new Transport(this.url); }
|
|
89
|
+
catch { this.scheduleReconnect(); return; }
|
|
90
|
+
this.ws = ws;
|
|
91
|
+
this.opening = setTimeout(() => this.lost(ws), this.options.timeout ?? 15000);
|
|
92
|
+
ws.onopen = () => {
|
|
93
|
+
if (this.ws !== ws) return;
|
|
94
|
+
clearTimeout(this.opening);
|
|
95
|
+
// A server that accepts sockets and then drops them must not reset every client's backoff at once, so
|
|
96
|
+
// backoff restarts only after the connection has proved stable.
|
|
97
|
+
this.stable = setTimeout(() => {
|
|
98
|
+
if (this.ws === ws) this.attempts = 0;
|
|
99
|
+
}, this.options.stableAfterMs ?? 10000);
|
|
100
|
+
for (const cb of this.opens) cb();
|
|
101
|
+
for (const channel of this.channels.values()) channel.rejoin();
|
|
102
|
+
this.flush();
|
|
103
|
+
this.heartbeat = setInterval(() => this.sendHeartbeat(ws), 25000);
|
|
104
|
+
};
|
|
105
|
+
ws.onmessage = (event) => {
|
|
106
|
+
if (this.ws !== ws || typeof event.data !== "string") return;
|
|
107
|
+
let frame: Frame;
|
|
108
|
+
try {
|
|
109
|
+
const value: unknown = JSON.parse(event.data);
|
|
110
|
+
if (!value || typeof value !== "object" || !("topic" in value) || typeof value.topic !== "string"
|
|
111
|
+
|| !("event" in value) || typeof value.event !== "string" || !("session" in value)
|
|
112
|
+
|| !(value.session === null || typeof value.session === "string") || !("ref" in value)
|
|
113
|
+
|| !(value.ref === null || typeof value.ref === "string") || !("payload" in value)) return;
|
|
114
|
+
frame = value as Frame;
|
|
115
|
+
} catch { return; }
|
|
116
|
+
// ws can deliver several frames in one task. Let reply consumers settle before the next push.
|
|
117
|
+
this.incoming = this.incoming.then(() => {
|
|
118
|
+
if (this.ws === ws) this.receive(frame);
|
|
119
|
+
}).catch(() => this.lost(ws));
|
|
120
|
+
};
|
|
121
|
+
ws.onclose = () => this.lost(ws);
|
|
122
|
+
ws.onerror = () => this.lost(ws);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private receive(frame: Frame): void {
|
|
126
|
+
if (frame.event === "reply" && frame.ref) {
|
|
127
|
+
const body = frame.payload;
|
|
128
|
+
if (!body || typeof body !== "object" || !("status" in body) || !("response" in body)) return;
|
|
129
|
+
if (body.status !== "ok" && body.status !== "error") return;
|
|
130
|
+
if (frame.ref === this.awaitingHeartbeat?.ref && frame.topic === "" && frame.session === null && body.status === "ok") {
|
|
131
|
+
clearTimeout(this.awaitingHeartbeat.timer);
|
|
132
|
+
this.awaitingHeartbeat = undefined;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const pending = this.pending.get(frame.ref);
|
|
136
|
+
if (!pending?.sent || pending.sent.topic !== frame.topic || pending.sent.session !== frame.session) return;
|
|
137
|
+
this.pending.delete(frame.ref);
|
|
138
|
+
clearTimeout(pending.timer);
|
|
139
|
+
if (body.status === "ok") pending.resolve(body.response);
|
|
140
|
+
else {
|
|
141
|
+
const error = EraspaceError.from(body.response);
|
|
142
|
+
pending.reject(new EraspaceError(error.name, error.message, body.response));
|
|
143
|
+
}
|
|
144
|
+
this.flush();
|
|
145
|
+
} else {
|
|
146
|
+
this.channels.get(frame.topic)?.receive(frame);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private sendHeartbeat(ws: WebSocketTransport): void {
|
|
151
|
+
if (this.ws !== ws || ws.readyState !== 1 || this.awaitingHeartbeat) return;
|
|
152
|
+
const ref = this.ref();
|
|
153
|
+
// Liveness checks bypass the application window and start their deadline only after send succeeds.
|
|
154
|
+
try {
|
|
155
|
+
ws.send(JSON.stringify({ session: null, ref, topic: "", event: "ping", payload: {} }));
|
|
156
|
+
this.awaitingHeartbeat = { ref, timer: setTimeout(() => this.lost(ws), 10000) };
|
|
157
|
+
} catch { this.lost(ws); }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private lost(ws: WebSocketTransport): void {
|
|
161
|
+
if (this.ws !== ws) return;
|
|
162
|
+
this.ws = undefined;
|
|
163
|
+
clearTimeout(this.opening);
|
|
164
|
+
clearTimeout(this.stable);
|
|
165
|
+
ws.onopen = ws.onclose = ws.onmessage = null;
|
|
166
|
+
ws.onerror = () => {};
|
|
167
|
+
ws.close();
|
|
168
|
+
clearInterval(this.heartbeat);
|
|
169
|
+
clearTimeout(this.awaitingHeartbeat?.timer);
|
|
170
|
+
this.awaitingHeartbeat = undefined;
|
|
171
|
+
for (const [ref, pending] of this.pending) {
|
|
172
|
+
if (pending.sent) this.fail(ref, new EraspaceError("OutcomeUnknownError", "Connection lost before the reply arrived"));
|
|
173
|
+
}
|
|
174
|
+
for (const channel of this.channels.values()) channel.disconnected();
|
|
175
|
+
for (const cb of this.closes) cb();
|
|
176
|
+
this.scheduleReconnect();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private scheduleReconnect(): void {
|
|
180
|
+
if (this.stopped) return;
|
|
181
|
+
clearTimeout(this.reconnect);
|
|
182
|
+
this.reconnect = setTimeout(() => this.open(), (this.options.reconnectAfterMs ?? retryDelay)(++this.attempts));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
disconnect(): void {
|
|
186
|
+
this.stopped = true;
|
|
187
|
+
clearTimeout(this.reconnect);
|
|
188
|
+
clearInterval(this.heartbeat);
|
|
189
|
+
for (const channel of [...this.channels.values()]) channel.leave();
|
|
190
|
+
if (this.ws) this.lost(this.ws);
|
|
191
|
+
for (const ref of this.pending.keys()) this.fail(ref, new EraspaceError("UnavailableError", "Socket closed"));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
channel(topic: string, payload: object | (() => object) = {}): Channel {
|
|
195
|
+
const previous = this.channels.get(topic);
|
|
196
|
+
if (previous) return previous;
|
|
197
|
+
const channel = new Channel(this, topic, payload);
|
|
198
|
+
this.channels.set(topic, channel);
|
|
199
|
+
return channel;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
remove(channel: Channel, session: string | null): void {
|
|
203
|
+
if (this.channels.get(channel.topic) !== channel) return;
|
|
204
|
+
const ws = this.ws;
|
|
205
|
+
if (session !== null && !this.stopped && ws?.readyState === 1) {
|
|
206
|
+
// Cleanup must also work while the request window is full or a join reply is still pending.
|
|
207
|
+
try { ws.send(JSON.stringify({ session, ref: this.ref(), topic: channel.topic, event: "leave", payload: {} })); }
|
|
208
|
+
catch { this.lost(ws); }
|
|
209
|
+
}
|
|
210
|
+
this.channels.delete(channel.topic);
|
|
211
|
+
for (const [ref, pending] of this.pending) {
|
|
212
|
+
if (pending.topic === channel.topic) this.fail(ref, new EraspaceError("UnavailableError", "Channel closed"));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
request<T>(frame: () => Frame | undefined | null, topic: string, timeout = this.options.timeout ?? 15000): Promise<T> {
|
|
217
|
+
if (this.pending.size >= 256) return Promise.reject(new EraspaceError("ResourceError", "Too many pending socket requests"));
|
|
218
|
+
const ref = this.ref();
|
|
219
|
+
return new Promise<T>((resolve, reject) => {
|
|
220
|
+
// Work that never left this client has no unknown outcome, so it does not time out like a sent request.
|
|
221
|
+
const timer = setTimeout(() => this.fail(ref, this.pending.get(ref)?.sent
|
|
222
|
+
? new EraspaceError("TimeoutError", "Socket request timed out")
|
|
223
|
+
: new EraspaceError("UnavailableError", "Not connected; the request was not sent")), timeout);
|
|
224
|
+
this.pending.set(ref, { frame, topic, timer, resolve: value => resolve(value as T), reject });
|
|
225
|
+
this.flush();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private fail(ref: string, error: EraspaceError): void {
|
|
230
|
+
const pending = this.pending.get(ref);
|
|
231
|
+
if (!pending) return;
|
|
232
|
+
this.pending.delete(ref);
|
|
233
|
+
clearTimeout(pending.timer);
|
|
234
|
+
pending.reject(error);
|
|
235
|
+
this.flush();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
flush(): void {
|
|
239
|
+
const ws = this.ws;
|
|
240
|
+
if (!ws || ws.readyState !== 1) return;
|
|
241
|
+
// A join can produce a reply plus presence and state pushes. Bound bursts below the desk's mailboxes.
|
|
242
|
+
let inFlight = [...this.pending.values()].filter(pending => pending.sent).length;
|
|
243
|
+
for (const [ref, pending] of this.pending) {
|
|
244
|
+
if (inFlight >= 16) break;
|
|
245
|
+
if (pending.sent) continue;
|
|
246
|
+
const frame = pending.frame();
|
|
247
|
+
if (frame === null) {
|
|
248
|
+
this.pending.delete(ref);
|
|
249
|
+
clearTimeout(pending.timer);
|
|
250
|
+
pending.reject(new EraspaceError("UnavailableError", "Request withdrawn before it was sent"));
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (!frame) continue;
|
|
254
|
+
frame.ref = ref;
|
|
255
|
+
try { ws.send(JSON.stringify(frame)); pending.sent = frame; inFlight++; }
|
|
256
|
+
catch { this.lost(ws); break; }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A join generation prevents late replies and pushes from a replaced channel being delivered. */
|
|
262
|
+
export class Channel {
|
|
263
|
+
readonly topic: string;
|
|
264
|
+
private socket: Socket;
|
|
265
|
+
private payload: object | (() => object);
|
|
266
|
+
private session: string | null = null;
|
|
267
|
+
private joined = false;
|
|
268
|
+
private started = false;
|
|
269
|
+
private closed = false;
|
|
270
|
+
private joining = false;
|
|
271
|
+
private attempts = 0;
|
|
272
|
+
private retry: ReturnType<typeof setTimeout> | undefined;
|
|
273
|
+
private events = new Map<string, Set<(payload: unknown) => void>>();
|
|
274
|
+
private joins = new Set<(reply: JoinReply) => void>();
|
|
275
|
+
private errors = new Set<(error: unknown, rejected: boolean) => void>();
|
|
276
|
+
private closes = new Set<() => void>();
|
|
277
|
+
onMessage: ((event: string, payload: unknown) => void) | undefined;
|
|
278
|
+
|
|
279
|
+
constructor(socket: Socket, topic: string, payload: object | (() => object)) {
|
|
280
|
+
this.socket = socket;
|
|
281
|
+
this.topic = topic;
|
|
282
|
+
this.payload = payload;
|
|
283
|
+
}
|
|
284
|
+
on<T = unknown>(event: string, cb: (payload: T) => void): () => void {
|
|
285
|
+
const handler = (value: unknown) => cb(value as T);
|
|
286
|
+
let handlers = this.events.get(event);
|
|
287
|
+
if (!handlers) this.events.set(event, handlers = new Set());
|
|
288
|
+
handlers.add(handler);
|
|
289
|
+
return () => handlers.delete(handler);
|
|
290
|
+
}
|
|
291
|
+
onJoin(cb: (reply: JoinReply) => void): void { this.joins.add(cb); }
|
|
292
|
+
onError(cb: (error: unknown, rejected: boolean) => void): void { this.errors.add(cb); }
|
|
293
|
+
onClose(cb: () => void): void { this.closes.add(cb); }
|
|
294
|
+
|
|
295
|
+
join(): void {
|
|
296
|
+
if (this.started || this.closed) return;
|
|
297
|
+
this.started = true;
|
|
298
|
+
this.rejoin();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
rejoin(): void {
|
|
302
|
+
if (!this.started || this.closed || this.joined || this.joining || !this.socket.connected) return;
|
|
303
|
+
clearTimeout(this.retry);
|
|
304
|
+
const session = this.socket.ref();
|
|
305
|
+
this.session = session;
|
|
306
|
+
this.joining = true;
|
|
307
|
+
void this.socket.request<JoinReply>(() => ({ session, ref: null, topic: this.topic, event: "join", payload: typeof this.payload === "function" ? this.payload() : this.payload }), this.topic)
|
|
308
|
+
.then(reply => {
|
|
309
|
+
if (this.closed || this.session !== session) return;
|
|
310
|
+
this.joining = false;
|
|
311
|
+
this.joined = true;
|
|
312
|
+
this.attempts = 0;
|
|
313
|
+
for (const cb of this.joins) cb(reply);
|
|
314
|
+
this.socket.flush();
|
|
315
|
+
}, error => {
|
|
316
|
+
if (this.closed || this.session !== session) return;
|
|
317
|
+
this.joining = false;
|
|
318
|
+
for (const cb of this.errors) cb(error, error instanceof EraspaceError && error.data !== undefined);
|
|
319
|
+
if (!this.closed) this.retry = setTimeout(() => this.rejoin(), (this.socket.options.rejoinAfterMs ?? retryDelay)(++this.attempts));
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** A function payload is read when the request is sent; returning null withdraws the request unsent. */
|
|
324
|
+
push<T = unknown>(event: string, payload: object | (() => object | null), timeout?: number): Promise<T> {
|
|
325
|
+
if (this.closed) return Promise.reject(new EraspaceError("UnavailableError", "Channel closed"));
|
|
326
|
+
return this.socket.request<T>(() => {
|
|
327
|
+
if (!this.joined) return undefined;
|
|
328
|
+
const body = typeof payload === "function" ? payload() : payload;
|
|
329
|
+
return body === null ? null : { session: this.session, ref: null, topic: this.topic, event, payload: body };
|
|
330
|
+
}, this.topic, timeout);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
receive(frame: Frame): void {
|
|
334
|
+
if (this.closed || frame.session !== this.session) return;
|
|
335
|
+
if (frame.event === "closed") { this.leave(); return; }
|
|
336
|
+
this.onMessage?.(frame.event, frame.payload);
|
|
337
|
+
for (const cb of this.events.get(frame.event) ?? []) cb(frame.payload);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
disconnected(): void {
|
|
341
|
+
this.joined = this.joining = false;
|
|
342
|
+
this.session = null;
|
|
343
|
+
clearTimeout(this.retry);
|
|
344
|
+
for (const cb of this.errors) cb(new EraspaceError("UnavailableError", "Connection lost"), false);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
leave(): void {
|
|
348
|
+
if (this.closed) return;
|
|
349
|
+
const session = this.session;
|
|
350
|
+
this.closed = true;
|
|
351
|
+
this.joined = false;
|
|
352
|
+
clearTimeout(this.retry);
|
|
353
|
+
this.socket.remove(this, session);
|
|
354
|
+
for (const cb of this.closes) cb();
|
|
355
|
+
}
|
|
356
|
+
}
|
package/sdk/src/client/types.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReadonlySignal } from "@preact/signals";
|
|
2
2
|
import type { EraspaceError } from "./errors.ts";
|
|
3
3
|
import type { StorageClient } from "./storage.ts";
|
|
4
|
+
import type { Upload, UseUploadOptions } from "./files.ts";
|
|
4
5
|
|
|
5
6
|
import type {
|
|
6
7
|
ActionArgs,
|
|
@@ -72,11 +73,15 @@ export interface Room<M = Record<string, unknown>, S extends Record<string, unkn
|
|
|
72
73
|
on(event: string, cb: (payload: any, from: string | undefined) => void): () => void;
|
|
73
74
|
/** Ephemeral shared state (kept by the room process, never stored). */
|
|
74
75
|
state: Partial<S>;
|
|
76
|
+
/** Last room join or write error. Cleared by a successful join or state update. */
|
|
77
|
+
error: EraspaceError | undefined;
|
|
75
78
|
setState<K extends keyof S & string>(key: K, value: S[K]): void;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
export interface EraspaceClient<C> {
|
|
79
82
|
readonly storage: StorageClient;
|
|
83
|
+
/** Upload files to app storage with progress and errors as component state. `<FileUpload>` wraps this. */
|
|
84
|
+
useUpload(options?: UseUploadOptions): Upload;
|
|
80
85
|
/** Subscribe to a query. Returns `undefined` until the first result arrives, then live values. */
|
|
81
86
|
useQuery<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): QueryResult<C, N> | undefined;
|
|
82
87
|
/** Pass computed signals directly to JSX text nodes for updates without a component render. */
|
|
@@ -102,6 +107,8 @@ export interface EraspaceClient<C> {
|
|
|
102
107
|
|
|
103
108
|
query<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): Promise<QueryResult<C, N>>;
|
|
104
109
|
mutation<N extends MutationName<C>>(name: N, ...args: MutationArgs<C, N>): Promise<MutationResult<C, N>>;
|
|
110
|
+
/** Recover an unknown mutation outcome with its original name, arguments and request key, within 24 hours. */
|
|
111
|
+
retryMutation<N extends MutationName<C>>(name: N, args: MutationArgs<C, N>, requestKey: string): Promise<MutationResult<C, N>>;
|
|
105
112
|
action<N extends ActionName<C>>(name: N, ...args: ActionArgs<C, N>): Promise<ActionResult<C, N>>;
|
|
106
113
|
/** Imperative live subscription. Returns an unsubscribe function. */
|
|
107
114
|
subscribe<N extends QueryName<C>>(
|
package/sdk/src/server/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/generate-server-api.ts. Edit api/server-api.ts or api/server.ts.in.
|
|
2
2
|
// eraspace/server: capsule definition API.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// module also works in Bun (tests, tooling) and, mainly, so the types drive inference.
|
|
4
|
+
// V8 supplies this module from the engine bootstrap. These descriptors also run in Node for tooling and tests;
|
|
5
|
+
// their types drive client and server inference.
|
|
7
6
|
//
|
|
8
7
|
// Type design. `ctx.db` must be typed from `schema` even though both live in the same object literal:
|
|
9
8
|
//
|
|
@@ -476,7 +475,7 @@ export interface EndpointRequest {
|
|
|
476
475
|
query: EndpointQuery;
|
|
477
476
|
text(): Promise<string>;
|
|
478
477
|
json<T = unknown>(): Promise<T>;
|
|
479
|
-
/** Request body bytes
|
|
478
|
+
/** Request body bytes as a JSON-compatible number array. */
|
|
480
479
|
bytes(): Promise<number[]>;
|
|
481
480
|
}
|
|
482
481
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createClient } from "eraspace/client";
|
|
1
|
+
import { createClient, FilePreview, FileUpload, type FileRef } from "eraspace/client";
|
|
2
2
|
import { useState, useEffect } from "preact/hooks";
|
|
3
3
|
import type app from "../server/index";
|
|
4
4
|
const client = createClient<typeof app>({ reloadOnDeploy: true });
|
|
@@ -9,6 +9,7 @@ export default function App() {
|
|
|
9
9
|
const [error, setError] = useState("");
|
|
10
10
|
const [sending, setSending] = useState(false);
|
|
11
11
|
const [typing, setTyping] = useState("");
|
|
12
|
+
const [file, setFile] = useState<FileRef | null>(null);
|
|
12
13
|
const messages = client.useQuery("messages", room);
|
|
13
14
|
const send = client.useMutation("send");
|
|
14
15
|
const channel = client.useRoom<{ name: string }> (room, { name: author });
|
|
@@ -22,14 +23,17 @@ export default function App() {
|
|
|
22
23
|
<input name="room" aria-label="Room" defaultValue={room} maxLength={40}/><button class="subtle">Join room</button>
|
|
23
24
|
</form>
|
|
24
25
|
<div class="presence">{channel.presence.length} online · {channel.presence.map(member => member.meta.name || "Guest").join(", ")}</div>
|
|
25
|
-
{messages === undefined ? <p>Loading messages…</p> : messages.length === 0 ? <p>Send the first message.</p> : <ul>{[...messages].reverse().map(message => <li key={message.id}><strong>{message.author}</strong><span>{message.body}</span><time>{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time></li>)}</ul>}
|
|
26
|
+
{messages === undefined ? <p>Loading messages…</p> : messages.length === 0 ? <p>Send the first message.</p> : <ul>{[...messages].reverse().map(message => <li key={message.id}><strong>{message.author}</strong><span>{message.body}{message.file && <FilePreview client={client} file={message.file} class="attachment"/>}</span><time>{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</time></li>)}</ul>}
|
|
26
27
|
<form onSubmit={async e => {
|
|
27
28
|
e.preventDefault(); setSending(true); setError("");
|
|
28
|
-
try { await send(room, author, body); setBody(""); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); }
|
|
29
|
+
try { await send(room, author, body, file); setBody(""); setFile(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); }
|
|
29
30
|
}}>
|
|
30
31
|
<input aria-label="Your name" value={author} maxLength={40} onInput={e => setAuthor(e.currentTarget.value)}/>
|
|
31
|
-
<input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required onInput={e => { setBody(e.currentTarget.value); channel.broadcast("typing", author); }}/>
|
|
32
|
-
<button disabled={sending || !body.trim()}>Send</button>
|
|
32
|
+
<input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required={!file} onInput={e => { setBody(e.currentTarget.value); channel.broadcast("typing", author); }}/>
|
|
33
|
+
<button disabled={sending || (!body.trim() && !file)}>Send</button>
|
|
34
|
+
{file
|
|
35
|
+
? <div class="pending"><FilePreview client={client} file={file} caption={false} class="attachment"/><span>{file.name}</span><button type="button" class="subtle" disabled={sending} onClick={() => { void client.storage.delete(file.key).catch(() => {}); setFile(null); }}>Remove</button></div>
|
|
36
|
+
: <FileUpload client={client} class="attach" onUpload={setFile}>Attach a file (up to 5 MB)</FileUpload>}
|
|
33
37
|
</form>
|
|
34
38
|
{typing && <small>{typing} is typing</small>}
|
|
35
39
|
{error && <p role="alert">{error}</p>}
|
|
@@ -24,5 +24,20 @@
|
|
|
24
24
|
meter::-moz-meter-bar { background: #fff; }
|
|
25
25
|
.done { text-decoration: line-through; }
|
|
26
26
|
.presence { padding: 12px 0; font-size: 13px; }
|
|
27
|
+
.attach { flex-basis: 100%; padding: 10px; border: 1px dashed #666; color: #aaa; font-size: 13px; text-align: center; cursor: pointer; position: relative; }
|
|
28
|
+
.attach[data-dragging], .attach:hover, .attach:focus-within { border-color: #fff; color: #fff; }
|
|
29
|
+
.attach progress { display: block; width: 100%; margin-top: 6px; accent-color: #fff; }
|
|
30
|
+
.pending { flex-basis: 100%; display: flex; align-items: center; gap: 12px; font-size: 13px; }
|
|
31
|
+
.pending > span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
32
|
+
li:has(.attachment) { align-items: flex-start; }
|
|
33
|
+
.attachment { margin: 8px 0 0; }
|
|
34
|
+
li > span > .attachment:first-child { margin-top: 0; }
|
|
35
|
+
.attachment img, .attachment video { display: block; max-width: 100%; max-height: 280px; border: 1px solid #333; }
|
|
36
|
+
.pending .attachment { margin: 0; }
|
|
37
|
+
.pending .attachment img { max-height: 48px; }
|
|
38
|
+
.attachment audio { display: block; width: 100%; }
|
|
39
|
+
.attachment iframe { display: block; width: 100%; height: 160px; border: 1px solid #333; background: #fff; }
|
|
40
|
+
.attachment a { color: #ccc; display: inline-flex; gap: 8px; }
|
|
41
|
+
.attachment figcaption { display: flex; gap: 8px; margin-top: 4px; font-size: 12px; color: #888; }
|
|
27
42
|
@media (max-width: 480px) { main { padding: 20px 12px; } li { flex-wrap: wrap; } }
|
|
28
43
|
}
|
|
@@ -1,17 +1,27 @@
|
|
|
1
|
-
import { capsule, table, query, mutation, endpoint, string, text } from "eraspace/server";
|
|
1
|
+
import { capsule, table, query, mutation, endpoint, string, number, object, text } from "eraspace/server";
|
|
2
|
+
type Attachment = { key: string; name: string; contentType: string; size: number };
|
|
3
|
+
const KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
2
4
|
export default capsule({
|
|
3
|
-
schema: {
|
|
5
|
+
schema: {
|
|
6
|
+
messages: table({
|
|
7
|
+
room: string(), author: string(), body: string(),
|
|
8
|
+
// A file in app storage, saved from the client's FileUpload.
|
|
9
|
+
file: object({ key: string(), name: string(), contentType: string(), size: number() }).optional(),
|
|
10
|
+
}).index("by_room", ["room"]),
|
|
11
|
+
},
|
|
4
12
|
queries: {
|
|
5
13
|
messages: query(async (ctx, room: string) => ctx.db.messages.withIndex("by_room", q => q.eq("room", room)).order("desc").take(100)),
|
|
6
14
|
},
|
|
7
15
|
mutations: {
|
|
8
|
-
send: mutation(async (ctx, room: string, author: string, body: string) => {
|
|
16
|
+
send: mutation(async (ctx, room: string, author: string, body: string, file?: Attachment | null) => {
|
|
9
17
|
const trimmed = body.trim();
|
|
10
|
-
if (
|
|
18
|
+
if (trimmed.length > 2000 || (!trimmed && !file)) throw new Error("Write a message of 1 to 2000 characters.");
|
|
19
|
+
if (file && (!KEY.test(String(file.key)) || typeof file.name !== "string" || typeof file.contentType !== "string" || !Number.isInteger(file.size) || file.size < 0)) throw new Error("That attachment is not a stored file.");
|
|
11
20
|
const name = author.trim().slice(0, 40) || "Guest";
|
|
12
21
|
ctx.log.info("message sent", { room });
|
|
13
|
-
|
|
22
|
+
const attachment = file ? { key: file.key, name: file.name.slice(0, 255) || "file", contentType: file.contentType.slice(0, 256), size: file.size } : undefined;
|
|
23
|
+
return ctx.db.messages.insert({ room, author: name, body: trimmed, file: attachment });
|
|
14
24
|
}),
|
|
15
25
|
},
|
|
16
|
-
endpoints: { hi: endpoint({ method: "GET", path: "/hi", readOnly: true }, () => text("hello from
|
|
26
|
+
endpoints: { hi: endpoint({ method: "GET", path: "/hi", readOnly: true }, () => text("hello from eraspace")) },
|
|
17
27
|
});
|