@holo-js/broadcast 0.2.5 → 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/dist/auth.d.ts +3 -1
- package/dist/auth.mjs +3 -2
- package/dist/{chunk-TTKGDABI.mjs → chunk-6DCCSFFM.mjs} +97 -4
- package/dist/{chunk-U5JDBKXC.mjs → chunk-JVDFH2TW.mjs} +3 -3
- package/dist/chunk-XLUGF257.mjs +268 -0
- package/dist/client-config.d.ts +3 -3
- package/dist/config.d.ts +138 -0
- package/dist/config.mjs +34 -0
- package/dist/contracts.d.ts +1 -1
- package/dist/index.d.ts +88 -24
- package/dist/index.mjs +366 -226
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.mjs +2 -1
- package/package.json +10 -3
package/dist/index.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
resolveBroadcastChannelGuard,
|
|
7
7
|
resolveBroadcastWhisperSchema,
|
|
8
8
|
validateBroadcastWhisperPayload
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-JVDFH2TW.mjs";
|
|
10
10
|
import {
|
|
11
11
|
renderBroadcastClientConfigResponse,
|
|
12
12
|
resolveBroadcastClientConfig
|
|
@@ -21,10 +21,17 @@ import {
|
|
|
21
21
|
getBroadcastRuntimeBindings,
|
|
22
22
|
getRegisteredBroadcastDriver,
|
|
23
23
|
listRegisteredBroadcastDrivers,
|
|
24
|
+
loadBroadcastPluginDrivers,
|
|
24
25
|
registerBroadcastDriver,
|
|
25
26
|
resetBroadcastDriverRegistry,
|
|
27
|
+
resetBroadcastPluginDrivers,
|
|
26
28
|
resetBroadcastRuntime
|
|
27
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-6DCCSFFM.mjs";
|
|
30
|
+
import {
|
|
31
|
+
defineBroadcastConfig,
|
|
32
|
+
holoBroadcastDefaults,
|
|
33
|
+
normalizeBroadcastConfig
|
|
34
|
+
} from "./chunk-XLUGF257.mjs";
|
|
28
35
|
import {
|
|
29
36
|
broadcastInternals,
|
|
30
37
|
channel,
|
|
@@ -40,23 +47,22 @@ import {
|
|
|
40
47
|
} from "./chunk-HE6HN7ID.mjs";
|
|
41
48
|
|
|
42
49
|
// src/worker.ts
|
|
43
|
-
import { createHash, createHmac, randomInt, randomUUID, timingSafeEqual } from "crypto";
|
|
50
|
+
import { createHash, createHmac as createHmac2, randomInt, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
44
51
|
import { createServer } from "http";
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
|
|
53
|
+
// src/workerProtocol.ts
|
|
54
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
55
|
+
function normalizeWorkerRequiredString(value, label) {
|
|
47
56
|
const normalized = value.trim();
|
|
48
57
|
if (!normalized) {
|
|
49
58
|
throw new Error(`[@holo-js/broadcast] ${label} must be a non-empty string.`);
|
|
50
59
|
}
|
|
51
60
|
return normalized;
|
|
52
61
|
}
|
|
53
|
-
function
|
|
54
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
55
|
-
}
|
|
56
|
-
function parseSocketMessage(rawMessage) {
|
|
62
|
+
function parseWorkerSocketMessage(rawMessage) {
|
|
57
63
|
const message = parseJsonObject(rawMessage, "Websocket message");
|
|
58
|
-
const event =
|
|
59
|
-
const channel2 = typeof message.channel === "string" ?
|
|
64
|
+
const event = normalizeWorkerRequiredString(String(message.event ?? ""), "Websocket event");
|
|
65
|
+
const channel2 = typeof message.channel === "string" ? normalizeWorkerRequiredString(message.channel, "Websocket channel") : void 0;
|
|
60
66
|
const data = typeof message.data === "string" ? parseJsonObject(message.data, "Websocket message data") : isPlainObject(message.data) ? message.data : {};
|
|
61
67
|
return Object.freeze({
|
|
62
68
|
event,
|
|
@@ -64,41 +70,12 @@ function parseSocketMessage(rawMessage) {
|
|
|
64
70
|
data
|
|
65
71
|
});
|
|
66
72
|
}
|
|
67
|
-
function
|
|
68
|
-
if (value === "query" || value === "mutation" || value === "subscribe" || value === "unsubscribe") {
|
|
69
|
-
return value;
|
|
70
|
-
}
|
|
71
|
-
throw new Error("[@holo-js/broadcast] Realtime action is invalid.");
|
|
72
|
-
}
|
|
73
|
-
function normalizeRealtimeArgs(value) {
|
|
74
|
-
if (!isPlainObject(value)) {
|
|
75
|
-
return {};
|
|
76
|
-
}
|
|
77
|
-
return value;
|
|
78
|
-
}
|
|
79
|
-
function isRecord(value) {
|
|
80
|
-
return isObjectRecord(value);
|
|
81
|
-
}
|
|
82
|
-
function parseRealtimeSocketMessage(data) {
|
|
83
|
-
const id = normalizeRequiredString(String(data.id ?? ""), "Realtime request id");
|
|
84
|
-
const action = normalizeRealtimeAction(data.action);
|
|
85
|
-
const name = typeof data.name === "string" ? normalizeRequiredString(data.name, "Realtime definition name") : void 0;
|
|
86
|
-
if (action !== "unsubscribe" && !name) {
|
|
87
|
-
throw new Error("[@holo-js/broadcast] Realtime definition name is required.");
|
|
88
|
-
}
|
|
89
|
-
return Object.freeze({
|
|
90
|
-
id,
|
|
91
|
-
action,
|
|
92
|
-
...typeof name === "undefined" ? {} : { name },
|
|
93
|
-
args: normalizeRealtimeArgs(data.args)
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
function normalizePublishBody(value) {
|
|
73
|
+
function normalizeWorkerPublishBody(value) {
|
|
97
74
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
98
75
|
throw new Error("[@holo-js/broadcast] Publish payload must be a JSON object.");
|
|
99
76
|
}
|
|
100
77
|
const body = value;
|
|
101
|
-
const name = typeof body.name === "string" ?
|
|
78
|
+
const name = typeof body.name === "string" ? normalizeWorkerRequiredString(body.name, "Publish name") : typeof body.event === "string" ? normalizeWorkerRequiredString(body.event, "Publish event") : "";
|
|
102
79
|
if (!name) {
|
|
103
80
|
throw new Error("[@holo-js/broadcast] Publish payload must include an event name.");
|
|
104
81
|
}
|
|
@@ -106,13 +83,13 @@ function normalizePublishBody(value) {
|
|
|
106
83
|
if (typeof channel2 !== "string") {
|
|
107
84
|
throw new Error("[@holo-js/broadcast] Publish channel must be a non-empty string.");
|
|
108
85
|
}
|
|
109
|
-
return
|
|
110
|
-
}) : typeof body.channel === "string" ? [
|
|
86
|
+
return normalizeWorkerRequiredString(channel2, "Publish channel");
|
|
87
|
+
}) : typeof body.channel === "string" ? [normalizeWorkerRequiredString(body.channel, "Publish channel")] : [];
|
|
111
88
|
if (channels.length === 0) {
|
|
112
89
|
throw new Error("[@holo-js/broadcast] Publish payload must include at least one channel.");
|
|
113
90
|
}
|
|
114
91
|
const data = typeof body.data === "string" ? body.data : JSON.stringify(body.data ?? {});
|
|
115
|
-
const socketId = typeof body.socket_id === "string" ?
|
|
92
|
+
const socketId = typeof body.socket_id === "string" ? normalizeWorkerRequiredString(body.socket_id, "Publish socket_id") : void 0;
|
|
116
93
|
return Object.freeze({
|
|
117
94
|
name,
|
|
118
95
|
channels: Object.freeze(channels),
|
|
@@ -120,19 +97,178 @@ function normalizePublishBody(value) {
|
|
|
120
97
|
...typeof socketId === "undefined" ? {} : { socket_id: socketId }
|
|
121
98
|
});
|
|
122
99
|
}
|
|
123
|
-
function
|
|
100
|
+
function createWorkerPusherSignature(secret, method, pathname, params) {
|
|
124
101
|
const sorted = [...params.entries()].filter(([key]) => key !== "auth_signature").sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
|
|
125
102
|
const payload = `${method.toUpperCase()}
|
|
126
103
|
${pathname}
|
|
127
104
|
${sorted}`;
|
|
128
105
|
return createHmac("sha256", secret).update(payload).digest("hex");
|
|
129
106
|
}
|
|
130
|
-
function
|
|
107
|
+
function verifyWorkerPusherSignature(providedSignature, expectedSignature) {
|
|
131
108
|
if (providedSignature.length !== expectedSignature.length || !/^[a-f0-9]+$/i.test(providedSignature) || !/^[a-f0-9]+$/i.test(expectedSignature)) {
|
|
132
109
|
return false;
|
|
133
110
|
}
|
|
134
111
|
return timingSafeEqual(Buffer.from(providedSignature, "hex"), Buffer.from(expectedSignature, "hex"));
|
|
135
112
|
}
|
|
113
|
+
function parseWorkerChannelKind(channel2) {
|
|
114
|
+
if (channel2.startsWith("private-")) {
|
|
115
|
+
return Object.freeze({
|
|
116
|
+
kind: "private",
|
|
117
|
+
canonical: channel2.slice("private-".length)
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
if (channel2.startsWith("presence-")) {
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
kind: "presence",
|
|
123
|
+
canonical: channel2.slice("presence-".length)
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
kind: "public",
|
|
128
|
+
canonical: channel2
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// src/workerNodeTransport.ts
|
|
133
|
+
var BroadcastPayloadTooLargeError = class extends Error {
|
|
134
|
+
};
|
|
135
|
+
async function readLimitedRequestText(request, maxBytes) {
|
|
136
|
+
const contentLength = Number(request.headers.get("content-length"));
|
|
137
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
138
|
+
throw new BroadcastPayloadTooLargeError();
|
|
139
|
+
}
|
|
140
|
+
if (!request.body) return "";
|
|
141
|
+
const reader = request.body.getReader();
|
|
142
|
+
const chunks = [];
|
|
143
|
+
let size = 0;
|
|
144
|
+
while (true) {
|
|
145
|
+
const result = await reader.read();
|
|
146
|
+
if (result.done) break;
|
|
147
|
+
size += result.value.byteLength;
|
|
148
|
+
if (size > maxBytes) {
|
|
149
|
+
await reader.cancel();
|
|
150
|
+
throw new BroadcastPayloadTooLargeError();
|
|
151
|
+
}
|
|
152
|
+
chunks.push(result.value);
|
|
153
|
+
}
|
|
154
|
+
const bytes = new Uint8Array(size);
|
|
155
|
+
let offset = 0;
|
|
156
|
+
for (const chunk of chunks) {
|
|
157
|
+
bytes.set(chunk, offset);
|
|
158
|
+
offset += chunk.byteLength;
|
|
159
|
+
}
|
|
160
|
+
return new TextDecoder().decode(bytes);
|
|
161
|
+
}
|
|
162
|
+
function toNodeHeaders(headers) {
|
|
163
|
+
const normalized = new Headers();
|
|
164
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
165
|
+
if (typeof value === "undefined") continue;
|
|
166
|
+
if (Array.isArray(value)) {
|
|
167
|
+
for (const item of value) normalized.append(key, item);
|
|
168
|
+
} else {
|
|
169
|
+
normalized.set(key, value);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return normalized;
|
|
173
|
+
}
|
|
174
|
+
function toNodeRequestUrl(request, fallbackHost) {
|
|
175
|
+
const path = request.url ?? "/";
|
|
176
|
+
const host = request.headers.host ?? fallbackHost;
|
|
177
|
+
return `http://${host}${path}`;
|
|
178
|
+
}
|
|
179
|
+
async function readNodeRequestBody(request, maxBytes) {
|
|
180
|
+
if (request.method === "GET" || request.method === "HEAD") return void 0;
|
|
181
|
+
const chunks = [];
|
|
182
|
+
let size = 0;
|
|
183
|
+
for await (const chunk of request) {
|
|
184
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
185
|
+
size += buffer.byteLength;
|
|
186
|
+
if (size > maxBytes) throw new BroadcastPayloadTooLargeError();
|
|
187
|
+
chunks.push(buffer);
|
|
188
|
+
}
|
|
189
|
+
return chunks.length === 0 ? void 0 : Buffer.concat(chunks);
|
|
190
|
+
}
|
|
191
|
+
function writeNodeRequestBodyError(response, error) {
|
|
192
|
+
if (error instanceof BroadcastPayloadTooLargeError) {
|
|
193
|
+
response.statusCode = 413;
|
|
194
|
+
response.end("Payload Too Large");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
response.statusCode = 500;
|
|
198
|
+
response.end("Internal Server Error");
|
|
199
|
+
}
|
|
200
|
+
async function writeNodeResponse(response, value) {
|
|
201
|
+
response.statusCode = value.status;
|
|
202
|
+
response.statusMessage = value.statusText;
|
|
203
|
+
value.headers.forEach((headerValue, headerName) => response.setHeader(headerName, headerValue));
|
|
204
|
+
response.end(Buffer.from(await value.arrayBuffer()));
|
|
205
|
+
}
|
|
206
|
+
function decodeNodeWebSocketMessage(message) {
|
|
207
|
+
if (typeof message === "string") return message;
|
|
208
|
+
if (message instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(message));
|
|
209
|
+
if (Array.isArray(message)) return Buffer.concat(message).toString("utf8");
|
|
210
|
+
if (message instanceof Uint8Array) return Buffer.from(message).toString("utf8");
|
|
211
|
+
return String(message);
|
|
212
|
+
}
|
|
213
|
+
function getNodeWebSocketMessageBytes(message) {
|
|
214
|
+
if (typeof message === "string") return Buffer.byteLength(message);
|
|
215
|
+
if (message instanceof ArrayBuffer || message instanceof Uint8Array) return message.byteLength;
|
|
216
|
+
return message.reduce((size, chunk) => size + chunk.byteLength, 0);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/worker.ts
|
|
220
|
+
function createScalingMessageListener(runtime) {
|
|
221
|
+
return (payload) => {
|
|
222
|
+
void runtime.receiveScalingMessage(payload).catch((error) => {
|
|
223
|
+
logScalingMessageError(error);
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function isAllowedWorkerOrigin(request, allowedOrigins) {
|
|
228
|
+
const origin = request.headers.get("origin");
|
|
229
|
+
if (!origin) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const normalized = new URL(origin).origin;
|
|
234
|
+
return allowedOrigins.includes("*") || allowedOrigins.includes(normalized);
|
|
235
|
+
} catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
var MAX_PUBLISH_TIMESTAMP_SKEW_SECONDS = 300;
|
|
240
|
+
function escapeRegExp(value) {
|
|
241
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
242
|
+
}
|
|
243
|
+
function normalizeRealtimeAction(value) {
|
|
244
|
+
if (value === "query" || value === "mutation" || value === "subscribe" || value === "unsubscribe") {
|
|
245
|
+
return value;
|
|
246
|
+
}
|
|
247
|
+
throw new Error("[@holo-js/broadcast] Realtime action is invalid.");
|
|
248
|
+
}
|
|
249
|
+
function normalizeRealtimeArgs(value) {
|
|
250
|
+
if (!isPlainObject(value)) {
|
|
251
|
+
return {};
|
|
252
|
+
}
|
|
253
|
+
return value;
|
|
254
|
+
}
|
|
255
|
+
function isRecord(value) {
|
|
256
|
+
return isObjectRecord(value);
|
|
257
|
+
}
|
|
258
|
+
function parseRealtimeSocketMessage(data) {
|
|
259
|
+
const id = normalizeWorkerRequiredString(String(data.id ?? ""), "Realtime request id");
|
|
260
|
+
const action = normalizeRealtimeAction(data.action);
|
|
261
|
+
const name = typeof data.name === "string" ? normalizeWorkerRequiredString(data.name, "Realtime definition name") : void 0;
|
|
262
|
+
if (action !== "unsubscribe" && !name) {
|
|
263
|
+
throw new Error("[@holo-js/broadcast] Realtime definition name is required.");
|
|
264
|
+
}
|
|
265
|
+
return Object.freeze({
|
|
266
|
+
id,
|
|
267
|
+
action,
|
|
268
|
+
...typeof name === "undefined" ? {} : { name },
|
|
269
|
+
args: normalizeRealtimeArgs(data.args)
|
|
270
|
+
});
|
|
271
|
+
}
|
|
136
272
|
function logSocketMessageError(socketId, error) {
|
|
137
273
|
const message = error instanceof Error ? error.message : String(error);
|
|
138
274
|
console.error(`[@holo-js/broadcast] WebSocket message handling failed for socket "${socketId}": ${message}`);
|
|
@@ -152,18 +288,18 @@ function logRealtimeSubscriptionCleanupError(socketId, subscriptionId, error) {
|
|
|
152
288
|
function safeEqual(left, right) {
|
|
153
289
|
const leftBuffer = Buffer.from(left);
|
|
154
290
|
const rightBuffer = Buffer.from(right);
|
|
155
|
-
return leftBuffer.length === rightBuffer.length &&
|
|
291
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual2(leftBuffer, rightBuffer);
|
|
156
292
|
}
|
|
157
293
|
function signChannelAuth(secret, socketId, channel2, channelData) {
|
|
158
294
|
const value = channelData ? `${socketId}:${channel2}:${channelData}` : `${socketId}:${channel2}`;
|
|
159
|
-
return
|
|
295
|
+
return createHmac2("sha256", secret).update(value).digest("hex");
|
|
160
296
|
}
|
|
161
297
|
function parseClientChannelAuth(data) {
|
|
162
298
|
if (typeof data.auth !== "string") {
|
|
163
299
|
return void 0;
|
|
164
300
|
}
|
|
165
301
|
return Object.freeze({
|
|
166
|
-
auth:
|
|
302
|
+
auth: normalizeWorkerRequiredString(data.auth, "Subscription auth"),
|
|
167
303
|
...typeof data.channel_data === "string" ? { channelData: data.channel_data } : {}
|
|
168
304
|
});
|
|
169
305
|
}
|
|
@@ -174,7 +310,7 @@ function parseSignedChannelData(value) {
|
|
|
174
310
|
});
|
|
175
311
|
}
|
|
176
312
|
const data = parseJsonObject(value, "Subscription channel data");
|
|
177
|
-
const whispers = Array.isArray(data.whispers) ? Object.freeze(data.whispers.map((item) =>
|
|
313
|
+
const whispers = Array.isArray(data.whispers) ? Object.freeze(data.whispers.map((item) => normalizeWorkerRequiredString(String(item), "Auth whisper"))) : Object.freeze([]);
|
|
178
314
|
const member = data.member && typeof data.member === "object" && !Array.isArray(data.member) ? Object.freeze(data.member) : void 0;
|
|
179
315
|
return Object.freeze({
|
|
180
316
|
whispers,
|
|
@@ -199,29 +335,14 @@ function unsubscribeRealtimeSubscription(socket, id, subscription) {
|
|
|
199
335
|
logRealtimeSubscriptionCleanupError(socket.socketId, id, error);
|
|
200
336
|
}
|
|
201
337
|
}
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
return Object.freeze({
|
|
205
|
-
kind: "private",
|
|
206
|
-
canonical: channel2.slice("private-".length)
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
if (channel2.startsWith("presence-")) {
|
|
210
|
-
return Object.freeze({
|
|
211
|
-
kind: "presence",
|
|
212
|
-
canonical: channel2.slice("presence-".length)
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
return Object.freeze({
|
|
216
|
-
kind: "public",
|
|
217
|
-
canonical: channel2
|
|
218
|
-
});
|
|
338
|
+
function isRealtimeSubscriptionCurrent(socket, id, token) {
|
|
339
|
+
return socket.active && socket.realtimeSubscriptionTokens.get(id) === token;
|
|
219
340
|
}
|
|
220
341
|
function createSocketId() {
|
|
221
342
|
return `${randomInt(1, 999999)}.${randomInt(1, 999999)}`;
|
|
222
343
|
}
|
|
223
344
|
function createScalingNodeId() {
|
|
224
|
-
return
|
|
345
|
+
return normalizeWorkerRequiredString(`${process.env.HOSTNAME ?? "node"}-${randomUUID()}`, "Broadcast worker node id");
|
|
225
346
|
}
|
|
226
347
|
function resolveScalingEventChannel(connection) {
|
|
227
348
|
return `holo:broadcast:scaling:${connection}:events`;
|
|
@@ -268,14 +389,15 @@ function parsePresenceHashMembers(values) {
|
|
|
268
389
|
function resolveRedisScalingConnection(queueConfig, connectionName, redisConfig) {
|
|
269
390
|
const queueConnection = queueConfig?.connections[connectionName];
|
|
270
391
|
if (queueConnection?.driver === "redis") {
|
|
392
|
+
const redisQueueConnection = queueConnection;
|
|
271
393
|
return Object.freeze({
|
|
272
|
-
...typeof
|
|
273
|
-
...typeof
|
|
274
|
-
host:
|
|
275
|
-
port:
|
|
276
|
-
username:
|
|
277
|
-
password:
|
|
278
|
-
db:
|
|
394
|
+
...typeof redisQueueConnection.redis.url === "undefined" ? {} : { url: redisQueueConnection.redis.url },
|
|
395
|
+
...typeof redisQueueConnection.redis.clusters === "undefined" ? {} : { clusters: redisQueueConnection.redis.clusters },
|
|
396
|
+
host: redisQueueConnection.redis.host,
|
|
397
|
+
port: redisQueueConnection.redis.port,
|
|
398
|
+
username: redisQueueConnection.redis.username,
|
|
399
|
+
password: redisQueueConnection.redis.password,
|
|
400
|
+
db: redisQueueConnection.redis.db
|
|
279
401
|
});
|
|
280
402
|
}
|
|
281
403
|
const redisConnection = redisConfig?.connections[connectionName];
|
|
@@ -478,7 +600,7 @@ function buildWorkerApps(config) {
|
|
|
478
600
|
continue;
|
|
479
601
|
}
|
|
480
602
|
const holoConnection = connection;
|
|
481
|
-
const authEndpoint = typeof holoConnection.clientOptions.authEndpoint === "string" ?
|
|
603
|
+
const authEndpoint = typeof holoConnection.clientOptions.authEndpoint === "string" ? normalizeWorkerRequiredString(holoConnection.clientOptions.authEndpoint, `Broadcast connection "${name}" authEndpoint`) : void 0;
|
|
482
604
|
if (appsByKey.has(holoConnection.key)) {
|
|
483
605
|
throw new Error(`[@holo-js/broadcast] duplicate broadcast app key "${holoConnection.key}" is already configured.`);
|
|
484
606
|
}
|
|
@@ -503,7 +625,7 @@ function pusherEvent(event, data, channel2) {
|
|
|
503
625
|
});
|
|
504
626
|
}
|
|
505
627
|
async function authenticateSubscription(app, connection, channel2, clientAuth, channelAuth, fetcher) {
|
|
506
|
-
const { kind, canonical } =
|
|
628
|
+
const { kind, canonical } = parseWorkerChannelKind(channel2);
|
|
507
629
|
if (kind === "public") {
|
|
508
630
|
return Object.freeze({
|
|
509
631
|
whispers: Object.freeze([])
|
|
@@ -531,7 +653,7 @@ async function authenticateSubscription(app, connection, channel2, clientAuth, c
|
|
|
531
653
|
throw new Error(`[@holo-js/broadcast] Channel authorization rejected (${response.status}).`);
|
|
532
654
|
}
|
|
533
655
|
const body = await response.json();
|
|
534
|
-
const whispers = Array.isArray(body.whispers) ? Object.freeze(body.whispers.map((value) =>
|
|
656
|
+
const whispers = Array.isArray(body.whispers) ? Object.freeze(body.whispers.map((value) => normalizeWorkerRequiredString(String(value), "Auth whisper"))) : Object.freeze([]);
|
|
535
657
|
const member = body.member && typeof body.member === "object" && !Array.isArray(body.member) ? Object.freeze(body.member) : void 0;
|
|
536
658
|
return Object.freeze({
|
|
537
659
|
whispers,
|
|
@@ -564,6 +686,42 @@ async function authenticateSubscription(app, connection, channel2, clientAuth, c
|
|
|
564
686
|
...authorized.type === "presence" ? { member: authorized.member } : {}
|
|
565
687
|
});
|
|
566
688
|
}
|
|
689
|
+
function resolveRealtimeErrorStatus(error) {
|
|
690
|
+
if (!isRecord(error)) {
|
|
691
|
+
return void 0;
|
|
692
|
+
}
|
|
693
|
+
const decision = isRecord(error.decision) ? error.decision : void 0;
|
|
694
|
+
const status = decision?.status ?? error.status ?? error.statusCode;
|
|
695
|
+
if (typeof status === "number" && Number.isInteger(status) && status >= 400 && status <= 599) {
|
|
696
|
+
return status;
|
|
697
|
+
}
|
|
698
|
+
const name = typeof error.name === "string" ? error.name : "";
|
|
699
|
+
if (name === "RealtimeUnauthorizedError") {
|
|
700
|
+
return 401;
|
|
701
|
+
}
|
|
702
|
+
if (name === "RealtimeForbiddenError") {
|
|
703
|
+
return 403;
|
|
704
|
+
}
|
|
705
|
+
return void 0;
|
|
706
|
+
}
|
|
707
|
+
function resolveRealtimeErrorCode(error) {
|
|
708
|
+
if (!isRecord(error)) {
|
|
709
|
+
return void 0;
|
|
710
|
+
}
|
|
711
|
+
const decision = isRecord(error.decision) ? error.decision : void 0;
|
|
712
|
+
const code = decision?.code ?? error.code;
|
|
713
|
+
return typeof code === "string" ? code : void 0;
|
|
714
|
+
}
|
|
715
|
+
function resolveRealtimeErrorKind(error, status) {
|
|
716
|
+
if (!isRecord(error)) {
|
|
717
|
+
return "runtime";
|
|
718
|
+
}
|
|
719
|
+
const name = typeof error.name === "string" ? error.name : "";
|
|
720
|
+
if (name === "AuthorizationError" || name === "RealtimeUnauthorizedError" || name === "RealtimeForbiddenError" || status === 401 || status === 403 || status === 404) {
|
|
721
|
+
return "authorization";
|
|
722
|
+
}
|
|
723
|
+
return name === "RealtimeAuthUnavailableError" ? "transport" : "runtime";
|
|
724
|
+
}
|
|
567
725
|
function createBroadcastWorkerRuntime(options) {
|
|
568
726
|
const appsByKey = buildWorkerApps(options.config);
|
|
569
727
|
const connectedSockets = /* @__PURE__ */ new Map();
|
|
@@ -573,14 +731,12 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
573
731
|
const presenceSockets = /* @__PURE__ */ new Map();
|
|
574
732
|
const scaling = options.scaling;
|
|
575
733
|
const startedAt = options.now?.() ?? Date.now();
|
|
576
|
-
const scalingUnsubscribe = options.scalingUnsubscribe ? Promise.resolve(options.scalingUnsubscribe) : scaling && options.scalingAutoSubscribe !== false ? scaling.adapter.subscribe(scaling.eventChannel, (
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
});
|
|
580
|
-
}) : Promise.resolve(async () => {
|
|
734
|
+
const scalingUnsubscribe = options.scalingUnsubscribe ? Promise.resolve(options.scalingUnsubscribe) : scaling && options.scalingAutoSubscribe !== false ? scaling.adapter.subscribe(scaling.eventChannel, createScalingMessageListener({
|
|
735
|
+
receiveScalingMessage: handleScalingMessage
|
|
736
|
+
})) : Promise.resolve(async () => {
|
|
581
737
|
});
|
|
582
738
|
function createPresenceSocketRef(channel2, socketId) {
|
|
583
|
-
return scaling &&
|
|
739
|
+
return scaling && parseWorkerChannelKind(channel2).kind === "presence" ? createNodeSocketRef(scaling.nodeId, socketId) : socketId;
|
|
584
740
|
}
|
|
585
741
|
function setPresenceState(key, socketMembers) {
|
|
586
742
|
if (socketMembers.size === 0) {
|
|
@@ -614,7 +770,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
614
770
|
if (!scaling) {
|
|
615
771
|
return;
|
|
616
772
|
}
|
|
617
|
-
const { kind } =
|
|
773
|
+
const { kind } = parseWorkerChannelKind(channel2);
|
|
618
774
|
if (kind !== "presence") {
|
|
619
775
|
return;
|
|
620
776
|
}
|
|
@@ -743,43 +899,10 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
743
899
|
);
|
|
744
900
|
}
|
|
745
901
|
function sendRealtimeMessage(socket, event, data) {
|
|
746
|
-
socket.
|
|
747
|
-
|
|
748
|
-
function resolveRealtimeErrorStatus(error) {
|
|
749
|
-
if (!isRecord(error)) {
|
|
750
|
-
return void 0;
|
|
751
|
-
}
|
|
752
|
-
const decision = isRecord(error.decision) ? error.decision : void 0;
|
|
753
|
-
const status = decision?.status ?? error.status ?? error.statusCode;
|
|
754
|
-
if (typeof status === "number" && Number.isInteger(status) && status >= 400 && status <= 599) {
|
|
755
|
-
return status;
|
|
756
|
-
}
|
|
757
|
-
const name = typeof error.name === "string" ? error.name : "";
|
|
758
|
-
if (name === "RealtimeUnauthorizedError") {
|
|
759
|
-
return 401;
|
|
760
|
-
}
|
|
761
|
-
if (name === "RealtimeForbiddenError") {
|
|
762
|
-
return 403;
|
|
763
|
-
}
|
|
764
|
-
return void 0;
|
|
765
|
-
}
|
|
766
|
-
function resolveRealtimeErrorCode(error) {
|
|
767
|
-
if (!isRecord(error)) {
|
|
768
|
-
return void 0;
|
|
769
|
-
}
|
|
770
|
-
const decision = isRecord(error.decision) ? error.decision : void 0;
|
|
771
|
-
const code = decision?.code ?? error.code;
|
|
772
|
-
return typeof code === "string" ? code : void 0;
|
|
773
|
-
}
|
|
774
|
-
function resolveRealtimeErrorKind(error, status) {
|
|
775
|
-
if (!isRecord(error)) {
|
|
776
|
-
return "runtime";
|
|
777
|
-
}
|
|
778
|
-
const name = typeof error.name === "string" ? error.name : "";
|
|
779
|
-
if (name === "AuthorizationError" || name === "RealtimeUnauthorizedError" || name === "RealtimeForbiddenError" || status === 401 || status === 403 || status === 404) {
|
|
780
|
-
return "authorization";
|
|
902
|
+
if (!socket.active) {
|
|
903
|
+
return;
|
|
781
904
|
}
|
|
782
|
-
|
|
905
|
+
socket.send(pusherEvent(event, data));
|
|
783
906
|
}
|
|
784
907
|
function sendRealtimeError(socket, id, error) {
|
|
785
908
|
const status = resolveRealtimeErrorStatus(error);
|
|
@@ -812,6 +935,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
812
935
|
}
|
|
813
936
|
if (request.action === "unsubscribe") {
|
|
814
937
|
const subscription = socket.realtimeSubscriptions.get(request.id);
|
|
938
|
+
socket.realtimeSubscriptionTokens.delete(request.id);
|
|
815
939
|
if (subscription) {
|
|
816
940
|
unsubscribeRealtimeSubscription(socket, request.id, subscription);
|
|
817
941
|
}
|
|
@@ -822,6 +946,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
822
946
|
return;
|
|
823
947
|
}
|
|
824
948
|
const context = createRealtimeExecutionContext(socket);
|
|
949
|
+
let pendingSubscriptionToken;
|
|
825
950
|
try {
|
|
826
951
|
if (request.action === "query") {
|
|
827
952
|
const result = await realtime.query(request.name, request.args, context);
|
|
@@ -845,24 +970,53 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
845
970
|
return;
|
|
846
971
|
}
|
|
847
972
|
const previousSubscription = socket.realtimeSubscriptions.get(request.id);
|
|
973
|
+
socket.realtimeSubscriptionTokens.delete(request.id);
|
|
848
974
|
if (previousSubscription) {
|
|
849
975
|
unsubscribeRealtimeSubscription(socket, request.id, previousSubscription);
|
|
850
976
|
socket.realtimeSubscriptions.delete(request.id);
|
|
851
977
|
}
|
|
978
|
+
const subscriptionToken = {};
|
|
979
|
+
pendingSubscriptionToken = subscriptionToken;
|
|
980
|
+
socket.realtimeSubscriptionTokens.set(request.id, subscriptionToken);
|
|
852
981
|
const subscription = await realtime.subscribe(request.name, request.args, {
|
|
853
982
|
context,
|
|
854
983
|
onData(snapshot) {
|
|
984
|
+
if (!isRealtimeSubscriptionCurrent(socket, request.id, subscriptionToken)) {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
855
987
|
sendRealtimeMessage(socket, "holo:realtime:snapshot", {
|
|
856
988
|
id: request.id,
|
|
857
989
|
snapshot
|
|
858
990
|
});
|
|
859
991
|
},
|
|
992
|
+
onPatch(patch) {
|
|
993
|
+
if (!isRealtimeSubscriptionCurrent(socket, request.id, subscriptionToken)) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
sendRealtimeMessage(socket, "holo:realtime:patch", {
|
|
997
|
+
id: request.id,
|
|
998
|
+
patch
|
|
999
|
+
});
|
|
1000
|
+
},
|
|
860
1001
|
onError(error) {
|
|
1002
|
+
if (!isRealtimeSubscriptionCurrent(socket, request.id, subscriptionToken)) {
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
861
1005
|
sendRealtimeError(socket, request.id, error);
|
|
862
1006
|
}
|
|
863
1007
|
});
|
|
1008
|
+
if (!isRealtimeSubscriptionCurrent(socket, request.id, subscriptionToken)) {
|
|
1009
|
+
unsubscribeRealtimeSubscription(socket, request.id, subscription);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
864
1012
|
socket.realtimeSubscriptions.set(request.id, subscription);
|
|
865
1013
|
} catch (error) {
|
|
1014
|
+
if (pendingSubscriptionToken) {
|
|
1015
|
+
if (!isRealtimeSubscriptionCurrent(socket, request.id, pendingSubscriptionToken)) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
socket.realtimeSubscriptionTokens.delete(request.id);
|
|
1019
|
+
}
|
|
866
1020
|
sendRealtimeError(socket, request.id, error);
|
|
867
1021
|
}
|
|
868
1022
|
}
|
|
@@ -978,7 +1132,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
978
1132
|
}
|
|
979
1133
|
}
|
|
980
1134
|
async function handleSubscribe(socket, rawChannel, clientAuth) {
|
|
981
|
-
const channel2 =
|
|
1135
|
+
const channel2 = normalizeWorkerRequiredString(rawChannel, "Subscription channel");
|
|
982
1136
|
const authorization = await authenticateSubscription(socket.app, socket, channel2, clientAuth, options.channelAuth, options.fetch);
|
|
983
1137
|
if (!socket.active || connectedSockets.get(socket.socketId) !== socket) {
|
|
984
1138
|
return;
|
|
@@ -997,7 +1151,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
997
1151
|
channelWhispers.delete(key);
|
|
998
1152
|
}
|
|
999
1153
|
}
|
|
1000
|
-
const { kind } =
|
|
1154
|
+
const { kind } = parseWorkerChannelKind(channel2);
|
|
1001
1155
|
if (kind === "presence") {
|
|
1002
1156
|
const member = authorization.member ?? Object.freeze({ id: socket.socketId });
|
|
1003
1157
|
const synchronized = await synchronizePresenceChannel(
|
|
@@ -1019,7 +1173,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1019
1173
|
socket.send(pusherEvent("pusher_internal:subscription_succeeded", {}, channel2));
|
|
1020
1174
|
}
|
|
1021
1175
|
async function handleUnsubscribe(socket, rawChannel) {
|
|
1022
|
-
const channel2 =
|
|
1176
|
+
const channel2 = normalizeWorkerRequiredString(rawChannel, "Unsubscribe channel");
|
|
1023
1177
|
socket.subscribedChannels.delete(channel2);
|
|
1024
1178
|
const removedPresenceMember = removeSubscriptionLocal(socket.app.appId, socket.socketId, channel2);
|
|
1025
1179
|
if (removedPresenceMember.removed && removedPresenceMember.member) {
|
|
@@ -1032,11 +1186,11 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1032
1186
|
socket.send(pusherEvent("pusher_internal:unsubscribed", {}, channel2));
|
|
1033
1187
|
}
|
|
1034
1188
|
async function handleClientEvent(socket, message) {
|
|
1035
|
-
const channel2 =
|
|
1189
|
+
const channel2 = normalizeWorkerRequiredString(message.channel ?? "", "Whisper channel");
|
|
1036
1190
|
if (!socket.subscribedChannels.has(channel2)) {
|
|
1037
1191
|
throw new Error(`[@holo-js/broadcast] Socket is not subscribed to "${channel2}".`);
|
|
1038
1192
|
}
|
|
1039
|
-
const { kind, canonical } =
|
|
1193
|
+
const { kind, canonical } = parseWorkerChannelKind(channel2);
|
|
1040
1194
|
if (kind === "public") {
|
|
1041
1195
|
throw new Error("[@holo-js/broadcast] Client events are only allowed on private or presence channels.");
|
|
1042
1196
|
}
|
|
@@ -1055,15 +1209,9 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1055
1209
|
appId: socket.app.appId,
|
|
1056
1210
|
socket_id: socket.socketId
|
|
1057
1211
|
});
|
|
1058
|
-
await publishToChannels(payload
|
|
1059
|
-
fromScaling: false,
|
|
1060
|
-
shouldReplicate: true
|
|
1061
|
-
});
|
|
1212
|
+
await publishToChannels(payload);
|
|
1062
1213
|
}
|
|
1063
|
-
async function publishToChannels(body
|
|
1064
|
-
fromScaling: false,
|
|
1065
|
-
shouldReplicate: true
|
|
1066
|
-
}) {
|
|
1214
|
+
async function publishToChannels(body) {
|
|
1067
1215
|
let deliveredSockets = 0;
|
|
1068
1216
|
const deliveredChannels = [];
|
|
1069
1217
|
for (const channel2 of body.channels) {
|
|
@@ -1073,9 +1221,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1073
1221
|
}
|
|
1074
1222
|
deliveredSockets += result.deliveredSockets;
|
|
1075
1223
|
}
|
|
1076
|
-
|
|
1077
|
-
await publishScalingEvent(body);
|
|
1078
|
-
}
|
|
1224
|
+
await publishScalingEvent(body);
|
|
1079
1225
|
return Object.freeze({
|
|
1080
1226
|
deliveredChannels: Object.freeze(deliveredChannels),
|
|
1081
1227
|
deliveredSockets
|
|
@@ -1084,19 +1230,27 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1084
1230
|
async function handlePublishRequest(request) {
|
|
1085
1231
|
const url = new URL(request.url);
|
|
1086
1232
|
const match = url.pathname.match(/\/apps\/([^/]+)\/events$/);
|
|
1087
|
-
const appId =
|
|
1233
|
+
const appId = normalizeWorkerRequiredString(match?.[1] ?? "", "Publish appId");
|
|
1088
1234
|
const app = Object.values(appsByKey).find((candidate) => candidate.appId === appId);
|
|
1089
1235
|
if (!app) {
|
|
1090
1236
|
return new Response("App not found", { status: 404 });
|
|
1091
1237
|
}
|
|
1092
|
-
|
|
1238
|
+
let bodyText;
|
|
1239
|
+
try {
|
|
1240
|
+
bodyText = await readLimitedRequestText(request, options.config.worker.maxRequestBytes);
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
if (error instanceof BroadcastPayloadTooLargeError) {
|
|
1243
|
+
return new Response("Payload Too Large", { status: 413 });
|
|
1244
|
+
}
|
|
1245
|
+
throw error;
|
|
1246
|
+
}
|
|
1093
1247
|
const bodyMd5 = createHash("md5").update(bodyText).digest("hex");
|
|
1094
1248
|
if (url.searchParams.get("body_md5") !== bodyMd5) {
|
|
1095
1249
|
return new Response("Invalid body signature", { status: 401 });
|
|
1096
1250
|
}
|
|
1097
1251
|
let authKey;
|
|
1098
1252
|
try {
|
|
1099
|
-
authKey =
|
|
1253
|
+
authKey = normalizeWorkerRequiredString(url.searchParams.get("auth_key") ?? "", "Publish auth_key");
|
|
1100
1254
|
} catch (error) {
|
|
1101
1255
|
const message = error instanceof Error ? error.message : "Invalid credentials";
|
|
1102
1256
|
return new Response(message, { status: 401 });
|
|
@@ -1107,7 +1261,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1107
1261
|
let authTimestamp;
|
|
1108
1262
|
try {
|
|
1109
1263
|
authTimestamp = Number.parseInt(
|
|
1110
|
-
|
|
1264
|
+
normalizeWorkerRequiredString(url.searchParams.get("auth_timestamp") ?? "", "Publish auth_timestamp"),
|
|
1111
1265
|
10
|
|
1112
1266
|
);
|
|
1113
1267
|
} catch (error) {
|
|
@@ -1124,8 +1278,8 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1124
1278
|
let providedSignature;
|
|
1125
1279
|
let expectedSignature;
|
|
1126
1280
|
try {
|
|
1127
|
-
providedSignature =
|
|
1128
|
-
expectedSignature =
|
|
1281
|
+
providedSignature = normalizeWorkerRequiredString(url.searchParams.get("auth_signature") ?? "", "Publish auth_signature");
|
|
1282
|
+
expectedSignature = createWorkerPusherSignature(
|
|
1129
1283
|
app.secret,
|
|
1130
1284
|
request.method,
|
|
1131
1285
|
url.pathname,
|
|
@@ -1135,12 +1289,12 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1135
1289
|
const message = error instanceof Error ? error.message : "Invalid auth signature";
|
|
1136
1290
|
return new Response(message, { status: 401 });
|
|
1137
1291
|
}
|
|
1138
|
-
if (!
|
|
1292
|
+
if (!verifyWorkerPusherSignature(providedSignature, expectedSignature)) {
|
|
1139
1293
|
return new Response("Invalid auth signature", { status: 401 });
|
|
1140
1294
|
}
|
|
1141
1295
|
let publishBody;
|
|
1142
1296
|
try {
|
|
1143
|
-
publishBody =
|
|
1297
|
+
publishBody = normalizeWorkerPublishBody(parseJsonObject(bodyText, "Publish body"));
|
|
1144
1298
|
} catch (error) {
|
|
1145
1299
|
return new Response(error.message, { status: 400 });
|
|
1146
1300
|
}
|
|
@@ -1149,9 +1303,6 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1149
1303
|
result = await publishToChannels({
|
|
1150
1304
|
...publishBody,
|
|
1151
1305
|
appId: app.appId
|
|
1152
|
-
}, {
|
|
1153
|
-
fromScaling: false,
|
|
1154
|
-
shouldReplicate: true
|
|
1155
1306
|
});
|
|
1156
1307
|
} catch (error) {
|
|
1157
1308
|
const message = error instanceof Error ? error.message : "Broadcast publish failed.";
|
|
@@ -1180,7 +1331,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1180
1331
|
}
|
|
1181
1332
|
});
|
|
1182
1333
|
}
|
|
1183
|
-
if (request.method.toUpperCase() === "GET" && url.pathname === options.config.worker.statsPath) {
|
|
1334
|
+
if (options.config.worker.statsEnabled && request.method.toUpperCase() === "GET" && url.pathname === options.config.worker.statsPath) {
|
|
1184
1335
|
return new Response(JSON.stringify({
|
|
1185
1336
|
...this.getStats()
|
|
1186
1337
|
}), {
|
|
@@ -1203,6 +1354,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1203
1354
|
close: connection.close,
|
|
1204
1355
|
subscribedChannels: /* @__PURE__ */ new Set(),
|
|
1205
1356
|
realtimeSubscriptions: /* @__PURE__ */ new Map(),
|
|
1357
|
+
realtimeSubscriptionTokens: /* @__PURE__ */ new Map(),
|
|
1206
1358
|
active: true,
|
|
1207
1359
|
pendingMessage: Promise.resolve()
|
|
1208
1360
|
});
|
|
@@ -1220,7 +1372,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1220
1372
|
if (!socket.active || connectedSockets.get(socketId) !== socket) {
|
|
1221
1373
|
return;
|
|
1222
1374
|
}
|
|
1223
|
-
const message =
|
|
1375
|
+
const message = parseWorkerSocketMessage(rawMessage);
|
|
1224
1376
|
if (message.event === "pusher:ping") {
|
|
1225
1377
|
socket.send(pusherEvent("pusher:pong", {}));
|
|
1226
1378
|
return;
|
|
@@ -1259,6 +1411,7 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1259
1411
|
unsubscribeRealtimeSubscription(socket, subscriptionId, subscription);
|
|
1260
1412
|
}
|
|
1261
1413
|
socket.realtimeSubscriptions.clear();
|
|
1414
|
+
socket.realtimeSubscriptionTokens.clear();
|
|
1262
1415
|
const channelsToCleanup = [...socket.subscribedChannels];
|
|
1263
1416
|
const scalingCleanupTasks = channelsToCleanup.map((channel2) => {
|
|
1264
1417
|
const removedPresenceMember = removeSubscriptionLocal(socket.app.appId, socket.socketId, channel2);
|
|
@@ -1281,11 +1434,8 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1281
1434
|
await Promise.all(scalingCleanupTasks.map(async (task) => {
|
|
1282
1435
|
await task();
|
|
1283
1436
|
}));
|
|
1284
|
-
}).catch((error) => {
|
|
1285
|
-
logSocketMessageError(socket.socketId, error);
|
|
1286
|
-
});
|
|
1287
|
-
socket.pendingMessage = cleanupTask.catch(() => {
|
|
1288
1437
|
});
|
|
1438
|
+
socket.pendingMessage = cleanupTask;
|
|
1289
1439
|
},
|
|
1290
1440
|
getStats() {
|
|
1291
1441
|
return Object.freeze({
|
|
@@ -1316,64 +1466,6 @@ function createBroadcastWorkerRuntime(options) {
|
|
|
1316
1466
|
}
|
|
1317
1467
|
});
|
|
1318
1468
|
}
|
|
1319
|
-
function toNodeHeaders(headers) {
|
|
1320
|
-
const normalized = new Headers();
|
|
1321
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
1322
|
-
if (typeof value === "undefined") {
|
|
1323
|
-
continue;
|
|
1324
|
-
}
|
|
1325
|
-
if (Array.isArray(value)) {
|
|
1326
|
-
for (const item of value) {
|
|
1327
|
-
normalized.append(key, item);
|
|
1328
|
-
}
|
|
1329
|
-
continue;
|
|
1330
|
-
}
|
|
1331
|
-
normalized.set(key, value);
|
|
1332
|
-
}
|
|
1333
|
-
return normalized;
|
|
1334
|
-
}
|
|
1335
|
-
function toNodeRequestUrl(request, fallbackHost) {
|
|
1336
|
-
const path = request.url ?? "/";
|
|
1337
|
-
const host = request.headers.host ?? fallbackHost;
|
|
1338
|
-
return `http://${host}${path}`;
|
|
1339
|
-
}
|
|
1340
|
-
async function readNodeRequestBody(request) {
|
|
1341
|
-
if (request.method === "GET" || request.method === "HEAD") {
|
|
1342
|
-
return void 0;
|
|
1343
|
-
}
|
|
1344
|
-
const chunks = [];
|
|
1345
|
-
for await (const chunk of request) {
|
|
1346
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1347
|
-
}
|
|
1348
|
-
if (chunks.length === 0) {
|
|
1349
|
-
return void 0;
|
|
1350
|
-
}
|
|
1351
|
-
return Buffer.concat(chunks);
|
|
1352
|
-
}
|
|
1353
|
-
async function writeNodeResponse(response, value) {
|
|
1354
|
-
response.statusCode = value.status;
|
|
1355
|
-
response.statusMessage = value.statusText;
|
|
1356
|
-
value.headers.forEach((headerValue, headerName) => {
|
|
1357
|
-
response.setHeader(headerName, headerValue);
|
|
1358
|
-
});
|
|
1359
|
-
const body = await value.arrayBuffer();
|
|
1360
|
-
response.end(Buffer.from(body));
|
|
1361
|
-
}
|
|
1362
|
-
function decodeNodeWebSocketMessage(message) {
|
|
1363
|
-
if (typeof message === "string") {
|
|
1364
|
-
return message;
|
|
1365
|
-
}
|
|
1366
|
-
if (message instanceof ArrayBuffer) {
|
|
1367
|
-
return new TextDecoder().decode(new Uint8Array(message));
|
|
1368
|
-
}
|
|
1369
|
-
if (Array.isArray(message)) {
|
|
1370
|
-
return Buffer.concat(message).toString("utf8");
|
|
1371
|
-
}
|
|
1372
|
-
if (message instanceof Uint8Array) {
|
|
1373
|
-
return Buffer.from(message).toString("utf8");
|
|
1374
|
-
}
|
|
1375
|
-
return String(message);
|
|
1376
|
-
}
|
|
1377
1469
|
async function handleSubscribeFailure(runtime, subscribeError) {
|
|
1378
1470
|
try {
|
|
1379
1471
|
await runtime.close();
|
|
@@ -1412,11 +1504,10 @@ async function startBroadcastWorker(runtimeBindings) {
|
|
|
1412
1504
|
}
|
|
1413
1505
|
});
|
|
1414
1506
|
if (scalingConfig) {
|
|
1415
|
-
scalingUnsubscribe = await scalingConfig.adapter.subscribe(
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
}).catch((subscribeError) => handleSubscribeFailure(runtime, subscribeError));
|
|
1507
|
+
scalingUnsubscribe = await scalingConfig.adapter.subscribe(
|
|
1508
|
+
scalingConfig.eventChannel,
|
|
1509
|
+
createScalingMessageListener(runtime)
|
|
1510
|
+
).catch((subscribeError) => handleSubscribeFailure(runtime, subscribeError));
|
|
1420
1511
|
}
|
|
1421
1512
|
const bun = globalThis.Bun;
|
|
1422
1513
|
const appsByKey = buildWorkerApps(config);
|
|
@@ -1435,6 +1526,9 @@ async function startBroadcastWorker(runtimeBindings) {
|
|
|
1435
1526
|
if (!app) {
|
|
1436
1527
|
return new Response("Unknown app key", { status: 401 });
|
|
1437
1528
|
}
|
|
1529
|
+
if (!isAllowedWorkerOrigin(request, config.worker.allowedOrigins)) {
|
|
1530
|
+
return new Response("Forbidden", { status: 403 });
|
|
1531
|
+
}
|
|
1438
1532
|
const upgraded = wsServer2.upgrade(request, {
|
|
1439
1533
|
data: {
|
|
1440
1534
|
socketId: createSocketId(),
|
|
@@ -1462,6 +1556,12 @@ async function startBroadcastWorker(runtimeBindings) {
|
|
|
1462
1556
|
});
|
|
1463
1557
|
},
|
|
1464
1558
|
message(socket, message) {
|
|
1559
|
+
const messageBytes = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
1560
|
+
if (messageBytes > config.worker.maxMessageBytes) {
|
|
1561
|
+
runtime.disconnectWebSocket(socket.data.socketId);
|
|
1562
|
+
socket.close(1009, "Message too large");
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1465
1565
|
const value = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
1466
1566
|
void runtime.receiveWebSocketMessage(socket.data.socketId, value).catch((error) => {
|
|
1467
1567
|
logSocketMessageError(socket.data.socketId, error);
|
|
@@ -1497,7 +1597,7 @@ async function startBroadcastWorker(runtimeBindings) {
|
|
|
1497
1597
|
throw new Error("[@holo-js/broadcast] Node runtime websocket module is missing WebSocketServer export.");
|
|
1498
1598
|
}
|
|
1499
1599
|
const requestConnectionInfo = /* @__PURE__ */ new WeakMap();
|
|
1500
|
-
const wsServer = new WebSocketServer({ noServer: true });
|
|
1600
|
+
const wsServer = new WebSocketServer({ noServer: true, maxPayload: config.worker.maxMessageBytes });
|
|
1501
1601
|
wsServer.on("connection", (socket, request) => {
|
|
1502
1602
|
const connectionInfo = requestConnectionInfo.get(request);
|
|
1503
1603
|
const socketId = connectionInfo.socketId;
|
|
@@ -1511,6 +1611,12 @@ async function startBroadcastWorker(runtimeBindings) {
|
|
|
1511
1611
|
}
|
|
1512
1612
|
});
|
|
1513
1613
|
socket.on("message", (message) => {
|
|
1614
|
+
const messageBytes = getNodeWebSocketMessageBytes(message);
|
|
1615
|
+
if (messageBytes > config.worker.maxMessageBytes) {
|
|
1616
|
+
runtime.disconnectWebSocket(socketId);
|
|
1617
|
+
socket.close(1009, "Message too large");
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1514
1620
|
const value = decodeNodeWebSocketMessage(message);
|
|
1515
1621
|
void runtime.receiveWebSocketMessage(socketId, value).catch((error) => {
|
|
1516
1622
|
logSocketMessageError(socketId, error);
|
|
@@ -1524,7 +1630,13 @@ async function startBroadcastWorker(runtimeBindings) {
|
|
|
1524
1630
|
});
|
|
1525
1631
|
const httpServer = createServer(async (request, response) => {
|
|
1526
1632
|
const requestUrl = toNodeRequestUrl(request, `${config.worker.host}:${config.worker.port}`);
|
|
1527
|
-
|
|
1633
|
+
let requestBody;
|
|
1634
|
+
try {
|
|
1635
|
+
requestBody = await readNodeRequestBody(request, config.worker.maxRequestBytes);
|
|
1636
|
+
} catch (error) {
|
|
1637
|
+
writeNodeRequestBodyError(response, error);
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1528
1640
|
const requestInit = {
|
|
1529
1641
|
method: request.method,
|
|
1530
1642
|
headers: toNodeHeaders(request.headers)
|
|
@@ -1551,6 +1663,12 @@ async function startBroadcastWorker(runtimeBindings) {
|
|
|
1551
1663
|
socket.destroy();
|
|
1552
1664
|
return;
|
|
1553
1665
|
}
|
|
1666
|
+
const originRequest = new Request(requestUrl, { headers: toNodeHeaders(request.headers) });
|
|
1667
|
+
if (!isAllowedWorkerOrigin(originRequest, config.worker.allowedOrigins)) {
|
|
1668
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
1669
|
+
socket.destroy();
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1554
1672
|
requestConnectionInfo.set(request, {
|
|
1555
1673
|
socketId: createSocketId(),
|
|
1556
1674
|
app,
|
|
@@ -1603,19 +1721,37 @@ var workerInternals = {
|
|
|
1603
1721
|
buildWorkerApps,
|
|
1604
1722
|
createScalingNodeId,
|
|
1605
1723
|
createRedisScalingAdapter,
|
|
1606
|
-
createPusherSignature,
|
|
1607
|
-
verifyPusherSignature,
|
|
1724
|
+
createPusherSignature: createWorkerPusherSignature,
|
|
1725
|
+
verifyPusherSignature: verifyWorkerPusherSignature,
|
|
1608
1726
|
createSocketId,
|
|
1609
1727
|
resolveRedisScalingConnection,
|
|
1610
1728
|
resolveScalingEventChannel,
|
|
1611
|
-
normalizePublishBody,
|
|
1612
|
-
parseChannelKind,
|
|
1729
|
+
normalizePublishBody: normalizeWorkerPublishBody,
|
|
1730
|
+
parseChannelKind: parseWorkerChannelKind,
|
|
1613
1731
|
parsePresenceHashMembers,
|
|
1614
|
-
parseSocketMessage
|
|
1732
|
+
parseSocketMessage: parseWorkerSocketMessage,
|
|
1733
|
+
isAllowedWorkerOrigin,
|
|
1734
|
+
readLimitedRequestText,
|
|
1735
|
+
normalizeRealtimeAction,
|
|
1736
|
+
normalizeRealtimeArgs,
|
|
1737
|
+
parseRealtimeSocketMessage,
|
|
1738
|
+
logSocketMessageError,
|
|
1739
|
+
logScalingMessageError,
|
|
1740
|
+
logSocketCleanupError,
|
|
1741
|
+
logRealtimeSubscriptionCleanupError,
|
|
1742
|
+
safeEqual,
|
|
1743
|
+
signChannelAuth,
|
|
1744
|
+
parseClientChannelAuth,
|
|
1745
|
+
parseSignedChannelData,
|
|
1746
|
+
verifyClientChannelAuth,
|
|
1747
|
+
resolvePresenceMemberId,
|
|
1748
|
+
resolveRealtimeErrorStatus,
|
|
1749
|
+
resolveRealtimeErrorCode,
|
|
1750
|
+
resolveRealtimeErrorKind,
|
|
1751
|
+
writeNodeRequestBodyError
|
|
1615
1752
|
};
|
|
1616
1753
|
|
|
1617
1754
|
// src/index.ts
|
|
1618
|
-
import { defineBroadcastConfig } from "@holo-js/config";
|
|
1619
1755
|
var broadcastPackage = Object.freeze({
|
|
1620
1756
|
authorizeBroadcastChannel,
|
|
1621
1757
|
broadcast,
|
|
@@ -1658,9 +1794,12 @@ export {
|
|
|
1658
1794
|
getBroadcastRuntime,
|
|
1659
1795
|
getBroadcastRuntimeBindings,
|
|
1660
1796
|
getRegisteredBroadcastDriver,
|
|
1797
|
+
holoBroadcastDefaults,
|
|
1661
1798
|
isBroadcastDefinition,
|
|
1662
1799
|
isChannelDefinition,
|
|
1663
1800
|
listRegisteredBroadcastDrivers,
|
|
1801
|
+
loadBroadcastPluginDrivers,
|
|
1802
|
+
normalizeBroadcastConfig,
|
|
1664
1803
|
parseBroadcastAuthEndpointPayload,
|
|
1665
1804
|
presenceChannel,
|
|
1666
1805
|
privateChannel,
|
|
@@ -1668,6 +1807,7 @@ export {
|
|
|
1668
1807
|
renderBroadcastAuthResponse,
|
|
1669
1808
|
renderBroadcastClientConfigResponse,
|
|
1670
1809
|
resetBroadcastDriverRegistry,
|
|
1810
|
+
resetBroadcastPluginDrivers,
|
|
1671
1811
|
resetBroadcastRuntime,
|
|
1672
1812
|
resolveBroadcastChannelGuard,
|
|
1673
1813
|
resolveBroadcastClientConfig,
|