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.
@@ -6,25 +6,113 @@ export interface UploadedObject {
6
6
  size: number;
7
7
  contentType: string;
8
8
  name: string | null;
9
+ /** Private upload capability. Keep it out of public rows and file references. */
10
+ deleteToken?: string;
11
+ }
12
+
13
+ export interface StorageUploadOptions {
14
+ name?: string;
15
+ signal?: AbortSignal;
16
+ /** Called with the uploaded fraction, from 0 to 1, as the bytes leave the browser. */
17
+ onProgress?: (fraction: number) => void;
9
18
  }
10
19
 
11
20
  export interface StorageClient {
12
- /** Store a file in this app's Postgres schema. v1 apps and their files are public. */
13
- upload(body: Blob, options?: { name?: string; signal?: AbortSignal }): Promise<UploadedObject>;
21
+ /** Store a file in this app's bucket. Apps and their files are public. */
22
+ upload(body: Blob, options?: StorageUploadOptions): Promise<UploadedObject>;
14
23
  url(key: string): string;
15
- delete(key: string, options?: { signal?: AbortSignal }): Promise<void>;
24
+ /** A stored file's metadata, or null when there is no such file. */
25
+ info(key: string, options?: { signal?: AbortSignal }): Promise<UploadedObject | null>;
26
+ delete(key: string, options?: { signal?: AbortSignal; deleteToken?: string }): Promise<void>;
27
+ /**
28
+ * A page-local URL for an image, video or audio file this client uploaded, so previews show it without
29
+ * downloading it again. Undefined for any other file, or after the file is deleted or evicted. Optional, so other
30
+ * implementations keep working; previews then use the server URL.
31
+ */
32
+ localUrl?(key: string): string | undefined;
33
+ }
34
+
35
+ /** Uploaded media kept as object URLs. Each keeps its Blob alive, so only the most recent are retained. */
36
+ const LOCAL_FILES = 50;
37
+
38
+ function isMedia(contentType: string): boolean {
39
+ return /^(image|video|audio)\//i.test(contentType.trim());
40
+ }
41
+
42
+ /** Storage keys are UUIDs. */
43
+ export function isStorageKey(key: unknown): key is string {
44
+ return typeof key === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(key);
45
+ }
46
+
47
+ /** The filename in a `filename*=UTF-8''...` Content-Disposition. */
48
+ function dispositionName(value: string | null): string | null {
49
+ const encoded = value?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
50
+ if (!encoded) return null;
51
+ try {
52
+ return decodeURIComponent(encoded);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** POST `body` with XMLHttpRequest, which reports upload progress where fetch cannot. */
59
+ function sendWithProgress(target: string, headers: Headers, body: Blob, signal: AbortSignal | undefined, onProgress: (fraction: number) => void) {
60
+ return new Promise<{ status: number; text: string }>((resolve, reject) => {
61
+ const abortError = () => signal?.reason ?? new DOMException("The upload was aborted", "AbortError");
62
+ if (signal?.aborted) return reject(abortError());
63
+ const xhr = new XMLHttpRequest();
64
+ const abort = () => xhr.abort();
65
+ signal?.addEventListener("abort", abort, { once: true });
66
+ const done = () => signal?.removeEventListener("abort", abort);
67
+ xhr.open("POST", target);
68
+ headers.forEach((value, name) => xhr.setRequestHeader(name, value));
69
+ xhr.upload.onprogress = (e) => {
70
+ if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
71
+ };
72
+ xhr.onload = () => {
73
+ done();
74
+ if (xhr.status >= 200 && xhr.status < 300) onProgress(1);
75
+ resolve({ status: xhr.status, text: xhr.responseText });
76
+ };
77
+ xhr.onerror = () => {
78
+ done();
79
+ reject(new EraspaceError("StorageError", "The upload failed. Check your connection and try again."));
80
+ };
81
+ xhr.onabort = () => {
82
+ done();
83
+ reject(abortError());
84
+ };
85
+ xhr.send(body);
86
+ });
16
87
  }
17
88
 
18
89
  export function createStorageClient(base: () => string): StorageClient {
90
+ const deleteTokens = new Map<string, string>();
91
+ const localFiles = new Map<string, string>();
92
+ function forgetLocal(key: string) {
93
+ const local = localFiles.get(key);
94
+ if (local === undefined) return;
95
+ localFiles.delete(key);
96
+ URL.revokeObjectURL(local);
97
+ }
98
+ function keepLocal(key: string, body: Blob, contentType: string) {
99
+ if (!isMedia(contentType) || typeof URL.createObjectURL !== "function") return;
100
+ forgetLocal(key);
101
+ localFiles.set(key, URL.createObjectURL(body));
102
+ while (localFiles.size > LOCAL_FILES) forgetLocal(localFiles.keys().next().value!);
103
+ }
19
104
  function url(key?: string): string {
20
105
  const path = `${base().replace(/\/+$/, "")}/_sb/storage`;
21
106
  return key === undefined ? path : `${path}/${encodeURIComponent(key)}`;
22
107
  }
23
- async function check(response: Response): Promise<Record<string, unknown>> {
24
- const payload = await response.json() as unknown;
25
- if (!payload || typeof payload !== "object") throw new EraspaceError("StorageError", `HTTP ${response.status}`);
108
+ function check(status: number, text: string): Record<string, unknown> {
109
+ let payload: unknown;
110
+ try {
111
+ payload = JSON.parse(text);
112
+ } catch {}
113
+ if (!payload || typeof payload !== "object") throw new EraspaceError("StorageError", `HTTP ${status}`);
26
114
  const result = payload as Record<string, unknown>;
27
- if (!response.ok || result.ok !== true) throw EraspaceError.from(result.error, `Storage request failed: HTTP ${response.status}`);
115
+ if (status < 200 || status > 299 || result.ok !== true) throw EraspaceError.from(result.error, `Storage request failed: HTTP ${status}`);
28
116
  return result;
29
117
  }
30
118
  return {
@@ -33,14 +121,43 @@ export function createStorageClient(base: () => string): StorageClient {
33
121
  const headers = new Headers({ "content-type": body.type || "application/octet-stream" });
34
122
  const name = options.name ?? (typeof File !== "undefined" && body instanceof File ? body.name : undefined);
35
123
  if (name) headers.set("x-filename", encodeURIComponent(name));
36
- const result = await check(await fetch(url(), { method: "POST", headers, body, signal: options.signal }));
124
+ const { onProgress, signal } = options;
125
+ const response = onProgress && typeof XMLHttpRequest !== "undefined"
126
+ ? await sendWithProgress(url(), headers, body, signal, onProgress)
127
+ : await fetch(url(), { method: "POST", headers, body, signal }).then(async (r) => ({ status: r.status, text: await r.text() }));
128
+ const result = check(response.status, response.text);
37
129
  if (typeof result.key !== "string" || typeof result.url !== "string" || typeof result.size !== "number" || typeof result.contentType !== "string" || (result.name !== null && typeof result.name !== "string")) {
38
130
  throw new EraspaceError("StorageError", "Invalid upload response");
39
131
  }
40
- return { key: result.key, url: result.url, size: result.size, contentType: result.contentType, name: result.name };
132
+ const deleteToken = typeof result.deleteToken === "string" ? result.deleteToken : undefined;
133
+ if (deleteToken) deleteTokens.set(result.key, deleteToken);
134
+ keepLocal(result.key, body, result.contentType);
135
+ return { key: result.key, url: result.url, size: result.size, contentType: result.contentType, name: result.name, ...(deleteToken ? { deleteToken } : {}) };
136
+ },
137
+ async info(key, options = {}) {
138
+ if (!isStorageKey(key)) return null;
139
+ const response = await fetch(url(key), { method: "HEAD", signal: options.signal });
140
+ if (response.status === 404) return null;
141
+ if (!response.ok) throw new EraspaceError("StorageError", `Storage request failed: HTTP ${response.status}`);
142
+ return {
143
+ key,
144
+ url: url(key),
145
+ size: Number(response.headers.get("content-length") ?? 0),
146
+ contentType: response.headers.get("content-type") ?? "application/octet-stream",
147
+ name: dispositionName(response.headers.get("content-disposition")),
148
+ };
41
149
  },
42
150
  async delete(key, options = {}) {
43
- await check(await fetch(url(key), { method: "DELETE", signal: options.signal }));
151
+ const deleteToken = options.deleteToken ?? deleteTokens.get(key);
152
+ const headers = new Headers();
153
+ if (deleteToken) headers.set("x-eraspace-file-token", deleteToken);
154
+ const response = await fetch(url(key), { method: "DELETE", headers, signal: options.signal });
155
+ check(response.status, await response.text());
156
+ deleteTokens.delete(key);
157
+ forgetLocal(key);
158
+ },
159
+ localUrl(key) {
160
+ return localFiles.get(key);
44
161
  },
45
162
  };
46
163
  }
@@ -2,6 +2,22 @@
2
2
  export function subKey(fn: string, args: unknown[]): string {
3
3
  return fn + ":" + stableStringify(args);
4
4
  }
5
+ /**
6
+ * Arguments as sent to the server: object keys in sorted order, so equal arguments written in any order share
7
+ * one server cache entry. Serialization is unchanged otherwise.
8
+ */
9
+ export function canonicalArgs(args: unknown[]): unknown[] {
10
+ return args.map(canonical);
11
+ }
12
+ function canonical(v: unknown): unknown {
13
+ if (v === null || typeof v !== "object") return v;
14
+ const json = (v as { toJSON?: unknown }).toJSON;
15
+ if (typeof json === "function") return canonical(json.call(v));
16
+ if (Array.isArray(v)) return v.map(canonical);
17
+ const o = v as Record<string, unknown>;
18
+ // Own data properties, so a `__proto__` key stays a key rather than setting the prototype.
19
+ return Object.fromEntries(Object.keys(o).sort().map((k) => [k, canonical(o[k])]));
20
+ }
5
21
  export function stableStringify(v: unknown): string {
6
22
  if (v === undefined) return "null";
7
23
  if (v === null || typeof v !== "object") return JSON.stringify(v);
@@ -1,11 +1,20 @@
1
1
  import { signal, type Signal } from "@preact/signals";
2
2
  import { EraspaceError } from "./errors.ts";
3
3
  import type { Patch } from "./result-diff.ts";
4
+ import { canonicalArgs } from "./sub-key.ts";
4
5
 
5
6
  export interface FullResult {
6
7
  value: unknown;
7
8
  commitSeq: number;
8
9
  revision: number;
10
+ /** The server cache entry holding the value, echoed as `known` on a resubscription. */
11
+ entry?: string;
12
+ }
13
+ /** A resubscription whose `known` entry and revision still hold: the client keeps its value. */
14
+ export interface UnchangedResult {
15
+ unchanged: true;
16
+ commitSeq: number;
17
+ revision: number;
9
18
  }
10
19
  export type DataMessage = { id: string } & (FullResult | {
11
20
  patch: Patch[];
@@ -27,10 +36,16 @@ export class Subscription {
27
36
  hasServer = false;
28
37
  commitSeq = -1;
29
38
  revision: number | undefined;
39
+ /** The server cache entry of `revision`. */
40
+ entry: string | undefined;
30
41
  request = 0;
42
+ retries = 0;
43
+ retryTimer: ReturnType<typeof setTimeout> | undefined;
31
44
  refs = 0;
32
45
  layers: Layer[] = [];
33
46
  timer: ReturnType<typeof setTimeout> | undefined;
47
+ /** The id last sent to the server, and the app channel join it was sent on. */
48
+ sent: { id: string; join: number } | undefined;
34
49
  // Resubscriptions take a new id so messages for the previous one are ignored.
35
50
  id: string;
36
51
  readonly fn: string;
@@ -39,7 +54,7 @@ export class Subscription {
39
54
  constructor(id: string, fn: string, args: unknown[], key: string) {
40
55
  this.id = id;
41
56
  this.fn = fn;
42
- this.args = args;
57
+ this.args = canonicalArgs(args);
43
58
  this.key = key;
44
59
  }
45
60
 
@@ -0,0 +1,423 @@
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
+ /** Accept large payloads as deflated binary frames. Defaults to on where `DecompressionStream` inflates raw deflate. */
26
+ compression?: boolean;
27
+ }
28
+
29
+ interface Frame {
30
+ session: string | null;
31
+ ref: string | null;
32
+ topic: string;
33
+ event: string;
34
+ payload: unknown;
35
+ }
36
+ interface Pending {
37
+ /** The frame to send: undefined while not ready, null to withdraw a request that was never sent. */
38
+ frame: () => Frame | undefined | null;
39
+ sent?: Frame;
40
+ resolve: (value: unknown) => void;
41
+ reject: (error: EraspaceError) => void;
42
+ timer: ReturnType<typeof setTimeout>;
43
+ topic: string;
44
+ }
45
+ export interface JoinReply { self?: string; now?: number; version?: number }
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
+
99
+ /** Multiplexes topics over one native WebSocket. Sent requests are never replayed on reconnect. */
100
+ export class Socket {
101
+ private ws: WebSocketTransport | undefined;
102
+ private channels = new Map<string, Channel>();
103
+ private pending = new Map<string, Pending>();
104
+ private nextRef = 0;
105
+ private incoming = Promise.resolve();
106
+ private stopped = true;
107
+ private attempts = 0;
108
+ private reconnect: ReturnType<typeof setTimeout> | undefined;
109
+ private opening: ReturnType<typeof setTimeout> | undefined;
110
+ private stable: ReturnType<typeof setTimeout> | undefined;
111
+ private heartbeat: ReturnType<typeof setInterval> | undefined;
112
+ private awaitingHeartbeat: { ref: string; timer: ReturnType<typeof setTimeout> } | undefined;
113
+ private opens = new Set<() => void>();
114
+ private closes = new Set<() => void>();
115
+ readonly options: SocketOptions;
116
+ private url: string;
117
+
118
+ constructor(endpoint: string, options: SocketOptions = {}) {
119
+ const url = new URL(endpoint, typeof location === "undefined" ? undefined : location.href);
120
+ url.protocol = url.protocol === "https:" || url.protocol === "wss:" ? "wss:" : "ws:";
121
+ url.searchParams.set("v", "1");
122
+ if (options.compression ?? inflates()) url.searchParams.set("z", "1");
123
+ this.url = url.href;
124
+ this.options = options;
125
+ }
126
+
127
+ onOpen(cb: () => void): void { this.opens.add(cb); }
128
+ onClose(cb: () => void): void { this.closes.add(cb); }
129
+ ref(): string { return String(++this.nextRef); }
130
+ get connected(): boolean { return this.ws?.readyState === 1; }
131
+
132
+ connect(): void {
133
+ if (!this.stopped) return;
134
+ this.stopped = false;
135
+ this.attempts = 0;
136
+ this.open();
137
+ }
138
+
139
+ private open(): void {
140
+ if (this.stopped) return;
141
+ const Transport = this.options.transport ?? WebSocket;
142
+ let ws: WebSocketTransport;
143
+ try { ws = new Transport(this.url); }
144
+ catch { this.scheduleReconnect(); return; }
145
+ this.ws = ws;
146
+ (ws as { binaryType?: string }).binaryType = "arraybuffer";
147
+ this.opening = setTimeout(() => this.lost(ws), this.options.timeout ?? 15000);
148
+ ws.onopen = () => {
149
+ if (this.ws !== ws) return;
150
+ clearTimeout(this.opening);
151
+ // A server that accepts sockets and then drops them must not reset every client's backoff at once, so
152
+ // backoff restarts only after the connection has proved stable.
153
+ this.stable = setTimeout(() => {
154
+ if (this.ws === ws) this.attempts = 0;
155
+ }, this.options.stableAfterMs ?? 10000);
156
+ for (const cb of this.opens) cb();
157
+ for (const channel of this.channels.values()) channel.rejoin();
158
+ this.flush();
159
+ this.heartbeat = setInterval(() => this.sendHeartbeat(ws), 25000);
160
+ };
161
+ ws.onmessage = (event) => {
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;
177
+ // ws can deliver several frames in one task. Let reply consumers settle before the next push.
178
+ this.incoming = this.incoming.then(() => this.deliver(ws, received));
179
+ };
180
+ ws.onclose = () => this.lost(ws);
181
+ ws.onerror = () => this.lost(ws);
182
+ }
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
+
189
+ private receive(frame: Frame): void {
190
+ if (frame.event === "reply" && frame.ref) {
191
+ const body = frame.payload;
192
+ if (!body || typeof body !== "object" || !("status" in body) || !("response" in body)) return;
193
+ if (body.status !== "ok" && body.status !== "error") return;
194
+ if (frame.ref === this.awaitingHeartbeat?.ref && frame.topic === "" && frame.session === null && body.status === "ok") {
195
+ clearTimeout(this.awaitingHeartbeat.timer);
196
+ this.awaitingHeartbeat = undefined;
197
+ return;
198
+ }
199
+ const pending = this.pending.get(frame.ref);
200
+ if (!pending?.sent || pending.sent.topic !== frame.topic || pending.sent.session !== frame.session) return;
201
+ this.pending.delete(frame.ref);
202
+ clearTimeout(pending.timer);
203
+ if (body.status === "ok") pending.resolve(body.response);
204
+ else {
205
+ const error = EraspaceError.from(body.response);
206
+ pending.reject(new EraspaceError(error.name, error.message, body.response));
207
+ }
208
+ this.flush();
209
+ } else {
210
+ this.channels.get(frame.topic)?.receive(frame);
211
+ }
212
+ }
213
+
214
+ private sendHeartbeat(ws: WebSocketTransport): void {
215
+ if (this.ws !== ws || ws.readyState !== 1 || this.awaitingHeartbeat) return;
216
+ const ref = this.ref();
217
+ // Liveness checks bypass the application window and start their deadline only after send succeeds.
218
+ try {
219
+ ws.send(JSON.stringify({ session: null, ref, topic: "", event: "ping", payload: {} }));
220
+ this.awaitingHeartbeat = { ref, timer: setTimeout(() => this.lost(ws), 10000) };
221
+ } catch { this.lost(ws); }
222
+ }
223
+
224
+ private lost(ws: WebSocketTransport): void {
225
+ if (this.ws !== ws) return;
226
+ this.ws = undefined;
227
+ clearTimeout(this.opening);
228
+ clearTimeout(this.stable);
229
+ ws.onopen = ws.onclose = ws.onmessage = null;
230
+ ws.onerror = () => {};
231
+ ws.close();
232
+ clearInterval(this.heartbeat);
233
+ clearTimeout(this.awaitingHeartbeat?.timer);
234
+ this.awaitingHeartbeat = undefined;
235
+ for (const [ref, pending] of this.pending) {
236
+ if (pending.sent) this.fail(ref, new EraspaceError("OutcomeUnknownError", "Connection lost before the reply arrived"));
237
+ }
238
+ for (const channel of this.channels.values()) channel.disconnected();
239
+ for (const cb of this.closes) cb();
240
+ this.scheduleReconnect();
241
+ }
242
+
243
+ private scheduleReconnect(): void {
244
+ if (this.stopped) return;
245
+ clearTimeout(this.reconnect);
246
+ this.reconnect = setTimeout(() => this.open(), (this.options.reconnectAfterMs ?? retryDelay)(++this.attempts));
247
+ }
248
+
249
+ disconnect(): void {
250
+ this.stopped = true;
251
+ clearTimeout(this.reconnect);
252
+ clearInterval(this.heartbeat);
253
+ for (const channel of [...this.channels.values()]) channel.leave();
254
+ if (this.ws) this.lost(this.ws);
255
+ for (const ref of this.pending.keys()) this.fail(ref, new EraspaceError("UnavailableError", "Socket closed"));
256
+ }
257
+
258
+ channel(topic: string, payload: object | (() => object) = {}): Channel {
259
+ const previous = this.channels.get(topic);
260
+ if (previous) return previous;
261
+ const channel = new Channel(this, topic, payload);
262
+ this.channels.set(topic, channel);
263
+ return channel;
264
+ }
265
+
266
+ remove(channel: Channel, session: string | null): void {
267
+ if (this.channels.get(channel.topic) !== channel) return;
268
+ const ws = this.ws;
269
+ if (session !== null && !this.stopped && ws?.readyState === 1) {
270
+ // Cleanup must also work while the request window is full or a join reply is still pending.
271
+ try { ws.send(JSON.stringify({ session, ref: this.ref(), topic: channel.topic, event: "leave", payload: {} })); }
272
+ catch { this.lost(ws); }
273
+ }
274
+ this.channels.delete(channel.topic);
275
+ for (const [ref, pending] of this.pending) {
276
+ if (pending.topic === channel.topic) this.fail(ref, new EraspaceError("UnavailableError", "Channel closed"));
277
+ }
278
+ }
279
+
280
+ request<T>(frame: () => Frame | undefined | null, topic: string, timeout = this.options.timeout ?? 15000): Promise<T> {
281
+ if (this.pending.size >= 256) return Promise.reject(new EraspaceError("ResourceError", "Too many pending socket requests"));
282
+ const ref = this.ref();
283
+ return new Promise<T>((resolve, reject) => {
284
+ // Work that never left this client has no unknown outcome, so it does not time out like a sent request.
285
+ const timer = setTimeout(() => this.fail(ref, this.pending.get(ref)?.sent
286
+ ? new EraspaceError("TimeoutError", "Socket request timed out")
287
+ : new EraspaceError("UnavailableError", "Not connected; the request was not sent")), timeout);
288
+ this.pending.set(ref, { frame, topic, timer, resolve: value => resolve(value as T), reject });
289
+ this.flush();
290
+ });
291
+ }
292
+
293
+ private fail(ref: string, error: EraspaceError): void {
294
+ const pending = this.pending.get(ref);
295
+ if (!pending) return;
296
+ this.pending.delete(ref);
297
+ clearTimeout(pending.timer);
298
+ pending.reject(error);
299
+ this.flush();
300
+ }
301
+
302
+ flush(): void {
303
+ const ws = this.ws;
304
+ if (!ws || ws.readyState !== 1) return;
305
+ // A join can produce a reply plus presence and state pushes. Bound bursts below the desk's mailboxes.
306
+ let inFlight = [...this.pending.values()].filter(pending => pending.sent).length;
307
+ for (const [ref, pending] of this.pending) {
308
+ if (inFlight >= 16) break;
309
+ if (pending.sent) continue;
310
+ const frame = pending.frame();
311
+ if (frame === null) {
312
+ this.pending.delete(ref);
313
+ clearTimeout(pending.timer);
314
+ pending.reject(new EraspaceError("UnavailableError", "Request withdrawn before it was sent"));
315
+ continue;
316
+ }
317
+ if (!frame) continue;
318
+ frame.ref = ref;
319
+ try { ws.send(JSON.stringify(frame)); pending.sent = frame; inFlight++; }
320
+ catch { this.lost(ws); break; }
321
+ }
322
+ }
323
+ }
324
+
325
+ /** A join generation prevents late replies and pushes from a replaced channel being delivered. */
326
+ export class Channel {
327
+ readonly topic: string;
328
+ private socket: Socket;
329
+ private payload: object | (() => object);
330
+ private session: string | null = null;
331
+ private joined = false;
332
+ private started = false;
333
+ private closed = false;
334
+ private joining = false;
335
+ private attempts = 0;
336
+ private retry: ReturnType<typeof setTimeout> | undefined;
337
+ private events = new Map<string, Set<(payload: unknown) => void>>();
338
+ private joins = new Set<(reply: JoinReply) => void>();
339
+ private errors = new Set<(error: unknown, rejected: boolean) => void>();
340
+ private closes = new Set<() => void>();
341
+ onMessage: ((event: string, payload: unknown) => void) | undefined;
342
+
343
+ constructor(socket: Socket, topic: string, payload: object | (() => object)) {
344
+ this.socket = socket;
345
+ this.topic = topic;
346
+ this.payload = payload;
347
+ }
348
+ on<T = unknown>(event: string, cb: (payload: T) => void): () => void {
349
+ const handler = (value: unknown) => cb(value as T);
350
+ let handlers = this.events.get(event);
351
+ if (!handlers) this.events.set(event, handlers = new Set());
352
+ handlers.add(handler);
353
+ return () => handlers.delete(handler);
354
+ }
355
+ onJoin(cb: (reply: JoinReply) => void): void { this.joins.add(cb); }
356
+ onError(cb: (error: unknown, rejected: boolean) => void): void { this.errors.add(cb); }
357
+ onClose(cb: () => void): void { this.closes.add(cb); }
358
+
359
+ join(): void {
360
+ if (this.started || this.closed) return;
361
+ this.started = true;
362
+ this.rejoin();
363
+ }
364
+
365
+ rejoin(): void {
366
+ if (!this.started || this.closed || this.joined || this.joining || !this.socket.connected) return;
367
+ clearTimeout(this.retry);
368
+ const session = this.socket.ref();
369
+ this.session = session;
370
+ this.joining = true;
371
+ void this.socket.request<JoinReply>(() => ({ session, ref: null, topic: this.topic, event: "join", payload: typeof this.payload === "function" ? this.payload() : this.payload }), this.topic)
372
+ .then(reply => {
373
+ if (this.closed || this.session !== session) return;
374
+ this.joining = false;
375
+ this.joined = true;
376
+ this.attempts = 0;
377
+ for (const cb of this.joins) cb(reply);
378
+ this.socket.flush();
379
+ }, error => {
380
+ if (this.closed || this.session !== session) return;
381
+ this.joining = false;
382
+ for (const cb of this.errors) cb(error, error instanceof EraspaceError && error.data !== undefined);
383
+ if (!this.closed) this.retry = setTimeout(() => this.rejoin(), (this.socket.options.rejoinAfterMs ?? retryDelay)(++this.attempts));
384
+ });
385
+ }
386
+
387
+ /** A function payload is read when the request is sent; returning null withdraws the request unsent. */
388
+ push<T = unknown>(event: string, payload: object | (() => object | null), timeout?: number): Promise<T> {
389
+ if (this.closed) return Promise.reject(new EraspaceError("UnavailableError", "Channel closed"));
390
+ return this.socket.request<T>(() => {
391
+ if (!this.joined) return undefined;
392
+ const body = typeof payload === "function" ? payload() : payload;
393
+ return body === null ? null : { session: this.session, ref: null, topic: this.topic, event, payload: body };
394
+ }, this.topic, timeout);
395
+ }
396
+
397
+ receive(frame: Frame): void {
398
+ if (this.closed || frame.session !== this.session) return;
399
+ if (frame.event === "closed") { this.leave(); return; }
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
+ }
405
+ }
406
+
407
+ disconnected(): void {
408
+ this.joined = this.joining = false;
409
+ this.session = null;
410
+ clearTimeout(this.retry);
411
+ for (const cb of this.errors) cb(new EraspaceError("UnavailableError", "Connection lost"), false);
412
+ }
413
+
414
+ leave(): void {
415
+ if (this.closed) return;
416
+ const session = this.session;
417
+ this.closed = true;
418
+ this.joined = false;
419
+ clearTimeout(this.retry);
420
+ this.socket.remove(this, session);
421
+ for (const cb of this.closes) cb();
422
+ }
423
+ }