@luckystack/sync 0.1.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/CHANGELOG.md +14 -0
- package/CLAUDE.md +104 -0
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/client.d.ts +126 -0
- package/dist/client.js +537 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +1203 -0
- package/dist/index.js.map +1 -0
- package/docs/callback-registration.md +257 -0
- package/docs/error-states.md +252 -0
- package/docs/ignore-self.md +162 -0
- package/docs/room-fanout.md +233 -0
- package/docs/server-vs-client-handlers.md +321 -0
- package/docs/streaming.md +349 -0
- package/docs/sync-request.md +284 -0
- package/docs/version-policy.md +362 -0
- package/package.json +75 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
// src/handleSyncRequest.ts
|
|
2
|
+
import { getSession } from "@luckystack/login";
|
|
3
|
+
import { getProjectConfig as getProjectConfig2 } from "@luckystack/core";
|
|
4
|
+
import { getRuntimeSyncMaps } from "@luckystack/core";
|
|
5
|
+
import {
|
|
6
|
+
validateRequest,
|
|
7
|
+
extractTokenFromSocket,
|
|
8
|
+
getIoInstance as getIoInstance2,
|
|
9
|
+
tryCatch,
|
|
10
|
+
parseTransportRouteName,
|
|
11
|
+
checkRateLimit,
|
|
12
|
+
buildSyncProgressEventName,
|
|
13
|
+
buildSyncResponseEventName,
|
|
14
|
+
socketEventNames as socketEventNames2,
|
|
15
|
+
dispatchHook as dispatchHook2,
|
|
16
|
+
validateInputByType,
|
|
17
|
+
getLogger as getLogger2
|
|
18
|
+
} from "@luckystack/core";
|
|
19
|
+
import { extractLanguageFromHeader, normalizeErrorResponse, applyErrorFormatter } from "@luckystack/core";
|
|
20
|
+
|
|
21
|
+
// src/_shared/streamEmitters.ts
|
|
22
|
+
import {
|
|
23
|
+
dispatchHook,
|
|
24
|
+
getIoInstance,
|
|
25
|
+
getLogger,
|
|
26
|
+
getProjectConfig,
|
|
27
|
+
socketEventNames
|
|
28
|
+
} from "@luckystack/core";
|
|
29
|
+
var chunkCounters = /* @__PURE__ */ new Map();
|
|
30
|
+
var counterKey = (routeName, recipient) => `${routeName}|${recipient}`;
|
|
31
|
+
var bumpChunkIndex = (routeName, recipient) => {
|
|
32
|
+
const key = counterKey(routeName, recipient);
|
|
33
|
+
const next = (chunkCounters.get(key) ?? 0) + 1;
|
|
34
|
+
chunkCounters.set(key, next);
|
|
35
|
+
return next;
|
|
36
|
+
};
|
|
37
|
+
var dispatchStreamHooks = (routeName, recipient, chunk) => {
|
|
38
|
+
void dispatchHook("preSyncStream", { routeName, chunk, recipient });
|
|
39
|
+
const chunkIndex = bumpChunkIndex(routeName, recipient);
|
|
40
|
+
void dispatchHook("postSyncStream", { routeName, chunk, recipient, chunkIndex });
|
|
41
|
+
};
|
|
42
|
+
var shouldLogStream = () => getProjectConfig().logging.stream;
|
|
43
|
+
var DEFAULT_THRESHOLD_BYTES = 1048576;
|
|
44
|
+
var AVG_PACKET_BYTES = 1024;
|
|
45
|
+
var POLL_INTERVAL_MS = 10;
|
|
46
|
+
var MAX_SOCKETS_FOR_PRESSURE_SAMPLE = 32;
|
|
47
|
+
var isEngineConnLike = (value) => typeof value === "object" && value !== null;
|
|
48
|
+
var readSocketPressure = (socket) => {
|
|
49
|
+
const maybeConn = socket.conn;
|
|
50
|
+
if (!isEngineConnLike(maybeConn)) return { packets: 0, writable: true };
|
|
51
|
+
const packets = maybeConn.writeBuffer?.length ?? 0;
|
|
52
|
+
const writable = maybeConn.transport?.writable ?? true;
|
|
53
|
+
return { packets, writable };
|
|
54
|
+
};
|
|
55
|
+
var waitUntilSocketDrained = async (socket, packetThreshold, isAborted) => {
|
|
56
|
+
let { packets, writable } = readSocketPressure(socket);
|
|
57
|
+
while (writable && packets >= packetThreshold && !isAborted()) {
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
59
|
+
({ packets, writable } = readSocketPressure(socket));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var collectRoomSocketsForPressure = (receiver) => {
|
|
63
|
+
const io = getIoInstance();
|
|
64
|
+
if (!io) return [];
|
|
65
|
+
if (!receiver) return [];
|
|
66
|
+
if (receiver === "all") {
|
|
67
|
+
const out2 = [];
|
|
68
|
+
let i2 = 0;
|
|
69
|
+
for (const [, sock] of io.sockets.sockets) {
|
|
70
|
+
if (i2 >= MAX_SOCKETS_FOR_PRESSURE_SAMPLE) break;
|
|
71
|
+
out2.push(sock);
|
|
72
|
+
i2++;
|
|
73
|
+
}
|
|
74
|
+
return out2;
|
|
75
|
+
}
|
|
76
|
+
const ids = io.sockets.adapter.rooms.get(receiver);
|
|
77
|
+
if (!ids || ids.size === 0) return [];
|
|
78
|
+
const out = [];
|
|
79
|
+
let i = 0;
|
|
80
|
+
for (const id of ids) {
|
|
81
|
+
if (i >= MAX_SOCKETS_FOR_PRESSURE_SAMPLE) break;
|
|
82
|
+
const sock = io.sockets.sockets.get(id);
|
|
83
|
+
if (sock) out.push(sock);
|
|
84
|
+
i++;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
var buildSyncStreamEmitters = ({
|
|
89
|
+
cb,
|
|
90
|
+
receiver,
|
|
91
|
+
resolvedName,
|
|
92
|
+
emitOriginatorChunk,
|
|
93
|
+
logLabel,
|
|
94
|
+
signal,
|
|
95
|
+
originatorSocket
|
|
96
|
+
}) => {
|
|
97
|
+
const buildBroadcastFrame = (payload) => ({
|
|
98
|
+
...payload,
|
|
99
|
+
cb,
|
|
100
|
+
fullName: resolvedName,
|
|
101
|
+
status: "stream"
|
|
102
|
+
});
|
|
103
|
+
const isAborted = () => signal?.aborted === true;
|
|
104
|
+
const logAbortedDrop = (kind) => {
|
|
105
|
+
if (shouldLogStream()) {
|
|
106
|
+
getLogger().debug(`${logLabel}: ${resolvedName} ${kind} skipped \u2014 request aborted`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const emitServerSyncStream = (payload = {}) => {
|
|
110
|
+
if (isAborted()) {
|
|
111
|
+
logAbortedDrop("server stream");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (shouldLogStream()) {
|
|
115
|
+
getLogger().debug(`${logLabel}: ${resolvedName} server stream`, { payload });
|
|
116
|
+
}
|
|
117
|
+
dispatchStreamHooks(resolvedName, "originator", payload);
|
|
118
|
+
emitOriginatorChunk(payload);
|
|
119
|
+
};
|
|
120
|
+
const emitBroadcastSyncStream = (payload = {}) => {
|
|
121
|
+
if (isAborted()) {
|
|
122
|
+
logAbortedDrop("broadcastStream");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (shouldLogStream()) {
|
|
126
|
+
getLogger().debug(`${logLabel}: ${resolvedName} broadcastStream`, { payload });
|
|
127
|
+
}
|
|
128
|
+
if (!receiver) return;
|
|
129
|
+
const io = getIoInstance();
|
|
130
|
+
if (!io) return;
|
|
131
|
+
dispatchStreamHooks(resolvedName, receiver, payload);
|
|
132
|
+
io.to(receiver).emit(socketEventNames.sync, buildBroadcastFrame(payload));
|
|
133
|
+
};
|
|
134
|
+
const emitStreamToTokens = (tokens, payload = {}) => {
|
|
135
|
+
if (isAborted()) {
|
|
136
|
+
logAbortedDrop("streamTo");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const list = Array.isArray(tokens) ? tokens : [tokens];
|
|
140
|
+
const filtered = list.filter((t) => typeof t === "string" && t.length > 0);
|
|
141
|
+
if (filtered.length === 0) return;
|
|
142
|
+
if (shouldLogStream()) {
|
|
143
|
+
getLogger().debug(`${logLabel}: ${resolvedName} streamTo`, { tokens: filtered, payload });
|
|
144
|
+
}
|
|
145
|
+
const io = getIoInstance();
|
|
146
|
+
if (!io) return;
|
|
147
|
+
for (const recipient of filtered) {
|
|
148
|
+
dispatchStreamHooks(resolvedName, recipient, payload);
|
|
149
|
+
}
|
|
150
|
+
const frame = buildBroadcastFrame(payload);
|
|
151
|
+
io.to(filtered).emit(socketEventNames.sync, frame);
|
|
152
|
+
};
|
|
153
|
+
const flushPressure = async ({ thresholdBytes } = {}) => {
|
|
154
|
+
if (isAborted()) return;
|
|
155
|
+
const effectiveThresholdBytes = typeof thresholdBytes === "number" && thresholdBytes > 0 ? thresholdBytes : DEFAULT_THRESHOLD_BYTES;
|
|
156
|
+
const packetThreshold = Math.max(1, Math.ceil(effectiveThresholdBytes / AVG_PACKET_BYTES));
|
|
157
|
+
const targets = [];
|
|
158
|
+
if (originatorSocket) targets.push(originatorSocket);
|
|
159
|
+
for (const sock of collectRoomSocketsForPressure(receiver)) {
|
|
160
|
+
if (sock !== originatorSocket) targets.push(sock);
|
|
161
|
+
}
|
|
162
|
+
if (targets.length === 0) return;
|
|
163
|
+
await Promise.all(targets.map((sock) => waitUntilSocketDrained(sock, packetThreshold, isAborted)));
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
emitServerSyncStream,
|
|
167
|
+
emitBroadcastSyncStream,
|
|
168
|
+
emitStreamToTokens,
|
|
169
|
+
buildBroadcastFrame,
|
|
170
|
+
flushPressure
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// src/handleSyncRequest.ts
|
|
175
|
+
import {
|
|
176
|
+
registerSyncAbortController,
|
|
177
|
+
unregisterSyncAbortController
|
|
178
|
+
} from "@luckystack/core";
|
|
179
|
+
var shouldLogDev = () => getProjectConfig2().logging.devLogs;
|
|
180
|
+
var shouldLogStream2 = () => getProjectConfig2().logging.stream;
|
|
181
|
+
var applySyncRateLimits = async ({
|
|
182
|
+
resolvedName,
|
|
183
|
+
token,
|
|
184
|
+
socket,
|
|
185
|
+
user,
|
|
186
|
+
responseIndex,
|
|
187
|
+
buildSyncError,
|
|
188
|
+
preferredLocale
|
|
189
|
+
}) => {
|
|
190
|
+
const config = getProjectConfig2();
|
|
191
|
+
const defaultApiLimit = config.rateLimiting.defaultApiLimit;
|
|
192
|
+
if (defaultApiLimit !== false && defaultApiLimit > 0) {
|
|
193
|
+
const requesterIdentity = token ?? socket.handshake.address ?? "unknown";
|
|
194
|
+
const keyPrefix = token ? "token" : "ip";
|
|
195
|
+
const rateLimitKey = `${keyPrefix}:${requesterIdentity}:sync:${resolvedName}`;
|
|
196
|
+
const { allowed, resetIn } = await checkRateLimit({
|
|
197
|
+
key: rateLimitKey,
|
|
198
|
+
limit: defaultApiLimit,
|
|
199
|
+
windowMs: config.rateLimiting.windowMs
|
|
200
|
+
});
|
|
201
|
+
if (!allowed) {
|
|
202
|
+
void dispatchHook2("rateLimitExceeded", {
|
|
203
|
+
scope: token ? "user" : "route",
|
|
204
|
+
key: rateLimitKey,
|
|
205
|
+
limit: defaultApiLimit,
|
|
206
|
+
windowMs: config.rateLimiting.windowMs,
|
|
207
|
+
count: defaultApiLimit + 1,
|
|
208
|
+
route: resolvedName,
|
|
209
|
+
userId: user?.id
|
|
210
|
+
});
|
|
211
|
+
if (typeof responseIndex === "number") {
|
|
212
|
+
socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
213
|
+
response: {
|
|
214
|
+
status: "error",
|
|
215
|
+
errorCode: "sync.rateLimitExceeded",
|
|
216
|
+
errorParams: [{ key: "seconds", value: resetIn }],
|
|
217
|
+
httpStatus: 429
|
|
218
|
+
},
|
|
219
|
+
preferred: preferredLocale,
|
|
220
|
+
userLanguage: user?.language
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const defaultIpLimit = config.rateLimiting.defaultIpLimit;
|
|
227
|
+
if (defaultIpLimit !== false && defaultIpLimit > 0) {
|
|
228
|
+
const requesterIp = socket.handshake.address ?? "unknown";
|
|
229
|
+
const ipKey = `ip:${requesterIp}:sync:all`;
|
|
230
|
+
const { allowed, resetIn } = await checkRateLimit({
|
|
231
|
+
key: ipKey,
|
|
232
|
+
limit: defaultIpLimit,
|
|
233
|
+
windowMs: config.rateLimiting.windowMs
|
|
234
|
+
});
|
|
235
|
+
if (!allowed) {
|
|
236
|
+
void dispatchHook2("rateLimitExceeded", {
|
|
237
|
+
scope: "ip",
|
|
238
|
+
key: ipKey,
|
|
239
|
+
limit: defaultIpLimit,
|
|
240
|
+
windowMs: config.rateLimiting.windowMs,
|
|
241
|
+
count: defaultIpLimit + 1,
|
|
242
|
+
ip: requesterIp
|
|
243
|
+
});
|
|
244
|
+
if (typeof responseIndex === "number") {
|
|
245
|
+
socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
246
|
+
response: {
|
|
247
|
+
status: "error",
|
|
248
|
+
errorCode: "sync.rateLimitExceeded",
|
|
249
|
+
errorParams: [{ key: "seconds", value: resetIn }],
|
|
250
|
+
httpStatus: 429
|
|
251
|
+
},
|
|
252
|
+
preferred: preferredLocale,
|
|
253
|
+
userLanguage: user?.language
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
};
|
|
261
|
+
async function handleSyncRequest({ msg, socket, token }) {
|
|
262
|
+
const ioInstance = getIoInstance2();
|
|
263
|
+
if (!ioInstance) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (typeof msg != "object") {
|
|
267
|
+
if (shouldLogDev()) {
|
|
268
|
+
getLogger2().warn("sync: socket message was not a json object");
|
|
269
|
+
}
|
|
270
|
+
const normalized = normalizeErrorResponse({
|
|
271
|
+
response: { status: "error", errorCode: "sync.invalidRequest" },
|
|
272
|
+
preferredLocale: extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"])
|
|
273
|
+
});
|
|
274
|
+
return socket.emit(socketEventNames2.sync, {
|
|
275
|
+
status: normalized.status,
|
|
276
|
+
message: normalized.message,
|
|
277
|
+
errorCode: normalized.errorCode,
|
|
278
|
+
errorParams: normalized.errorParams,
|
|
279
|
+
httpStatus: normalized.httpStatus
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const { name, data, cb, receiver: rawReceiver, responseIndex, ignoreSelf } = msg;
|
|
283
|
+
const receiver = typeof rawReceiver === "string" ? rawReceiver.trim() : "";
|
|
284
|
+
const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"]);
|
|
285
|
+
let currentRouteName;
|
|
286
|
+
let currentPerRouteFormatter;
|
|
287
|
+
let currentUserId;
|
|
288
|
+
const buildSyncError = ({
|
|
289
|
+
response,
|
|
290
|
+
preferred,
|
|
291
|
+
userLanguage
|
|
292
|
+
}) => {
|
|
293
|
+
const normalized = normalizeErrorResponse({
|
|
294
|
+
response,
|
|
295
|
+
preferredLocale: preferred,
|
|
296
|
+
userLanguage
|
|
297
|
+
});
|
|
298
|
+
const baseEnvelope = {
|
|
299
|
+
status: normalized.status,
|
|
300
|
+
message: normalized.message,
|
|
301
|
+
errorCode: normalized.errorCode,
|
|
302
|
+
errorParams: normalized.errorParams,
|
|
303
|
+
httpStatus: normalized.httpStatus
|
|
304
|
+
};
|
|
305
|
+
return applyErrorFormatter({
|
|
306
|
+
response: baseEnvelope,
|
|
307
|
+
routeName: currentRouteName ?? "sync/unknown",
|
|
308
|
+
transport: "socket",
|
|
309
|
+
userId: currentUserId,
|
|
310
|
+
perRouteFormatter: currentPerRouteFormatter
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
const ensureSyncErrorShape = (response) => {
|
|
314
|
+
if (typeof response.errorCode === "string" && response.errorCode.trim().length > 0) {
|
|
315
|
+
return response;
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
...response,
|
|
319
|
+
errorCode: "sync.clientRejected"
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
if (!name || !data || typeof name != "string" || typeof data != "object") {
|
|
323
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
324
|
+
response: { status: "error", errorCode: "sync.invalidRequest" },
|
|
325
|
+
preferred: preferredLocale
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
const normalizedData = data;
|
|
329
|
+
const parsedRoute = parseTransportRouteName({ value: name, prefix: "sync" });
|
|
330
|
+
if (parsedRoute.status === "error") {
|
|
331
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
332
|
+
response: {
|
|
333
|
+
status: "error",
|
|
334
|
+
errorCode: "routing.invalidServiceRouteName",
|
|
335
|
+
errorParams: [{ key: "name", value: name }]
|
|
336
|
+
},
|
|
337
|
+
preferred: preferredLocale
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
340
|
+
const resolvedName = parsedRoute.normalizedFullName;
|
|
341
|
+
currentRouteName = resolvedName;
|
|
342
|
+
if (!cb || typeof cb != "string") {
|
|
343
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
344
|
+
response: { status: "error", errorCode: "sync.invalidCallback" },
|
|
345
|
+
preferred: preferredLocale
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
if (!receiver) {
|
|
349
|
+
if (shouldLogDev()) {
|
|
350
|
+
getLogger2().warn("sync: missing receiver / roomCode", { receiver });
|
|
351
|
+
}
|
|
352
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
353
|
+
response: { status: "error", errorCode: "sync.missingReceiver" },
|
|
354
|
+
preferred: preferredLocale
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
if (shouldLogDev()) {
|
|
358
|
+
getLogger2().debug(`sync: ${resolvedName} called`, { sync: resolvedName });
|
|
359
|
+
}
|
|
360
|
+
const user = await getSession(token);
|
|
361
|
+
currentUserId = user?.id;
|
|
362
|
+
const { syncObject, functionsObject } = await getRuntimeSyncMaps();
|
|
363
|
+
const abortController = new AbortController();
|
|
364
|
+
const abortKey = registerSyncAbortController(socket.id, cb, abortController);
|
|
365
|
+
const onSocketDisconnect = () => {
|
|
366
|
+
abortController.abort();
|
|
367
|
+
};
|
|
368
|
+
socket.once(socketEventNames2.disconnect, onSocketDisconnect);
|
|
369
|
+
let cleanupDone = false;
|
|
370
|
+
const cleanupRequest = () => {
|
|
371
|
+
if (cleanupDone) return;
|
|
372
|
+
cleanupDone = true;
|
|
373
|
+
socket.off(socketEventNames2.disconnect, onSocketDisconnect);
|
|
374
|
+
unregisterSyncAbortController(abortKey);
|
|
375
|
+
};
|
|
376
|
+
if (!syncObject[`${resolvedName}_client`] && !syncObject[`${resolvedName}_server`]) {
|
|
377
|
+
if (shouldLogDev()) {
|
|
378
|
+
getLogger2().warn(`sync: ${name} has no _client or _server file`, { sync: name });
|
|
379
|
+
}
|
|
380
|
+
cleanupRequest();
|
|
381
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
382
|
+
response: { status: "error", errorCode: "sync.notFound" },
|
|
383
|
+
preferred: preferredLocale,
|
|
384
|
+
userLanguage: user?.language
|
|
385
|
+
}));
|
|
386
|
+
}
|
|
387
|
+
const { emitServerSyncStream, emitBroadcastSyncStream, emitStreamToTokens, flushPressure } = buildSyncStreamEmitters({
|
|
388
|
+
cb,
|
|
389
|
+
receiver,
|
|
390
|
+
resolvedName,
|
|
391
|
+
logLabel: "sync",
|
|
392
|
+
signal: abortController.signal,
|
|
393
|
+
originatorSocket: socket,
|
|
394
|
+
emitOriginatorChunk: (payload) => {
|
|
395
|
+
if (typeof responseIndex !== "number") return;
|
|
396
|
+
socket.emit(buildSyncProgressEventName(responseIndex), payload);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
const serverSyncEntry = syncObject[`${resolvedName}_server`];
|
|
400
|
+
currentPerRouteFormatter = serverSyncEntry?.errorFormatter;
|
|
401
|
+
if (serverSyncEntry) {
|
|
402
|
+
const { auth } = serverSyncEntry;
|
|
403
|
+
if (auth.login && !user?.id) {
|
|
404
|
+
if (shouldLogDev()) {
|
|
405
|
+
getLogger2().warn(`sync: ${resolvedName} requires login`, { sync: resolvedName });
|
|
406
|
+
}
|
|
407
|
+
cleanupRequest();
|
|
408
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
409
|
+
response: { status: "error", errorCode: "auth.required" },
|
|
410
|
+
preferred: preferredLocale
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
const validationResult = validateRequest({ auth, user });
|
|
414
|
+
if (validationResult.status === "error") {
|
|
415
|
+
if (shouldLogDev()) {
|
|
416
|
+
getLogger2().warn(`sync: auth failed for ${resolvedName}`, { sync: resolvedName, errorCode: validationResult.errorCode });
|
|
417
|
+
}
|
|
418
|
+
cleanupRequest();
|
|
419
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
420
|
+
response: {
|
|
421
|
+
status: "error",
|
|
422
|
+
errorCode: validationResult.errorCode || "auth.forbidden",
|
|
423
|
+
errorParams: validationResult.errorParams,
|
|
424
|
+
httpStatus: validationResult.httpStatus
|
|
425
|
+
},
|
|
426
|
+
preferred: preferredLocale,
|
|
427
|
+
userLanguage: user?.language
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const preAuthorizeResult = await dispatchHook2("preSyncAuthorize", {
|
|
432
|
+
routeName: resolvedName,
|
|
433
|
+
data: normalizedData,
|
|
434
|
+
user,
|
|
435
|
+
receiver,
|
|
436
|
+
transport: "socket"
|
|
437
|
+
});
|
|
438
|
+
if (preAuthorizeResult.stopped) {
|
|
439
|
+
if (shouldLogDev()) {
|
|
440
|
+
getLogger2().warn(`sync: preSyncAuthorize stopped ${resolvedName}`, { sync: resolvedName, errorCode: preAuthorizeResult.signal.errorCode });
|
|
441
|
+
}
|
|
442
|
+
cleanupRequest();
|
|
443
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
444
|
+
response: {
|
|
445
|
+
status: "error",
|
|
446
|
+
errorCode: preAuthorizeResult.signal.errorCode,
|
|
447
|
+
httpStatus: preAuthorizeResult.signal.httpStatus
|
|
448
|
+
},
|
|
449
|
+
preferred: preferredLocale,
|
|
450
|
+
userLanguage: user?.language
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
void dispatchHook2("postSyncAuthorize", {
|
|
454
|
+
routeName: resolvedName,
|
|
455
|
+
data: normalizedData,
|
|
456
|
+
user,
|
|
457
|
+
receiver,
|
|
458
|
+
transport: "socket"
|
|
459
|
+
});
|
|
460
|
+
const rateLimitOk = await applySyncRateLimits({
|
|
461
|
+
resolvedName,
|
|
462
|
+
token,
|
|
463
|
+
socket,
|
|
464
|
+
user,
|
|
465
|
+
responseIndex,
|
|
466
|
+
buildSyncError,
|
|
467
|
+
preferredLocale
|
|
468
|
+
});
|
|
469
|
+
if (!rateLimitOk) {
|
|
470
|
+
cleanupRequest();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
let serverOutput = {};
|
|
474
|
+
if (serverSyncEntry) {
|
|
475
|
+
const { main: serverMain, inputType, inputTypeFilePath } = serverSyncEntry;
|
|
476
|
+
const inputValidation = await validateInputByType({
|
|
477
|
+
typeText: inputType,
|
|
478
|
+
value: normalizedData,
|
|
479
|
+
rootKey: "clientInput",
|
|
480
|
+
filePath: inputTypeFilePath
|
|
481
|
+
});
|
|
482
|
+
if (inputValidation.status === "error") {
|
|
483
|
+
cleanupRequest();
|
|
484
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
485
|
+
response: {
|
|
486
|
+
status: "error",
|
|
487
|
+
errorCode: "sync.invalidInputType",
|
|
488
|
+
errorParams: [{ key: "message", value: inputValidation.message }]
|
|
489
|
+
},
|
|
490
|
+
preferred: preferredLocale,
|
|
491
|
+
userLanguage: user?.language
|
|
492
|
+
}));
|
|
493
|
+
}
|
|
494
|
+
const [serverSyncError, serverSyncResult] = await tryCatch(
|
|
495
|
+
async () => await serverMain({
|
|
496
|
+
clientInput: normalizedData,
|
|
497
|
+
user,
|
|
498
|
+
functions: functionsObject,
|
|
499
|
+
roomCode: receiver,
|
|
500
|
+
stream: emitServerSyncStream,
|
|
501
|
+
broadcastStream: emitBroadcastSyncStream,
|
|
502
|
+
streamTo: emitStreamToTokens,
|
|
503
|
+
abortSignal: abortController.signal,
|
|
504
|
+
flushPressure
|
|
505
|
+
}),
|
|
506
|
+
void 0,
|
|
507
|
+
{
|
|
508
|
+
handler: "handleSyncRequest",
|
|
509
|
+
sync: resolvedName,
|
|
510
|
+
stage: "server",
|
|
511
|
+
userId: user?.id,
|
|
512
|
+
receiver,
|
|
513
|
+
transport: "socket"
|
|
514
|
+
}
|
|
515
|
+
);
|
|
516
|
+
if (serverSyncError) {
|
|
517
|
+
if (shouldLogDev()) {
|
|
518
|
+
getLogger2().error(`sync: server execution failed for ${resolvedName}`, serverSyncError, { sync: resolvedName });
|
|
519
|
+
}
|
|
520
|
+
cleanupRequest();
|
|
521
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
522
|
+
response: { status: "error", errorCode: "sync.serverExecutionFailed" },
|
|
523
|
+
preferred: preferredLocale,
|
|
524
|
+
userLanguage: user?.language
|
|
525
|
+
}));
|
|
526
|
+
} else if (serverSyncResult?.status == "error") {
|
|
527
|
+
const normalizedServerError = buildSyncError({
|
|
528
|
+
response: serverSyncResult,
|
|
529
|
+
preferred: preferredLocale,
|
|
530
|
+
userLanguage: user?.language
|
|
531
|
+
});
|
|
532
|
+
if (shouldLogDev()) {
|
|
533
|
+
getLogger2().warn(`sync: server returned error for ${resolvedName}`, { sync: resolvedName, message: normalizedServerError.message });
|
|
534
|
+
}
|
|
535
|
+
cleanupRequest();
|
|
536
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), normalizedServerError);
|
|
537
|
+
} else if (serverSyncResult?.status !== "success") {
|
|
538
|
+
if (shouldLogDev()) {
|
|
539
|
+
getLogger2().warn(`sync: ${resolvedName}_server returned invalid response`, { sync: resolvedName });
|
|
540
|
+
}
|
|
541
|
+
cleanupRequest();
|
|
542
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
543
|
+
response: { status: "error", errorCode: "sync.invalidServerResponse" },
|
|
544
|
+
preferred: preferredLocale,
|
|
545
|
+
userLanguage: user?.language
|
|
546
|
+
}));
|
|
547
|
+
} else if (serverSyncResult?.status == "success") {
|
|
548
|
+
serverOutput = serverSyncResult;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const sockets = receiver === "all" ? await ioInstance.fetchSockets() : await ioInstance.in(receiver).fetchSockets();
|
|
552
|
+
if (sockets.length === 0) {
|
|
553
|
+
if (shouldLogDev()) {
|
|
554
|
+
getLogger2().warn("sync: no sockets found for receiver", { receiver, sync: resolvedName });
|
|
555
|
+
}
|
|
556
|
+
cleanupRequest();
|
|
557
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
558
|
+
response: { status: "error", errorCode: "sync.noReceiversFound" },
|
|
559
|
+
preferred: preferredLocale,
|
|
560
|
+
userLanguage: user?.language
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
const fanoutPayload = {
|
|
564
|
+
routeName: resolvedName,
|
|
565
|
+
data: normalizedData,
|
|
566
|
+
user,
|
|
567
|
+
receiver,
|
|
568
|
+
serverOutput,
|
|
569
|
+
transport: "socket",
|
|
570
|
+
recipientCount: 0
|
|
571
|
+
};
|
|
572
|
+
const preFanoutResult = await dispatchHook2("preSyncFanout", fanoutPayload);
|
|
573
|
+
if (preFanoutResult.stopped) {
|
|
574
|
+
cleanupRequest();
|
|
575
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
|
|
576
|
+
response: {
|
|
577
|
+
status: "error",
|
|
578
|
+
errorCode: preFanoutResult.signal.errorCode,
|
|
579
|
+
httpStatus: preFanoutResult.signal.httpStatus
|
|
580
|
+
},
|
|
581
|
+
preferred: preferredLocale,
|
|
582
|
+
userLanguage: user?.language
|
|
583
|
+
}));
|
|
584
|
+
}
|
|
585
|
+
const { fanoutYieldEvery, fanoutYieldMs } = getProjectConfig2().sync;
|
|
586
|
+
let recipientCount = 0;
|
|
587
|
+
let tempCount = 1;
|
|
588
|
+
for (const tempSocket of sockets) {
|
|
589
|
+
tempCount++;
|
|
590
|
+
if (tempCount % fanoutYieldEvery === 0) {
|
|
591
|
+
await new Promise((resolve) => setTimeout(resolve, fanoutYieldMs));
|
|
592
|
+
}
|
|
593
|
+
const tempToken = extractTokenFromSocket(tempSocket);
|
|
594
|
+
if (ignoreSelf && typeof ignoreSelf == "boolean" && token == tempToken) {
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
recipientCount++;
|
|
598
|
+
if (syncObject[`${resolvedName}_client`]) {
|
|
599
|
+
const clientSyncHandler = syncObject[`${resolvedName}_client`];
|
|
600
|
+
const emitClientSyncStream = (payload = {}) => {
|
|
601
|
+
if (shouldLogStream2()) {
|
|
602
|
+
getLogger2().debug(`sync: ${resolvedName} client stream`, { payload });
|
|
603
|
+
}
|
|
604
|
+
tempSocket.emit(socketEventNames2.sync, {
|
|
605
|
+
...payload,
|
|
606
|
+
cb,
|
|
607
|
+
fullName: resolvedName,
|
|
608
|
+
status: "stream"
|
|
609
|
+
});
|
|
610
|
+
};
|
|
611
|
+
const [clientSyncError, clientSyncResult] = await tryCatch(
|
|
612
|
+
async () => await clientSyncHandler({ clientInput: normalizedData, token: tempToken, functions: functionsObject, serverOutput, roomCode: receiver, stream: emitClientSyncStream }),
|
|
613
|
+
void 0,
|
|
614
|
+
{
|
|
615
|
+
handler: "handleSyncRequest",
|
|
616
|
+
sync: resolvedName,
|
|
617
|
+
stage: "client",
|
|
618
|
+
sourceUserId: user?.id,
|
|
619
|
+
targetToken: tempToken,
|
|
620
|
+
receiver,
|
|
621
|
+
transport: "socket"
|
|
622
|
+
}
|
|
623
|
+
);
|
|
624
|
+
if (clientSyncError) {
|
|
625
|
+
tempSocket.emit(socketEventNames2.sync, {
|
|
626
|
+
cb,
|
|
627
|
+
fullName: resolvedName,
|
|
628
|
+
...buildSyncError({
|
|
629
|
+
response: { status: "error", errorCode: "sync.clientExecutionFailed" },
|
|
630
|
+
preferred: extractLanguageFromHeader(tempSocket.handshake.headers["x-language"]) || extractLanguageFromHeader(tempSocket.handshake.headers["accept-language"])
|
|
631
|
+
})
|
|
632
|
+
});
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (clientSyncResult?.status == "error") {
|
|
636
|
+
tempSocket.emit(socketEventNames2.sync, {
|
|
637
|
+
cb,
|
|
638
|
+
fullName: resolvedName,
|
|
639
|
+
...buildSyncError({
|
|
640
|
+
response: ensureSyncErrorShape(clientSyncResult),
|
|
641
|
+
preferred: extractLanguageFromHeader(tempSocket.handshake.headers["x-language"]) || extractLanguageFromHeader(tempSocket.handshake.headers["accept-language"])
|
|
642
|
+
})
|
|
643
|
+
});
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (clientSyncResult?.status !== "success") {
|
|
647
|
+
tempSocket.emit(socketEventNames2.sync, {
|
|
648
|
+
cb,
|
|
649
|
+
fullName: resolvedName,
|
|
650
|
+
...buildSyncError({
|
|
651
|
+
response: { status: "error", errorCode: "sync.invalidClientResponse" },
|
|
652
|
+
preferred: extractLanguageFromHeader(tempSocket.handshake.headers["x-language"]) || extractLanguageFromHeader(tempSocket.handshake.headers["accept-language"])
|
|
653
|
+
})
|
|
654
|
+
});
|
|
655
|
+
continue;
|
|
656
|
+
} else if (clientSyncResult?.status == "success") {
|
|
657
|
+
const result = {
|
|
658
|
+
cb,
|
|
659
|
+
fullName: resolvedName,
|
|
660
|
+
serverOutput,
|
|
661
|
+
clientOutput: clientSyncResult,
|
|
662
|
+
// Return from _client file (success only)
|
|
663
|
+
message: clientSyncResult.message || `${resolvedName} sync success`,
|
|
664
|
+
status: "success"
|
|
665
|
+
};
|
|
666
|
+
if (shouldLogDev()) {
|
|
667
|
+
getLogger2().debug(`sync: ${resolvedName} client success`, { result });
|
|
668
|
+
}
|
|
669
|
+
tempSocket.emit(socketEventNames2.sync, result);
|
|
670
|
+
}
|
|
671
|
+
} else {
|
|
672
|
+
const result = {
|
|
673
|
+
cb,
|
|
674
|
+
fullName: resolvedName,
|
|
675
|
+
serverOutput,
|
|
676
|
+
clientOutput: {},
|
|
677
|
+
// No client file, so empty output
|
|
678
|
+
message: `${resolvedName} sync success`,
|
|
679
|
+
status: "success"
|
|
680
|
+
};
|
|
681
|
+
if (shouldLogDev()) {
|
|
682
|
+
getLogger2().debug(`sync: ${resolvedName} server-only success`, { result });
|
|
683
|
+
}
|
|
684
|
+
tempSocket.emit(socketEventNames2.sync, result);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
fanoutPayload.recipientCount = recipientCount;
|
|
688
|
+
await dispatchHook2("postSyncFanout", fanoutPayload);
|
|
689
|
+
cleanupRequest();
|
|
690
|
+
return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), {
|
|
691
|
+
status: "success",
|
|
692
|
+
message: `sync ${resolvedName} success`,
|
|
693
|
+
result: serverOutput
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/handleHttpSyncRequest.ts
|
|
698
|
+
import { getSession as getSession2 } from "@luckystack/login";
|
|
699
|
+
import { getProjectConfig as getProjectConfig3 } from "@luckystack/core";
|
|
700
|
+
import { getRuntimeSyncMaps as getRuntimeSyncMapsFromSource } from "@luckystack/core";
|
|
701
|
+
import {
|
|
702
|
+
validateRequest as validateRequest2,
|
|
703
|
+
extractTokenFromSocket as extractTokenFromSocket2,
|
|
704
|
+
getIoInstance as getIoInstance3,
|
|
705
|
+
tryCatch as tryCatch2,
|
|
706
|
+
parseTransportRouteName as parseTransportRouteName2,
|
|
707
|
+
checkRateLimit as checkRateLimit2,
|
|
708
|
+
socketEventNames as socketEventNames3,
|
|
709
|
+
validateInputByType as validateInputByType2,
|
|
710
|
+
dispatchHook as dispatchHook3,
|
|
711
|
+
getLogger as getLogger3
|
|
712
|
+
} from "@luckystack/core";
|
|
713
|
+
import { extractLanguageFromHeader as extractLanguageFromHeader2, normalizeErrorResponse as normalizeErrorResponse2, applyErrorFormatter as applyErrorFormatter2 } from "@luckystack/core";
|
|
714
|
+
var shouldLogDev2 = () => getProjectConfig3().logging.devLogs;
|
|
715
|
+
var shouldLogStream3 = () => getProjectConfig3().logging.stream;
|
|
716
|
+
var applyHttpSyncRateLimits = async ({
|
|
717
|
+
resolvedName,
|
|
718
|
+
token,
|
|
719
|
+
requesterIp,
|
|
720
|
+
user,
|
|
721
|
+
buildSyncError,
|
|
722
|
+
preferredLocale
|
|
723
|
+
}) => {
|
|
724
|
+
const config = getProjectConfig3();
|
|
725
|
+
const effectiveSyncLimit = config.rateLimiting.defaultApiLimit;
|
|
726
|
+
if (effectiveSyncLimit !== false && effectiveSyncLimit > 0) {
|
|
727
|
+
const requesterIdentity = token ?? requesterIp ?? "anonymous";
|
|
728
|
+
const keyPrefix = token ? "token" : "ip";
|
|
729
|
+
const rateLimitKey = `${keyPrefix}:${requesterIdentity}:sync:${resolvedName}`;
|
|
730
|
+
const { allowed, resetIn } = await checkRateLimit2({
|
|
731
|
+
key: rateLimitKey,
|
|
732
|
+
limit: effectiveSyncLimit,
|
|
733
|
+
windowMs: config.rateLimiting.windowMs
|
|
734
|
+
});
|
|
735
|
+
if (!allowed) {
|
|
736
|
+
void dispatchHook3("rateLimitExceeded", {
|
|
737
|
+
scope: token ? "user" : "route",
|
|
738
|
+
key: rateLimitKey,
|
|
739
|
+
limit: effectiveSyncLimit,
|
|
740
|
+
windowMs: config.rateLimiting.windowMs,
|
|
741
|
+
count: effectiveSyncLimit + 1,
|
|
742
|
+
route: resolvedName,
|
|
743
|
+
userId: user?.id
|
|
744
|
+
});
|
|
745
|
+
return buildSyncError({
|
|
746
|
+
response: {
|
|
747
|
+
status: "error",
|
|
748
|
+
errorCode: "sync.rateLimitExceeded",
|
|
749
|
+
errorParams: [{ key: "seconds", value: resetIn }],
|
|
750
|
+
httpStatus: 429
|
|
751
|
+
},
|
|
752
|
+
preferred: preferredLocale,
|
|
753
|
+
userLanguage: user?.language
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
const defaultIpLimit = config.rateLimiting.defaultIpLimit;
|
|
758
|
+
const requesterIsLoopback = process.env.NODE_ENV !== "production" && (requesterIp === "127.0.0.1" || requesterIp === "::1" || requesterIp === "::ffff:127.0.0.1" || typeof requesterIp === "string" && requesterIp.startsWith("127."));
|
|
759
|
+
if (!requesterIsLoopback && defaultIpLimit !== false && defaultIpLimit > 0) {
|
|
760
|
+
const ipBucket = requesterIp ?? "unknown";
|
|
761
|
+
const ipKey = `ip:${ipBucket}:sync:all`;
|
|
762
|
+
const { allowed, resetIn } = await checkRateLimit2({
|
|
763
|
+
key: ipKey,
|
|
764
|
+
limit: defaultIpLimit,
|
|
765
|
+
windowMs: config.rateLimiting.windowMs
|
|
766
|
+
});
|
|
767
|
+
if (!allowed) {
|
|
768
|
+
void dispatchHook3("rateLimitExceeded", {
|
|
769
|
+
scope: "ip",
|
|
770
|
+
key: ipKey,
|
|
771
|
+
limit: defaultIpLimit,
|
|
772
|
+
windowMs: config.rateLimiting.windowMs,
|
|
773
|
+
count: defaultIpLimit + 1,
|
|
774
|
+
ip: ipBucket
|
|
775
|
+
});
|
|
776
|
+
return buildSyncError({
|
|
777
|
+
response: {
|
|
778
|
+
status: "error",
|
|
779
|
+
errorCode: "sync.rateLimitExceeded",
|
|
780
|
+
errorParams: [{ key: "seconds", value: resetIn }],
|
|
781
|
+
httpStatus: 429
|
|
782
|
+
},
|
|
783
|
+
preferred: preferredLocale,
|
|
784
|
+
userLanguage: user?.language
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return null;
|
|
789
|
+
};
|
|
790
|
+
async function handleHttpSyncRequest({
|
|
791
|
+
name,
|
|
792
|
+
cb,
|
|
793
|
+
data,
|
|
794
|
+
receiver,
|
|
795
|
+
ignoreSelf,
|
|
796
|
+
token,
|
|
797
|
+
requesterIp,
|
|
798
|
+
xLanguageHeader,
|
|
799
|
+
acceptLanguageHeader,
|
|
800
|
+
stream,
|
|
801
|
+
abortSignal
|
|
802
|
+
}) {
|
|
803
|
+
if (shouldLogDev2()) {
|
|
804
|
+
getLogger3().debug(`http sync: ${name} called`);
|
|
805
|
+
}
|
|
806
|
+
const effectiveAbortSignal = abortSignal ?? new AbortController().signal;
|
|
807
|
+
const normalizedReceiver = typeof receiver === "string" ? receiver.trim() : "";
|
|
808
|
+
const preferredLocale = extractLanguageFromHeader2(xLanguageHeader) || extractLanguageFromHeader2(acceptLanguageHeader);
|
|
809
|
+
const user = await getSession2(token);
|
|
810
|
+
let currentRouteName;
|
|
811
|
+
let currentPerRouteFormatter;
|
|
812
|
+
const buildSyncError = ({
|
|
813
|
+
response,
|
|
814
|
+
preferred,
|
|
815
|
+
userLanguage
|
|
816
|
+
}) => {
|
|
817
|
+
const normalized = normalizeErrorResponse2({
|
|
818
|
+
response,
|
|
819
|
+
preferredLocale: preferred,
|
|
820
|
+
userLanguage
|
|
821
|
+
});
|
|
822
|
+
const baseEnvelope = {
|
|
823
|
+
status: normalized.status,
|
|
824
|
+
message: normalized.message,
|
|
825
|
+
errorCode: normalized.errorCode,
|
|
826
|
+
errorParams: normalized.errorParams,
|
|
827
|
+
httpStatus: normalized.httpStatus
|
|
828
|
+
};
|
|
829
|
+
return applyErrorFormatter2({
|
|
830
|
+
response: baseEnvelope,
|
|
831
|
+
routeName: currentRouteName ?? "sync/unknown",
|
|
832
|
+
transport: "http",
|
|
833
|
+
userId: user?.id,
|
|
834
|
+
perRouteFormatter: currentPerRouteFormatter
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
const ensureSyncErrorShape = (response) => {
|
|
838
|
+
if (typeof response.errorCode === "string" && response.errorCode.trim().length > 0) {
|
|
839
|
+
return response;
|
|
840
|
+
}
|
|
841
|
+
return {
|
|
842
|
+
...response,
|
|
843
|
+
errorCode: "sync.clientRejected"
|
|
844
|
+
};
|
|
845
|
+
};
|
|
846
|
+
const ioInstance = getIoInstance3();
|
|
847
|
+
const [bodyError, bodyResult] = await tryCatch2(async () => {
|
|
848
|
+
if (!ioInstance) {
|
|
849
|
+
return buildSyncError({
|
|
850
|
+
response: { status: "error", errorCode: "sync.ioUnavailable" },
|
|
851
|
+
preferred: preferredLocale,
|
|
852
|
+
userLanguage: user?.language
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
if (!name || typeof name !== "string") {
|
|
856
|
+
return buildSyncError({
|
|
857
|
+
response: { status: "error", errorCode: "sync.invalidRequest" },
|
|
858
|
+
preferred: preferredLocale,
|
|
859
|
+
userLanguage: user?.language
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
const parsedRoute = parseTransportRouteName2({ value: name, prefix: "sync" });
|
|
863
|
+
if (parsedRoute.status === "error") {
|
|
864
|
+
return buildSyncError({
|
|
865
|
+
response: {
|
|
866
|
+
status: "error",
|
|
867
|
+
errorCode: "routing.invalidServiceRouteName",
|
|
868
|
+
errorParams: [{ key: "name", value: name }]
|
|
869
|
+
},
|
|
870
|
+
preferred: preferredLocale,
|
|
871
|
+
userLanguage: user?.language
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
const resolvedName = parsedRoute.normalizedFullName;
|
|
875
|
+
currentRouteName = resolvedName;
|
|
876
|
+
const callbackName = typeof cb === "string" && cb.trim().length > 0 ? cb.trim() : `${parsedRoute.serviceRoute.normalizedRouteName}/${parsedRoute.version}`;
|
|
877
|
+
if (!normalizedReceiver) {
|
|
878
|
+
return buildSyncError({
|
|
879
|
+
response: { status: "error", errorCode: "sync.missingReceiver" },
|
|
880
|
+
preferred: preferredLocale,
|
|
881
|
+
userLanguage: user?.language
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
const { syncObject, functionsObject } = await getRuntimeSyncMapsFromSource();
|
|
885
|
+
if (!syncObject[`${resolvedName}_client`] && !syncObject[`${resolvedName}_server`]) {
|
|
886
|
+
return buildSyncError({
|
|
887
|
+
response: { status: "error", errorCode: "sync.notFound" },
|
|
888
|
+
preferred: preferredLocale,
|
|
889
|
+
userLanguage: user?.language
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
const serverSyncEntry = syncObject[`${resolvedName}_server`];
|
|
893
|
+
currentPerRouteFormatter = serverSyncEntry?.errorFormatter;
|
|
894
|
+
if (serverSyncEntry) {
|
|
895
|
+
const { auth } = serverSyncEntry;
|
|
896
|
+
if (auth.login && !user?.id) {
|
|
897
|
+
return buildSyncError({
|
|
898
|
+
response: { status: "error", errorCode: "auth.required" },
|
|
899
|
+
preferred: preferredLocale
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
const validationResult = validateRequest2({ auth, user });
|
|
903
|
+
if (validationResult.status === "error") {
|
|
904
|
+
return buildSyncError({
|
|
905
|
+
response: {
|
|
906
|
+
status: "error",
|
|
907
|
+
errorCode: validationResult.errorCode || "auth.forbidden",
|
|
908
|
+
errorParams: validationResult.errorParams,
|
|
909
|
+
httpStatus: validationResult.httpStatus
|
|
910
|
+
},
|
|
911
|
+
preferred: preferredLocale,
|
|
912
|
+
userLanguage: user?.language
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const preAuthorizeResult = await dispatchHook3("preSyncAuthorize", {
|
|
917
|
+
routeName: resolvedName,
|
|
918
|
+
data,
|
|
919
|
+
user,
|
|
920
|
+
receiver: normalizedReceiver,
|
|
921
|
+
transport: "http"
|
|
922
|
+
});
|
|
923
|
+
if (preAuthorizeResult.stopped) {
|
|
924
|
+
return buildSyncError({
|
|
925
|
+
response: {
|
|
926
|
+
status: "error",
|
|
927
|
+
errorCode: preAuthorizeResult.signal.errorCode,
|
|
928
|
+
httpStatus: preAuthorizeResult.signal.httpStatus
|
|
929
|
+
},
|
|
930
|
+
preferred: preferredLocale,
|
|
931
|
+
userLanguage: user?.language
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
const rateLimitResult = await applyHttpSyncRateLimits({
|
|
935
|
+
resolvedName,
|
|
936
|
+
token,
|
|
937
|
+
requesterIp,
|
|
938
|
+
user,
|
|
939
|
+
buildSyncError,
|
|
940
|
+
preferredLocale
|
|
941
|
+
});
|
|
942
|
+
if (rateLimitResult) return rateLimitResult;
|
|
943
|
+
let serverOutput = {};
|
|
944
|
+
if (serverSyncEntry) {
|
|
945
|
+
const { main: serverMain, inputType, inputTypeFilePath } = serverSyncEntry;
|
|
946
|
+
const { emitServerSyncStream, emitBroadcastSyncStream, emitStreamToTokens, flushPressure } = buildSyncStreamEmitters({
|
|
947
|
+
cb,
|
|
948
|
+
receiver: normalizedReceiver,
|
|
949
|
+
resolvedName,
|
|
950
|
+
logLabel: "http sync",
|
|
951
|
+
signal: effectiveAbortSignal,
|
|
952
|
+
//? No originatorSocket for HTTP/SSE — `flushPressure` falls back
|
|
953
|
+
//? to room-socket measurement only. SSE backpressure is the
|
|
954
|
+
//? caller's responsibility (Node's `res.write` returns a bool).
|
|
955
|
+
//? Originator chunks travel back via SSE; broadcast / targeted
|
|
956
|
+
//? chunks still flow over Socket.io to recipients in the receiver room.
|
|
957
|
+
emitOriginatorChunk: (payload) => {
|
|
958
|
+
stream?.(payload);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
const inputValidation = await validateInputByType2({
|
|
962
|
+
typeText: inputType,
|
|
963
|
+
value: data,
|
|
964
|
+
rootKey: "clientInput",
|
|
965
|
+
filePath: inputTypeFilePath
|
|
966
|
+
});
|
|
967
|
+
if (inputValidation.status === "error") {
|
|
968
|
+
return buildSyncError({
|
|
969
|
+
response: {
|
|
970
|
+
status: "error",
|
|
971
|
+
errorCode: "sync.invalidInputType",
|
|
972
|
+
errorParams: [{ key: "message", value: inputValidation.message }]
|
|
973
|
+
},
|
|
974
|
+
preferred: preferredLocale,
|
|
975
|
+
userLanguage: user?.language
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
const [serverSyncError, serverSyncResult] = await tryCatch2(
|
|
979
|
+
async () => await serverMain({
|
|
980
|
+
clientInput: data,
|
|
981
|
+
user,
|
|
982
|
+
functions: functionsObject,
|
|
983
|
+
roomCode: normalizedReceiver,
|
|
984
|
+
stream: emitServerSyncStream,
|
|
985
|
+
broadcastStream: emitBroadcastSyncStream,
|
|
986
|
+
streamTo: emitStreamToTokens,
|
|
987
|
+
abortSignal: effectiveAbortSignal,
|
|
988
|
+
flushPressure
|
|
989
|
+
}),
|
|
990
|
+
void 0,
|
|
991
|
+
{
|
|
992
|
+
handler: "handleHttpSyncRequest",
|
|
993
|
+
sync: resolvedName,
|
|
994
|
+
stage: "server",
|
|
995
|
+
userId: user?.id,
|
|
996
|
+
receiver,
|
|
997
|
+
transport: "http"
|
|
998
|
+
}
|
|
999
|
+
);
|
|
1000
|
+
if (serverSyncError) {
|
|
1001
|
+
return buildSyncError({
|
|
1002
|
+
response: { status: "error", errorCode: "sync.serverExecutionFailed" },
|
|
1003
|
+
preferred: preferredLocale,
|
|
1004
|
+
userLanguage: user?.language
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
if (serverSyncResult?.status == "error") {
|
|
1008
|
+
return buildSyncError({
|
|
1009
|
+
response: serverSyncResult,
|
|
1010
|
+
preferred: preferredLocale,
|
|
1011
|
+
userLanguage: user?.language
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
if (serverSyncResult?.status !== "success") {
|
|
1015
|
+
return buildSyncError({
|
|
1016
|
+
response: { status: "error", errorCode: "sync.invalidServerResponse" },
|
|
1017
|
+
preferred: preferredLocale,
|
|
1018
|
+
userLanguage: user?.language
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
serverOutput = serverSyncResult;
|
|
1022
|
+
}
|
|
1023
|
+
const fanoutPayload = {
|
|
1024
|
+
routeName: resolvedName,
|
|
1025
|
+
data,
|
|
1026
|
+
user,
|
|
1027
|
+
receiver: normalizedReceiver,
|
|
1028
|
+
serverOutput,
|
|
1029
|
+
transport: "http",
|
|
1030
|
+
recipientCount: 0
|
|
1031
|
+
};
|
|
1032
|
+
await dispatchHook3("preSyncFanout", fanoutPayload);
|
|
1033
|
+
const sockets = receiver === "all" ? await ioInstance.fetchSockets() : await ioInstance.in(normalizedReceiver).fetchSockets();
|
|
1034
|
+
let recipientCount = 0;
|
|
1035
|
+
for (const tempSocket of sockets) {
|
|
1036
|
+
const tempToken = extractTokenFromSocket2(tempSocket);
|
|
1037
|
+
if (ignoreSelf && token && token === tempToken) {
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
if (syncObject[`${resolvedName}_client`]) {
|
|
1041
|
+
const clientSyncHandler = syncObject[`${resolvedName}_client`];
|
|
1042
|
+
const emitClientSyncStream = (payload = {}) => {
|
|
1043
|
+
if (shouldLogStream3()) {
|
|
1044
|
+
getLogger3().debug(`http sync: ${resolvedName} client stream`, { payload });
|
|
1045
|
+
}
|
|
1046
|
+
tempSocket.emit(socketEventNames3.sync, {
|
|
1047
|
+
...payload,
|
|
1048
|
+
cb: callbackName,
|
|
1049
|
+
fullName: resolvedName,
|
|
1050
|
+
status: "stream"
|
|
1051
|
+
});
|
|
1052
|
+
};
|
|
1053
|
+
const [clientSyncError, clientSyncResult] = await tryCatch2(
|
|
1054
|
+
async () => await clientSyncHandler({ clientInput: data, token: tempToken, functions: functionsObject, serverOutput, roomCode: normalizedReceiver, stream: emitClientSyncStream }),
|
|
1055
|
+
void 0,
|
|
1056
|
+
{
|
|
1057
|
+
handler: "handleHttpSyncRequest",
|
|
1058
|
+
sync: resolvedName,
|
|
1059
|
+
stage: "client",
|
|
1060
|
+
sourceUserId: user?.id,
|
|
1061
|
+
targetToken: tempToken,
|
|
1062
|
+
receiver,
|
|
1063
|
+
transport: "http"
|
|
1064
|
+
}
|
|
1065
|
+
);
|
|
1066
|
+
if (clientSyncError) {
|
|
1067
|
+
tempSocket.emit(socketEventNames3.sync, {
|
|
1068
|
+
cb: callbackName,
|
|
1069
|
+
fullName: resolvedName,
|
|
1070
|
+
...buildSyncError({
|
|
1071
|
+
response: { status: "error", errorCode: "sync.clientExecutionFailed" },
|
|
1072
|
+
preferred: extractLanguageFromHeader2(tempSocket.handshake.headers["accept-language"] || tempSocket.handshake.headers["x-language"])
|
|
1073
|
+
})
|
|
1074
|
+
});
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
if (clientSyncResult?.status === "error") {
|
|
1078
|
+
tempSocket.emit(socketEventNames3.sync, {
|
|
1079
|
+
cb: callbackName,
|
|
1080
|
+
fullName: resolvedName,
|
|
1081
|
+
...buildSyncError({
|
|
1082
|
+
response: ensureSyncErrorShape(clientSyncResult),
|
|
1083
|
+
preferred: extractLanguageFromHeader2(tempSocket.handshake.headers["accept-language"] || tempSocket.handshake.headers["x-language"])
|
|
1084
|
+
})
|
|
1085
|
+
});
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (clientSyncResult?.status !== "success") {
|
|
1089
|
+
tempSocket.emit(socketEventNames3.sync, {
|
|
1090
|
+
cb: callbackName,
|
|
1091
|
+
fullName: resolvedName,
|
|
1092
|
+
...buildSyncError({
|
|
1093
|
+
response: { status: "error", errorCode: "sync.invalidClientResponse" },
|
|
1094
|
+
preferred: extractLanguageFromHeader2(tempSocket.handshake.headers["accept-language"] || tempSocket.handshake.headers["x-language"])
|
|
1095
|
+
})
|
|
1096
|
+
});
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
tempSocket.emit(socketEventNames3.sync, {
|
|
1100
|
+
cb: callbackName,
|
|
1101
|
+
fullName: resolvedName,
|
|
1102
|
+
serverOutput,
|
|
1103
|
+
clientOutput: clientSyncResult,
|
|
1104
|
+
message: clientSyncResult.message || `${resolvedName} sync success`,
|
|
1105
|
+
status: "success"
|
|
1106
|
+
});
|
|
1107
|
+
recipientCount++;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
tempSocket.emit(socketEventNames3.sync, {
|
|
1111
|
+
cb: callbackName,
|
|
1112
|
+
fullName: resolvedName,
|
|
1113
|
+
serverOutput,
|
|
1114
|
+
clientOutput: {},
|
|
1115
|
+
message: `${resolvedName} sync success`,
|
|
1116
|
+
status: "success"
|
|
1117
|
+
});
|
|
1118
|
+
recipientCount++;
|
|
1119
|
+
}
|
|
1120
|
+
fanoutPayload.recipientCount = recipientCount;
|
|
1121
|
+
await dispatchHook3("postSyncFanout", fanoutPayload);
|
|
1122
|
+
if (shouldLogDev2()) {
|
|
1123
|
+
getLogger3().debug(`http sync: ${resolvedName} completed`);
|
|
1124
|
+
}
|
|
1125
|
+
const serverMessage = serverOutput.message;
|
|
1126
|
+
return {
|
|
1127
|
+
...serverOutput,
|
|
1128
|
+
status: "success",
|
|
1129
|
+
message: typeof serverMessage === "string" ? serverMessage : `${resolvedName} sync success`
|
|
1130
|
+
};
|
|
1131
|
+
});
|
|
1132
|
+
if (bodyError) {
|
|
1133
|
+
getLogger3().error(`http sync: ${name} threw`, bodyError, { sync: name });
|
|
1134
|
+
return buildSyncError({
|
|
1135
|
+
response: { status: "error", errorCode: "sync.serverExecutionFailed" },
|
|
1136
|
+
preferred: preferredLocale,
|
|
1137
|
+
userLanguage: user?.language
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
return bodyResult ?? buildSyncError({
|
|
1141
|
+
response: { status: "error", errorCode: "sync.serverExecutionFailed" },
|
|
1142
|
+
preferred: preferredLocale,
|
|
1143
|
+
userLanguage: user?.language
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// src/streamThrottle.ts
|
|
1148
|
+
import { getProjectConfig as getProjectConfig4 } from "@luckystack/core";
|
|
1149
|
+
var createStreamThrottle = (options = {}) => {
|
|
1150
|
+
const defaults = getProjectConfig4().sync.streamThrottle;
|
|
1151
|
+
const flushAtChars = options.flushAtChars ?? defaults.flushAtChars;
|
|
1152
|
+
const flushEveryMs = options.flushEveryMs ?? defaults.flushEveryMs;
|
|
1153
|
+
const field = options.field ?? defaults.field;
|
|
1154
|
+
let buffer = "";
|
|
1155
|
+
let timer = null;
|
|
1156
|
+
const clearTimer = () => {
|
|
1157
|
+
if (timer !== null) {
|
|
1158
|
+
clearTimeout(timer);
|
|
1159
|
+
timer = null;
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
const flushNow = (emit) => {
|
|
1163
|
+
if (buffer.length === 0) {
|
|
1164
|
+
clearTimer();
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const payload = { [field]: buffer };
|
|
1168
|
+
buffer = "";
|
|
1169
|
+
clearTimer();
|
|
1170
|
+
emit(payload);
|
|
1171
|
+
};
|
|
1172
|
+
return {
|
|
1173
|
+
push: (text, emit) => {
|
|
1174
|
+
if (!text) return;
|
|
1175
|
+
buffer += text;
|
|
1176
|
+
if (buffer.length >= flushAtChars) {
|
|
1177
|
+
flushNow(emit);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
if (flushEveryMs !== false && timer === null) {
|
|
1181
|
+
timer = setTimeout(() => {
|
|
1182
|
+
flushNow(emit);
|
|
1183
|
+
}, flushEveryMs);
|
|
1184
|
+
if (typeof timer === "object" && "unref" in timer) {
|
|
1185
|
+
timer.unref();
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
},
|
|
1189
|
+
flush: (emit) => {
|
|
1190
|
+
flushNow(emit);
|
|
1191
|
+
},
|
|
1192
|
+
reset: () => {
|
|
1193
|
+
buffer = "";
|
|
1194
|
+
clearTimer();
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
};
|
|
1198
|
+
export {
|
|
1199
|
+
createStreamThrottle,
|
|
1200
|
+
handleHttpSyncRequest,
|
|
1201
|
+
handleSyncRequest
|
|
1202
|
+
};
|
|
1203
|
+
//# sourceMappingURL=index.js.map
|