eraspace 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/bin.js +562 -208
- package/package.json +1 -1
- package/sdk/src/client/client.ts +14 -8
- package/sdk/src/client/config.ts +17 -1
- package/sdk/src/client/connection.ts +67 -12
- package/sdk/src/client/files.ts +36 -9
- package/sdk/src/client/index.ts +1 -0
- package/sdk/src/client/router.ts +1 -1
- package/sdk/src/client/storage.ts +31 -0
- package/sdk/src/client/sub-key.ts +19 -0
- package/sdk/src/client/subscription.ts +12 -1
- package/sdk/src/client/transport.ts +82 -15
- package/sdk/src/server/index.ts +41 -2
- package/templates/chat/client/index.tsx +12 -4
- package/templates/poll/client/index.tsx +1 -1
- package/templates/todo/client/index.tsx +1 -1
|
@@ -22,6 +22,8 @@ export interface SocketOptions {
|
|
|
22
22
|
rejoinAfterMs?: (attempt: number) => number;
|
|
23
23
|
/** How long a socket must stay open before reconnect backoff starts again from the first attempt. */
|
|
24
24
|
stableAfterMs?: number;
|
|
25
|
+
/** Accept large payloads as deflated binary frames. Defaults to on where `DecompressionStream` inflates raw deflate. */
|
|
26
|
+
compression?: boolean;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
interface Frame {
|
|
@@ -42,6 +44,58 @@ interface Pending {
|
|
|
42
44
|
}
|
|
43
45
|
export interface JoinReply { self?: string; now?: number; version?: number }
|
|
44
46
|
|
|
47
|
+
function asFrame(value: unknown): Frame | undefined {
|
|
48
|
+
if (!value || typeof value !== "object" || !("topic" in value) || typeof value.topic !== "string"
|
|
49
|
+
|| !("event" in value) || typeof value.event !== "string" || !("session" in value)
|
|
50
|
+
|| !(value.session === null || typeof value.session === "string") || !("ref" in value)
|
|
51
|
+
|| !(value.ref === null || typeof value.ref === "string") || !("payload" in value)) return undefined;
|
|
52
|
+
return value as Frame;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reports an exception thrown by app code that consumes frames, as an uncaught event handler exception would be.
|
|
57
|
+
* It must not look like a transport failure, which would fail every in-flight request with an unknown outcome.
|
|
58
|
+
*/
|
|
59
|
+
function report(error: unknown): void {
|
|
60
|
+
if (typeof reportError === "function") reportError(error);
|
|
61
|
+
else setTimeout(() => { throw error; });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function inflates(): boolean {
|
|
65
|
+
try { return typeof DecompressionStream === "function" && Boolean(new DecompressionStream("deflate-raw")); }
|
|
66
|
+
catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function bytesOf(data: unknown): Promise<Uint8Array<ArrayBuffer> | undefined> {
|
|
70
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
71
|
+
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer);
|
|
72
|
+
if (typeof Blob !== "undefined" && data instanceof Blob) return new Uint8Array(await data.arrayBuffer());
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A binary frame is a four-byte big-endian header length, the frame's JSON header and a raw-deflated JSON
|
|
78
|
+
* object. The header's `z` places the object: `"response"` is the reply's response, `"members"` adds its
|
|
79
|
+
* members to the header payload.
|
|
80
|
+
*/
|
|
81
|
+
async function inflateFrame(data: unknown): Promise<Frame | undefined> {
|
|
82
|
+
const bytes = await bytesOf(data);
|
|
83
|
+
if (!bytes || bytes.length < 4) return undefined;
|
|
84
|
+
const length = new DataView(bytes.buffer, bytes.byteOffset).getUint32(0);
|
|
85
|
+
if (4 + length > bytes.length) return undefined;
|
|
86
|
+
const header: unknown = JSON.parse(new TextDecoder().decode(bytes.subarray(4, 4 + length)));
|
|
87
|
+
const frame = asFrame(header);
|
|
88
|
+
const z = header && typeof header === "object" && "z" in header ? header.z : undefined;
|
|
89
|
+
if (!frame || !frame.payload || typeof frame.payload !== "object") return undefined;
|
|
90
|
+
const inflated = new Blob([bytes.subarray(4 + length)]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
|
91
|
+
const body: unknown = JSON.parse(await new Response(inflated).text());
|
|
92
|
+
const payload = frame.payload as Record<string, unknown>;
|
|
93
|
+
if (z === "response") payload.response = body;
|
|
94
|
+
else if (z === "members" && body && typeof body === "object") frame.payload = { ...body, ...payload };
|
|
95
|
+
else return undefined;
|
|
96
|
+
return { session: frame.session, ref: frame.ref, topic: frame.topic, event: frame.event, payload: frame.payload };
|
|
97
|
+
}
|
|
98
|
+
|
|
45
99
|
/** Multiplexes topics over one native WebSocket. Sent requests are never replayed on reconnect. */
|
|
46
100
|
export class Socket {
|
|
47
101
|
private ws: WebSocketTransport | undefined;
|
|
@@ -65,6 +119,7 @@ export class Socket {
|
|
|
65
119
|
const url = new URL(endpoint, typeof location === "undefined" ? undefined : location.href);
|
|
66
120
|
url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:";
|
|
67
121
|
url.searchParams.set("v", "1");
|
|
122
|
+
if (options.compression ?? inflates()) url.searchParams.set("z", "1");
|
|
68
123
|
this.url = url.href;
|
|
69
124
|
this.options = options;
|
|
70
125
|
}
|
|
@@ -88,6 +143,7 @@ export class Socket {
|
|
|
88
143
|
try { ws = new Transport(this.url); }
|
|
89
144
|
catch { this.scheduleReconnect(); return; }
|
|
90
145
|
this.ws = ws;
|
|
146
|
+
(ws as { binaryType?: string }).binaryType = "arraybuffer";
|
|
91
147
|
this.opening = setTimeout(() => this.lost(ws), this.options.timeout ?? 15000);
|
|
92
148
|
ws.onopen = () => {
|
|
93
149
|
if (this.ws !== ws) return;
|
|
@@ -103,25 +159,33 @@ export class Socket {
|
|
|
103
159
|
this.heartbeat = setInterval(() => this.sendHeartbeat(ws), 25000);
|
|
104
160
|
};
|
|
105
161
|
ws.onmessage = (event) => {
|
|
106
|
-
if (this.ws !== ws
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
162
|
+
if (this.ws !== ws) return;
|
|
163
|
+
if (typeof event.data !== "string") {
|
|
164
|
+
// Deflated frames inflate asynchronously, in arrival order with every other frame.
|
|
165
|
+
const data = event.data;
|
|
166
|
+
// A frame that does not inflate is a transport failure; an exception while delivering one is not.
|
|
167
|
+
this.incoming = this.incoming.then(() => inflateFrame(data)).then(
|
|
168
|
+
frame => { if (frame) this.deliver(ws, frame); },
|
|
169
|
+
() => this.lost(ws),
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let frame: Frame | undefined;
|
|
174
|
+
try { frame = asFrame(JSON.parse(event.data)); } catch { return; }
|
|
175
|
+
if (!frame) return;
|
|
176
|
+
const received = frame;
|
|
116
177
|
// 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));
|
|
178
|
+
this.incoming = this.incoming.then(() => this.deliver(ws, received));
|
|
120
179
|
};
|
|
121
180
|
ws.onclose = () => this.lost(ws);
|
|
122
181
|
ws.onerror = () => this.lost(ws);
|
|
123
182
|
}
|
|
124
183
|
|
|
184
|
+
private deliver(ws: WebSocketTransport, frame: Frame): void {
|
|
185
|
+
if (this.ws !== ws) return;
|
|
186
|
+
try { this.receive(frame); } catch (error) { report(error); }
|
|
187
|
+
}
|
|
188
|
+
|
|
125
189
|
private receive(frame: Frame): void {
|
|
126
190
|
if (frame.event === "reply" && frame.ref) {
|
|
127
191
|
const body = frame.payload;
|
|
@@ -333,8 +397,11 @@ export class Channel {
|
|
|
333
397
|
receive(frame: Frame): void {
|
|
334
398
|
if (this.closed || frame.session !== this.session) return;
|
|
335
399
|
if (frame.event === "closed") { this.leave(); return; }
|
|
336
|
-
|
|
337
|
-
|
|
400
|
+
// One throwing handler must not keep the frame from the others.
|
|
401
|
+
try { this.onMessage?.(frame.event, frame.payload); } catch (error) { report(error); }
|
|
402
|
+
for (const cb of this.events.get(frame.event) ?? []) {
|
|
403
|
+
try { cb(frame.payload); } catch (error) { report(error); }
|
|
404
|
+
}
|
|
338
405
|
}
|
|
339
406
|
|
|
340
407
|
disconnected(): void {
|
package/sdk/src/server/index.ts
CHANGED
|
@@ -151,8 +151,24 @@ export interface TableDefinition<F extends FieldMap = FieldMap, I extends IndexM
|
|
|
151
151
|
name: N,
|
|
152
152
|
fields: Fs,
|
|
153
153
|
): TableDefinition<F, Simplify<I & { [P in N]: Fs }>>;
|
|
154
|
+
/**
|
|
155
|
+
* Upgrade rows written before this migration to the table's current fields. Versions start at any positive
|
|
156
|
+
* number and each call takes the next one; rows are written at the last version. A deploy then accepts rows of
|
|
157
|
+
* an older shape: they are upgraded when read and rewritten in the background. `upgrade` receives the stored
|
|
158
|
+
* row with `id` and `createdAt` and must return the new row synchronously, depending only on the row.
|
|
159
|
+
* Undeclared fields of the result are dropped. Keep a migration until every row has passed it.
|
|
160
|
+
*/
|
|
161
|
+
migrate(version: number, upgrade: (row: StoredRow<F>) => MigratedRow<F>): TableDefinition<F, I>;
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A row as an earlier deployment stored it: `id`, `createdAt` and whatever fields it had then. Their types are
|
|
166
|
+
* unknown, and fields of the current schema may be absent, so they read as `any`.
|
|
167
|
+
*/
|
|
168
|
+
export type StoredRow<F extends FieldMap = FieldMap> = { id: string; createdAt: number } & { [K in keyof F]: any } & { [field: string]: any };
|
|
169
|
+
/** What a migration returns: the row with the table's current fields. `id` and `createdAt` keep their values. */
|
|
170
|
+
export type MigratedRow<F extends FieldMap> = InferInsert<F> & { id?: string; createdAt?: number };
|
|
171
|
+
|
|
156
172
|
export type AnyTable = TableDefinition<any, any>;
|
|
157
173
|
export type Schema = Record<string, AnyTable>;
|
|
158
174
|
/** The schema a capsule gets when it declares none. Tables and fields are untyped. */
|
|
@@ -160,6 +176,7 @@ export type DefaultSchema = Record<string, TableDefinition<Record<string, Field<
|
|
|
160
176
|
|
|
161
177
|
export function table<const F extends FieldMap>(fields: F): TableDefinition<F, {}> {
|
|
162
178
|
const indexes: Record<string, readonly string[]> = {};
|
|
179
|
+
const migrations: number[] = [];
|
|
163
180
|
const def: any = {
|
|
164
181
|
kind: "table",
|
|
165
182
|
fields,
|
|
@@ -169,10 +186,20 @@ export function table<const F extends FieldMap>(fields: F): TableDefinition<F, {
|
|
|
169
186
|
indexes[name] = idx;
|
|
170
187
|
return def;
|
|
171
188
|
},
|
|
189
|
+
migrate(version: number, upgrade: unknown) {
|
|
190
|
+
if (!Number.isInteger(version) || version < 1) throw new TypeError("migrate() expects a positive integer version");
|
|
191
|
+
if (typeof upgrade !== "function") throw new TypeError("migrate() expects a function from a stored row to the upgraded row");
|
|
192
|
+
const last = migrations.length ? migrations[migrations.length - 1]! : version - 1;
|
|
193
|
+
if (version !== last + 1) throw new Error(`migrate(${version}) must follow migrate(${last}) with the next version, ${last + 1}`);
|
|
194
|
+
migrations.push(version);
|
|
195
|
+
return def;
|
|
196
|
+
},
|
|
172
197
|
toJSON() {
|
|
198
|
+
const versions = migrations.length ? { version: migrations[migrations.length - 1], migratesFrom: migrations[0]! - 1 } : {};
|
|
173
199
|
return {
|
|
174
200
|
fields: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, describeField(v)])),
|
|
175
201
|
indexes,
|
|
202
|
+
...versions,
|
|
176
203
|
};
|
|
177
204
|
},
|
|
178
205
|
};
|
|
@@ -349,6 +376,16 @@ interface BaseContext {
|
|
|
349
376
|
interface DbHolder<Db> extends BaseContext {
|
|
350
377
|
db: Db;
|
|
351
378
|
}
|
|
379
|
+
export interface Scheduler {
|
|
380
|
+
/**
|
|
381
|
+
* Runs mutation `name` with `args` once, `delayMs` milliseconds after this mutation commits. The job commits with
|
|
382
|
+
* this mutation, so it exists only if this mutation does, and its run commits at most once. Returns the job's id.
|
|
383
|
+
*/
|
|
384
|
+
runAfter(delayMs: number, name: string, ...args: any[]): string;
|
|
385
|
+
}
|
|
386
|
+
interface MutationHolder<Db> extends DbHolder<Db> {
|
|
387
|
+
scheduler: Scheduler;
|
|
388
|
+
}
|
|
352
389
|
interface ActionHolder<Db> extends DbHolder<Db> {
|
|
353
390
|
runQuery(name: string, ...args: any[]): Promise<any>;
|
|
354
391
|
runMutation(name: string, ...args: any[]): Promise<any>;
|
|
@@ -357,8 +394,8 @@ interface ActionHolder<Db> extends DbHolder<Db> {
|
|
|
357
394
|
|
|
358
395
|
/** The `ctx` a `query()` (or a `readOnly` endpoint) receives. */
|
|
359
396
|
export type QueryServerContext<S extends Schema = DefaultSchema> = DbHolder<DbContext<S>>;
|
|
360
|
-
/** The `ctx` a `mutation()` or writable `endpoint()` receives. Its `db` can write. */
|
|
361
|
-
export type ServerContext<S extends Schema = DefaultSchema> =
|
|
397
|
+
/** The `ctx` a `mutation()` or writable `endpoint()` receives. Its `db` can write and its `scheduler` schedules mutations. */
|
|
398
|
+
export type ServerContext<S extends Schema = DefaultSchema> = MutationHolder<MutationDbContext<S>>;
|
|
362
399
|
/** The `ctx` an `action()` receives: read-only db plus `runQuery`/`runMutation`. Use global `fetch` for HTTP. */
|
|
363
400
|
export type ActionServerContext<S extends Schema = DefaultSchema> = ActionHolder<DbContext<S>>;
|
|
364
401
|
|
|
@@ -477,6 +514,8 @@ export interface EndpointRequest {
|
|
|
477
514
|
json<T = unknown>(): Promise<T>;
|
|
478
515
|
/** Request body bytes as a JSON-compatible number array. */
|
|
479
516
|
bytes(): Promise<number[]>;
|
|
517
|
+
/** A multipart/form-data or application/x-www-form-urlencoded body. Other content types reject with a TypeError. */
|
|
518
|
+
formData(): Promise<FormData>;
|
|
480
519
|
}
|
|
481
520
|
|
|
482
521
|
export interface EndpointResponseOptions {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createClient, FilePreview, FileUpload, type FileRef } from "eraspace/client";
|
|
2
|
-
import { useState, useEffect } from "preact/hooks";
|
|
2
|
+
import { useState, useEffect, useRef } from "preact/hooks";
|
|
3
3
|
import type app from "../server/index";
|
|
4
|
-
const client = createClient<typeof app>(
|
|
4
|
+
const client = createClient<typeof app>();
|
|
5
5
|
export default function App() {
|
|
6
6
|
const [room, setRoom] = useState("general");
|
|
7
7
|
const [author, setAuthor] = useState("Guest");
|
|
@@ -12,7 +12,12 @@ export default function App() {
|
|
|
12
12
|
const [file, setFile] = useState<FileRef | null>(null);
|
|
13
13
|
const messages = client.useQuery("messages", room);
|
|
14
14
|
const send = client.useMutation("send");
|
|
15
|
-
|
|
15
|
+
// Room messages share the app's request budget, so presence takes the name once typing pauses and the typing
|
|
16
|
+
// notice goes out at most once a second; the 1.5 s display below bridges the gap.
|
|
17
|
+
const [shownName, setShownName] = useState(author);
|
|
18
|
+
useEffect(() => { const timer = setTimeout(() => setShownName(author), 500); return () => clearTimeout(timer); }, [author]);
|
|
19
|
+
const lastTyping = useRef(0);
|
|
20
|
+
const channel = client.useRoom<{ name: string }> (room, { name: shownName });
|
|
16
21
|
useEffect(() => channel.on("typing", (payload: unknown) => {
|
|
17
22
|
if (typeof payload === "string") setTyping(payload);
|
|
18
23
|
}), [room]);
|
|
@@ -29,7 +34,10 @@ export default function App() {
|
|
|
29
34
|
try { await send(room, author, body, file); setBody(""); setFile(null); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setSending(false); }
|
|
30
35
|
}}>
|
|
31
36
|
<input aria-label="Your name" value={author} maxLength={40} onInput={e => setAuthor(e.currentTarget.value)}/>
|
|
32
|
-
<input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required={!file} onInput={e => {
|
|
37
|
+
<input aria-label="Message" placeholder="Message" value={body} maxLength={2000} required={!file} onInput={e => {
|
|
38
|
+
setBody(e.currentTarget.value);
|
|
39
|
+
if (Date.now() - lastTyping.current >= 1000) { lastTyping.current = Date.now(); channel.broadcast("typing", author); }
|
|
40
|
+
}}/>
|
|
33
41
|
<button disabled={sending || (!body.trim() && !file)}>Send</button>
|
|
34
42
|
{file
|
|
35
43
|
? <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>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createClient } from "eraspace/client";
|
|
2
2
|
import { useState } from "preact/hooks";
|
|
3
3
|
import type app from "../server/index";
|
|
4
|
-
const client = createClient<typeof app>(
|
|
4
|
+
const client = createClient<typeof app>();
|
|
5
5
|
export default function App() {
|
|
6
6
|
const options = client.useQuery("results");
|
|
7
7
|
const add = client.useMutation("addOption");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createClient } from "eraspace/client";
|
|
2
2
|
import { useState } from "preact/hooks";
|
|
3
3
|
import type app from "../server/index";
|
|
4
|
-
const client = createClient<typeof app>(
|
|
4
|
+
const client = createClient<typeof app>();
|
|
5
5
|
export default function App() {
|
|
6
6
|
const todos = client.useQuery("list");
|
|
7
7
|
const [title, setTitle] = useState("");
|