redweb-client 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 +83 -4
- package/dist/client-Dq8oKJwh.d.cts +132 -0
- package/dist/client-Dq8oKJwh.d.ts +132 -0
- package/dist/index.cjs +54 -11
- package/dist/index.d.cts +3 -128
- package/dist/index.d.ts +3 -128
- package/dist/index.js +54 -11
- package/dist/live-html.cjs +1049 -0
- package/dist/live-html.d.cts +12 -0
- package/dist/live-html.d.ts +12 -0
- package/dist/live-html.js +1021 -0
- package/package.json +11 -4
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
// src/protocol.ts
|
|
2
|
+
var ERROR_CODES = Object.freeze({
|
|
3
|
+
INVALID_MESSAGE: "INVALID_MESSAGE",
|
|
4
|
+
UNKNOWN_HANDLER: "UNKNOWN_HANDLER",
|
|
5
|
+
HANDLER_FAILED: "HANDLER_FAILED",
|
|
6
|
+
BINARY_UNSUPPORTED: "BINARY_UNSUPPORTED",
|
|
7
|
+
RATE_LIMITED: "RATE_LIMITED",
|
|
8
|
+
QUEUE_FULL: "QUEUE_FULL",
|
|
9
|
+
CAPACITY_REACHED: "CAPACITY_REACHED",
|
|
10
|
+
INITIALIZATION_FAILED: "INITIALIZATION_FAILED"
|
|
11
|
+
});
|
|
12
|
+
function boundedString(value, name, maxLength) {
|
|
13
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) {
|
|
14
|
+
throw new TypeError(`${name} must be a non-empty string of at most ${maxLength} characters.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function validateMetadata(metadata) {
|
|
19
|
+
if (metadata.requestId !== void 0) boundedString(metadata.requestId, "requestId", 256);
|
|
20
|
+
if (metadata.sequence !== void 0 && (!Number.isSafeInteger(metadata.sequence) || metadata.sequence < 0)) {
|
|
21
|
+
throw new TypeError("sequence must be a non-negative safe integer.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function createProtocolEnvelope(version, type, payload, metadata = {}) {
|
|
25
|
+
validateMetadata(metadata);
|
|
26
|
+
return {
|
|
27
|
+
v: boundedString(version, "version", 64),
|
|
28
|
+
type: boundedString(type, "type", 256),
|
|
29
|
+
payload,
|
|
30
|
+
...metadata
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function createLegacyEnvelope(type, payload, metadata = {}) {
|
|
34
|
+
validateMetadata(metadata);
|
|
35
|
+
const checkedType = boundedString(type, "type", 256);
|
|
36
|
+
if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
|
|
37
|
+
return { ...payload, type: checkedType, ...metadata };
|
|
38
|
+
}
|
|
39
|
+
return { type: checkedType, payload, ...metadata };
|
|
40
|
+
}
|
|
41
|
+
function parseMessage(input, version) {
|
|
42
|
+
const value = typeof input === "string" ? JSON.parse(input) : input;
|
|
43
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
44
|
+
throw new TypeError("Received an invalid Redweb message.");
|
|
45
|
+
}
|
|
46
|
+
const message = value;
|
|
47
|
+
if (message.requestId !== void 0) boundedString(message.requestId, "requestId", 256);
|
|
48
|
+
if (message.sequence !== void 0 && (!Number.isSafeInteger(message.sequence) || message.sequence < 0)) {
|
|
49
|
+
throw new TypeError("sequence must be a non-negative safe integer.");
|
|
50
|
+
}
|
|
51
|
+
if (version !== void 0) {
|
|
52
|
+
boundedString(message.type, "type", 256);
|
|
53
|
+
if (message.v !== version) throw new TypeError("Received a Redweb message for a different protocol version.");
|
|
54
|
+
if (message.type === "error") {
|
|
55
|
+
if (Object.prototype.hasOwnProperty.call(message, "payload")) {
|
|
56
|
+
throw new TypeError("Received an invalid Redweb protocol error.");
|
|
57
|
+
}
|
|
58
|
+
const error = message.error;
|
|
59
|
+
if (!error || typeof error !== "object") {
|
|
60
|
+
throw new TypeError("Received an invalid Redweb protocol error.");
|
|
61
|
+
}
|
|
62
|
+
boundedString(error.code, "error.code", 256);
|
|
63
|
+
boundedString(error.message, "error.message", 1024);
|
|
64
|
+
} else {
|
|
65
|
+
if (!Object.prototype.hasOwnProperty.call(message, "payload") || message.error !== void 0) {
|
|
66
|
+
throw new TypeError("Received an invalid Redweb protocol envelope.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} else if (typeof message.type === "string") {
|
|
70
|
+
boundedString(message.type, "type", 256);
|
|
71
|
+
} else if (typeof message.error !== "string") {
|
|
72
|
+
throw new TypeError("Received an invalid Redweb message.");
|
|
73
|
+
}
|
|
74
|
+
return message;
|
|
75
|
+
}
|
|
76
|
+
function resolveWebSocketUrl(input, base) {
|
|
77
|
+
const fallbackBase = base ?? (typeof globalThis.location === "object" ? globalThis.location.href : void 0);
|
|
78
|
+
let url;
|
|
79
|
+
try {
|
|
80
|
+
url = fallbackBase === void 0 ? new URL(input) : new URL(input, fallbackBase);
|
|
81
|
+
} catch {
|
|
82
|
+
throw new TypeError("url must be an absolute ws:/wss: URL or resolvable against baseUrl/location.");
|
|
83
|
+
}
|
|
84
|
+
if (url.protocol === "http:") url.protocol = "ws:";
|
|
85
|
+
if (url.protocol === "https:") url.protocol = "wss:";
|
|
86
|
+
if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new TypeError("url must use ws: or wss:.");
|
|
87
|
+
return url.toString();
|
|
88
|
+
}
|
|
89
|
+
var RedwebProtocolError = class extends Error {
|
|
90
|
+
code;
|
|
91
|
+
envelope;
|
|
92
|
+
constructor(envelope) {
|
|
93
|
+
super(envelope.error.message);
|
|
94
|
+
this.name = "RedwebProtocolError";
|
|
95
|
+
this.code = envelope.error.code;
|
|
96
|
+
this.envelope = envelope;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// src/client.ts
|
|
101
|
+
var OPEN = 1;
|
|
102
|
+
var CONNECTING = 0;
|
|
103
|
+
var defaultReconnect = {
|
|
104
|
+
enabled: false,
|
|
105
|
+
maxAttempts: 8,
|
|
106
|
+
initialDelayMs: 250,
|
|
107
|
+
maxDelayMs: 1e4,
|
|
108
|
+
factor: 2,
|
|
109
|
+
jitter: 0.2,
|
|
110
|
+
shouldReconnect: (event) => event.code !== 1e3 && event.code !== 1008
|
|
111
|
+
};
|
|
112
|
+
function defaultFactory(url) {
|
|
113
|
+
if (typeof globalThis.WebSocket !== "function") {
|
|
114
|
+
throw new Error("WebSocket is unavailable; provide webSocketFactory outside a browser.");
|
|
115
|
+
}
|
|
116
|
+
return new globalThis.WebSocket(url);
|
|
117
|
+
}
|
|
118
|
+
function toError(value, fallback) {
|
|
119
|
+
return value instanceof Error ? value : new Error(fallback);
|
|
120
|
+
}
|
|
121
|
+
function assertNonNegativeInteger(value, name) {
|
|
122
|
+
if (!Number.isInteger(value) || value < 0) throw new TypeError(`${name} must be a non-negative integer.`);
|
|
123
|
+
}
|
|
124
|
+
var RedwebClient = class {
|
|
125
|
+
url;
|
|
126
|
+
version;
|
|
127
|
+
state = "idle";
|
|
128
|
+
socket;
|
|
129
|
+
factory;
|
|
130
|
+
reconnect;
|
|
131
|
+
maxQueueSize;
|
|
132
|
+
requestTimeoutMs;
|
|
133
|
+
createRequestId;
|
|
134
|
+
random;
|
|
135
|
+
listeners = /* @__PURE__ */ new Map();
|
|
136
|
+
anyListeners = /* @__PURE__ */ new Set();
|
|
137
|
+
binaryListeners = /* @__PURE__ */ new Set();
|
|
138
|
+
errorListeners = /* @__PURE__ */ new Set();
|
|
139
|
+
stateListeners = /* @__PURE__ */ new Set();
|
|
140
|
+
closeListeners = /* @__PURE__ */ new Set();
|
|
141
|
+
queue = [];
|
|
142
|
+
pending = /* @__PURE__ */ new Map();
|
|
143
|
+
connectPromise;
|
|
144
|
+
reconnectTimer;
|
|
145
|
+
reconnectAttempts = 0;
|
|
146
|
+
manualClose = false;
|
|
147
|
+
disposed = false;
|
|
148
|
+
requestSequence = 0;
|
|
149
|
+
generation = 0;
|
|
150
|
+
constructor(url, options = {}) {
|
|
151
|
+
this.url = resolveWebSocketUrl(url, options.baseUrl);
|
|
152
|
+
const queryVersion = new URL(this.url).searchParams.get("redwebVersion") ?? void 0;
|
|
153
|
+
if (options.version !== void 0 && queryVersion !== void 0 && options.version !== queryVersion) {
|
|
154
|
+
throw new TypeError("options.version conflicts with the redwebVersion URL query.");
|
|
155
|
+
}
|
|
156
|
+
this.version = options.version ?? queryVersion;
|
|
157
|
+
if (this.version !== void 0) createProtocolEnvelope(this.version, "validation", null);
|
|
158
|
+
this.factory = options.webSocketFactory ?? defaultFactory;
|
|
159
|
+
this.reconnect = { ...defaultReconnect, ...options.reconnect };
|
|
160
|
+
this.maxQueueSize = options.maxQueueSize ?? 0;
|
|
161
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 1e4;
|
|
162
|
+
this.createRequestId = options.createRequestId ?? (() => {
|
|
163
|
+
this.requestSequence += 1;
|
|
164
|
+
return `${Date.now().toString(36)}-${this.requestSequence.toString(36)}`;
|
|
165
|
+
});
|
|
166
|
+
this.random = options.random ?? Math.random;
|
|
167
|
+
assertNonNegativeInteger(this.maxQueueSize, "maxQueueSize");
|
|
168
|
+
assertNonNegativeInteger(this.requestTimeoutMs, "requestTimeoutMs");
|
|
169
|
+
assertNonNegativeInteger(this.reconnect.maxAttempts, "reconnect.maxAttempts");
|
|
170
|
+
assertNonNegativeInteger(this.reconnect.initialDelayMs, "reconnect.initialDelayMs");
|
|
171
|
+
assertNonNegativeInteger(this.reconnect.maxDelayMs, "reconnect.maxDelayMs");
|
|
172
|
+
if (!Number.isFinite(this.reconnect.factor) || this.reconnect.factor < 1) {
|
|
173
|
+
throw new TypeError("reconnect.factor must be a finite number of at least 1.");
|
|
174
|
+
}
|
|
175
|
+
if (!Number.isFinite(this.reconnect.jitter) || this.reconnect.jitter < 0 || this.reconnect.jitter > 1) {
|
|
176
|
+
throw new TypeError("reconnect.jitter must be between 0 and 1.");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
connect() {
|
|
180
|
+
if (this.disposed) return Promise.reject(new Error("RedwebClient has been disposed."));
|
|
181
|
+
if (this.state === "open") return Promise.resolve();
|
|
182
|
+
if (this.connectPromise) return this.connectPromise;
|
|
183
|
+
this.manualClose = false;
|
|
184
|
+
if (this.reconnectTimer) {
|
|
185
|
+
clearTimeout(this.reconnectTimer);
|
|
186
|
+
this.reconnectTimer = void 0;
|
|
187
|
+
}
|
|
188
|
+
let resolve;
|
|
189
|
+
let reject;
|
|
190
|
+
const promise = new Promise((yes, no) => {
|
|
191
|
+
resolve = yes;
|
|
192
|
+
reject = no;
|
|
193
|
+
});
|
|
194
|
+
this.connectPromise = promise;
|
|
195
|
+
const finish = () => this.retire(promise);
|
|
196
|
+
void this.openSocket(false).then(() => {
|
|
197
|
+
finish();
|
|
198
|
+
resolve();
|
|
199
|
+
}, (error) => {
|
|
200
|
+
finish();
|
|
201
|
+
reject(error);
|
|
202
|
+
});
|
|
203
|
+
return promise;
|
|
204
|
+
}
|
|
205
|
+
current(generation) {
|
|
206
|
+
return this.generation === generation && !this.manualClose && !this.disposed;
|
|
207
|
+
}
|
|
208
|
+
retire(promise) {
|
|
209
|
+
if (this.connectPromise === promise) this.connectPromise = void 0;
|
|
210
|
+
}
|
|
211
|
+
openSocket(reconnecting) {
|
|
212
|
+
const opening = this.connectPromise;
|
|
213
|
+
const generation = ++this.generation;
|
|
214
|
+
this.transition(reconnecting ? "reconnecting" : "connecting");
|
|
215
|
+
if (!this.current(generation)) return Promise.reject(new Error("WebSocket connection attempt was cancelled."));
|
|
216
|
+
let socket;
|
|
217
|
+
try {
|
|
218
|
+
socket = this.factory(this.connectionUrl());
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (this.current(generation)) {
|
|
221
|
+
this.retire(opening);
|
|
222
|
+
this.transition("closed");
|
|
223
|
+
if (reconnecting && this.current(generation)) this.scheduleReconnect();
|
|
224
|
+
}
|
|
225
|
+
return Promise.reject(toError(error, "Unable to create WebSocket."));
|
|
226
|
+
}
|
|
227
|
+
if (this.current(generation)) this.socket = socket;
|
|
228
|
+
if ("binaryType" in socket) socket.binaryType = "arraybuffer";
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
let settled = false;
|
|
231
|
+
const onOpen = () => {
|
|
232
|
+
if (this.socket !== socket || !this.current(generation)) {
|
|
233
|
+
if (!settled) reject(new Error("WebSocket connection attempt was superseded."));
|
|
234
|
+
socket.close(1e3, "Superseded");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
settled = true;
|
|
238
|
+
this.reconnectAttempts = 0;
|
|
239
|
+
this.transition("open");
|
|
240
|
+
if (!this.current(generation)) {
|
|
241
|
+
reject(new Error("WebSocket connection attempt was cancelled."));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.flushQueue();
|
|
245
|
+
resolve();
|
|
246
|
+
};
|
|
247
|
+
const onMessage = (event) => {
|
|
248
|
+
if (this.socket === socket && this.current(generation)) void this.handleIncoming(event.data);
|
|
249
|
+
};
|
|
250
|
+
const onError = (event) => {
|
|
251
|
+
if (this.socket !== socket) return;
|
|
252
|
+
const error = toError(event.error, "WebSocket connection failed.");
|
|
253
|
+
this.emitError(error);
|
|
254
|
+
};
|
|
255
|
+
const onClose = (event) => {
|
|
256
|
+
socket.removeEventListener("open", onOpen);
|
|
257
|
+
socket.removeEventListener("message", onMessage);
|
|
258
|
+
socket.removeEventListener("error", onError);
|
|
259
|
+
socket.removeEventListener("close", onClose);
|
|
260
|
+
if (this.socket !== socket) {
|
|
261
|
+
if (!settled) reject(new Error("WebSocket connection attempt was superseded."));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
this.socket = void 0;
|
|
265
|
+
if (!settled) {
|
|
266
|
+
settled = true;
|
|
267
|
+
reject(new Error("WebSocket closed before opening."));
|
|
268
|
+
}
|
|
269
|
+
this.rejectPending(new Error("WebSocket connection closed."));
|
|
270
|
+
const closedGeneration = this.generation;
|
|
271
|
+
this.retire(opening);
|
|
272
|
+
this.transition("closed");
|
|
273
|
+
this.notify(this.closeListeners, event);
|
|
274
|
+
if (!this.manualClose && this.reconnect.shouldReconnect(event) && this.generation === closedGeneration) this.scheduleReconnect();
|
|
275
|
+
};
|
|
276
|
+
socket.addEventListener("open", onOpen);
|
|
277
|
+
socket.addEventListener("message", onMessage);
|
|
278
|
+
socket.addEventListener("error", onError);
|
|
279
|
+
socket.addEventListener("close", onClose);
|
|
280
|
+
if (!this.current(generation)) {
|
|
281
|
+
settled = true;
|
|
282
|
+
reject(new Error("WebSocket connection attempt was cancelled."));
|
|
283
|
+
socket.close(1e3, "Cancelled");
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
close(code = 1e3, reason = "Client closed") {
|
|
288
|
+
this.manualClose = true;
|
|
289
|
+
this.generation++;
|
|
290
|
+
this.connectPromise = void 0;
|
|
291
|
+
if (this.reconnectTimer) {
|
|
292
|
+
clearTimeout(this.reconnectTimer);
|
|
293
|
+
this.reconnectTimer = void 0;
|
|
294
|
+
}
|
|
295
|
+
this.rejectPending(new Error("Client closed."));
|
|
296
|
+
this.queue.length = 0;
|
|
297
|
+
const socket = this.socket;
|
|
298
|
+
if (socket && (socket.readyState === OPEN || socket.readyState === CONNECTING)) {
|
|
299
|
+
this.transition("closing");
|
|
300
|
+
socket.close(code, reason);
|
|
301
|
+
} else {
|
|
302
|
+
this.transition("closed");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
dispose() {
|
|
306
|
+
if (this.disposed) return;
|
|
307
|
+
this.disposed = true;
|
|
308
|
+
this.close(1e3, "Client disposed");
|
|
309
|
+
this.queue.length = 0;
|
|
310
|
+
this.listeners.clear();
|
|
311
|
+
this.anyListeners.clear();
|
|
312
|
+
this.binaryListeners.clear();
|
|
313
|
+
this.errorListeners.clear();
|
|
314
|
+
this.stateListeners.clear();
|
|
315
|
+
this.closeListeners.clear();
|
|
316
|
+
}
|
|
317
|
+
send(type, payload, metadata = {}) {
|
|
318
|
+
const envelope = this.version ? createProtocolEnvelope(this.version, type, payload, metadata) : createLegacyEnvelope(type, payload, metadata);
|
|
319
|
+
this.transmit(JSON.stringify(envelope));
|
|
320
|
+
}
|
|
321
|
+
sendRaw(data) {
|
|
322
|
+
this.transmit(data);
|
|
323
|
+
}
|
|
324
|
+
transmit(data, requestId) {
|
|
325
|
+
if (this.disposed) throw new Error("RedwebClient has been disposed.");
|
|
326
|
+
if (this.socket?.readyState === OPEN) {
|
|
327
|
+
this.socket.send(data);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (this.queue.length >= this.maxQueueSize) {
|
|
331
|
+
throw new Error(this.maxQueueSize === 0 ? "WebSocket is not open." : "Outbound message queue is full.");
|
|
332
|
+
}
|
|
333
|
+
this.queue.push(requestId === void 0 ? { data } : { data, requestId });
|
|
334
|
+
}
|
|
335
|
+
request(type, payload, options = {}) {
|
|
336
|
+
const requestId = options.requestId ?? this.createRequestId();
|
|
337
|
+
const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
|
|
338
|
+
assertNonNegativeInteger(timeoutMs, "timeoutMs");
|
|
339
|
+
if (options.signal?.aborted) return Promise.reject(new DOMException("The request was aborted.", "AbortError"));
|
|
340
|
+
if (this.pending.has(requestId)) return Promise.reject(new Error(`A Redweb request with id "${requestId}" is already pending.`));
|
|
341
|
+
return new Promise((resolve, reject) => {
|
|
342
|
+
const timer = setTimeout(() => {
|
|
343
|
+
this.finishPending(requestId);
|
|
344
|
+
reject(new Error(`Redweb request timed out after ${timeoutMs}ms.`));
|
|
345
|
+
}, timeoutMs);
|
|
346
|
+
const pending = { resolve, reject, timer };
|
|
347
|
+
if (options.signal) {
|
|
348
|
+
pending.signal = options.signal;
|
|
349
|
+
pending.abort = () => {
|
|
350
|
+
this.finishPending(requestId);
|
|
351
|
+
reject(new DOMException("The request was aborted.", "AbortError"));
|
|
352
|
+
};
|
|
353
|
+
options.signal.addEventListener("abort", pending.abort, { once: true });
|
|
354
|
+
}
|
|
355
|
+
this.pending.set(requestId, pending);
|
|
356
|
+
try {
|
|
357
|
+
const metadata = { requestId, ...options.sequence === void 0 ? {} : { sequence: options.sequence } };
|
|
358
|
+
const envelope = this.version ? createProtocolEnvelope(this.version, type, payload, metadata) : createLegacyEnvelope(type, payload, metadata);
|
|
359
|
+
this.transmit(JSON.stringify(envelope), requestId);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
this.finishPending(requestId);
|
|
362
|
+
reject(toError(error, "Unable to send Redweb request."));
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
waitFor(type, options = {}) {
|
|
367
|
+
const timeoutMs = options.timeoutMs ?? this.requestTimeoutMs;
|
|
368
|
+
assertNonNegativeInteger(timeoutMs, "timeoutMs");
|
|
369
|
+
if (options.signal?.aborted) return Promise.reject(new DOMException("The wait was aborted.", "AbortError"));
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
const off = this.on(type, (message) => {
|
|
372
|
+
cleanup();
|
|
373
|
+
resolve(message);
|
|
374
|
+
});
|
|
375
|
+
const timer = setTimeout(() => {
|
|
376
|
+
cleanup();
|
|
377
|
+
reject(new Error(`Timed out waiting for Redweb message "${type}" after ${timeoutMs}ms.`));
|
|
378
|
+
}, timeoutMs);
|
|
379
|
+
const abort = () => {
|
|
380
|
+
cleanup();
|
|
381
|
+
reject(new DOMException("The wait was aborted.", "AbortError"));
|
|
382
|
+
};
|
|
383
|
+
const cleanup = () => {
|
|
384
|
+
clearTimeout(timer);
|
|
385
|
+
off();
|
|
386
|
+
options.signal?.removeEventListener("abort", abort);
|
|
387
|
+
};
|
|
388
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
on(type, listener) {
|
|
392
|
+
let group = this.listeners.get(type);
|
|
393
|
+
if (!group) {
|
|
394
|
+
group = /* @__PURE__ */ new Set();
|
|
395
|
+
this.listeners.set(type, group);
|
|
396
|
+
}
|
|
397
|
+
group.add(listener);
|
|
398
|
+
return () => {
|
|
399
|
+
group?.delete(listener);
|
|
400
|
+
if (group?.size === 0) this.listeners.delete(type);
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
onAny(listener) {
|
|
404
|
+
return this.subscribe(this.anyListeners, listener);
|
|
405
|
+
}
|
|
406
|
+
onBinary(listener) {
|
|
407
|
+
return this.subscribe(this.binaryListeners, listener);
|
|
408
|
+
}
|
|
409
|
+
onError(listener) {
|
|
410
|
+
return this.subscribe(this.errorListeners, listener);
|
|
411
|
+
}
|
|
412
|
+
onStateChange(listener) {
|
|
413
|
+
return this.subscribe(this.stateListeners, listener);
|
|
414
|
+
}
|
|
415
|
+
onClose(listener) {
|
|
416
|
+
return this.subscribe(this.closeListeners, listener);
|
|
417
|
+
}
|
|
418
|
+
subscribe(set, listener) {
|
|
419
|
+
set.add(listener);
|
|
420
|
+
return () => {
|
|
421
|
+
set.delete(listener);
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
connectionUrl() {
|
|
425
|
+
const url = new URL(this.url);
|
|
426
|
+
if (this.version && !url.searchParams.has("redwebVersion")) url.searchParams.set("redwebVersion", this.version);
|
|
427
|
+
return url.toString();
|
|
428
|
+
}
|
|
429
|
+
transition(state) {
|
|
430
|
+
if (this.state === state) return;
|
|
431
|
+
this.state = state;
|
|
432
|
+
this.notify(this.stateListeners, state);
|
|
433
|
+
}
|
|
434
|
+
async handleIncoming(data) {
|
|
435
|
+
if (typeof data !== "string") {
|
|
436
|
+
if (data instanceof Blob || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
|
437
|
+
this.notify(this.binaryListeners, data);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
this.emitError(new TypeError("Received an unsupported WebSocket message type."));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
let message;
|
|
444
|
+
try {
|
|
445
|
+
message = parseMessage(data, this.version);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
this.emitError(toError(error, "Unable to parse Redweb message."));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const requestId = message.requestId;
|
|
451
|
+
if (typeof requestId === "string") {
|
|
452
|
+
const pending = this.pending.get(requestId);
|
|
453
|
+
if (pending) {
|
|
454
|
+
this.finishPending(requestId);
|
|
455
|
+
if (message.type === "error" && "error" in message) pending.reject(new RedwebProtocolError(message));
|
|
456
|
+
else pending.resolve(message);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const messageType = "type" in message && typeof message.type === "string" ? message.type : "error";
|
|
460
|
+
const group = this.listeners.get(messageType);
|
|
461
|
+
if (group) this.notify(group, message);
|
|
462
|
+
this.notify(this.anyListeners, message);
|
|
463
|
+
}
|
|
464
|
+
finishPending(requestId) {
|
|
465
|
+
const pending = this.pending.get(requestId);
|
|
466
|
+
if (!pending) return;
|
|
467
|
+
clearTimeout(pending.timer);
|
|
468
|
+
if (pending.signal && pending.abort) pending.signal.removeEventListener("abort", pending.abort);
|
|
469
|
+
this.pending.delete(requestId);
|
|
470
|
+
for (let index = this.queue.length - 1; index >= 0; index -= 1) {
|
|
471
|
+
if (this.queue[index]?.requestId === requestId) this.queue.splice(index, 1);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
rejectPending(error) {
|
|
475
|
+
for (const [requestId, pending] of this.pending) {
|
|
476
|
+
this.finishPending(requestId);
|
|
477
|
+
pending.reject(error);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
emitError(error) {
|
|
481
|
+
for (const listener of this.errorListeners) {
|
|
482
|
+
try {
|
|
483
|
+
listener(error);
|
|
484
|
+
} catch {
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
notify(listeners, value) {
|
|
489
|
+
for (const listener of listeners) {
|
|
490
|
+
try {
|
|
491
|
+
listener(value);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
this.emitError(toError(error, "Redweb listener failed."));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
flushQueue() {
|
|
498
|
+
while (this.socket?.readyState === OPEN && this.queue.length > 0) {
|
|
499
|
+
const entry = this.queue.shift();
|
|
500
|
+
this.socket.send(entry.data);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
scheduleReconnect() {
|
|
504
|
+
if (this.disposed || this.manualClose || !this.reconnect.enabled || this.reconnectAttempts >= this.reconnect.maxAttempts) return;
|
|
505
|
+
const generation = this.generation;
|
|
506
|
+
const baseDelay = Math.min(
|
|
507
|
+
this.reconnect.maxDelayMs,
|
|
508
|
+
this.reconnect.initialDelayMs * this.reconnect.factor ** this.reconnectAttempts
|
|
509
|
+
);
|
|
510
|
+
const spread = baseDelay * this.reconnect.jitter;
|
|
511
|
+
const delay = Math.max(0, Math.round(baseDelay - spread + this.random() * spread * 2));
|
|
512
|
+
if (!this.current(generation)) return;
|
|
513
|
+
this.reconnectAttempts += 1;
|
|
514
|
+
this.transition("reconnecting");
|
|
515
|
+
if (!this.current(generation)) return;
|
|
516
|
+
this.reconnectTimer = setTimeout(() => {
|
|
517
|
+
this.reconnectTimer = void 0;
|
|
518
|
+
if (!this.current(generation)) return;
|
|
519
|
+
void this.openSocket(true).catch(() => {
|
|
520
|
+
});
|
|
521
|
+
}, delay);
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// src/live-html/morph.js
|
|
526
|
+
function createMorph() {
|
|
527
|
+
const clientNodes = /* @__PURE__ */ new WeakSet();
|
|
528
|
+
const marker = (node) => node?.nodeType === 8 && /^rw:[ck][0-9a-f]*$/.test(node.data) ? node.data : null;
|
|
529
|
+
const units = (parent, start = null, end = null) => {
|
|
530
|
+
const result = [];
|
|
531
|
+
const keys = /* @__PURE__ */ new Set();
|
|
532
|
+
let node = start ? start.nextSibling : parent.firstChild;
|
|
533
|
+
while (node && node !== end) {
|
|
534
|
+
if (clientNodes.has(node)) {
|
|
535
|
+
node = node.nextSibling;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const key = marker(node);
|
|
539
|
+
let last = node;
|
|
540
|
+
if (key) {
|
|
541
|
+
if (key.startsWith("rw:k") && keys.has(key)) throw new Error("Duplicate JSX sibling key.");
|
|
542
|
+
keys.add(key);
|
|
543
|
+
while (last && last !== end && !(last.nodeType === 8 && last.data === "/" + key)) last = last.nextSibling;
|
|
544
|
+
if (!last || last === end) throw new Error("Incomplete Redweb render boundary.");
|
|
545
|
+
}
|
|
546
|
+
result.push({ first: node, last, key });
|
|
547
|
+
node = last.nextSibling;
|
|
548
|
+
}
|
|
549
|
+
return result;
|
|
550
|
+
};
|
|
551
|
+
const matchKey = (unit) => unit.key || [unit.first.nodeType, unit.first.nodeName, unit.first.namespaceURI, unit.first.id || ""].join(":");
|
|
552
|
+
const rangeNodes = (unit) => {
|
|
553
|
+
const nodes = [];
|
|
554
|
+
for (let node = unit.first; node; node = node.nextSibling) {
|
|
555
|
+
nodes.push(node);
|
|
556
|
+
if (node === unit.last) break;
|
|
557
|
+
}
|
|
558
|
+
return nodes;
|
|
559
|
+
};
|
|
560
|
+
const optionDefaults = (node) => {
|
|
561
|
+
const seen = /* @__PURE__ */ new Map();
|
|
562
|
+
return [...node.options].map((option) => {
|
|
563
|
+
const occurrence = seen.get(option.value) || 0;
|
|
564
|
+
seen.set(option.value, occurrence + 1);
|
|
565
|
+
return { option, value: option.value, selected: option.defaultSelected, key: JSON.stringify([option.value, option.id || occurrence]) };
|
|
566
|
+
});
|
|
567
|
+
};
|
|
568
|
+
const defaultsChanged = (previous, node) => {
|
|
569
|
+
const original = new Map(previous.map((entry) => [entry.option, entry]));
|
|
570
|
+
const pending = /* @__PURE__ */ new Map();
|
|
571
|
+
const count = (key, change) => pending.set(key, (pending.get(key) || 0) + change);
|
|
572
|
+
for (const entry of previous) if (entry.selected) count(entry.key, 1);
|
|
573
|
+
for (const entry of optionDefaults(node)) {
|
|
574
|
+
const before = original.get(entry.option);
|
|
575
|
+
if (before && before.value === entry.value) {
|
|
576
|
+
if (before.selected !== entry.selected) return true;
|
|
577
|
+
if (entry.selected) count(before.key, -1);
|
|
578
|
+
} else if (entry.selected) count(entry.key, -1);
|
|
579
|
+
}
|
|
580
|
+
return [...pending.values()].some((value) => value !== 0);
|
|
581
|
+
};
|
|
582
|
+
const restoreSelection = (node, incoming, previous, resetToDefaults) => {
|
|
583
|
+
const options = [...node.options];
|
|
584
|
+
const selected = /* @__PURE__ */ new Set();
|
|
585
|
+
if (resetToDefaults) {
|
|
586
|
+
[...incoming.options].forEach((option, index) => {
|
|
587
|
+
if (option.selected) selected.add(options[index]);
|
|
588
|
+
});
|
|
589
|
+
} else {
|
|
590
|
+
const available = new Set(options);
|
|
591
|
+
const missing = /* @__PURE__ */ new Map();
|
|
592
|
+
for (const entry of previous) {
|
|
593
|
+
if (available.has(entry.option) && entry.option.value === entry.value) selected.add(entry.option);
|
|
594
|
+
else missing.set(entry.value, (missing.get(entry.value) || 0) + 1);
|
|
595
|
+
}
|
|
596
|
+
for (const option of options) {
|
|
597
|
+
const count = missing.get(option.value) || 0;
|
|
598
|
+
if (count && !selected.has(option)) {
|
|
599
|
+
selected.add(option);
|
|
600
|
+
missing.set(option.value, count - 1);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (resetToDefaults || selected.size || !previous.length) {
|
|
605
|
+
for (const option of options) option.selected = selected.has(option);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
const morphNode = (node, incoming) => {
|
|
609
|
+
if (node.nodeType !== 1) {
|
|
610
|
+
if (node.nodeValue !== incoming.nodeValue) node.nodeValue = incoming.nodeValue;
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const input = node.localName === "input";
|
|
614
|
+
const textarea = node.localName === "textarea";
|
|
615
|
+
const select = node.localName === "select";
|
|
616
|
+
const previousValue = node.value;
|
|
617
|
+
const previousChecked = node.checked;
|
|
618
|
+
const valueChanged = input ? node.getAttribute("value") !== incoming.getAttribute("value") : textarea && node.defaultValue !== incoming.defaultValue;
|
|
619
|
+
const checkedChanged = input && node.hasAttribute("checked") !== incoming.hasAttribute("checked");
|
|
620
|
+
const selections = select ? [...node.selectedOptions].map((option) => ({ option, value: option.value })) : [];
|
|
621
|
+
const defaults = select ? optionDefaults(node) : null;
|
|
622
|
+
for (const attribute of [...node.attributes]) if (!incoming.hasAttribute(attribute.name)) node.removeAttribute(attribute.name);
|
|
623
|
+
for (const attribute of incoming.attributes) if (node.getAttribute(attribute.name) !== attribute.value) node.setAttribute(attribute.name, attribute.value);
|
|
624
|
+
reconcile(node, incoming);
|
|
625
|
+
if (input && node.type !== "file" || textarea) node.value = valueChanged ? incoming.value : previousValue;
|
|
626
|
+
if (input) node.checked = checkedChanged ? incoming.checked : previousChecked;
|
|
627
|
+
if (select) restoreSelection(node, incoming, selections, defaultsChanged(defaults, node));
|
|
628
|
+
};
|
|
629
|
+
const reconcile = (parent, incoming, start = null, end = null, incomingStart = null, incomingEnd = null) => {
|
|
630
|
+
const previous = units(parent, start, end);
|
|
631
|
+
const desired = units(incoming, incomingStart, incomingEnd);
|
|
632
|
+
const remaining = new Set(previous);
|
|
633
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
634
|
+
for (const unit of previous) {
|
|
635
|
+
const key = matchKey(unit);
|
|
636
|
+
const matches = candidates.get(key) || { units: [], next: 0 };
|
|
637
|
+
matches.units.push(unit);
|
|
638
|
+
candidates.set(key, matches);
|
|
639
|
+
}
|
|
640
|
+
let cursor = start ? start.nextSibling : parent.firstChild;
|
|
641
|
+
for (const wanted of desired) {
|
|
642
|
+
const matches = candidates.get(matchKey(wanted));
|
|
643
|
+
const found = matches?.units[matches.next++];
|
|
644
|
+
if (!found) {
|
|
645
|
+
for (const node of rangeNodes(wanted)) parent.insertBefore(node.cloneNode(true), cursor);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
remaining.delete(found);
|
|
649
|
+
if (found.first !== cursor) for (const node of rangeNodes(found)) parent.insertBefore(node, cursor);
|
|
650
|
+
if (found.key) reconcile(parent, incoming, found.first, found.last, wanted.first, wanted.last);
|
|
651
|
+
else morphNode(found.first, wanted.first);
|
|
652
|
+
cursor = found.last.nextSibling;
|
|
653
|
+
}
|
|
654
|
+
for (const unit of remaining) for (const node of rangeNodes(unit)) node.remove();
|
|
655
|
+
};
|
|
656
|
+
const preserveFocus = (update) => {
|
|
657
|
+
const active = document.activeElement;
|
|
658
|
+
const selection = active && typeof active.selectionStart === "number" ? [active.selectionStart, active.selectionEnd, active.selectionDirection] : null;
|
|
659
|
+
update();
|
|
660
|
+
if (active?.isConnected && document.activeElement !== active) active.focus({ preventScroll: true });
|
|
661
|
+
if (active?.isConnected && selection && typeof active.selectionStart === "number") active.setSelectionRange(...selection);
|
|
662
|
+
};
|
|
663
|
+
const morphContent = (node, html) => {
|
|
664
|
+
const range = document.createRange();
|
|
665
|
+
range.selectNodeContents(node);
|
|
666
|
+
reconcile(node, range.createContextualFragment(html));
|
|
667
|
+
};
|
|
668
|
+
const applyPatch = (patch) => {
|
|
669
|
+
if (patch.id === "root") {
|
|
670
|
+
const incoming = new DOMParser().parseFromString(patch.html, "text/html");
|
|
671
|
+
morphNode(document.documentElement, incoming.documentElement);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
|
|
675
|
+
const starts = [];
|
|
676
|
+
while (walker.nextNode()) if (walker.currentNode.data === "rw:" + patch.id) starts.push(walker.currentNode);
|
|
677
|
+
for (const start of starts) {
|
|
678
|
+
let end = start.nextSibling;
|
|
679
|
+
while (end && !(end.nodeType === 8 && end.data === "/rw:" + patch.id)) end = end.nextSibling;
|
|
680
|
+
if (!end) throw new Error("Missing Redweb component boundary.");
|
|
681
|
+
const range = document.createRange();
|
|
682
|
+
range.setStartAfter(start);
|
|
683
|
+
range.setEndBefore(end);
|
|
684
|
+
reconcile(start.parentNode, range.createContextualFragment(patch.html), start, end);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
return { clientNodes, marker, units, rangeNodes, morphNode, morphContent, preserveFocus, applyPatch };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/live-html/ActionFeedback.js
|
|
691
|
+
var ActionFeedback = class {
|
|
692
|
+
constructor(changed, report) {
|
|
693
|
+
this.records = /* @__PURE__ */ new WeakMap();
|
|
694
|
+
this.pending = 0;
|
|
695
|
+
this.changed = changed;
|
|
696
|
+
this.report = report;
|
|
697
|
+
}
|
|
698
|
+
get(source) {
|
|
699
|
+
return this.records.get(source);
|
|
700
|
+
}
|
|
701
|
+
async run(source, request) {
|
|
702
|
+
if (this.get(source)?.status === "pending") return false;
|
|
703
|
+
const record = { status: "pending", message: "Working\u2026" };
|
|
704
|
+
this.records.set(source, record);
|
|
705
|
+
if (this.pending >= 32) {
|
|
706
|
+
this.fail(source, record, { code: "ACTION_CAPACITY" });
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
this.pending += 1;
|
|
710
|
+
try {
|
|
711
|
+
this.changed(source, record);
|
|
712
|
+
await request();
|
|
713
|
+
record.status = "success";
|
|
714
|
+
record.message = "Done.";
|
|
715
|
+
this.changed(source, record);
|
|
716
|
+
return true;
|
|
717
|
+
} catch (error) {
|
|
718
|
+
this.fail(source, record, error);
|
|
719
|
+
return false;
|
|
720
|
+
} finally {
|
|
721
|
+
this.pending -= 1;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
fail(source, record, error) {
|
|
725
|
+
record.status = "error";
|
|
726
|
+
const messages = {
|
|
727
|
+
ACTION_INVALID_INPUT: "Check the form values and try again.",
|
|
728
|
+
ACTION_VALIDATION_TIMEOUT: "Input validation timed out. The action was not run.",
|
|
729
|
+
ACTION_CANCELLED: "Input validation was cancelled. The action was not run.",
|
|
730
|
+
ACTION_OFFLINE: "Not connected. The action was not sent.",
|
|
731
|
+
ACTION_CAPACITY: "Too many pending actions. Wait before trying again. This action was not sent.",
|
|
732
|
+
ACCESS_DENIED: "You do not have permission to perform this action.",
|
|
733
|
+
ACCESS_TIMEOUT: "Authorization timed out. The action was not run.",
|
|
734
|
+
ACCESS_CANCELLED: "Authorization was cancelled. The action was not run."
|
|
735
|
+
};
|
|
736
|
+
record.message = Object.hasOwn(messages, error?.code) ? messages[error.code] : "The action could not be confirmed. Check before trying again.";
|
|
737
|
+
this.changed(source, record);
|
|
738
|
+
this.report(error);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// src/live-html/feedback.js
|
|
743
|
+
function createFeedback({ componentOf, report, stateKey, clientNodes, client, listen, active }) {
|
|
744
|
+
const bindingOf = (source) => JSON.stringify([componentOf(source), source.getAttribute("rw-submit"), source.getAttribute("rw-click")]);
|
|
745
|
+
const revisions = /* @__PURE__ */ new WeakMap();
|
|
746
|
+
const feedbackNodes = /* @__PURE__ */ new WeakMap();
|
|
747
|
+
const feedbackSources = /* @__PURE__ */ new WeakMap();
|
|
748
|
+
const slotOwners = /* @__PURE__ */ new WeakMap();
|
|
749
|
+
let feedbackSequence = 0;
|
|
750
|
+
const feedback = new ActionFeedback((source, record) => {
|
|
751
|
+
if (!active()) return;
|
|
752
|
+
if (record.binding === void 0) {
|
|
753
|
+
record.source = source;
|
|
754
|
+
record.order = ++feedbackSequence;
|
|
755
|
+
record.binding = bindingOf(source);
|
|
756
|
+
record.component = componentOf(source);
|
|
757
|
+
record.node = feedbackNodes.get(source);
|
|
758
|
+
record.name = source.getAttribute("rw-submit") || source.getAttribute("rw-click");
|
|
759
|
+
}
|
|
760
|
+
showFeedback(source, record);
|
|
761
|
+
}, report);
|
|
762
|
+
const clearSlot = (slot, owner) => {
|
|
763
|
+
if (slot.textContent === owner.message) slot.textContent = "";
|
|
764
|
+
if (slot.getAttribute("data-rw-status") === owner.status) slot.removeAttribute("data-rw-status");
|
|
765
|
+
slotOwners.delete(slot);
|
|
766
|
+
};
|
|
767
|
+
const indexSlots = () => {
|
|
768
|
+
const all = document.querySelectorAll("[rw-status]");
|
|
769
|
+
const byAction = /* @__PURE__ */ new Map();
|
|
770
|
+
for (const slot of all) {
|
|
771
|
+
const key = stateKey(componentOf(slot), slot.getAttribute("rw-status"));
|
|
772
|
+
const entries = byAction.get(key) || [];
|
|
773
|
+
entries.push(slot);
|
|
774
|
+
byAction.set(key, entries);
|
|
775
|
+
}
|
|
776
|
+
return { all, byAction };
|
|
777
|
+
};
|
|
778
|
+
const showFeedback = (source, record, index = indexSlots()) => {
|
|
779
|
+
if (!source.isConnected || record.binding !== bindingOf(source)) {
|
|
780
|
+
record.node?.remove();
|
|
781
|
+
for (const slot of index.all) {
|
|
782
|
+
const owner = slotOwners.get(slot);
|
|
783
|
+
if (owner?.record === record) clearSlot(slot, owner);
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
source.setAttribute("data-rw-status", record.status);
|
|
788
|
+
const slots = index.byAction.get(stateKey(record.component, record.name)) || [];
|
|
789
|
+
if (slots.length) {
|
|
790
|
+
record.node?.remove();
|
|
791
|
+
for (const slot of slots) {
|
|
792
|
+
if ((slotOwners.get(slot)?.record.order || 0) > record.order) continue;
|
|
793
|
+
slot.textContent = record.message;
|
|
794
|
+
slot.setAttribute("data-rw-status", record.status);
|
|
795
|
+
slotOwners.set(slot, { record, message: record.message, status: record.status });
|
|
796
|
+
}
|
|
797
|
+
} else {
|
|
798
|
+
if (!record.node) {
|
|
799
|
+
record.node = document.createElement("span");
|
|
800
|
+
record.node.setAttribute("data-rw-feedback", "");
|
|
801
|
+
record.node.setAttribute("role", "status");
|
|
802
|
+
record.node.setAttribute("aria-live", "polite");
|
|
803
|
+
clientNodes.add(record.node);
|
|
804
|
+
feedbackNodes.set(source, record.node);
|
|
805
|
+
feedbackSources.set(record.node, source);
|
|
806
|
+
}
|
|
807
|
+
record.node.textContent = record.message;
|
|
808
|
+
record.node.setAttribute("data-rw-status", record.status);
|
|
809
|
+
if (source.localName === "form") source.append(record.node);
|
|
810
|
+
else source.after(record.node);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
const refreshFeedback = () => {
|
|
814
|
+
document.documentElement.setAttribute("data-rw-connection", client.state);
|
|
815
|
+
const slots = indexSlots();
|
|
816
|
+
for (const slot of slots.all) {
|
|
817
|
+
const owner = slotOwners.get(slot);
|
|
818
|
+
if (owner && (!owner.record.source.isConnected || owner.record.binding !== bindingOf(owner.record.source) || slot.getAttribute("rw-status") !== owner.record.name || componentOf(slot) !== owner.record.component)) clearSlot(slot, owner);
|
|
819
|
+
}
|
|
820
|
+
for (const node of document.querySelectorAll("[data-rw-feedback]")) {
|
|
821
|
+
const source = feedbackSources.get(node);
|
|
822
|
+
if (source && !source.isConnected) node.remove();
|
|
823
|
+
}
|
|
824
|
+
for (const source of document.querySelectorAll("[rw-click],form[rw-submit]")) {
|
|
825
|
+
const record = feedback.get(source);
|
|
826
|
+
if (record) showFeedback(source, record, slots);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
const performAction = (source, payload, completed) => feedback.run(source, () => {
|
|
830
|
+
if (client.state !== "open") throw Object.assign(new Error("Action was not sent while disconnected."), { code: "ACTION_OFFLINE" });
|
|
831
|
+
return client.request("redweb:html", payload);
|
|
832
|
+
}).then((success) => {
|
|
833
|
+
if (success && active()) completed?.();
|
|
834
|
+
});
|
|
835
|
+
const noteDraftChange = (event) => {
|
|
836
|
+
const form = event.target.form;
|
|
837
|
+
if (form) revisions.set(form, (revisions.get(form) || 0) + 1);
|
|
838
|
+
};
|
|
839
|
+
listen("input", noteDraftChange);
|
|
840
|
+
listen("change", noteDraftChange);
|
|
841
|
+
const dispose = () => {
|
|
842
|
+
for (const slot of indexSlots().all) {
|
|
843
|
+
const owner = slotOwners.get(slot);
|
|
844
|
+
if (owner) clearSlot(slot, owner);
|
|
845
|
+
}
|
|
846
|
+
for (const node of document.querySelectorAll("[data-rw-feedback]")) {
|
|
847
|
+
if (feedbackSources.has(node)) node.remove();
|
|
848
|
+
}
|
|
849
|
+
for (const source of document.querySelectorAll("[rw-click],form[rw-submit]")) {
|
|
850
|
+
const record = feedback.get(source);
|
|
851
|
+
if (record && source.getAttribute("data-rw-status") === record.status) source.removeAttribute("data-rw-status");
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
return { bindingOf, revisions, feedbackNodes, slotOwners, feedback, showFeedback, refreshFeedback, indexSlots, performAction, dispose };
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/live-html/runtime.js
|
|
858
|
+
var LivePageClient = class {
|
|
859
|
+
constructor() {
|
|
860
|
+
this.disposed = false;
|
|
861
|
+
this.events = new AbortController();
|
|
862
|
+
}
|
|
863
|
+
start() {
|
|
864
|
+
const listen = (type, listener) => document.addEventListener(type, listener, { signal: this.events.signal });
|
|
865
|
+
const configNode = document.getElementById("__redweb_page");
|
|
866
|
+
const config = JSON.parse(configNode.textContent);
|
|
867
|
+
const morph = createMorph();
|
|
868
|
+
const { clientNodes, morphContent, preserveFocus, applyPatch } = morph;
|
|
869
|
+
this.morph = morph;
|
|
870
|
+
const client = this.client = new RedwebClient(config.socketPath + "?pageId=" + encodeURIComponent(config.pageId), {
|
|
871
|
+
baseUrl: window.location.href,
|
|
872
|
+
version: config.version,
|
|
873
|
+
reconnect: { enabled: true, maxAttempts: 8 },
|
|
874
|
+
maxQueueSize: 0
|
|
875
|
+
});
|
|
876
|
+
const emit = (type, detail) => document.dispatchEvent(new CustomEvent(type, { detail }));
|
|
877
|
+
let stateTargets = /* @__PURE__ */ new Map();
|
|
878
|
+
const componentOf = (node) => node.closest("[data-rw-component]")?.getAttribute("data-rw-component") || null;
|
|
879
|
+
const stateKey = (component, name) => (component || "") + "\0" + name;
|
|
880
|
+
const indexState = () => {
|
|
881
|
+
stateTargets = /* @__PURE__ */ new Map();
|
|
882
|
+
document.querySelectorAll("[data-rw-state]").forEach((node) => {
|
|
883
|
+
const name = node.getAttribute("data-rw-state");
|
|
884
|
+
const key = stateKey(componentOf(node), name);
|
|
885
|
+
const targets = stateTargets.get(key) || [];
|
|
886
|
+
targets.push(node);
|
|
887
|
+
stateTargets.set(key, targets);
|
|
888
|
+
});
|
|
889
|
+
};
|
|
890
|
+
const named = (attribute, name, component) => attribute === "data-rw-state" ? stateTargets.get(stateKey(component, name)) || [] : [...document.querySelectorAll("[" + attribute + "]")].filter((node) => node.getAttribute(attribute) === name && componentOf(node) === component);
|
|
891
|
+
indexState();
|
|
892
|
+
const applyState = (update) => {
|
|
893
|
+
const component = update.component || null;
|
|
894
|
+
named("data-rw-state", update.name, component).forEach((node) => {
|
|
895
|
+
if (update.html) {
|
|
896
|
+
morphContent(node, update.value);
|
|
897
|
+
indexState();
|
|
898
|
+
} else node.textContent = update.value;
|
|
899
|
+
});
|
|
900
|
+
named("rw-bind", update.name, component).forEach((node) => {
|
|
901
|
+
if (node.type === "checkbox") node.checked = update.value === true || update.value === "true";
|
|
902
|
+
else if (node.value !== update.value) node.value = update.value;
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
client.on("redweb:state", (message) => preserveFocus(() => {
|
|
906
|
+
applyState(message.payload);
|
|
907
|
+
refreshFeedback();
|
|
908
|
+
}));
|
|
909
|
+
client.on("redweb:patch", (message) => {
|
|
910
|
+
try {
|
|
911
|
+
preserveFocus(() => {
|
|
912
|
+
message.payload.patches.forEach(applyPatch);
|
|
913
|
+
indexState();
|
|
914
|
+
message.payload.states.forEach(applyState);
|
|
915
|
+
refreshFeedback();
|
|
916
|
+
});
|
|
917
|
+
} catch (error) {
|
|
918
|
+
report(error);
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
const report = (error) => {
|
|
922
|
+
if (!this.disposed) emit("redweb:error", error);
|
|
923
|
+
};
|
|
924
|
+
const feedbackUI = createFeedback({ componentOf, report, stateKey, clientNodes, client, listen, active: () => !this.disposed });
|
|
925
|
+
const { bindingOf, revisions, refreshFeedback, performAction } = feedbackUI;
|
|
926
|
+
this.feedback = feedbackUI;
|
|
927
|
+
const send = (payload) => {
|
|
928
|
+
try {
|
|
929
|
+
client.send("redweb:html", payload);
|
|
930
|
+
} catch (error) {
|
|
931
|
+
report(error);
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
const formValues = (form) => {
|
|
935
|
+
const values = /* @__PURE__ */ Object.create(null);
|
|
936
|
+
for (const [name, value] of new FormData(form)) {
|
|
937
|
+
if (!Object.hasOwn(values, name)) values[name] = value;
|
|
938
|
+
else values[name] = Array.isArray(values[name]) ? [...values[name], value] : [values[name], value];
|
|
939
|
+
}
|
|
940
|
+
return values;
|
|
941
|
+
};
|
|
942
|
+
listen("click", (event) => {
|
|
943
|
+
const target = event.target.closest("[rw-click]");
|
|
944
|
+
if (!target) return;
|
|
945
|
+
event.preventDefault();
|
|
946
|
+
performAction(target, {
|
|
947
|
+
kind: "action",
|
|
948
|
+
name: target.getAttribute("rw-click"),
|
|
949
|
+
component: componentOf(target),
|
|
950
|
+
args: []
|
|
951
|
+
}).catch(report);
|
|
952
|
+
});
|
|
953
|
+
listen("submit", (event) => {
|
|
954
|
+
const form = event.target.closest("form[rw-submit]");
|
|
955
|
+
if (!form) return;
|
|
956
|
+
event.preventDefault();
|
|
957
|
+
const values = formValues(form);
|
|
958
|
+
const revision = revisions.get(form) || 0;
|
|
959
|
+
const binding = bindingOf(form);
|
|
960
|
+
performAction(form, {
|
|
961
|
+
kind: "action",
|
|
962
|
+
name: form.getAttribute("rw-submit"),
|
|
963
|
+
component: componentOf(form),
|
|
964
|
+
args: [values]
|
|
965
|
+
}, () => {
|
|
966
|
+
if (form.isConnected && bindingOf(form) === binding && (revisions.get(form) || 0) === revision && JSON.stringify(formValues(form)) === JSON.stringify(values)) {
|
|
967
|
+
HTMLFormElement.prototype.reset.call(form);
|
|
968
|
+
}
|
|
969
|
+
}).catch(report);
|
|
970
|
+
});
|
|
971
|
+
listen("input", (event) => {
|
|
972
|
+
const target = event.target.closest("[rw-bind]");
|
|
973
|
+
if (target) send({
|
|
974
|
+
kind: "state",
|
|
975
|
+
name: target.getAttribute("rw-bind"),
|
|
976
|
+
component: componentOf(target),
|
|
977
|
+
value: target.type === "checkbox" ? target.checked : target.value
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
this.runtime = { applyState, indexState, formValues, send, client };
|
|
981
|
+
client.onError(report);
|
|
982
|
+
client.onStateChange((state) => {
|
|
983
|
+
if (this.disposed) return;
|
|
984
|
+
document.documentElement.setAttribute("data-rw-connection", state);
|
|
985
|
+
emit("redweb:connection", state);
|
|
986
|
+
});
|
|
987
|
+
client.connect().catch(report);
|
|
988
|
+
}
|
|
989
|
+
dispose() {
|
|
990
|
+
if (this.disposed) return;
|
|
991
|
+
this.disposed = true;
|
|
992
|
+
mounted.delete(document);
|
|
993
|
+
this.events.abort();
|
|
994
|
+
this.feedback?.dispose();
|
|
995
|
+
document.documentElement.setAttribute("data-rw-connection", "closed");
|
|
996
|
+
this.client?.dispose();
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
var mounted = /* @__PURE__ */ new WeakMap();
|
|
1000
|
+
function mountLivePage() {
|
|
1001
|
+
const previous = mounted.get(document);
|
|
1002
|
+
if (previous && !previous.disposed) return previous;
|
|
1003
|
+
const page = new LivePageClient();
|
|
1004
|
+
mounted.set(document, page);
|
|
1005
|
+
try {
|
|
1006
|
+
page.start();
|
|
1007
|
+
} catch (error) {
|
|
1008
|
+
page.dispose();
|
|
1009
|
+
throw error;
|
|
1010
|
+
}
|
|
1011
|
+
return page;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// src/live-html.ts
|
|
1015
|
+
function mountLivePage2() {
|
|
1016
|
+
return mountLivePage();
|
|
1017
|
+
}
|
|
1018
|
+
export {
|
|
1019
|
+
RedwebClient,
|
|
1020
|
+
mountLivePage2 as mountLivePage
|
|
1021
|
+
};
|