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
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { Socket, type Channel } from "
|
|
1
|
+
import { Socket, type Channel } from "./transport.ts";
|
|
2
2
|
import { signal, effect, batch, type Signal } from "@preact/signals";
|
|
3
3
|
import { applyPatch } from "./result-diff.ts";
|
|
4
|
-
import { EraspaceError } from "./errors.ts";
|
|
4
|
+
import { EraspaceError, OutcomeUnknownError } from "./errors.ts";
|
|
5
5
|
import { readConfig, type ClientOptions } from "./config.ts";
|
|
6
|
+
import { requestKey } from "./request-key.ts";
|
|
6
7
|
import { subKey } from "./sub-key.ts";
|
|
7
8
|
import { Subscription, type FullResult, type DataMessage } from "./subscription.ts";
|
|
8
9
|
import { RoomConn } from "./room.ts";
|
|
10
|
+
import { retryDelay } from "./retry.ts";
|
|
9
11
|
import type { Status } from "./types.ts";
|
|
10
12
|
|
|
11
13
|
type Fn = (...args: any[]) => any;
|
|
@@ -21,6 +23,11 @@ export class ClientImpl {
|
|
|
21
23
|
private socket: Socket | undefined;
|
|
22
24
|
private channel: Channel | undefined;
|
|
23
25
|
private joinedOnce = false;
|
|
26
|
+
/** Counts app channel joins. Server subscription ids live only as long as the join that created them. */
|
|
27
|
+
private joins = 0;
|
|
28
|
+
/** Server time minus local time, from the latest join reply. */
|
|
29
|
+
private clockOffset = 0;
|
|
30
|
+
private gone: EraspaceError | undefined;
|
|
24
31
|
private nextSub = 1;
|
|
25
32
|
private nextMutation = 1;
|
|
26
33
|
private subs = new Map<string, Subscription>();
|
|
@@ -42,16 +49,13 @@ export class ClientImpl {
|
|
|
42
49
|
|
|
43
50
|
private ensureSocket(): Socket {
|
|
44
51
|
if (this.socket) return this.socket;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
timeout: this.opts.timeout ?? 15000,
|
|
52
|
+
const socket = new Socket(this.socketUrl, {
|
|
53
|
+
timeout: this.opts.timeout,
|
|
48
54
|
...this.opts.socketOptions,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const socket = new Socket(this.socketUrl, socketOpts as any);
|
|
55
|
+
transport: this.opts.transport,
|
|
56
|
+
});
|
|
52
57
|
socket.onOpen(() => { this.status.value = "open"; });
|
|
53
58
|
socket.onClose(() => { this.status.value = "closed"; });
|
|
54
|
-
socket.onError(() => { this.status.value = "closed"; });
|
|
55
59
|
socket.connect();
|
|
56
60
|
this.socket = socket;
|
|
57
61
|
return socket;
|
|
@@ -60,41 +64,103 @@ export class ClientImpl {
|
|
|
60
64
|
private ensureChannel(): Channel {
|
|
61
65
|
if (this.channel) return this.channel;
|
|
62
66
|
const socket = this.ensureSocket();
|
|
63
|
-
const channel = socket.channel(`app:${this.app}`, {
|
|
67
|
+
const channel = socket.channel(`app:${this.app}`, {});
|
|
64
68
|
channel.on("data", (m: DataMessage) => this.onData(m));
|
|
65
69
|
channel.on("error", (m: { id?: string; name?: string; message?: string }) => {
|
|
66
70
|
const sub = m.id !== undefined ? this.subsById.get(String(m.id)) : undefined;
|
|
67
|
-
if (sub)
|
|
71
|
+
if (sub) {
|
|
72
|
+
sub.error.value = EraspaceError.from(m);
|
|
73
|
+
this.retrySub(sub);
|
|
74
|
+
}
|
|
68
75
|
});
|
|
76
|
+
channel.on("gone", () => this.onGone());
|
|
77
|
+
// Transport loss changes status; reconnect rejoins and resubscribes while cached values stay valid.
|
|
78
|
+
// A join the server rejects is reported to every subscription.
|
|
79
|
+
const failed = (payload: unknown, rejected: boolean) => {
|
|
80
|
+
if (this.gone) return;
|
|
81
|
+
if (payload instanceof EraspaceError && payload.name === "AppGone") { this.onGone(); return; }
|
|
82
|
+
this.status.value = "closed";
|
|
83
|
+
if (rejected) for (const sub of this.subs.values()) sub.error.value = EraspaceError.from(payload, "App channel join failed");
|
|
84
|
+
};
|
|
85
|
+
channel.onError((reason, rejected) => failed(reason, rejected));
|
|
86
|
+
channel.onClose(() => failed(undefined, false));
|
|
69
87
|
channel.on("deployed", (m: { version?: number }) => this.onDeployed(m?.version));
|
|
70
|
-
channel.
|
|
88
|
+
channel.onJoin((resp) => {
|
|
89
|
+
this.joins++;
|
|
90
|
+
if (this.gone) return;
|
|
91
|
+
this.status.value = "open";
|
|
71
92
|
if (resp && typeof resp.self === "string") this.self.value = resp.self;
|
|
93
|
+
if (resp && typeof resp.now === "number") this.clockOffset = resp.now - Date.now();
|
|
72
94
|
// The channel process is new after a reconnect, so every live subscription is sent again.
|
|
73
95
|
if (this.joinedOnce) this.resubscribeAll();
|
|
74
96
|
this.joinedOnce = true;
|
|
75
97
|
});
|
|
98
|
+
channel.join();
|
|
76
99
|
this.channel = channel;
|
|
77
100
|
return channel;
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
private push<T>(event: string, payload: object): Promise<T> {
|
|
103
|
+
private push<T>(event: string, payload: object | (() => object | null)): Promise<T> {
|
|
104
|
+
if (this.gone) return Promise.reject(this.gone);
|
|
81
105
|
const channel = this.ensureChannel();
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
return channel.push<T>(event, payload, this.opts.timeout ?? 15000);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async call(fn: string, args: unknown[], kind: "query" | "action" = "query"): Promise<unknown> {
|
|
110
|
+
const key = this.requestKey();
|
|
111
|
+
try {
|
|
112
|
+
const resp = await this.push<{ value?: unknown }>("call", { fn, args, kind, key });
|
|
113
|
+
return resp?.value;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (kind === "action" && error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError"].includes(error.name)) {
|
|
116
|
+
throw new OutcomeUnknownError(key, "The action may have performed effects; it cannot be replayed safely");
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Request keys carry server time, so a skewed client clock cannot place them outside the retry window. */
|
|
123
|
+
private requestKey(): string {
|
|
124
|
+
return requestKey(Date.now() + this.clockOffset);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private async mutationRequest(fn: string, args: unknown[], key = this.requestKey(), retry = false): Promise<{ value: unknown; commitSeq?: number }> {
|
|
128
|
+
let rekeyed = false;
|
|
129
|
+
for (let attempt = 0; ; attempt++) {
|
|
130
|
+
try {
|
|
131
|
+
return await this.push("call", { fn, args, kind: "mutation", key, retry });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// The server refused the key before running anything. The join reply has corrected the clock by now.
|
|
134
|
+
if (error instanceof EraspaceError && error.name === "RequestKeyError" && !retry && !rekeyed) {
|
|
135
|
+
rekeyed = true;
|
|
136
|
+
attempt--;
|
|
137
|
+
key = this.requestKey();
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const uncertain = error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError", "RetryExpiredError"].includes(error.name);
|
|
141
|
+
// A rejected recovery attempt cannot resolve the original request's unknown outcome.
|
|
142
|
+
if (!uncertain && !retry) throw error;
|
|
143
|
+
if (uncertain && attempt === 0 && error.name !== "RetryExpiredError") {
|
|
144
|
+
retry = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const message = error instanceof Error ? error.message : "Recovery failed";
|
|
148
|
+
throw new OutcomeUnknownError(key, `${message}. Recover this mutation only with the same request key.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
89
151
|
}
|
|
90
152
|
|
|
91
|
-
async
|
|
92
|
-
|
|
93
|
-
return resp?.value;
|
|
153
|
+
async retryMutation(fn: string, args: unknown[], key: string): Promise<unknown> {
|
|
154
|
+
return (await this.mutationRequest(fn, args, key, true)).value;
|
|
94
155
|
}
|
|
95
156
|
|
|
96
157
|
close(): void {
|
|
97
|
-
for (const
|
|
158
|
+
for (const sub of this.subs.values()) {
|
|
159
|
+
clearTimeout(sub.timer);
|
|
160
|
+
clearTimeout(sub.retryTimer);
|
|
161
|
+
sub.request++;
|
|
162
|
+
}
|
|
163
|
+
for (const r of this.rooms.values()) r.close();
|
|
98
164
|
this.rooms.clear();
|
|
99
165
|
this.channel?.leave();
|
|
100
166
|
this.socket?.disconnect();
|
|
@@ -102,12 +168,38 @@ export class ClientImpl {
|
|
|
102
168
|
this.socket = undefined;
|
|
103
169
|
this.subs.clear();
|
|
104
170
|
this.subsById.clear();
|
|
171
|
+
this.joinedOnce = false;
|
|
172
|
+
this.status.value = "closed";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private onGone(): void {
|
|
176
|
+
this.gone = new EraspaceError("AppGone", "This app is no longer serving");
|
|
177
|
+
for (const sub of this.subs.values()) {
|
|
178
|
+
clearTimeout(sub.retryTimer);
|
|
179
|
+
sub.retryTimer = undefined;
|
|
180
|
+
sub.request++;
|
|
181
|
+
sub.error.value = this.gone;
|
|
182
|
+
}
|
|
183
|
+
for (const r of this.rooms.values()) r.close();
|
|
184
|
+
this.channel?.leave();
|
|
185
|
+
this.socket?.disconnect();
|
|
105
186
|
this.status.value = "closed";
|
|
106
187
|
}
|
|
107
188
|
|
|
189
|
+
private retrySub(sub: Subscription): void {
|
|
190
|
+
if (this.gone || sub.retryTimer || this.subs.get(sub.key) !== sub) return;
|
|
191
|
+
sub.retryTimer = setTimeout(() => {
|
|
192
|
+
sub.retryTimer = undefined;
|
|
193
|
+
if (this.subs.get(sub.key) === sub) this.sendSub(sub);
|
|
194
|
+
}, retryDelay(++sub.retries));
|
|
195
|
+
}
|
|
196
|
+
|
|
108
197
|
// -- subscriptions --------------------------------------------------------------------------------------------
|
|
109
198
|
|
|
110
199
|
private sendSub(sub: Subscription): void {
|
|
200
|
+
if (this.gone) { sub.error.value = this.gone; return; }
|
|
201
|
+
clearTimeout(sub.retryTimer);
|
|
202
|
+
sub.retryTimer = undefined;
|
|
111
203
|
const oldId = sub.id;
|
|
112
204
|
const request = ++sub.request;
|
|
113
205
|
if (request > 1) {
|
|
@@ -115,25 +207,42 @@ export class ClientImpl {
|
|
|
115
207
|
sub.id = String(this.nextSub++);
|
|
116
208
|
this.subsById.set(sub.id, sub);
|
|
117
209
|
sub.revision = undefined;
|
|
118
|
-
this.
|
|
210
|
+
this.unsubscribe(sub, oldId);
|
|
119
211
|
}
|
|
120
|
-
|
|
121
|
-
|
|
212
|
+
const id = sub.id;
|
|
213
|
+
this.push<FullResult>("sub", () => {
|
|
214
|
+
// A newer request or a drop replaced this one before it was sent: withdraw it.
|
|
215
|
+
if (sub.request !== request) return null;
|
|
216
|
+
sub.sent = { id, join: this.joins };
|
|
217
|
+
return { id, fn: sub.fn, args: sub.args, minCommitSeq: Math.max(0, ...sub.layers.map(layer => layer.commitSeq ?? 0)) };
|
|
122
218
|
}).then(
|
|
123
219
|
(resp) => {
|
|
124
220
|
if (this.subsById.get(sub.id) !== sub || sub.request !== request) return;
|
|
125
221
|
this.setServer(sub, resp.value, resp.commitSeq, resp.revision);
|
|
126
222
|
},
|
|
127
223
|
(err: EraspaceError) => {
|
|
128
|
-
if (this.subsById.get(sub.id) === sub && sub.request === request)
|
|
224
|
+
if (this.subsById.get(sub.id) === sub && sub.request === request) {
|
|
225
|
+
sub.error.value = err;
|
|
226
|
+
this.retrySub(sub);
|
|
227
|
+
}
|
|
129
228
|
},
|
|
130
229
|
);
|
|
131
230
|
}
|
|
132
231
|
|
|
232
|
+
/** Tells the server to drop `id`, unless the server cannot have it: never sent, or sent before a reconnect. */
|
|
233
|
+
private unsubscribe(sub: Subscription, id: string): void {
|
|
234
|
+
if (sub.sent?.id !== id || sub.sent.join !== this.joins) return;
|
|
235
|
+
sub.sent = undefined;
|
|
236
|
+
this.push("unsub", { id }).catch(() => {});
|
|
237
|
+
}
|
|
238
|
+
|
|
133
239
|
// Messages for one subscription id arrive in order, so only commit watermarks are compared. Revisions
|
|
134
240
|
// belong to a server cache entry and may restart lower when an identity-dependent query splits.
|
|
135
241
|
private setServer(sub: Subscription, value: unknown, commitSeq: number, revision: number): void {
|
|
136
242
|
if (commitSeq < sub.commitSeq) return;
|
|
243
|
+
clearTimeout(sub.retryTimer);
|
|
244
|
+
sub.retryTimer = undefined;
|
|
245
|
+
sub.retries = 0;
|
|
137
246
|
batch(() => {
|
|
138
247
|
sub.server = value;
|
|
139
248
|
sub.hasServer = true;
|
|
@@ -146,6 +255,7 @@ export class ClientImpl {
|
|
|
146
255
|
}
|
|
147
256
|
|
|
148
257
|
private onData(message: DataMessage): void {
|
|
258
|
+
if (this.gone) return;
|
|
149
259
|
const sub = this.subsById.get(String(message.id));
|
|
150
260
|
if (!sub || message.commitSeq < sub.commitSeq) return;
|
|
151
261
|
if ("patch" in message) {
|
|
@@ -167,9 +277,9 @@ export class ClientImpl {
|
|
|
167
277
|
for (const sub of this.subs.values()) this.sendSub(sub);
|
|
168
278
|
}
|
|
169
279
|
|
|
280
|
+
// Subscriptions survive a deploy: the server re-runs them with the new code and pushes changed values.
|
|
170
281
|
private onDeployed(version: number | undefined): void {
|
|
171
282
|
if (typeof version === "number") this.version.value = version;
|
|
172
|
-
this.resubscribeAll();
|
|
173
283
|
for (const cb of [...this.deployedCbs]) cb(this.version.value);
|
|
174
284
|
if (this.opts.reloadOnDeploy && typeof location !== "undefined") location.reload();
|
|
175
285
|
}
|
|
@@ -221,9 +331,12 @@ export class ClientImpl {
|
|
|
221
331
|
sub.timer = setTimeout(() => {
|
|
222
332
|
sub.timer = undefined;
|
|
223
333
|
if (sub.refs > 0 || this.subs.get(sub.key) !== sub) return;
|
|
334
|
+
clearTimeout(sub.retryTimer);
|
|
335
|
+
sub.retryTimer = undefined;
|
|
336
|
+
sub.request++;
|
|
224
337
|
this.subs.delete(sub.key);
|
|
225
338
|
this.subsById.delete(sub.id);
|
|
226
|
-
this.
|
|
339
|
+
this.unsubscribe(sub, sub.id);
|
|
227
340
|
}, ms);
|
|
228
341
|
}
|
|
229
342
|
|
|
@@ -245,7 +358,7 @@ export class ClientImpl {
|
|
|
245
358
|
// -- mutations ------------------------------------------------------------------------------------------------
|
|
246
359
|
|
|
247
360
|
async mutate(fn: string, args: unknown[], optimistic?: Fn): Promise<any> {
|
|
248
|
-
if (!optimistic) return this.
|
|
361
|
+
if (!optimistic) return (await this.mutationRequest(fn, args)).value;
|
|
249
362
|
const mid = this.nextMutation++;
|
|
250
363
|
const touched = new Set<Subscription>();
|
|
251
364
|
const store = {
|
|
@@ -256,7 +369,7 @@ export class ClientImpl {
|
|
|
256
369
|
};
|
|
257
370
|
try {
|
|
258
371
|
batch(() => optimistic(store, ...args));
|
|
259
|
-
const { value, commitSeq } = await this.
|
|
372
|
+
const { value, commitSeq } = await this.mutationRequest(fn, args);
|
|
260
373
|
const timeout = this.opts.optimisticTimeoutMs ?? 2000;
|
|
261
374
|
for (const sub of touched) {
|
|
262
375
|
// Only mutations report a commit. Without one, no watermark can cover the layer.
|
|
@@ -296,6 +409,10 @@ export class ClientImpl {
|
|
|
296
409
|
|
|
297
410
|
// -- rooms ----------------------------------------------------------------------------------------------------
|
|
298
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Get or create the room for `room`. Like a subscription, it is only held once retained: a render that never
|
|
414
|
+
* commits leaves an unretained room, which is dropped after a grace period.
|
|
415
|
+
*/
|
|
299
416
|
acquireRoom(room: string): RoomConn {
|
|
300
417
|
const topic = `room:${this.app}:${room}`;
|
|
301
418
|
let r = this.rooms.get(topic);
|
|
@@ -303,20 +420,31 @@ export class ClientImpl {
|
|
|
303
420
|
r = new RoomConn(this.ensureSocket(), topic, this.self);
|
|
304
421
|
this.rooms.set(topic, r);
|
|
305
422
|
}
|
|
306
|
-
if (r.
|
|
423
|
+
if (r.refs === 0) this.scheduleRoomDrop(r, 1000);
|
|
424
|
+
return r;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** False when the room was dropped after it was acquired; acquire it again. */
|
|
428
|
+
retainRoom(r: RoomConn): boolean {
|
|
429
|
+
if (this.rooms.get(r.topic) !== r) return false;
|
|
430
|
+
clearTimeout(r.timer);
|
|
307
431
|
r.timer = undefined;
|
|
308
432
|
r.refs++;
|
|
309
|
-
return
|
|
433
|
+
return true;
|
|
310
434
|
}
|
|
311
435
|
|
|
312
436
|
releaseRoom(r: RoomConn): void {
|
|
313
437
|
r.refs = Math.max(0, r.refs - 1);
|
|
314
|
-
if (r.refs
|
|
315
|
-
|
|
438
|
+
if (r.refs === 0) this.scheduleRoomDrop(r, this.opts.unsubscribeDelayMs ?? 50);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private scheduleRoomDrop(r: RoomConn, ms: number): void {
|
|
442
|
+
clearTimeout(r.timer);
|
|
316
443
|
r.timer = setTimeout(() => {
|
|
444
|
+
r.timer = undefined;
|
|
317
445
|
if (r.refs > 0 || this.rooms.get(r.topic) !== r) return;
|
|
318
446
|
this.rooms.delete(r.topic);
|
|
319
|
-
r.
|
|
320
|
-
},
|
|
447
|
+
r.close();
|
|
448
|
+
}, ms);
|
|
321
449
|
}
|
|
322
450
|
}
|
package/sdk/src/client/errors.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* An error raised by a server handler (or the transport). `name` is the server error name, for example `Error`,
|
|
3
|
-
* `TypeError`, or `
|
|
3
|
+
* `TypeError`, or `OutcomeUnknownError` when a mutation or action could have applied.
|
|
4
4
|
*/
|
|
5
5
|
export class EraspaceError extends Error {
|
|
6
6
|
readonly data?: unknown;
|
|
@@ -26,3 +26,12 @@ export class EraspaceError extends Error {
|
|
|
26
26
|
export function isEraspaceError(e: unknown): e is EraspaceError {
|
|
27
27
|
return e instanceof EraspaceError;
|
|
28
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
|
+
}
|