eraspace 0.1.0 → 0.3.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 +29 -7
- package/bin.js +437 -104
- package/package.json +2 -4
- package/sdk/src/client/client.ts +30 -13
- package/sdk/src/client/config.ts +22 -4
- package/sdk/src/client/connection.ts +230 -47
- package/sdk/src/client/errors.ts +10 -1
- package/sdk/src/client/files.ts +389 -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 +12 -3
- package/sdk/src/client/storage.ts +127 -10
- package/sdk/src/client/sub-key.ts +16 -0
- package/sdk/src/client/subscription.ts +16 -1
- package/sdk/src/client/transport.ts +423 -0
- package/sdk/src/client/types.ts +7 -0
- package/sdk/src/server/index.ts +44 -6
- package/templates/chat/client/index.tsx +10 -6
- package/templates/chat/client/styles.css +15 -0
- package/templates/chat/server/index.ts +16 -6
- package/templates/poll/client/index.tsx +1 -1
- package/templates/todo/client/index.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eraspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "eraspace: build, deploy and manage eraspace capsules",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"cli",
|
|
9
9
|
"realtime",
|
|
10
10
|
"preact",
|
|
11
|
-
"
|
|
11
|
+
"v8"
|
|
12
12
|
],
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"bin": {
|
|
@@ -24,10 +24,8 @@
|
|
|
24
24
|
"@preact/signals": "2.11.3",
|
|
25
25
|
"@tailwindcss/node": "4.3.3",
|
|
26
26
|
"@tailwindcss/oxide": "4.3.3",
|
|
27
|
-
"@types/phoenix": "1.6.7",
|
|
28
27
|
"esbuild": "0.28.2",
|
|
29
28
|
"oxc-parser": "0.154.0",
|
|
30
|
-
"phoenix": "1.8.15",
|
|
31
29
|
"preact": "11.0.1",
|
|
32
30
|
"preact-iso": "2.12.2",
|
|
33
31
|
"tailwindcss": "4.3.3",
|
package/sdk/src/client/client.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { computed, type ReadonlySignal } from "@preact/signals";
|
|
2
|
-
import { useEffect, useMemo, useRef } from "preact/hooks";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
3
3
|
import { createStorageClient } from "./storage.ts";
|
|
4
|
-
import type
|
|
4
|
+
import { useUpload, type UseUploadOptions } from "./files.ts";
|
|
5
|
+
import { readConfig, type ClientOptions } from "./config.ts";
|
|
5
6
|
import { subKey, stableStringify } from "./sub-key.ts";
|
|
6
7
|
import type { Subscription } from "./subscription.ts";
|
|
7
8
|
import type { RoomConn } from "./room.ts";
|
|
8
9
|
import type { EraspaceClient, Room } from "./types.ts";
|
|
10
|
+
import { EraspaceError } from "./errors.ts";
|
|
9
11
|
import { ClientImpl } from "./connection.ts";
|
|
10
12
|
import { Pager } from "./pagination.ts";
|
|
11
13
|
|
|
@@ -21,8 +23,10 @@ type Fn = (...args: any[]) => any;
|
|
|
21
23
|
*/
|
|
22
24
|
export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C> {
|
|
23
25
|
let impl: ClientImpl | undefined;
|
|
24
|
-
// Lazy so a module-level createClient() costs nothing until first use
|
|
26
|
+
// Lazy so a module-level createClient() costs nothing until first use, except in a browser page that reloads on
|
|
27
|
+
// deploy (the platform shell's default), where the client connects at once to hear deploys.
|
|
25
28
|
const get = () => (impl ??= new ClientImpl(options));
|
|
29
|
+
if (typeof location !== "undefined" && (options.reloadOnDeploy ?? readConfig().app)) get().watchDeploys();
|
|
26
30
|
|
|
27
31
|
function useSubscription(name: string, args: unknown[]): Subscription {
|
|
28
32
|
const c = get();
|
|
@@ -39,7 +43,8 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
|
|
|
39
43
|
return useMemo(() => computed(() => {
|
|
40
44
|
const err = sub.error.value;
|
|
41
45
|
const value: unknown = sub.value.value;
|
|
42
|
-
|
|
46
|
+
// Retried failures keep showing the last value; a gone app cannot recover.
|
|
47
|
+
if (err && (value === undefined || err.name === "AppGone")) throw err;
|
|
43
48
|
return value;
|
|
44
49
|
}), [sub]);
|
|
45
50
|
}
|
|
@@ -61,7 +66,7 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
function useAction(name: string) {
|
|
64
|
-
return useMemo(() => (...args: unknown[]) => get().call(name, args), [name]);
|
|
69
|
+
return useMemo(() => (...args: unknown[]) => get().call(name, args, "action"), [name]);
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
function usePaginatedQuery(name: string, args: unknown[], opts?: { initialNumItems?: number }) {
|
|
@@ -85,8 +90,17 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
|
|
|
85
90
|
|
|
86
91
|
function useRoomConn(room: string, meta: Record<string, unknown> | undefined): RoomConn {
|
|
87
92
|
const c = get();
|
|
88
|
-
const
|
|
89
|
-
|
|
93
|
+
const [generation, renew] = useState(0);
|
|
94
|
+
// Acquiring during render holds nothing: only a committed effect retains the room.
|
|
95
|
+
const r = useMemo(() => c.acquireRoom(room), [room, generation]);
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!c.retainRoom(r)) {
|
|
98
|
+
// Dropped between render and commit.
|
|
99
|
+
renew((n) => n + 1);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
return () => c.releaseRoom(r);
|
|
103
|
+
}, [r]);
|
|
90
104
|
const metaKey = meta === undefined ? undefined : stableStringify(meta);
|
|
91
105
|
useEffect(() => {
|
|
92
106
|
if (meta !== undefined) r.setMeta(meta);
|
|
@@ -104,8 +118,9 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
|
|
|
104
118
|
presence: r.members.value,
|
|
105
119
|
self: r.self.value,
|
|
106
120
|
state: r.state.value,
|
|
121
|
+
error: r.error.value,
|
|
107
122
|
broadcast: (event: string, payload?: unknown) => {
|
|
108
|
-
r.channel.push("broadcast", { event, payload: payload ?? null });
|
|
123
|
+
void r.channel.push("broadcast", { event, payload: payload ?? null }).catch(error => { r.error.value = EraspaceError.from(error); });
|
|
109
124
|
},
|
|
110
125
|
on: (event: string, cb: (payload: any, from: string | undefined) => void) => {
|
|
111
126
|
let set = r.listeners.get(event);
|
|
@@ -114,14 +129,15 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
|
|
|
114
129
|
return () => set!.delete(cb);
|
|
115
130
|
},
|
|
116
131
|
setState: (key: string, value: unknown) => {
|
|
117
|
-
r.
|
|
118
|
-
r.channel.push("state:set", { key, value: value ?? null });
|
|
132
|
+
r.setState(key, value);
|
|
119
133
|
},
|
|
120
134
|
};
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
const storage = createStorageClient(() => get().base);
|
|
123
138
|
const client = {
|
|
124
|
-
storage
|
|
139
|
+
storage,
|
|
140
|
+
useUpload: (opts?: UseUploadOptions) => useUpload(storage, opts),
|
|
125
141
|
useQuery,
|
|
126
142
|
useQuerySignal,
|
|
127
143
|
useQuerySelector,
|
|
@@ -131,8 +147,9 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
|
|
|
131
147
|
usePresence,
|
|
132
148
|
useRoom,
|
|
133
149
|
query: (name: string, ...args: unknown[]) => get().call(name, args),
|
|
134
|
-
mutation: (name: string, ...args: unknown[]) => get().
|
|
135
|
-
|
|
150
|
+
mutation: (name: string, ...args: unknown[]) => get().mutate(name, args),
|
|
151
|
+
retryMutation: (name: string, args: unknown[], requestKey: string) => get().retryMutation(name, args, requestKey),
|
|
152
|
+
action: (name: string, ...args: unknown[]) => get().call(name, args, "action"),
|
|
136
153
|
subscribe: (name: string, args: unknown[], onValue: Fn, onError?: Fn) => get().subscribe(name, args, onValue, onError),
|
|
137
154
|
get app() {
|
|
138
155
|
return get().app;
|
package/sdk/src/client/config.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
+
import type { SocketOptions, WebSocketConstructor } from "./transport.ts";
|
|
2
|
+
|
|
3
|
+
/** A query result the shell embedded, as a `sub` reply would carry it. */
|
|
4
|
+
export interface InitialResult {
|
|
5
|
+
fn: string;
|
|
6
|
+
args: unknown[];
|
|
7
|
+
value: unknown;
|
|
8
|
+
commitSeq: number;
|
|
9
|
+
revision: number;
|
|
10
|
+
entry: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
/** `window.__ERASPACE__`, injected by the platform's HTML shell (SPEC §6). */
|
|
2
14
|
export interface EraspaceConfig {
|
|
3
15
|
app: string;
|
|
4
16
|
base: string;
|
|
5
17
|
version: number;
|
|
6
18
|
socket: string;
|
|
19
|
+
/** Current results of the queries the app's pages usually open first. */
|
|
20
|
+
initial?: InitialResult[];
|
|
7
21
|
}
|
|
8
22
|
|
|
9
23
|
export interface ClientOptions {
|
|
@@ -12,7 +26,11 @@ export interface ClientOptions {
|
|
|
12
26
|
socket?: string;
|
|
13
27
|
base?: string;
|
|
14
28
|
version?: number;
|
|
15
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Reload the page when the app is redeployed, so a deploy reaches open tabs without a manual refresh. Default
|
|
31
|
+
* `true` for the shell's own app on pages served by the platform shell (`window.__ERASPACE__` is set), otherwise
|
|
32
|
+
* `false`. When enabled in a browser, the client connects at creation rather than on first use.
|
|
33
|
+
*/
|
|
16
34
|
reloadOnDeploy?: boolean;
|
|
17
35
|
/** Push timeout in ms. Default 15000. */
|
|
18
36
|
timeout?: number;
|
|
@@ -21,9 +39,9 @@ export interface ClientOptions {
|
|
|
21
39
|
/** Delay before refreshing a subscription still waiting for a mutation commit. Default 2000 ms. */
|
|
22
40
|
optimisticTimeoutMs?: number;
|
|
23
41
|
/** WebSocket constructor (tests, non-browser runtimes). */
|
|
24
|
-
transport?:
|
|
25
|
-
/**
|
|
26
|
-
socketOptions?:
|
|
42
|
+
transport?: WebSocketConstructor;
|
|
43
|
+
/** Reconnect and rejoin delay overrides. */
|
|
44
|
+
socketOptions?: Pick<SocketOptions, "reconnectAfterMs" | "rejoinAfterMs">;
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
declare global {
|
|
@@ -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";
|
|
5
|
-
import { readConfig, type ClientOptions } from "./config.ts";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { EraspaceError, OutcomeUnknownError } from "./errors.ts";
|
|
5
|
+
import { readConfig, type ClientOptions, type InitialResult } from "./config.ts";
|
|
6
|
+
import { requestKey } from "./request-key.ts";
|
|
7
|
+
import { canonicalArgs, subKey } from "./sub-key.ts";
|
|
8
|
+
import { Subscription, type FullResult, type UnchangedResult, 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;
|
|
@@ -16,17 +18,25 @@ export class ClientImpl {
|
|
|
16
18
|
readonly self = signal("");
|
|
17
19
|
readonly status = signal<Status>("connecting");
|
|
18
20
|
readonly version: Signal<number>;
|
|
21
|
+
readonly reloadOnDeploy: boolean;
|
|
19
22
|
private opts: ClientOptions;
|
|
20
23
|
private socketUrl: string;
|
|
21
24
|
private socket: Socket | undefined;
|
|
22
25
|
private channel: Channel | undefined;
|
|
23
26
|
private joinedOnce = false;
|
|
27
|
+
/** Counts app channel joins. Server subscription ids live only as long as the join that created them. */
|
|
28
|
+
private joins = 0;
|
|
29
|
+
/** Server time minus local time, from the latest join reply. */
|
|
30
|
+
private clockOffset = 0;
|
|
31
|
+
private gone: EraspaceError | undefined;
|
|
24
32
|
private nextSub = 1;
|
|
25
33
|
private nextMutation = 1;
|
|
26
34
|
private subs = new Map<string, Subscription>();
|
|
27
35
|
private subsById = new Map<string, Subscription>();
|
|
28
36
|
private rooms = new Map<string, RoomConn>();
|
|
29
37
|
private deployedCbs = new Set<(v: number) => void>();
|
|
38
|
+
/** Results the shell embedded, by subscription key, each used by the first subscription to it. */
|
|
39
|
+
private initial = new Map<string, InitialResult>();
|
|
30
40
|
|
|
31
41
|
constructor(opts: ClientOptions) {
|
|
32
42
|
const cfg = readConfig();
|
|
@@ -35,23 +45,31 @@ export class ClientImpl {
|
|
|
35
45
|
if (!this.app) throw new Error("eraspace: createClient() needs an app (window.__ERASPACE__ is missing)");
|
|
36
46
|
this.base = opts.base ?? cfg.base ?? `/${this.app}/`;
|
|
37
47
|
this.socketUrl = opts.socket ?? cfg.socket ?? "/socket";
|
|
38
|
-
|
|
48
|
+
// The shell's version and reload default describe the page's own app, not another app it talks to.
|
|
49
|
+
const ownApp = !opts.app || opts.app === cfg.app;
|
|
50
|
+
this.version = signal(opts.version ?? (ownApp ? cfg.version : undefined) ?? 0);
|
|
51
|
+
this.reloadOnDeploy = opts.reloadOnDeploy ?? (ownApp && Boolean(cfg.app));
|
|
52
|
+
if (ownApp && Array.isArray(cfg.initial)) {
|
|
53
|
+
for (const r of cfg.initial) if (typeof r?.fn === "string" && Array.isArray(r.args)) this.initial.set(subKey(r.fn, r.args), r);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Join the app topic now when deploys reload the page, so a page that has not queried yet still hears them. */
|
|
58
|
+
watchDeploys(): void {
|
|
59
|
+
if (this.reloadOnDeploy && !this.gone) this.ensureChannel();
|
|
39
60
|
}
|
|
40
61
|
|
|
41
62
|
// -- connection -----------------------------------------------------------------------------------------------
|
|
42
63
|
|
|
43
64
|
private ensureSocket(): Socket {
|
|
44
65
|
if (this.socket) return this.socket;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
timeout: this.opts.timeout ?? 15000,
|
|
66
|
+
const socket = new Socket(this.socketUrl, {
|
|
67
|
+
timeout: this.opts.timeout,
|
|
48
68
|
...this.opts.socketOptions,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const socket = new Socket(this.socketUrl, socketOpts as any);
|
|
69
|
+
transport: this.opts.transport,
|
|
70
|
+
});
|
|
52
71
|
socket.onOpen(() => { this.status.value = "open"; });
|
|
53
72
|
socket.onClose(() => { this.status.value = "closed"; });
|
|
54
|
-
socket.onError(() => { this.status.value = "closed"; });
|
|
55
73
|
socket.connect();
|
|
56
74
|
this.socket = socket;
|
|
57
75
|
return socket;
|
|
@@ -60,41 +78,105 @@ export class ClientImpl {
|
|
|
60
78
|
private ensureChannel(): Channel {
|
|
61
79
|
if (this.channel) return this.channel;
|
|
62
80
|
const socket = this.ensureSocket();
|
|
63
|
-
const channel = socket.channel(`app:${this.app}`, {
|
|
81
|
+
const channel = socket.channel(`app:${this.app}`, {});
|
|
64
82
|
channel.on("data", (m: DataMessage) => this.onData(m));
|
|
65
83
|
channel.on("error", (m: { id?: string; name?: string; message?: string }) => {
|
|
66
84
|
const sub = m.id !== undefined ? this.subsById.get(String(m.id)) : undefined;
|
|
67
|
-
if (sub)
|
|
85
|
+
if (sub) {
|
|
86
|
+
sub.error.value = EraspaceError.from(m);
|
|
87
|
+
this.retrySub(sub);
|
|
88
|
+
}
|
|
68
89
|
});
|
|
90
|
+
channel.on("gone", () => this.onGone());
|
|
91
|
+
// Transport loss changes status; reconnect rejoins and resubscribes while cached values stay valid.
|
|
92
|
+
// A join the server rejects is reported to every subscription.
|
|
93
|
+
const failed = (payload: unknown, rejected: boolean) => {
|
|
94
|
+
if (this.gone) return;
|
|
95
|
+
if (payload instanceof EraspaceError && payload.name === "AppGone") { this.onGone(); return; }
|
|
96
|
+
this.status.value = "closed";
|
|
97
|
+
if (rejected) for (const sub of this.subs.values()) sub.error.value = EraspaceError.from(payload, "App channel join failed");
|
|
98
|
+
};
|
|
99
|
+
channel.onError((reason, rejected) => failed(reason, rejected));
|
|
100
|
+
channel.onClose(() => failed(undefined, false));
|
|
69
101
|
channel.on("deployed", (m: { version?: number }) => this.onDeployed(m?.version));
|
|
70
|
-
channel.
|
|
102
|
+
channel.onJoin((resp) => {
|
|
103
|
+
this.joins++;
|
|
104
|
+
if (this.gone) return;
|
|
105
|
+
this.status.value = "open";
|
|
71
106
|
if (resp && typeof resp.self === "string") this.self.value = resp.self;
|
|
107
|
+
if (resp && typeof resp.now === "number") this.clockOffset = resp.now - Date.now();
|
|
108
|
+
if (resp && typeof resp.version === "number") this.onJoinVersion(resp.version);
|
|
72
109
|
// The channel process is new after a reconnect, so every live subscription is sent again.
|
|
73
110
|
if (this.joinedOnce) this.resubscribeAll();
|
|
74
111
|
this.joinedOnce = true;
|
|
75
112
|
});
|
|
113
|
+
channel.join();
|
|
76
114
|
this.channel = channel;
|
|
77
115
|
return channel;
|
|
78
116
|
}
|
|
79
117
|
|
|
80
|
-
private push<T>(event: string, payload: object): Promise<T> {
|
|
118
|
+
private push<T>(event: string, payload: object | (() => object | null)): Promise<T> {
|
|
119
|
+
if (this.gone) return Promise.reject(this.gone);
|
|
81
120
|
const channel = this.ensureChannel();
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
121
|
+
return channel.push<T>(event, payload, this.opts.timeout ?? 15000);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async call(fn: string, args: unknown[], kind: "query" | "action" = "query"): Promise<unknown> {
|
|
125
|
+
const key = this.requestKey();
|
|
126
|
+
try {
|
|
127
|
+
// Sorted object keys let equal query arguments share the server's cached result.
|
|
128
|
+
const resp = await this.push<{ value?: unknown }>("call", { fn, args: kind === "query" ? canonicalArgs(args) : args, kind, key });
|
|
129
|
+
return resp?.value;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (kind === "action" && error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError"].includes(error.name)) {
|
|
132
|
+
throw new OutcomeUnknownError(key, "The action may have performed effects; it cannot be replayed safely");
|
|
133
|
+
}
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
89
136
|
}
|
|
90
137
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return
|
|
138
|
+
/** Request keys carry server time, so a skewed client clock cannot place them outside the retry window. */
|
|
139
|
+
private requestKey(): string {
|
|
140
|
+
return requestKey(Date.now() + this.clockOffset);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async mutationRequest(fn: string, args: unknown[], key = this.requestKey(), retry = false): Promise<{ value: unknown; commitSeq?: number }> {
|
|
144
|
+
let rekeyed = false;
|
|
145
|
+
for (let attempt = 0; ; attempt++) {
|
|
146
|
+
try {
|
|
147
|
+
return await this.push("call", { fn, args, kind: "mutation", key, retry });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// The server refused the key before running anything. The join reply has corrected the clock by now.
|
|
150
|
+
if (error instanceof EraspaceError && error.name === "RequestKeyError" && !retry && !rekeyed) {
|
|
151
|
+
rekeyed = true;
|
|
152
|
+
attempt--;
|
|
153
|
+
key = this.requestKey();
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const uncertain = error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError", "RetryExpiredError"].includes(error.name);
|
|
157
|
+
// A rejected recovery attempt cannot resolve the original request's unknown outcome.
|
|
158
|
+
if (!uncertain && !retry) throw error;
|
|
159
|
+
if (uncertain && attempt === 0 && error.name !== "RetryExpiredError") {
|
|
160
|
+
retry = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const message = error instanceof Error ? error.message : "Recovery failed";
|
|
164
|
+
throw new OutcomeUnknownError(key, `${message}. Recover this mutation only with the same request key.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async retryMutation(fn: string, args: unknown[], key: string): Promise<unknown> {
|
|
170
|
+
return (await this.mutationRequest(fn, args, key, true)).value;
|
|
94
171
|
}
|
|
95
172
|
|
|
96
173
|
close(): void {
|
|
97
|
-
for (const
|
|
174
|
+
for (const sub of this.subs.values()) {
|
|
175
|
+
clearTimeout(sub.timer);
|
|
176
|
+
clearTimeout(sub.retryTimer);
|
|
177
|
+
sub.request++;
|
|
178
|
+
}
|
|
179
|
+
for (const r of this.rooms.values()) r.close();
|
|
98
180
|
this.rooms.clear();
|
|
99
181
|
this.channel?.leave();
|
|
100
182
|
this.socket?.disconnect();
|
|
@@ -102,38 +184,92 @@ export class ClientImpl {
|
|
|
102
184
|
this.socket = undefined;
|
|
103
185
|
this.subs.clear();
|
|
104
186
|
this.subsById.clear();
|
|
187
|
+
this.joinedOnce = false;
|
|
105
188
|
this.status.value = "closed";
|
|
106
189
|
}
|
|
107
190
|
|
|
191
|
+
private onGone(): void {
|
|
192
|
+
this.gone = new EraspaceError("AppGone", "This app is no longer serving");
|
|
193
|
+
for (const sub of this.subs.values()) {
|
|
194
|
+
clearTimeout(sub.retryTimer);
|
|
195
|
+
sub.retryTimer = undefined;
|
|
196
|
+
sub.request++;
|
|
197
|
+
sub.error.value = this.gone;
|
|
198
|
+
}
|
|
199
|
+
for (const r of this.rooms.values()) r.close();
|
|
200
|
+
this.channel?.leave();
|
|
201
|
+
this.socket?.disconnect();
|
|
202
|
+
this.status.value = "closed";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private retrySub(sub: Subscription): void {
|
|
206
|
+
if (this.gone || sub.retryTimer || this.subs.get(sub.key) !== sub) return;
|
|
207
|
+
sub.retryTimer = setTimeout(() => {
|
|
208
|
+
sub.retryTimer = undefined;
|
|
209
|
+
if (this.subs.get(sub.key) === sub) this.sendSub(sub);
|
|
210
|
+
}, retryDelay(++sub.retries));
|
|
211
|
+
}
|
|
212
|
+
|
|
108
213
|
// -- subscriptions --------------------------------------------------------------------------------------------
|
|
109
214
|
|
|
110
215
|
private sendSub(sub: Subscription): void {
|
|
216
|
+
if (this.gone) { sub.error.value = this.gone; return; }
|
|
217
|
+
clearTimeout(sub.retryTimer);
|
|
218
|
+
sub.retryTimer = undefined;
|
|
111
219
|
const oldId = sub.id;
|
|
112
220
|
const request = ++sub.request;
|
|
221
|
+
// The server replies without the value when its cache entry still holds the revision we have.
|
|
222
|
+
const known = sub.hasServer && sub.entry !== undefined && sub.revision !== undefined ? { entry: sub.entry, revision: sub.revision } : undefined;
|
|
113
223
|
if (request > 1) {
|
|
114
224
|
this.subsById.delete(oldId);
|
|
115
225
|
sub.id = String(this.nextSub++);
|
|
116
226
|
this.subsById.set(sub.id, sub);
|
|
117
227
|
sub.revision = undefined;
|
|
118
|
-
this.
|
|
228
|
+
this.unsubscribe(sub, oldId);
|
|
119
229
|
}
|
|
120
|
-
|
|
121
|
-
|
|
230
|
+
const id = sub.id;
|
|
231
|
+
this.push<FullResult | UnchangedResult>("sub", () => {
|
|
232
|
+
// A newer request or a drop replaced this one before it was sent: withdraw it.
|
|
233
|
+
if (sub.request !== request) return null;
|
|
234
|
+
sub.sent = { id, join: this.joins };
|
|
235
|
+
const minCommitSeq = Math.max(0, ...sub.layers.map(layer => layer.commitSeq ?? 0));
|
|
236
|
+
return known ? { id, fn: sub.fn, args: sub.args, minCommitSeq, known } : { id, fn: sub.fn, args: sub.args, minCommitSeq };
|
|
122
237
|
}).then(
|
|
123
238
|
(resp) => {
|
|
124
239
|
if (this.subsById.get(sub.id) !== sub || sub.request !== request) return;
|
|
125
|
-
|
|
240
|
+
if ("unchanged" in resp && resp.unchanged === true) {
|
|
241
|
+
// Only a known revision is answered this way, and the value cannot have changed since it was sent.
|
|
242
|
+
if (known && sub.hasServer) this.setServer(sub, sub.server, resp.commitSeq, resp.revision);
|
|
243
|
+
else this.sendSub(sub);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const full = resp as FullResult;
|
|
247
|
+
sub.entry = full.entry;
|
|
248
|
+
this.setServer(sub, full.value, full.commitSeq, full.revision);
|
|
126
249
|
},
|
|
127
250
|
(err: EraspaceError) => {
|
|
128
|
-
if (this.subsById.get(sub.id) === sub && sub.request === request)
|
|
251
|
+
if (this.subsById.get(sub.id) === sub && sub.request === request) {
|
|
252
|
+
sub.error.value = err;
|
|
253
|
+
this.retrySub(sub);
|
|
254
|
+
}
|
|
129
255
|
},
|
|
130
256
|
);
|
|
131
257
|
}
|
|
132
258
|
|
|
259
|
+
/** Tells the server to drop `id`, unless the server cannot have it: never sent, or sent before a reconnect. */
|
|
260
|
+
private unsubscribe(sub: Subscription, id: string): void {
|
|
261
|
+
if (sub.sent?.id !== id || sub.sent.join !== this.joins) return;
|
|
262
|
+
sub.sent = undefined;
|
|
263
|
+
this.push("unsub", { id }).catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
|
|
133
266
|
// Messages for one subscription id arrive in order, so only commit watermarks are compared. Revisions
|
|
134
267
|
// belong to a server cache entry and may restart lower when an identity-dependent query splits.
|
|
135
268
|
private setServer(sub: Subscription, value: unknown, commitSeq: number, revision: number): void {
|
|
136
269
|
if (commitSeq < sub.commitSeq) return;
|
|
270
|
+
clearTimeout(sub.retryTimer);
|
|
271
|
+
sub.retryTimer = undefined;
|
|
272
|
+
sub.retries = 0;
|
|
137
273
|
batch(() => {
|
|
138
274
|
sub.server = value;
|
|
139
275
|
sub.hasServer = true;
|
|
@@ -146,6 +282,7 @@ export class ClientImpl {
|
|
|
146
282
|
}
|
|
147
283
|
|
|
148
284
|
private onData(message: DataMessage): void {
|
|
285
|
+
if (this.gone) return;
|
|
149
286
|
const sub = this.subsById.get(String(message.id));
|
|
150
287
|
if (!sub || message.commitSeq < sub.commitSeq) return;
|
|
151
288
|
if ("patch" in message) {
|
|
@@ -159,6 +296,8 @@ export class ClientImpl {
|
|
|
159
296
|
this.sendSub(sub);
|
|
160
297
|
}
|
|
161
298
|
} else {
|
|
299
|
+
// Recovered subscriptions arrive as full pushes that name their new cache entry.
|
|
300
|
+
if (typeof message.entry === "string") sub.entry = message.entry;
|
|
162
301
|
this.setServer(sub, message.value, message.commitSeq, message.revision);
|
|
163
302
|
}
|
|
164
303
|
}
|
|
@@ -167,11 +306,18 @@ export class ClientImpl {
|
|
|
167
306
|
for (const sub of this.subs.values()) this.sendSub(sub);
|
|
168
307
|
}
|
|
169
308
|
|
|
309
|
+
// Subscriptions survive a deploy: the server re-runs them with the new code and pushes changed values.
|
|
170
310
|
private onDeployed(version: number | undefined): void {
|
|
171
311
|
if (typeof version === "number") this.version.value = version;
|
|
172
|
-
this.resubscribeAll();
|
|
173
312
|
for (const cb of [...this.deployedCbs]) cb(this.version.value);
|
|
174
|
-
if (this.
|
|
313
|
+
if (this.reloadOnDeploy && typeof location !== "undefined") location.reload();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A deploy announced while the socket was down, or before the first join, shows up as a newer join version.
|
|
317
|
+
// Without a known page version there is nothing to compare, so the first one seen becomes the baseline.
|
|
318
|
+
private onJoinVersion(version: number): void {
|
|
319
|
+
if (this.version.value === 0) this.version.value = version;
|
|
320
|
+
else if (version > this.version.value) this.onDeployed(version);
|
|
175
321
|
}
|
|
176
322
|
|
|
177
323
|
onDeployedCb(cb: (v: number) => void): () => void {
|
|
@@ -187,6 +333,14 @@ export class ClientImpl {
|
|
|
187
333
|
sub = new Subscription(String(this.nextSub++), fn, args, key);
|
|
188
334
|
this.subs.set(key, sub);
|
|
189
335
|
this.subsById.set(sub.id, sub);
|
|
336
|
+
// A result embedded in the page shows at once; the server confirms it with `unchanged` when the first
|
|
337
|
+
// `sub` sends it as `known`, or replies with the current value.
|
|
338
|
+
const seed = this.initial.get(key);
|
|
339
|
+
if (seed) {
|
|
340
|
+
this.initial.delete(key);
|
|
341
|
+
sub.entry = seed.entry;
|
|
342
|
+
this.setServer(sub, seed.value, seed.commitSeq, seed.revision);
|
|
343
|
+
}
|
|
190
344
|
this.sendSub(sub);
|
|
191
345
|
}
|
|
192
346
|
if (sub.refs === 0) this.scheduleDrop(sub, 1000);
|
|
@@ -221,9 +375,12 @@ export class ClientImpl {
|
|
|
221
375
|
sub.timer = setTimeout(() => {
|
|
222
376
|
sub.timer = undefined;
|
|
223
377
|
if (sub.refs > 0 || this.subs.get(sub.key) !== sub) return;
|
|
378
|
+
clearTimeout(sub.retryTimer);
|
|
379
|
+
sub.retryTimer = undefined;
|
|
380
|
+
sub.request++;
|
|
224
381
|
this.subs.delete(sub.key);
|
|
225
382
|
this.subsById.delete(sub.id);
|
|
226
|
-
this.
|
|
383
|
+
this.unsubscribe(sub, sub.id);
|
|
227
384
|
}, ms);
|
|
228
385
|
}
|
|
229
386
|
|
|
@@ -245,7 +402,7 @@ export class ClientImpl {
|
|
|
245
402
|
// -- mutations ------------------------------------------------------------------------------------------------
|
|
246
403
|
|
|
247
404
|
async mutate(fn: string, args: unknown[], optimistic?: Fn): Promise<any> {
|
|
248
|
-
if (!optimistic) return this.
|
|
405
|
+
if (!optimistic) return (await this.mutationRequest(fn, args)).value;
|
|
249
406
|
const mid = this.nextMutation++;
|
|
250
407
|
const touched = new Set<Subscription>();
|
|
251
408
|
const store = {
|
|
@@ -256,7 +413,7 @@ export class ClientImpl {
|
|
|
256
413
|
};
|
|
257
414
|
try {
|
|
258
415
|
batch(() => optimistic(store, ...args));
|
|
259
|
-
const { value, commitSeq } = await this.
|
|
416
|
+
const { value, commitSeq } = await this.mutationRequest(fn, args);
|
|
260
417
|
const timeout = this.opts.optimisticTimeoutMs ?? 2000;
|
|
261
418
|
for (const sub of touched) {
|
|
262
419
|
// Only mutations report a commit. Without one, no watermark can cover the layer.
|
|
@@ -269,9 +426,13 @@ export class ClientImpl {
|
|
|
269
426
|
l.commitSeq = commitSeq;
|
|
270
427
|
}
|
|
271
428
|
if (sub.commitSeq >= commitSeq) this.dropLayers(sub, mid);
|
|
272
|
-
else
|
|
273
|
-
|
|
274
|
-
|
|
429
|
+
else {
|
|
430
|
+
// The server sends unchanged values' newer watermarks only on request.
|
|
431
|
+
this.watch(sub, commitSeq);
|
|
432
|
+
setTimeout(() => {
|
|
433
|
+
if (sub.layers.some((l) => l.mid === mid) && this.subs.get(sub.key) === sub) this.sendSub(sub);
|
|
434
|
+
}, timeout);
|
|
435
|
+
}
|
|
275
436
|
}
|
|
276
437
|
return value;
|
|
277
438
|
} catch (e) {
|
|
@@ -280,6 +441,13 @@ export class ClientImpl {
|
|
|
280
441
|
}
|
|
281
442
|
}
|
|
282
443
|
|
|
444
|
+
/** Asks for a watermark covering `commitSeq` on the subscription's current server id. */
|
|
445
|
+
private watch(sub: Subscription, commitSeq: number): void {
|
|
446
|
+
const id = sub.id;
|
|
447
|
+
if (sub.sent?.id !== id || sub.sent.join !== this.joins) return;
|
|
448
|
+
this.push("watch", { id, commitSeq }).catch(() => {});
|
|
449
|
+
}
|
|
450
|
+
|
|
283
451
|
private addLayer(mid: number, name: string, args: unknown[], apply: Fn, touched: Set<Subscription>): void {
|
|
284
452
|
const sub = this.subs.get(subKey(name, args));
|
|
285
453
|
if (!sub) return;
|
|
@@ -296,6 +464,10 @@ export class ClientImpl {
|
|
|
296
464
|
|
|
297
465
|
// -- rooms ----------------------------------------------------------------------------------------------------
|
|
298
466
|
|
|
467
|
+
/**
|
|
468
|
+
* Get or create the room for `room`. Like a subscription, it is only held once retained: a render that never
|
|
469
|
+
* commits leaves an unretained room, which is dropped after a grace period.
|
|
470
|
+
*/
|
|
299
471
|
acquireRoom(room: string): RoomConn {
|
|
300
472
|
const topic = `room:${this.app}:${room}`;
|
|
301
473
|
let r = this.rooms.get(topic);
|
|
@@ -303,20 +475,31 @@ export class ClientImpl {
|
|
|
303
475
|
r = new RoomConn(this.ensureSocket(), topic, this.self);
|
|
304
476
|
this.rooms.set(topic, r);
|
|
305
477
|
}
|
|
306
|
-
if (r.
|
|
478
|
+
if (r.refs === 0) this.scheduleRoomDrop(r, 1000);
|
|
479
|
+
return r;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** False when the room was dropped after it was acquired; acquire it again. */
|
|
483
|
+
retainRoom(r: RoomConn): boolean {
|
|
484
|
+
if (this.rooms.get(r.topic) !== r) return false;
|
|
485
|
+
clearTimeout(r.timer);
|
|
307
486
|
r.timer = undefined;
|
|
308
487
|
r.refs++;
|
|
309
|
-
return
|
|
488
|
+
return true;
|
|
310
489
|
}
|
|
311
490
|
|
|
312
491
|
releaseRoom(r: RoomConn): void {
|
|
313
492
|
r.refs = Math.max(0, r.refs - 1);
|
|
314
|
-
if (r.refs
|
|
315
|
-
|
|
493
|
+
if (r.refs === 0) this.scheduleRoomDrop(r, this.opts.unsubscribeDelayMs ?? 50);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private scheduleRoomDrop(r: RoomConn, ms: number): void {
|
|
497
|
+
clearTimeout(r.timer);
|
|
316
498
|
r.timer = setTimeout(() => {
|
|
499
|
+
r.timer = undefined;
|
|
317
500
|
if (r.refs > 0 || this.rooms.get(r.topic) !== r) return;
|
|
318
501
|
this.rooms.delete(r.topic);
|
|
319
|
-
r.
|
|
320
|
-
},
|
|
502
|
+
r.close();
|
|
503
|
+
}, ms);
|
|
321
504
|
}
|
|
322
505
|
}
|
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
|
+
}
|