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,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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ReadonlySignal } from "@preact/signals";
|
|
2
|
+
import type { EraspaceError } from "./errors.ts";
|
|
3
|
+
import type { StorageClient } from "./storage.ts";
|
|
4
|
+
import type { Upload, UseUploadOptions } from "./files.ts";
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ActionArgs,
|
|
8
|
+
ActionName,
|
|
9
|
+
ActionResult,
|
|
10
|
+
MutationArgs,
|
|
11
|
+
MutationName,
|
|
12
|
+
MutationResult,
|
|
13
|
+
PaginationOptions,
|
|
14
|
+
PaginationResult,
|
|
15
|
+
QueryArgs,
|
|
16
|
+
QueryName,
|
|
17
|
+
QueryResult,
|
|
18
|
+
} from "../server/index.ts";
|
|
19
|
+
|
|
20
|
+
export type Status = "connecting" | "open" | "closed";
|
|
21
|
+
|
|
22
|
+
export interface PresenceEntry<M = Record<string, unknown>> {
|
|
23
|
+
id: string;
|
|
24
|
+
meta: M;
|
|
25
|
+
/** All metas for this id (one per open connection). */
|
|
26
|
+
metas: M[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Query names whose last argument is `PaginationOptions` and whose result is a `PaginationResult`. */
|
|
30
|
+
export type PaginatedQueryName<C> = {
|
|
31
|
+
[N in QueryName<C>]: QueryArgs<C, N> extends [...any[], PaginationOptions]
|
|
32
|
+
? QueryResult<C, N> extends PaginationResult<any>
|
|
33
|
+
? N
|
|
34
|
+
: never
|
|
35
|
+
: never;
|
|
36
|
+
}[QueryName<C>];
|
|
37
|
+
type PaginatedArgs<C, N extends QueryName<C>> = QueryArgs<C, N> extends [...infer P, PaginationOptions] ? P : never;
|
|
38
|
+
type PaginatedItem<C, N extends QueryName<C>> = QueryResult<C, N> extends PaginationResult<infer T> ? T : never;
|
|
39
|
+
|
|
40
|
+
export interface PaginatedQueryResult<T> {
|
|
41
|
+
/** Every loaded row, in page order. */
|
|
42
|
+
results: T[];
|
|
43
|
+
status: "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted";
|
|
44
|
+
isLoading: boolean;
|
|
45
|
+
loadMore(numItems?: number): void;
|
|
46
|
+
reset(): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The local cache handed to optimistic updaters. */
|
|
50
|
+
export interface OptimisticStore<C> {
|
|
51
|
+
get<N extends QueryName<C>>(name: N, args: QueryArgs<C, N>): QueryResult<C, N> | undefined;
|
|
52
|
+
set<N extends QueryName<C>>(name: N, args: QueryArgs<C, N>, value: QueryResult<C, N>): void;
|
|
53
|
+
/** Apply `updater` to the cached result of `name(...args)` if it is loaded. */
|
|
54
|
+
update<N extends QueryName<C>>(
|
|
55
|
+
name: N,
|
|
56
|
+
args: QueryArgs<C, N>,
|
|
57
|
+
updater: (prev: QueryResult<C, N>) => QueryResult<C, N>,
|
|
58
|
+
): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface MutationOptions<C, A extends any[]> {
|
|
62
|
+
optimistic?: (store: OptimisticStore<C>, ...args: A) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Room<M = Record<string, unknown>, S extends Record<string, unknown> = Record<string, unknown>> {
|
|
66
|
+
/** Members currently in the room. */
|
|
67
|
+
presence: PresenceEntry<M>[];
|
|
68
|
+
/** This connection's public guest id. Changes on reconnect. Empty until the room join completes. */
|
|
69
|
+
self: string;
|
|
70
|
+
/** Send `event` to every other member. */
|
|
71
|
+
broadcast(event: string, payload?: unknown): void;
|
|
72
|
+
/** Listen for `event` broadcasts. Returns an unsubscribe function. Call it in an effect. */
|
|
73
|
+
on(event: string, cb: (payload: any, from: string | undefined) => void): () => void;
|
|
74
|
+
/** Ephemeral shared state (kept by the room process, never stored). */
|
|
75
|
+
state: Partial<S>;
|
|
76
|
+
/** Last room join or write error. Cleared by a successful join or state update. */
|
|
77
|
+
error: EraspaceError | undefined;
|
|
78
|
+
setState<K extends keyof S & string>(key: K, value: S[K]): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface EraspaceClient<C> {
|
|
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;
|
|
85
|
+
/** Subscribe to a query. Returns `undefined` until the first result arrives, then live values. */
|
|
86
|
+
useQuery<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): QueryResult<C, N> | undefined;
|
|
87
|
+
/** Pass computed signals directly to JSX text nodes for updates without a component render. */
|
|
88
|
+
useQuerySignal<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): ReadonlySignal<QueryResult<C, N> | undefined>;
|
|
89
|
+
/** Re-render only when the selected result changes. */
|
|
90
|
+
useQuerySelector<N extends QueryName<C>, T>(name: N, args: QueryArgs<C, N>, select: (value: QueryResult<C, N> | undefined) => T): T;
|
|
91
|
+
useMutation<N extends MutationName<C>>(
|
|
92
|
+
name: N,
|
|
93
|
+
options?: MutationOptions<C, MutationArgs<C, N>>,
|
|
94
|
+
): (...args: MutationArgs<C, N>) => Promise<MutationResult<C, N>>;
|
|
95
|
+
useAction<N extends ActionName<C>>(name: N): (...args: ActionArgs<C, N>) => Promise<ActionResult<C, N>>;
|
|
96
|
+
usePaginatedQuery<N extends PaginatedQueryName<C>>(
|
|
97
|
+
name: N,
|
|
98
|
+
args: PaginatedArgs<C, N>,
|
|
99
|
+
options?: { initialNumItems?: number },
|
|
100
|
+
): PaginatedQueryResult<PaginatedItem<C, N>>;
|
|
101
|
+
/** Join `room` and return its members. `meta` is this tab's presence metadata (cursor, typing, name...). */
|
|
102
|
+
usePresence<M extends Record<string, unknown> = Record<string, unknown>>(room: string, meta?: M): PresenceEntry<M>[];
|
|
103
|
+
useRoom<M extends Record<string, unknown> = Record<string, unknown>, S extends Record<string, unknown> = Record<string, unknown>>(
|
|
104
|
+
room: string,
|
|
105
|
+
meta?: M,
|
|
106
|
+
): Room<M, S>;
|
|
107
|
+
|
|
108
|
+
query<N extends QueryName<C>>(name: N, ...args: QueryArgs<C, N>): Promise<QueryResult<C, N>>;
|
|
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>>;
|
|
112
|
+
action<N extends ActionName<C>>(name: N, ...args: ActionArgs<C, N>): Promise<ActionResult<C, N>>;
|
|
113
|
+
/** Imperative live subscription. Returns an unsubscribe function. */
|
|
114
|
+
subscribe<N extends QueryName<C>>(
|
|
115
|
+
name: N,
|
|
116
|
+
args: QueryArgs<C, N>,
|
|
117
|
+
onValue: (value: QueryResult<C, N>) => void,
|
|
118
|
+
onError?: (error: EraspaceError) => void,
|
|
119
|
+
): () => void;
|
|
120
|
+
|
|
121
|
+
readonly app: string;
|
|
122
|
+
readonly base: string;
|
|
123
|
+
/** This connection's public guest id. Changes on reconnect. Empty until the socket has joined. */
|
|
124
|
+
readonly guestId: string;
|
|
125
|
+
/** Socket status: `connecting`, `open` or `closed`. */
|
|
126
|
+
readonly status: ReadonlySignal<Status>;
|
|
127
|
+
/** Latest deployed version seen by this client. */
|
|
128
|
+
readonly version: ReadonlySignal<number>;
|
|
129
|
+
/** Called after each redeploy (after subscriptions are re-sent). */
|
|
130
|
+
onDeployed(cb: (version: number) => void): () => void;
|
|
131
|
+
close(): void;
|
|
132
|
+
}
|