@luckystack/sync 0.1.9 → 0.2.1

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/index.js CHANGED
@@ -1,45 +1,57 @@
1
1
  // src/handleSyncRequest.ts
2
- import { getSession } from "@luckystack/login";
3
- import { getProjectConfig as getProjectConfig2 } from "@luckystack/core";
4
- import { getRuntimeSyncMaps } from "@luckystack/core";
2
+ import { randomUUID } from "crypto";
5
3
  import {
4
+ readSession,
5
+ getProjectConfig as getProjectConfig3,
6
+ getRuntimeSyncMaps,
6
7
  validateRequest,
7
8
  extractTokenFromSocket,
9
+ formatRoomName,
8
10
  getIoInstance as getIoInstance2,
9
- tryCatch,
11
+ tryCatch as tryCatch2,
10
12
  parseTransportRouteName,
11
13
  checkRateLimit,
12
14
  buildSyncProgressEventName,
13
15
  buildSyncResponseEventName,
14
- socketEventNames as socketEventNames2,
16
+ socketEventNames as socketEventNames3,
15
17
  dispatchHook as dispatchHook2,
16
18
  validateInputByType,
17
- getLogger as getLogger2
19
+ getLogger as getLogger3,
20
+ resolveClientIp,
21
+ isLoopbackIp,
22
+ extractLanguageFromHeader,
23
+ normalizeErrorResponse as normalizeErrorResponse2,
24
+ runWithErrorTrackerIdentityScope,
25
+ setCurrentErrorTrackerIdentity,
26
+ registerSyncAbortController,
27
+ unregisterSyncAbortController,
28
+ deriveTokenBucketId
18
29
  } from "@luckystack/core";
19
- import { extractLanguageFromHeader, normalizeErrorResponse, applyErrorFormatter } from "@luckystack/core";
20
30
 
21
31
  // src/_shared/streamEmitters.ts
22
32
  import {
23
33
  dispatchHook,
24
34
  getIoInstance,
25
35
  getLogger,
26
- getProjectConfig,
36
+ getProjectConfig as getProjectConfig2,
27
37
  socketEventNames
28
38
  } 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
- };
39
+
40
+ // src/_shared/logFlags.ts
41
+ import { getProjectConfig } from "@luckystack/core";
42
+ var shouldLogDev = () => getProjectConfig().logging.devLogs;
42
43
  var shouldLogStream = () => getProjectConfig().logging.stream;
44
+
45
+ // src/_shared/redactToken.ts
46
+ var VISIBLE_PREFIX = 8;
47
+ var redactToken = (token) => {
48
+ if (!token) return token;
49
+ if (token.length <= VISIBLE_PREFIX) return token;
50
+ return `${token.slice(0, VISIBLE_PREFIX)}\u2026`;
51
+ };
52
+ var redactTokens = (tokens) => tokens.map((t) => redactToken(t) ?? t);
53
+
54
+ // src/_shared/streamEmitters.ts
43
55
  var DEFAULT_THRESHOLD_BYTES = 1048576;
44
56
  var AVG_PACKET_BYTES = 1024;
45
57
  var POLL_INTERVAL_MS = 10;
@@ -94,6 +106,17 @@ var buildSyncStreamEmitters = ({
94
106
  signal,
95
107
  originatorSocket
96
108
  }) => {
109
+ const chunkCounters = /* @__PURE__ */ new Map();
110
+ const bumpChunkIndex = (recipient) => {
111
+ const next = (chunkCounters.get(recipient) ?? 0) + 1;
112
+ chunkCounters.set(recipient, next);
113
+ return next;
114
+ };
115
+ const dispatchStreamHooks = (recipient, chunk) => {
116
+ void dispatchHook("preSyncStream", { routeName: resolvedName, chunk, recipient });
117
+ const chunkIndex = bumpChunkIndex(recipient);
118
+ void dispatchHook("postSyncStream", { routeName: resolvedName, chunk, recipient, chunkIndex });
119
+ };
97
120
  const buildBroadcastFrame = (payload) => ({
98
121
  ...payload,
99
122
  cb,
@@ -114,7 +137,7 @@ var buildSyncStreamEmitters = ({
114
137
  if (shouldLogStream()) {
115
138
  getLogger().debug(`${logLabel}: ${resolvedName} server stream`, { payload });
116
139
  }
117
- dispatchStreamHooks(resolvedName, "originator", payload);
140
+ dispatchStreamHooks("originator", payload);
118
141
  emitOriginatorChunk(payload);
119
142
  };
120
143
  const emitBroadcastSyncStream = (payload = {}) => {
@@ -128,7 +151,7 @@ var buildSyncStreamEmitters = ({
128
151
  if (!receiver) return;
129
152
  const io = getIoInstance();
130
153
  if (!io) return;
131
- dispatchStreamHooks(resolvedName, receiver, payload);
154
+ dispatchStreamHooks(receiver, payload);
132
155
  io.to(receiver).emit(socketEventNames.sync, buildBroadcastFrame(payload));
133
156
  };
134
157
  const emitStreamToTokens = (tokens, payload = {}) => {
@@ -140,19 +163,20 @@ var buildSyncStreamEmitters = ({
140
163
  const filtered = list.filter((t) => typeof t === "string" && t.length > 0);
141
164
  if (filtered.length === 0) return;
142
165
  if (shouldLogStream()) {
143
- getLogger().debug(`${logLabel}: ${resolvedName} streamTo`, { tokens: filtered, payload });
166
+ getLogger().debug(`${logLabel}: ${resolvedName} streamTo`, { tokens: redactTokens(filtered), payload });
144
167
  }
145
168
  const io = getIoInstance();
146
169
  if (!io) return;
147
170
  for (const recipient of filtered) {
148
- dispatchStreamHooks(resolvedName, recipient, payload);
171
+ dispatchStreamHooks(recipient, payload);
149
172
  }
150
173
  const frame = buildBroadcastFrame(payload);
151
174
  io.to(filtered).emit(socketEventNames.sync, frame);
152
175
  };
153
176
  const flushPressure = async ({ thresholdBytes } = {}) => {
154
177
  if (isAborted()) return;
155
- const effectiveThresholdBytes = typeof thresholdBytes === "number" && thresholdBytes > 0 ? thresholdBytes : DEFAULT_THRESHOLD_BYTES;
178
+ const configuredBytes = getProjectConfig2().sync.flushPressure.maxBufferedBytes;
179
+ const effectiveThresholdBytes = typeof thresholdBytes === "number" && thresholdBytes > 0 ? thresholdBytes : typeof configuredBytes === "number" && configuredBytes > 0 ? configuredBytes : DEFAULT_THRESHOLD_BYTES;
156
180
  const packetThreshold = Math.max(1, Math.ceil(effectiveThresholdBytes / AVG_PACKET_BYTES));
157
181
  const targets = [];
158
182
  if (originatorSocket) targets.push(originatorSocket);
@@ -171,13 +195,171 @@ var buildSyncStreamEmitters = ({
171
195
  };
172
196
  };
173
197
 
174
- // src/handleSyncRequest.ts
198
+ // src/_shared/errorBuilders.ts
175
199
  import {
176
- registerSyncAbortController,
177
- unregisterSyncAbortController
200
+ applyErrorFormatter,
201
+ normalizeErrorResponse
178
202
  } from "@luckystack/core";
179
- var shouldLogDev = () => getProjectConfig2().logging.devLogs;
180
- var shouldLogStream2 = () => getProjectConfig2().logging.stream;
203
+ var buildFormattedError = ({
204
+ response,
205
+ preferred,
206
+ userLanguage,
207
+ routeName,
208
+ transport,
209
+ userId,
210
+ perRouteFormatter
211
+ }) => {
212
+ const normalized = normalizeErrorResponse({
213
+ response,
214
+ preferredLocale: preferred,
215
+ userLanguage
216
+ });
217
+ const baseEnvelope = {
218
+ status: "error",
219
+ message: normalized.message,
220
+ errorCode: normalized.errorCode,
221
+ errorParams: normalized.errorParams,
222
+ httpStatus: normalized.httpStatus
223
+ };
224
+ return applyErrorFormatter({
225
+ response: baseEnvelope,
226
+ routeName: routeName ?? "sync/unknown",
227
+ transport,
228
+ userId,
229
+ perRouteFormatter
230
+ });
231
+ };
232
+ var ensureSyncErrorShape = (response) => {
233
+ if (typeof response.errorCode === "string" && response.errorCode.trim().length > 0) {
234
+ return response;
235
+ }
236
+ return { ...response, errorCode: "sync.clientRejected" };
237
+ };
238
+
239
+ // src/_shared/clientFanout.ts
240
+ import { tryCatch, socketEventNames as socketEventNames2, getLogger as getLogger2 } from "@luckystack/core";
241
+ var processClientSyncForRecipient = async ({
242
+ tempSocket,
243
+ tempToken,
244
+ clientSyncHandler,
245
+ data,
246
+ functionsObject,
247
+ serverOutput,
248
+ receiver,
249
+ resolvedName,
250
+ callbackKey,
251
+ transport,
252
+ handlerName,
253
+ logLabel,
254
+ shouldLogDev: shouldLogDev2,
255
+ shouldLogStream: shouldLogStream2,
256
+ buildSyncError,
257
+ resolvePreferredLocale,
258
+ sourceUserId
259
+ }) => {
260
+ const emitClientSyncStream = (payload = {}) => {
261
+ if (shouldLogStream2()) {
262
+ getLogger2().debug(`${logLabel}: ${resolvedName} client stream`, { payload });
263
+ }
264
+ tempSocket.emit(socketEventNames2.sync, {
265
+ ...payload,
266
+ cb: callbackKey,
267
+ fullName: resolvedName,
268
+ status: "stream"
269
+ });
270
+ };
271
+ const [clientSyncError, clientSyncResult] = await tryCatch(
272
+ async () => await clientSyncHandler({ clientInput: data, token: tempToken, functions: functionsObject, serverOutput, roomCode: receiver, stream: emitClientSyncStream }),
273
+ void 0,
274
+ {
275
+ handler: handlerName,
276
+ sync: resolvedName,
277
+ stage: "client",
278
+ sourceUserId,
279
+ //? Redact the raw bearer session token before it lands in the
280
+ //? error-tracker context (`captureException`) — a verbatim token there is
281
+ //? a usable credential (defeats HttpOnly-cookie mode). The 8-char prefix
282
+ //? still correlates error events.
283
+ targetToken: redactToken(tempToken),
284
+ receiver,
285
+ transport
286
+ }
287
+ );
288
+ const preferred = resolvePreferredLocale(tempSocket.handshake.headers);
289
+ if (clientSyncError) {
290
+ tempSocket.emit(socketEventNames2.sync, {
291
+ cb: callbackKey,
292
+ fullName: resolvedName,
293
+ ...buildSyncError({
294
+ response: { status: "error", errorCode: "sync.clientExecutionFailed" },
295
+ preferred
296
+ })
297
+ });
298
+ return;
299
+ }
300
+ if (clientSyncResult?.status === "error") {
301
+ tempSocket.emit(socketEventNames2.sync, {
302
+ cb: callbackKey,
303
+ fullName: resolvedName,
304
+ ...buildSyncError({
305
+ response: ensureSyncErrorShape(clientSyncResult),
306
+ preferred
307
+ })
308
+ });
309
+ return;
310
+ }
311
+ if (clientSyncResult?.status !== "success") {
312
+ tempSocket.emit(socketEventNames2.sync, {
313
+ cb: callbackKey,
314
+ fullName: resolvedName,
315
+ ...buildSyncError({
316
+ response: { status: "error", errorCode: "sync.invalidClientResponse" },
317
+ preferred
318
+ })
319
+ });
320
+ return;
321
+ }
322
+ const result = {
323
+ cb: callbackKey,
324
+ fullName: resolvedName,
325
+ serverOutput,
326
+ clientOutput: clientSyncResult,
327
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty message should fall back to the default too
328
+ message: clientSyncResult.message || `${resolvedName} sync success`,
329
+ status: "success"
330
+ };
331
+ if (shouldLogDev2()) {
332
+ getLogger2().debug(`${logLabel}: ${resolvedName} client success`, { result });
333
+ }
334
+ tempSocket.emit(socketEventNames2.sync, result);
335
+ };
336
+
337
+ // src/_shared/validationMode.ts
338
+ var resolveSyncValidationMode = (validation) => {
339
+ if (!validation) return "strict";
340
+ if (typeof validation === "string") {
341
+ return validation === "relaxed" ? "relaxed" : "strict";
342
+ }
343
+ return validation.input === "skip" ? "relaxed" : "strict";
344
+ };
345
+
346
+ // src/_shared/receiverAuth.ts
347
+ var authorizeSyncReceiver = ({
348
+ receiver,
349
+ allowClientReceiverAll,
350
+ requireRoomMembership,
351
+ isMember
352
+ }) => {
353
+ if (receiver === "all" && !allowClientReceiverAll) {
354
+ return { allowed: false, errorCode: "sync.receiverNotAllowed" };
355
+ }
356
+ if (requireRoomMembership && receiver !== "all" && !isMember?.()) {
357
+ return { allowed: false, errorCode: "sync.notRoomMember" };
358
+ }
359
+ return { allowed: true };
360
+ };
361
+
362
+ // src/handleSyncRequest.ts
181
363
  var applySyncRateLimits = async ({
182
364
  resolvedName,
183
365
  token,
@@ -185,13 +367,22 @@ var applySyncRateLimits = async ({
185
367
  user,
186
368
  responseIndex,
187
369
  buildSyncError,
188
- preferredLocale
370
+ preferredLocale,
371
+ routeLimit
189
372
  }) => {
190
- const config = getProjectConfig2();
191
- const defaultApiLimit = config.rateLimiting.defaultApiLimit;
373
+ const config = getProjectConfig3();
374
+ const resolvedIp = resolveClientIp({
375
+ rawAddress: socket.handshake.address,
376
+ headers: socket.handshake.headers,
377
+ trustProxy: config.http.trustProxy,
378
+ trustedProxyHopCount: config.http.trustedProxyHopCount
379
+ });
380
+ const defaultApiLimit = routeLimit === void 0 ? config.rateLimiting.defaultApiLimit : routeLimit;
192
381
  if (defaultApiLimit !== false && defaultApiLimit > 0) {
193
- const requesterIdentity = token ?? socket.handshake.address ?? "unknown";
194
- const keyPrefix = token ? "token" : "ip";
382
+ const identityCb = config.rateLimiting.identity;
383
+ const customIdentity = identityCb?.({ routeName: resolvedName, userId: user?.id ?? null, ip: resolvedIp, transport: "socket" }) ?? null;
384
+ const requesterIdentity = customIdentity?.id ?? (token ? deriveTokenBucketId(token) : resolvedIp);
385
+ const keyPrefix = customIdentity?.scope ?? (token ? "token" : "ip");
195
386
  const rateLimitKey = `${keyPrefix}:${requesterIdentity}:sync:${resolvedName}`;
196
387
  const { allowed, resetIn } = await checkRateLimit({
197
388
  key: rateLimitKey,
@@ -200,13 +391,17 @@ var applySyncRateLimits = async ({
200
391
  });
201
392
  if (!allowed) {
202
393
  void dispatchHook2("rateLimitExceeded", {
203
- scope: token ? "user" : "route",
394
+ //? Parity with the API handler: an anonymous per-route bucket is IP-keyed,
395
+ //? so the scope is `ip` (with `route` still set to mark it a per-route
396
+ //? bucket vs the global `:sync:all` IP bucket), never `route`.
397
+ scope: token ? "user" : "ip",
204
398
  key: rateLimitKey,
205
399
  limit: defaultApiLimit,
206
400
  windowMs: config.rateLimiting.windowMs,
207
401
  count: defaultApiLimit + 1,
208
402
  route: resolvedName,
209
- userId: user?.id
403
+ userId: user?.id,
404
+ ip: token ? void 0 : resolvedIp
210
405
  });
211
406
  if (typeof responseIndex === "number") {
212
407
  socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
@@ -224,8 +419,9 @@ var applySyncRateLimits = async ({
224
419
  }
225
420
  }
226
421
  const defaultIpLimit = config.rateLimiting.defaultIpLimit;
227
- if (defaultIpLimit !== false && defaultIpLimit > 0) {
228
- const requesterIp = socket.handshake.address ?? "unknown";
422
+ const requesterIsLoopback = config.rateLimiting.skipLoopbackInDev && process.env.NODE_ENV !== "production" && isLoopbackIp(resolvedIp);
423
+ if (!requesterIsLoopback && defaultIpLimit !== false && defaultIpLimit > 0) {
424
+ const requesterIp = resolvedIp;
229
425
  const ipKey = `ip:${requesterIp}:sync:all`;
230
426
  const { allowed, resetIn } = await checkRateLimit({
231
427
  key: ipKey,
@@ -258,176 +454,237 @@ var applySyncRateLimits = async ({
258
454
  }
259
455
  return true;
260
456
  };
261
- async function handleSyncRequest({ msg, socket, token }) {
262
- const ioInstance = getIoInstance2();
263
- if (!ioInstance) {
264
- return;
457
+ function rejectSync({
458
+ socket,
459
+ responseIndex,
460
+ buildSyncError,
461
+ response,
462
+ preferred,
463
+ userLanguage
464
+ }) {
465
+ if (typeof responseIndex === "number") {
466
+ socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
467
+ response,
468
+ preferred,
469
+ userLanguage
470
+ }));
265
471
  }
472
+ }
473
+ function validateSyncMessage({
474
+ msg,
475
+ socket,
476
+ preferredLocale
477
+ }) {
266
478
  if (typeof msg != "object") {
267
479
  if (shouldLogDev()) {
268
- getLogger2().warn("sync: socket message was not a json object");
480
+ getLogger3().warn("sync: socket message was not a json object");
269
481
  }
270
- const normalized = normalizeErrorResponse({
482
+ const normalized = normalizeErrorResponse2({
271
483
  response: { status: "error", errorCode: "sync.invalidRequest" },
272
- preferredLocale: extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"])
484
+ preferredLocale
273
485
  });
274
- return socket.emit(socketEventNames2.sync, {
486
+ socket.emit(socketEventNames3.sync, {
275
487
  status: normalized.status,
276
488
  message: normalized.message,
277
489
  errorCode: normalized.errorCode,
278
490
  errorParams: normalized.errorParams,
279
491
  httpStatus: normalized.httpStatus
280
492
  });
493
+ return false;
281
494
  }
282
- const { name, data, cb, receiver: rawReceiver, responseIndex, ignoreSelf } = msg;
495
+ return true;
496
+ }
497
+ function parseSyncFields({
498
+ msg,
499
+ socket,
500
+ responseIndex,
501
+ preferredLocale,
502
+ buildSyncError
503
+ }) {
504
+ const { name, data, cb, receiver: rawReceiver, ignoreSelf } = msg;
283
505
  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
- }));
506
+ if (!name || !data || typeof name != "string" || typeof data != "object" || Array.isArray(data)) {
507
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.invalidRequest" }, preferred: preferredLocale });
508
+ return { ok: false };
327
509
  }
328
- const normalizedData = data;
329
510
  const parsedRoute = parseTransportRouteName({ value: name, prefix: "sync" });
330
511
  if (parsedRoute.status === "error") {
331
- return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
512
+ rejectSync({
513
+ socket,
514
+ responseIndex,
515
+ buildSyncError,
332
516
  response: {
333
517
  status: "error",
334
518
  errorCode: "routing.invalidServiceRouteName",
335
519
  errorParams: [{ key: "name", value: name }]
336
520
  },
337
521
  preferred: preferredLocale
338
- }));
522
+ });
523
+ return { ok: false };
339
524
  }
340
525
  const resolvedName = parsedRoute.normalizedFullName;
341
- currentRouteName = resolvedName;
342
526
  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
- }));
527
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.invalidCallback" }, preferred: preferredLocale });
528
+ return { ok: false };
347
529
  }
348
530
  if (!receiver) {
349
531
  if (shouldLogDev()) {
350
- getLogger2().warn("sync: missing receiver / roomCode", { receiver });
532
+ getLogger3().warn("sync: missing receiver / roomCode", { receiver });
351
533
  }
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 });
534
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.missingReceiver" }, preferred: preferredLocale });
535
+ return { ok: false };
359
536
  }
360
- const user = await getSession(token);
361
- currentUserId = user?.id;
362
- const { syncObject, functionsObject } = await getRuntimeSyncMaps();
537
+ return {
538
+ ok: true,
539
+ name,
540
+ normalizedData: data,
541
+ cb,
542
+ receiver,
543
+ ignoreSelf: typeof ignoreSelf === "boolean" ? ignoreSelf : void 0,
544
+ resolvedName
545
+ };
546
+ }
547
+ function setupAbortController({
548
+ socket,
549
+ responseIndex
550
+ }) {
551
+ const cancelId = randomUUID();
363
552
  const abortController = new AbortController();
364
- const abortKey = registerSyncAbortController(socket.id, cb, abortController);
553
+ const abortKey = registerSyncAbortController(socket.id, cancelId, abortController);
554
+ if (typeof responseIndex === "number") {
555
+ socket.emit(buildSyncProgressEventName(responseIndex), { __cancelId: cancelId });
556
+ }
365
557
  const onSocketDisconnect = () => {
366
558
  abortController.abort();
367
559
  };
368
- socket.once(socketEventNames2.disconnect, onSocketDisconnect);
560
+ socket.once(socketEventNames3.disconnect, onSocketDisconnect);
369
561
  let cleanupDone = false;
370
562
  const cleanupRequest = () => {
371
563
  if (cleanupDone) return;
372
564
  cleanupDone = true;
373
- socket.off(socketEventNames2.disconnect, onSocketDisconnect);
565
+ socket.off(socketEventNames3.disconnect, onSocketDisconnect);
374
566
  unregisterSyncAbortController(abortKey);
375
567
  };
568
+ return { abortController, abortKey, cleanupRequest };
569
+ }
570
+ function checkRouteExists({
571
+ syncObject,
572
+ resolvedName,
573
+ name,
574
+ socket,
575
+ responseIndex,
576
+ buildSyncError,
577
+ preferredLocale,
578
+ user,
579
+ cleanupRequest
580
+ }) {
376
581
  if (!syncObject[`${resolvedName}_client`] && !syncObject[`${resolvedName}_server`]) {
377
582
  if (shouldLogDev()) {
378
- getLogger2().warn(`sync: ${name} has no _client or _server file`, { sync: name });
583
+ getLogger3().warn(`sync: ${name} has no _client or _server file`, { sync: name });
584
+ }
585
+ cleanupRequest();
586
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.notFound" }, preferred: preferredLocale, userLanguage: user?.language });
587
+ return false;
588
+ }
589
+ if (!syncObject[`${resolvedName}_server`]) {
590
+ if (shouldLogDev()) {
591
+ getLogger3().warn(`sync: ${resolvedName} has a _client file but no required _server file`, { sync: resolvedName });
592
+ }
593
+ cleanupRequest();
594
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.notFound" }, preferred: preferredLocale, userLanguage: user?.language });
595
+ return false;
596
+ }
597
+ return true;
598
+ }
599
+ function runSyncAuth({
600
+ serverSyncEntry,
601
+ user,
602
+ resolvedName,
603
+ socket,
604
+ responseIndex,
605
+ buildSyncError,
606
+ preferredLocale,
607
+ cleanupRequest
608
+ }) {
609
+ if (!serverSyncEntry) return true;
610
+ const { auth } = serverSyncEntry;
611
+ if (auth.login && !user?.id) {
612
+ if (shouldLogDev()) {
613
+ getLogger3().warn(`sync: ${resolvedName} requires login`, { sync: resolvedName });
614
+ }
615
+ cleanupRequest();
616
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "auth.required" }, preferred: preferredLocale });
617
+ return false;
618
+ }
619
+ if (auth.additional && auth.additional.length > 0 && !user) {
620
+ if (shouldLogDev()) {
621
+ getLogger3().warn(`sync: ${resolvedName} requires a session for additional auth`, { sync: resolvedName });
622
+ }
623
+ cleanupRequest();
624
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "auth.required" }, preferred: preferredLocale });
625
+ return false;
626
+ }
627
+ const validationResult = validateRequest({ auth, user });
628
+ if (validationResult.status === "error") {
629
+ if (shouldLogDev()) {
630
+ getLogger3().warn(`sync: auth failed for ${resolvedName}`, { sync: resolvedName, errorCode: validationResult.errorCode });
379
631
  }
380
632
  cleanupRequest();
381
- return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
382
- response: { status: "error", errorCode: "sync.notFound" },
633
+ rejectSync({
634
+ socket,
635
+ responseIndex,
636
+ buildSyncError,
637
+ response: {
638
+ status: "error",
639
+ errorCode: validationResult.errorCode || "auth.forbidden",
640
+ errorParams: validationResult.errorParams,
641
+ httpStatus: validationResult.httpStatus
642
+ },
383
643
  preferred: preferredLocale,
384
644
  userLanguage: user?.language
385
- }));
645
+ });
646
+ return false;
386
647
  }
387
- const { emitServerSyncStream, emitBroadcastSyncStream, emitStreamToTokens, flushPressure } = buildSyncStreamEmitters({
388
- cb,
648
+ return true;
649
+ }
650
+ function runReceiverAuth({
651
+ receiver,
652
+ resolvedName,
653
+ socket,
654
+ responseIndex,
655
+ buildSyncError,
656
+ preferredLocale,
657
+ user,
658
+ cleanupRequest
659
+ }) {
660
+ const syncConfig = getProjectConfig3().sync;
661
+ const receiverAuth = authorizeSyncReceiver({
389
662
  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
- }
663
+ allowClientReceiverAll: syncConfig.allowClientReceiverAll,
664
+ requireRoomMembership: syncConfig.requireRoomMembership,
665
+ isMember: () => socket.rooms.has(receiver)
398
666
  });
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
- }));
667
+ if (!receiverAuth.allowed) {
668
+ if (shouldLogDev()) {
669
+ getLogger3().warn(`sync: receiver authorization failed for ${resolvedName}`, { sync: resolvedName, receiver, errorCode: receiverAuth.errorCode });
429
670
  }
671
+ cleanupRequest();
672
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: receiverAuth.errorCode, httpStatus: 403 }, preferred: preferredLocale, userLanguage: user?.language });
673
+ return false;
430
674
  }
675
+ return true;
676
+ }
677
+ async function runPreAuthorizeHook({
678
+ resolvedName,
679
+ normalizedData,
680
+ user,
681
+ receiver,
682
+ socket,
683
+ responseIndex,
684
+ buildSyncError,
685
+ preferredLocale,
686
+ cleanupRequest
687
+ }) {
431
688
  const preAuthorizeResult = await dispatchHook2("preSyncAuthorize", {
432
689
  routeName: resolvedName,
433
690
  data: normalizedData,
@@ -437,10 +694,13 @@ async function handleSyncRequest({ msg, socket, token }) {
437
694
  });
438
695
  if (preAuthorizeResult.stopped) {
439
696
  if (shouldLogDev()) {
440
- getLogger2().warn(`sync: preSyncAuthorize stopped ${resolvedName}`, { sync: resolvedName, errorCode: preAuthorizeResult.signal.errorCode });
697
+ getLogger3().warn(`sync: preSyncAuthorize stopped ${resolvedName}`, { sync: resolvedName, errorCode: preAuthorizeResult.signal.errorCode });
441
698
  }
442
699
  cleanupRequest();
443
- return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
700
+ rejectSync({
701
+ socket,
702
+ responseIndex,
703
+ buildSyncError,
444
704
  response: {
445
705
  status: "error",
446
706
  errorCode: preAuthorizeResult.signal.errorCode,
@@ -448,7 +708,8 @@ async function handleSyncRequest({ msg, socket, token }) {
448
708
  },
449
709
  preferred: preferredLocale,
450
710
  userLanguage: user?.language
451
- }));
711
+ });
712
+ return false;
452
713
  }
453
714
  void dispatchHook2("postSyncAuthorize", {
454
715
  routeName: resolvedName,
@@ -457,108 +718,198 @@ async function handleSyncRequest({ msg, socket, token }) {
457
718
  receiver,
458
719
  transport: "socket"
459
720
  });
460
- const rateLimitOk = await applySyncRateLimits({
461
- resolvedName,
462
- token,
463
- socket,
721
+ return true;
722
+ }
723
+ async function runSyncServerExecution({
724
+ serverSyncEntry,
725
+ resolvedName,
726
+ normalizedData,
727
+ user,
728
+ receiver,
729
+ socket,
730
+ responseIndex,
731
+ buildSyncError,
732
+ preferredLocale,
733
+ cleanupRequest,
734
+ functionsObject,
735
+ emitServerSyncStream,
736
+ emitBroadcastSyncStream,
737
+ emitStreamToTokens,
738
+ flushPressure,
739
+ abortController
740
+ }) {
741
+ if (!serverSyncEntry) return { ok: true, serverOutput: {} };
742
+ const { main: serverMain, inputType, inputTypeFilePath } = serverSyncEntry;
743
+ const preValidateResult = await dispatchHook2("preSyncValidate", {
744
+ routeName: resolvedName,
745
+ data: normalizedData,
464
746
  user,
465
- responseIndex,
466
- buildSyncError,
467
- preferredLocale
747
+ receiver,
748
+ transport: "socket"
468
749
  });
469
- if (!rateLimitOk) {
750
+ if (preValidateResult.stopped) {
470
751
  cleanupRequest();
471
- return;
752
+ rejectSync({
753
+ socket,
754
+ responseIndex,
755
+ buildSyncError,
756
+ response: {
757
+ status: "error",
758
+ errorCode: preValidateResult.signal.errorCode,
759
+ httpStatus: preValidateResult.signal.httpStatus
760
+ },
761
+ preferred: preferredLocale,
762
+ userLanguage: user?.language
763
+ });
764
+ return { ok: false };
472
765
  }
473
- let serverOutput = {};
474
- if (serverSyncEntry) {
475
- const { main: serverMain, inputType, inputTypeFilePath } = serverSyncEntry;
766
+ if (resolveSyncValidationMode(serverSyncEntry.validation) === "strict") {
476
767
  const inputValidation = await validateInputByType({
477
768
  typeText: inputType,
478
769
  value: normalizedData,
479
770
  rootKey: "clientInput",
480
771
  filePath: inputTypeFilePath
481
772
  });
773
+ void dispatchHook2("postSyncValidate", {
774
+ routeName: resolvedName,
775
+ data: normalizedData,
776
+ user,
777
+ receiver,
778
+ transport: "socket",
779
+ validation: inputValidation.status === "error" ? { status: "error", message: inputValidation.message } : { status: "success" }
780
+ });
482
781
  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
782
  if (shouldLogDev()) {
518
- getLogger2().error(`sync: server execution failed for ${resolvedName}`, serverSyncError, { sync: resolvedName });
783
+ getLogger3().warn(`sync: input validation failed for ${resolvedName}`, { sync: resolvedName, message: inputValidation.message });
519
784
  }
520
785
  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;
786
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.invalidInputType" }, preferred: preferredLocale, userLanguage: user?.language });
787
+ return { ok: false };
549
788
  }
789
+ } else {
790
+ void dispatchHook2("postSyncValidate", {
791
+ routeName: resolvedName,
792
+ data: normalizedData,
793
+ user,
794
+ receiver,
795
+ transport: "socket",
796
+ validation: { status: "success" }
797
+ });
550
798
  }
551
- const sockets = receiver === "all" ? await ioInstance.fetchSockets() : await ioInstance.in(receiver).fetchSockets();
552
- if (sockets.length === 0) {
799
+ const executePayload = {
800
+ routeName: resolvedName,
801
+ data: normalizedData,
802
+ user,
803
+ receiver,
804
+ transport: "socket",
805
+ result: void 0,
806
+ error: null,
807
+ durationMs: 0
808
+ };
809
+ const preExecuteResult = await dispatchHook2("preSyncExecute", executePayload);
810
+ if (preExecuteResult.stopped) {
811
+ cleanupRequest();
812
+ rejectSync({
813
+ socket,
814
+ responseIndex,
815
+ buildSyncError,
816
+ response: {
817
+ status: "error",
818
+ errorCode: preExecuteResult.signal.errorCode,
819
+ httpStatus: preExecuteResult.signal.httpStatus
820
+ },
821
+ preferred: preferredLocale,
822
+ userLanguage: user?.language
823
+ });
824
+ return { ok: false };
825
+ }
826
+ const executeStart = Date.now();
827
+ const [serverSyncError, serverSyncResult] = await tryCatch2(
828
+ async () => await serverMain({
829
+ clientInput: normalizedData,
830
+ user,
831
+ functions: functionsObject,
832
+ roomCode: receiver,
833
+ stream: emitServerSyncStream,
834
+ broadcastStream: emitBroadcastSyncStream,
835
+ streamTo: emitStreamToTokens,
836
+ abortSignal: abortController.signal,
837
+ flushPressure
838
+ }),
839
+ void 0,
840
+ {
841
+ handler: "handleSyncRequest",
842
+ sync: resolvedName,
843
+ stage: "server",
844
+ userId: user?.id,
845
+ receiver,
846
+ transport: "socket"
847
+ }
848
+ );
849
+ executePayload.result = serverSyncResult;
850
+ executePayload.error = serverSyncError ?? null;
851
+ executePayload.durationMs = Date.now() - executeStart;
852
+ await dispatchHook2("postSyncExecute", executePayload);
853
+ if (serverSyncError) {
553
854
  if (shouldLogDev()) {
554
- getLogger2().warn("sync: no sockets found for receiver", { receiver, sync: resolvedName });
855
+ getLogger3().error(`sync: server execution failed for ${resolvedName}`, serverSyncError, { sync: resolvedName });
555
856
  }
556
857
  cleanupRequest();
557
- return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
558
- response: { status: "error", errorCode: "sync.noReceiversFound" },
858
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.serverExecutionFailed" }, preferred: preferredLocale, userLanguage: user?.language });
859
+ return { ok: false };
860
+ }
861
+ if (serverSyncResult?.status == "error") {
862
+ const normalizedServerError = buildSyncError({
863
+ response: serverSyncResult,
559
864
  preferred: preferredLocale,
560
865
  userLanguage: user?.language
561
- }));
866
+ });
867
+ if (shouldLogDev()) {
868
+ getLogger3().warn(`sync: server returned error for ${resolvedName}`, { sync: resolvedName, message: normalizedServerError.message });
869
+ }
870
+ cleanupRequest();
871
+ if (typeof responseIndex === "number") {
872
+ socket.emit(buildSyncResponseEventName(responseIndex), normalizedServerError);
873
+ }
874
+ return { ok: false };
875
+ }
876
+ if (serverSyncResult?.status !== "success") {
877
+ if (shouldLogDev()) {
878
+ getLogger3().warn(`sync: ${resolvedName}_server returned invalid response`, { sync: resolvedName });
879
+ }
880
+ cleanupRequest();
881
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.invalidServerResponse" }, preferred: preferredLocale, userLanguage: user?.language });
882
+ return { ok: false };
883
+ }
884
+ return { ok: true, serverOutput: serverSyncResult };
885
+ }
886
+ async function runSyncFanout({
887
+ ioInstance,
888
+ receiver,
889
+ resolvedName,
890
+ normalizedData,
891
+ serverOutput,
892
+ user,
893
+ socket,
894
+ responseIndex,
895
+ buildSyncError,
896
+ preferredLocale,
897
+ cleanupRequest,
898
+ ignoreSelf,
899
+ token,
900
+ cb,
901
+ syncObject,
902
+ functionsObject
903
+ }) {
904
+ const physicalReceiver = receiver === "all" ? "all" : formatRoomName(receiver, { purpose: "broadcast", userId: user?.id ?? null });
905
+ const sockets = physicalReceiver === "all" ? await ioInstance.fetchSockets() : await ioInstance.in(physicalReceiver).fetchSockets();
906
+ if (sockets.length === 0) {
907
+ if (shouldLogDev()) {
908
+ getLogger3().warn("sync: no sockets found for receiver", { receiver, sync: resolvedName });
909
+ }
910
+ cleanupRequest();
911
+ rejectSync({ socket, responseIndex, buildSyncError, response: { status: "error", errorCode: "sync.noReceiversFound" }, preferred: preferredLocale, userLanguage: user?.language });
912
+ return false;
562
913
  }
563
914
  const fanoutPayload = {
564
915
  routeName: resolvedName,
@@ -572,7 +923,10 @@ async function handleSyncRequest({ msg, socket, token }) {
572
923
  const preFanoutResult = await dispatchHook2("preSyncFanout", fanoutPayload);
573
924
  if (preFanoutResult.stopped) {
574
925
  cleanupRequest();
575
- return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
926
+ rejectSync({
927
+ socket,
928
+ responseIndex,
929
+ buildSyncError,
576
930
  response: {
577
931
  status: "error",
578
932
  errorCode: preFanoutResult.signal.errorCode,
@@ -580,152 +934,277 @@ async function handleSyncRequest({ msg, socket, token }) {
580
934
  },
581
935
  preferred: preferredLocale,
582
936
  userLanguage: user?.language
583
- }));
937
+ });
938
+ return false;
584
939
  }
585
- const { fanoutYieldEvery, fanoutYieldMs } = getProjectConfig2().sync;
940
+ let originatorAcked = false;
941
+ if (typeof responseIndex === "number") {
942
+ socket.emit(buildSyncResponseEventName(responseIndex), {
943
+ status: "success",
944
+ message: `sync ${resolvedName} success`,
945
+ result: serverOutput
946
+ });
947
+ originatorAcked = true;
948
+ }
949
+ const { fanoutYieldEvery, fanoutYieldMs } = getProjectConfig3().sync;
950
+ const yieldEvery = Math.max(1, fanoutYieldEvery);
586
951
  let recipientCount = 0;
587
952
  let tempCount = 1;
588
953
  for (const tempSocket of sockets) {
589
954
  tempCount++;
590
- if (tempCount % fanoutYieldEvery === 0) {
955
+ if (tempCount % yieldEvery === 0) {
591
956
  await new Promise((resolve) => setTimeout(resolve, fanoutYieldMs));
592
957
  }
593
958
  const tempToken = extractTokenFromSocket(tempSocket);
594
- if (ignoreSelf && typeof ignoreSelf == "boolean" && token == tempToken) {
959
+ if (ignoreSelf && typeof ignoreSelf === "boolean" && token && token === tempToken) {
960
+ continue;
961
+ }
962
+ const preRecipientResult = await dispatchHook2("preSyncRecipient", {
963
+ routeName: resolvedName,
964
+ receiver,
965
+ recipientSocketId: tempSocket.id,
966
+ recipientUserId: null,
967
+ serverOutput
968
+ });
969
+ if (preRecipientResult.stopped && preRecipientResult.signal.overrideOutput === void 0) {
595
970
  continue;
596
971
  }
972
+ const effectiveServerOutput = preRecipientResult.stopped ? preRecipientResult.signal.overrideOutput : serverOutput;
597
973
  recipientCount++;
598
974
  if (syncObject[`${resolvedName}_client`]) {
599
975
  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
- }
976
+ await processClientSyncForRecipient({
977
+ tempSocket,
978
+ tempToken,
979
+ clientSyncHandler,
980
+ data: normalizedData,
981
+ functionsObject,
982
+ serverOutput: effectiveServerOutput,
983
+ receiver,
984
+ resolvedName,
985
+ callbackKey: cb,
986
+ transport: "socket",
987
+ handlerName: "handleSyncRequest",
988
+ logLabel: "sync",
989
+ shouldLogDev,
990
+ shouldLogStream,
991
+ buildSyncError,
992
+ //? Socket transport prefers `x-language` over `accept-language` —
993
+ //? preserved verbatim from the previous inlined error path.
994
+ resolvePreferredLocale: (headers) => extractLanguageFromHeader(headers["x-language"]) || extractLanguageFromHeader(headers["accept-language"]),
995
+ sourceUserId: user?.id
996
+ });
671
997
  } else {
672
998
  const result = {
673
999
  cb,
674
1000
  fullName: resolvedName,
675
- serverOutput,
1001
+ serverOutput: effectiveServerOutput,
676
1002
  clientOutput: {},
677
- // No client file, so empty output
678
1003
  message: `${resolvedName} sync success`,
679
1004
  status: "success"
680
1005
  };
681
1006
  if (shouldLogDev()) {
682
- getLogger2().debug(`sync: ${resolvedName} server-only success`, { result });
1007
+ getLogger3().debug(`sync: ${resolvedName} server-only success`, { result });
683
1008
  }
684
- tempSocket.emit(socketEventNames2.sync, result);
1009
+ tempSocket.emit(socketEventNames3.sync, result);
685
1010
  }
686
1011
  }
687
1012
  fanoutPayload.recipientCount = recipientCount;
688
1013
  await dispatchHook2("postSyncFanout", fanoutPayload);
689
1014
  cleanupRequest();
690
- return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), {
691
- status: "success",
692
- message: `sync ${resolvedName} success`,
693
- result: serverOutput
1015
+ return originatorAcked;
1016
+ }
1017
+ async function handleSyncRequest(args) {
1018
+ await runWithErrorTrackerIdentityScope(() => handleSyncRequestInner(args));
1019
+ }
1020
+ async function handleSyncRequestInner({ msg, socket, token }) {
1021
+ const ioInstance = getIoInstance2();
1022
+ if (!ioInstance) {
1023
+ return;
1024
+ }
1025
+ const preferredLocale = extractLanguageFromHeader(socket.handshake.headers["x-language"]) || extractLanguageFromHeader(socket.handshake.headers["accept-language"]);
1026
+ if (!validateSyncMessage({ msg, socket, preferredLocale })) return;
1027
+ const { responseIndex } = msg;
1028
+ const preMessageResult = await dispatchHook2("preSocketMessage", {
1029
+ channel: "sync",
1030
+ socketId: socket.id,
1031
+ ip: socket.handshake.address,
1032
+ authenticated: Boolean(token),
1033
+ routeName: typeof msg.name === "string" ? msg.name : void 0
1034
+ });
1035
+ if (preMessageResult.stopped) {
1036
+ return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), normalizeErrorResponse2({
1037
+ response: {
1038
+ status: "error",
1039
+ errorCode: preMessageResult.signal.errorCode,
1040
+ httpStatus: preMessageResult.signal.httpStatus
1041
+ },
1042
+ preferredLocale
1043
+ }));
1044
+ }
1045
+ let currentRouteName;
1046
+ let currentPerRouteFormatter;
1047
+ let currentUserId;
1048
+ const buildSyncError = ({
1049
+ response,
1050
+ preferred,
1051
+ userLanguage
1052
+ }) => buildFormattedError({
1053
+ response,
1054
+ preferred,
1055
+ userLanguage,
1056
+ routeName: currentRouteName,
1057
+ transport: "socket",
1058
+ userId: currentUserId,
1059
+ perRouteFormatter: currentPerRouteFormatter
1060
+ });
1061
+ const parsedFields = parseSyncFields({ msg, socket, responseIndex, preferredLocale, buildSyncError });
1062
+ if (!parsedFields.ok) return;
1063
+ const { name, normalizedData, cb, receiver, resolvedName } = parsedFields;
1064
+ currentRouteName = resolvedName;
1065
+ if (shouldLogDev()) {
1066
+ getLogger3().debug(`sync: ${resolvedName} called`, { sync: resolvedName });
1067
+ }
1068
+ const user = await readSession(token);
1069
+ currentUserId = user?.id;
1070
+ setCurrentErrorTrackerIdentity(user?.id ? { id: user.id, email: user.email ?? void 0, username: user.name ?? void 0 } : null);
1071
+ const { syncObject, functionsObject } = await getRuntimeSyncMaps();
1072
+ const { abortController, cleanupRequest } = setupAbortController({ socket, responseIndex });
1073
+ let originatorAcked = false;
1074
+ const [handlerError] = await tryCatch2(async () => {
1075
+ if (!checkRouteExists({ syncObject, resolvedName, name, socket, responseIndex, buildSyncError, preferredLocale, user, cleanupRequest })) return;
1076
+ const { emitServerSyncStream, emitBroadcastSyncStream, emitStreamToTokens, flushPressure } = buildSyncStreamEmitters({
1077
+ cb,
1078
+ receiver,
1079
+ resolvedName,
1080
+ logLabel: "sync",
1081
+ signal: abortController.signal,
1082
+ originatorSocket: socket,
1083
+ emitOriginatorChunk: (payload) => {
1084
+ if (typeof responseIndex !== "number") return;
1085
+ socket.emit(buildSyncProgressEventName(responseIndex), payload);
1086
+ }
1087
+ });
1088
+ const serverSyncEntry = syncObject[`${resolvedName}_server`];
1089
+ currentPerRouteFormatter = serverSyncEntry?.errorFormatter;
1090
+ if (!runSyncAuth({ serverSyncEntry, user, resolvedName, socket, responseIndex, buildSyncError, preferredLocale, cleanupRequest })) return;
1091
+ if (!runReceiverAuth({ receiver, resolvedName, socket, responseIndex, buildSyncError, preferredLocale, user, cleanupRequest })) return;
1092
+ const rateLimitOk = await applySyncRateLimits({
1093
+ resolvedName,
1094
+ token,
1095
+ socket,
1096
+ user,
1097
+ responseIndex,
1098
+ buildSyncError,
1099
+ preferredLocale,
1100
+ routeLimit: serverSyncEntry?.rateLimit
1101
+ });
1102
+ if (!rateLimitOk) {
1103
+ cleanupRequest();
1104
+ return;
1105
+ }
1106
+ if (!await runPreAuthorizeHook({ resolvedName, normalizedData, user, receiver, socket, responseIndex, buildSyncError, preferredLocale, cleanupRequest })) return;
1107
+ const executionResult = await runSyncServerExecution({
1108
+ serverSyncEntry,
1109
+ resolvedName,
1110
+ normalizedData,
1111
+ user,
1112
+ receiver,
1113
+ socket,
1114
+ responseIndex,
1115
+ buildSyncError,
1116
+ preferredLocale,
1117
+ cleanupRequest,
1118
+ functionsObject,
1119
+ emitServerSyncStream,
1120
+ emitBroadcastSyncStream,
1121
+ emitStreamToTokens,
1122
+ flushPressure,
1123
+ abortController
1124
+ });
1125
+ if (!executionResult.ok) return;
1126
+ const { serverOutput } = executionResult;
1127
+ const fanoutAcked = await runSyncFanout({
1128
+ ioInstance,
1129
+ receiver,
1130
+ resolvedName,
1131
+ normalizedData,
1132
+ serverOutput,
1133
+ user,
1134
+ socket,
1135
+ responseIndex,
1136
+ buildSyncError,
1137
+ preferredLocale,
1138
+ cleanupRequest,
1139
+ ignoreSelf: parsedFields.ignoreSelf,
1140
+ token,
1141
+ cb,
1142
+ syncObject,
1143
+ functionsObject
1144
+ });
1145
+ if (typeof fanoutAcked === "boolean") {
1146
+ originatorAcked = fanoutAcked || originatorAcked;
1147
+ }
1148
+ }, void 0, {
1149
+ handler: "handleSyncRequest",
1150
+ sync: currentRouteName,
1151
+ stage: "handler",
1152
+ userId: currentUserId,
1153
+ receiver,
1154
+ transport: "socket"
694
1155
  });
1156
+ if (handlerError) {
1157
+ if (shouldLogDev()) {
1158
+ getLogger3().error(`sync: unhandled error for ${currentRouteName ?? msg.name}`, handlerError, { sync: currentRouteName });
1159
+ }
1160
+ cleanupRequest();
1161
+ if (!originatorAcked) {
1162
+ return typeof responseIndex == "number" && socket.emit(buildSyncResponseEventName(responseIndex), buildSyncError({
1163
+ response: { status: "error", errorCode: "sync.serverExecutionFailed" },
1164
+ preferred: preferredLocale
1165
+ }));
1166
+ }
1167
+ }
695
1168
  }
696
1169
 
697
1170
  // 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
1171
  import {
1172
+ readSession as readSession2,
1173
+ getProjectConfig as getProjectConfig4,
1174
+ getRuntimeSyncMaps as getRuntimeSyncMapsFromSource,
702
1175
  validateRequest as validateRequest2,
703
1176
  extractTokenFromSocket as extractTokenFromSocket2,
1177
+ formatRoomName as formatRoomName2,
704
1178
  getIoInstance as getIoInstance3,
705
- tryCatch as tryCatch2,
1179
+ tryCatch as tryCatch3,
706
1180
  parseTransportRouteName as parseTransportRouteName2,
707
1181
  checkRateLimit as checkRateLimit2,
708
- socketEventNames as socketEventNames3,
1182
+ socketEventNames as socketEventNames4,
709
1183
  validateInputByType as validateInputByType2,
710
1184
  dispatchHook as dispatchHook3,
711
- getLogger as getLogger3
1185
+ getLogger as getLogger4,
1186
+ runWithErrorTrackerIdentityScope as runWithErrorTrackerIdentityScope2,
1187
+ setCurrentErrorTrackerIdentity as setCurrentErrorTrackerIdentity2,
1188
+ deriveTokenBucketId as deriveTokenBucketId2,
1189
+ isLoopbackIp as isLoopbackIp2,
1190
+ extractLanguageFromHeader as extractLanguageFromHeader2
712
1191
  } 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
1192
  var applyHttpSyncRateLimits = async ({
717
1193
  resolvedName,
718
1194
  token,
719
1195
  requesterIp,
720
1196
  user,
721
1197
  buildSyncError,
722
- preferredLocale
1198
+ preferredLocale,
1199
+ routeLimit
723
1200
  }) => {
724
- const config = getProjectConfig3();
725
- const effectiveSyncLimit = config.rateLimiting.defaultApiLimit;
1201
+ const config = getProjectConfig4();
1202
+ const effectiveSyncLimit = routeLimit === void 0 ? config.rateLimiting.defaultApiLimit : routeLimit;
726
1203
  if (effectiveSyncLimit !== false && effectiveSyncLimit > 0) {
727
- const requesterIdentity = token ?? requesterIp ?? "anonymous";
728
- const keyPrefix = token ? "token" : "ip";
1204
+ const identityCb = config.rateLimiting.identity;
1205
+ const customIdentity = identityCb?.({ routeName: resolvedName, userId: user?.id ?? null, ip: requesterIp ?? "", transport: "http" }) ?? null;
1206
+ const requesterIdentity = customIdentity?.id ?? (token ? deriveTokenBucketId2(token) : requesterIp ?? "unknown");
1207
+ const keyPrefix = customIdentity?.scope ?? (token ? "token" : "ip");
729
1208
  const rateLimitKey = `${keyPrefix}:${requesterIdentity}:sync:${resolvedName}`;
730
1209
  const { allowed, resetIn } = await checkRateLimit2({
731
1210
  key: rateLimitKey,
@@ -734,13 +1213,17 @@ var applyHttpSyncRateLimits = async ({
734
1213
  });
735
1214
  if (!allowed) {
736
1215
  void dispatchHook3("rateLimitExceeded", {
737
- scope: token ? "user" : "route",
1216
+ //? Parity with the API handler: an anonymous per-route bucket is IP-keyed,
1217
+ //? so the scope is `ip` (with `route` still set to mark it a per-route
1218
+ //? bucket vs the global `:sync:all` IP bucket), never `route`.
1219
+ scope: token ? "user" : "ip",
738
1220
  key: rateLimitKey,
739
1221
  limit: effectiveSyncLimit,
740
1222
  windowMs: config.rateLimiting.windowMs,
741
1223
  count: effectiveSyncLimit + 1,
742
1224
  route: resolvedName,
743
- userId: user?.id
1225
+ userId: user?.id,
1226
+ ip: token ? void 0 : requesterIp
744
1227
  });
745
1228
  return buildSyncError({
746
1229
  response: {
@@ -755,7 +1238,7 @@ var applyHttpSyncRateLimits = async ({
755
1238
  }
756
1239
  }
757
1240
  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."));
1241
+ const requesterIsLoopback = config.rateLimiting.skipLoopbackInDev && process.env.NODE_ENV !== "production" && isLoopbackIp2(requesterIp ?? "");
759
1242
  if (!requesterIsLoopback && defaultIpLimit !== false && defaultIpLimit > 0) {
760
1243
  const ipBucket = requesterIp ?? "unknown";
761
1244
  const ipKey = `ip:${ipBucket}:sync:all`;
@@ -787,7 +1270,379 @@ var applyHttpSyncRateLimits = async ({
787
1270
  }
788
1271
  return null;
789
1272
  };
790
- async function handleHttpSyncRequest({
1273
+ async function stageResolveRoute(ctx) {
1274
+ const { name, cb, normalizedReceiver, user, buildSyncError, preferredLocale } = ctx;
1275
+ if (!name || typeof name !== "string") {
1276
+ return {
1277
+ error: buildSyncError({
1278
+ response: { status: "error", errorCode: "sync.invalidRequest" },
1279
+ preferred: preferredLocale,
1280
+ userLanguage: user?.language
1281
+ })
1282
+ };
1283
+ }
1284
+ const parsedRoute = parseTransportRouteName2({ value: name, prefix: "sync" });
1285
+ if (parsedRoute.status === "error") {
1286
+ return {
1287
+ error: buildSyncError({
1288
+ response: {
1289
+ status: "error",
1290
+ errorCode: "routing.invalidServiceRouteName",
1291
+ errorParams: [{ key: "name", value: name }]
1292
+ },
1293
+ preferred: preferredLocale,
1294
+ userLanguage: user?.language
1295
+ })
1296
+ };
1297
+ }
1298
+ const resolvedName = parsedRoute.normalizedFullName;
1299
+ const callbackName = typeof cb === "string" && cb.trim().length > 0 ? cb.trim() : `${parsedRoute.serviceRoute.normalizedRouteName}/${parsedRoute.version}`;
1300
+ if (!normalizedReceiver) {
1301
+ return {
1302
+ error: buildSyncError({
1303
+ response: { status: "error", errorCode: "sync.missingReceiver" },
1304
+ preferred: preferredLocale,
1305
+ userLanguage: user?.language
1306
+ })
1307
+ };
1308
+ }
1309
+ const { syncObject } = await getRuntimeSyncMapsFromSource();
1310
+ if (!syncObject[`${resolvedName}_client`] && !syncObject[`${resolvedName}_server`]) {
1311
+ return {
1312
+ error: buildSyncError({
1313
+ response: { status: "error", errorCode: "sync.notFound" },
1314
+ preferred: preferredLocale,
1315
+ userLanguage: user?.language
1316
+ })
1317
+ };
1318
+ }
1319
+ if (!syncObject[`${resolvedName}_server`]) {
1320
+ if (shouldLogDev()) {
1321
+ getLogger4().warn(`http sync: ${resolvedName} has a _client file but no required _server file`, { sync: resolvedName, transport: "http" });
1322
+ }
1323
+ return {
1324
+ error: buildSyncError({
1325
+ response: { status: "error", errorCode: "sync.notFound" },
1326
+ preferred: preferredLocale,
1327
+ userLanguage: user?.language
1328
+ })
1329
+ };
1330
+ }
1331
+ return { resolvedName, callbackName };
1332
+ }
1333
+ function stageCheckAuth(serverSyncEntry, ctx) {
1334
+ const { user, preferredLocale, buildSyncError } = ctx;
1335
+ const { auth } = serverSyncEntry;
1336
+ if (auth.login && !user?.id) {
1337
+ return buildSyncError({
1338
+ response: { status: "error", errorCode: "auth.required" },
1339
+ preferred: preferredLocale
1340
+ });
1341
+ }
1342
+ if (auth.additional && auth.additional.length > 0 && !user) {
1343
+ return buildSyncError({
1344
+ response: { status: "error", errorCode: "auth.required" },
1345
+ preferred: preferredLocale
1346
+ });
1347
+ }
1348
+ const validationResult = validateRequest2({ auth, user });
1349
+ if (validationResult.status === "error") {
1350
+ return buildSyncError({
1351
+ response: {
1352
+ status: "error",
1353
+ errorCode: validationResult.errorCode || "auth.forbidden",
1354
+ errorParams: validationResult.errorParams,
1355
+ httpStatus: validationResult.httpStatus
1356
+ },
1357
+ preferred: preferredLocale,
1358
+ userLanguage: user?.language
1359
+ });
1360
+ }
1361
+ return null;
1362
+ }
1363
+ function stageAuthorizeReceiver(resolvedName, ctx) {
1364
+ const { normalizedReceiver, user, preferredLocale, buildSyncError } = ctx;
1365
+ const syncConfig = getProjectConfig4().sync;
1366
+ const receiverAuth = authorizeSyncReceiver({
1367
+ receiver: normalizedReceiver,
1368
+ allowClientReceiverAll: syncConfig.allowClientReceiverAll,
1369
+ requireRoomMembership: syncConfig.requireRoomMembership,
1370
+ isMember: user ? () => Boolean(user.roomCodes?.includes(normalizedReceiver)) : null
1371
+ });
1372
+ if (!receiverAuth.allowed) {
1373
+ if (shouldLogDev()) {
1374
+ getLogger4().warn(`http sync: receiver authorization failed for ${resolvedName}`, { sync: resolvedName, receiver: normalizedReceiver, errorCode: receiverAuth.errorCode, transport: "http" });
1375
+ }
1376
+ return buildSyncError({
1377
+ response: { status: "error", errorCode: receiverAuth.errorCode, httpStatus: 403 },
1378
+ preferred: preferredLocale,
1379
+ userLanguage: user?.language
1380
+ });
1381
+ }
1382
+ return null;
1383
+ }
1384
+ async function stageRunAuthorizeHooks(resolvedName, ctx) {
1385
+ const { data, normalizedReceiver, user, preferredLocale, buildSyncError } = ctx;
1386
+ const preAuthorizeResult = await dispatchHook3("preSyncAuthorize", {
1387
+ routeName: resolvedName,
1388
+ data,
1389
+ user,
1390
+ receiver: normalizedReceiver,
1391
+ transport: "http"
1392
+ });
1393
+ if (preAuthorizeResult.stopped) {
1394
+ return buildSyncError({
1395
+ response: {
1396
+ status: "error",
1397
+ errorCode: preAuthorizeResult.signal.errorCode,
1398
+ httpStatus: preAuthorizeResult.signal.httpStatus
1399
+ },
1400
+ preferred: preferredLocale,
1401
+ userLanguage: user?.language
1402
+ });
1403
+ }
1404
+ void dispatchHook3("postSyncAuthorize", {
1405
+ routeName: resolvedName,
1406
+ data,
1407
+ user,
1408
+ receiver: normalizedReceiver,
1409
+ transport: "http"
1410
+ });
1411
+ return null;
1412
+ }
1413
+ async function stageValidateInput(resolvedName, serverSyncEntry, ctx) {
1414
+ const { data, normalizedReceiver, user, preferredLocale, buildSyncError } = ctx;
1415
+ const preValidateResult = await dispatchHook3("preSyncValidate", {
1416
+ routeName: resolvedName,
1417
+ data,
1418
+ user,
1419
+ receiver: normalizedReceiver,
1420
+ transport: "http"
1421
+ });
1422
+ if (preValidateResult.stopped) {
1423
+ return buildSyncError({
1424
+ response: {
1425
+ status: "error",
1426
+ errorCode: preValidateResult.signal.errorCode,
1427
+ httpStatus: preValidateResult.signal.httpStatus
1428
+ },
1429
+ preferred: preferredLocale,
1430
+ userLanguage: user?.language
1431
+ });
1432
+ }
1433
+ const { inputType, inputTypeFilePath } = serverSyncEntry;
1434
+ if (resolveSyncValidationMode(serverSyncEntry.validation) === "strict") {
1435
+ const inputValidation = await validateInputByType2({
1436
+ typeText: inputType,
1437
+ value: data,
1438
+ rootKey: "clientInput",
1439
+ filePath: inputTypeFilePath
1440
+ });
1441
+ void dispatchHook3("postSyncValidate", {
1442
+ routeName: resolvedName,
1443
+ data,
1444
+ user,
1445
+ receiver: normalizedReceiver,
1446
+ transport: "http",
1447
+ validation: inputValidation.status === "error" ? { status: "error", message: inputValidation.message } : { status: "success" }
1448
+ });
1449
+ if (inputValidation.status === "error") {
1450
+ if (shouldLogDev()) {
1451
+ getLogger4().warn(`http sync: input validation failed for ${resolvedName}`, { sync: resolvedName, message: inputValidation.message, transport: "http" });
1452
+ }
1453
+ return buildSyncError({
1454
+ response: {
1455
+ status: "error",
1456
+ errorCode: "sync.invalidInputType"
1457
+ },
1458
+ preferred: preferredLocale,
1459
+ userLanguage: user?.language
1460
+ });
1461
+ }
1462
+ } else {
1463
+ void dispatchHook3("postSyncValidate", {
1464
+ routeName: resolvedName,
1465
+ data,
1466
+ user,
1467
+ receiver: normalizedReceiver,
1468
+ transport: "http",
1469
+ validation: { status: "success" }
1470
+ });
1471
+ }
1472
+ return null;
1473
+ }
1474
+ async function stageExecuteServer(resolvedName, serverSyncEntry, functionsObject, ctx) {
1475
+ const { data, normalizedReceiver, name, user, preferredLocale, buildSyncError, effectiveAbortSignal, cb, stream } = ctx;
1476
+ const { main: serverMain } = serverSyncEntry;
1477
+ const { emitServerSyncStream, emitBroadcastSyncStream, emitStreamToTokens, flushPressure } = buildSyncStreamEmitters({
1478
+ cb,
1479
+ receiver: normalizedReceiver,
1480
+ resolvedName,
1481
+ logLabel: "http sync",
1482
+ signal: effectiveAbortSignal,
1483
+ //? No originatorSocket for HTTP/SSE — `flushPressure` falls back
1484
+ //? to room-socket measurement only. SSE backpressure is the
1485
+ //? caller's responsibility (Node's `res.write` returns a bool).
1486
+ //? Originator chunks travel back via SSE; broadcast / targeted
1487
+ //? chunks still flow over Socket.io to recipients in the receiver room.
1488
+ emitOriginatorChunk: (payload) => {
1489
+ stream?.(payload);
1490
+ }
1491
+ });
1492
+ const executePayload = {
1493
+ routeName: resolvedName,
1494
+ data,
1495
+ user,
1496
+ receiver: normalizedReceiver,
1497
+ transport: "http",
1498
+ result: void 0,
1499
+ error: null,
1500
+ durationMs: 0
1501
+ };
1502
+ const preExecuteResult = await dispatchHook3("preSyncExecute", executePayload);
1503
+ if (preExecuteResult.stopped) {
1504
+ return {
1505
+ error: buildSyncError({
1506
+ response: {
1507
+ status: "error",
1508
+ errorCode: preExecuteResult.signal.errorCode,
1509
+ httpStatus: preExecuteResult.signal.httpStatus
1510
+ },
1511
+ preferred: preferredLocale,
1512
+ userLanguage: user?.language
1513
+ })
1514
+ };
1515
+ }
1516
+ const executeStart = Date.now();
1517
+ const [serverSyncError, serverSyncResult] = await tryCatch3(
1518
+ async () => await serverMain({
1519
+ clientInput: data,
1520
+ user,
1521
+ functions: functionsObject,
1522
+ roomCode: normalizedReceiver,
1523
+ stream: emitServerSyncStream,
1524
+ broadcastStream: emitBroadcastSyncStream,
1525
+ streamTo: emitStreamToTokens,
1526
+ abortSignal: effectiveAbortSignal,
1527
+ flushPressure
1528
+ }),
1529
+ void 0,
1530
+ {
1531
+ handler: "handleHttpSyncRequest",
1532
+ sync: resolvedName,
1533
+ stage: "server",
1534
+ userId: user?.id,
1535
+ receiver: name,
1536
+ transport: "http"
1537
+ }
1538
+ );
1539
+ executePayload.result = serverSyncResult;
1540
+ executePayload.error = serverSyncError ?? null;
1541
+ executePayload.durationMs = Date.now() - executeStart;
1542
+ await dispatchHook3("postSyncExecute", executePayload);
1543
+ if (serverSyncError) {
1544
+ return {
1545
+ error: buildSyncError({
1546
+ response: { status: "error", errorCode: "sync.serverExecutionFailed" },
1547
+ preferred: preferredLocale,
1548
+ userLanguage: user?.language
1549
+ })
1550
+ };
1551
+ }
1552
+ if (serverSyncResult?.status == "error") {
1553
+ return {
1554
+ error: buildSyncError({
1555
+ response: serverSyncResult,
1556
+ preferred: preferredLocale,
1557
+ userLanguage: user?.language
1558
+ })
1559
+ };
1560
+ }
1561
+ if (serverSyncResult?.status !== "success") {
1562
+ return {
1563
+ error: buildSyncError({
1564
+ response: { status: "error", errorCode: "sync.invalidServerResponse" },
1565
+ preferred: preferredLocale,
1566
+ userLanguage: user?.language
1567
+ })
1568
+ };
1569
+ }
1570
+ return { serverOutput: serverSyncResult };
1571
+ }
1572
+ async function stageFanout(resolvedName, callbackName, serverOutput, syncObject, functionsObject, ctx) {
1573
+ const { data, normalizedReceiver, token, ignoreSelf, user, buildSyncError } = ctx;
1574
+ const ioInstance = getIoInstance3();
1575
+ const fanoutPayload = {
1576
+ routeName: resolvedName,
1577
+ data,
1578
+ user,
1579
+ receiver: normalizedReceiver,
1580
+ serverOutput,
1581
+ transport: "http",
1582
+ recipientCount: 0
1583
+ };
1584
+ await dispatchHook3("preSyncFanout", fanoutPayload);
1585
+ const physicalReceiver = normalizedReceiver === "all" ? "all" : formatRoomName2(normalizedReceiver, { purpose: "broadcast", userId: user?.id ?? null });
1586
+ const sockets = physicalReceiver === "all" ? await ioInstance.fetchSockets() : await ioInstance.in(physicalReceiver).fetchSockets();
1587
+ let recipientCount = 0;
1588
+ for (const tempSocket of sockets) {
1589
+ const tempToken = extractTokenFromSocket2(tempSocket);
1590
+ if (ignoreSelf && typeof ignoreSelf === "boolean" && token && token === tempToken) {
1591
+ continue;
1592
+ }
1593
+ const preRecipientResult = await dispatchHook3("preSyncRecipient", {
1594
+ routeName: resolvedName,
1595
+ receiver: normalizedReceiver,
1596
+ recipientSocketId: tempSocket.id,
1597
+ recipientUserId: null,
1598
+ serverOutput
1599
+ });
1600
+ if (preRecipientResult.stopped) {
1601
+ continue;
1602
+ }
1603
+ recipientCount++;
1604
+ if (syncObject[`${resolvedName}_client`]) {
1605
+ const clientSyncHandler = syncObject[`${resolvedName}_client`];
1606
+ await processClientSyncForRecipient({
1607
+ tempSocket,
1608
+ tempToken,
1609
+ clientSyncHandler,
1610
+ data,
1611
+ functionsObject,
1612
+ serverOutput,
1613
+ receiver: normalizedReceiver,
1614
+ resolvedName,
1615
+ callbackKey: callbackName,
1616
+ transport: "http",
1617
+ handlerName: "handleHttpSyncRequest",
1618
+ logLabel: "http sync",
1619
+ shouldLogDev,
1620
+ shouldLogStream,
1621
+ buildSyncError,
1622
+ //? HTTP transport prefers `accept-language` over `x-language`
1623
+ //? (reversed vs. the socket handler) — preserved verbatim from the
1624
+ //? previous inlined error path.
1625
+ resolvePreferredLocale: (headers) => extractLanguageFromHeader2(headers["accept-language"] || headers["x-language"]),
1626
+ sourceUserId: user?.id
1627
+ });
1628
+ continue;
1629
+ }
1630
+ tempSocket.emit(socketEventNames4.sync, {
1631
+ cb: callbackName,
1632
+ fullName: resolvedName,
1633
+ serverOutput,
1634
+ clientOutput: {},
1635
+ message: `${resolvedName} sync success`,
1636
+ status: "success"
1637
+ });
1638
+ }
1639
+ fanoutPayload.recipientCount = recipientCount;
1640
+ await dispatchHook3("postSyncFanout", fanoutPayload);
1641
+ }
1642
+ async function handleHttpSyncRequest(args) {
1643
+ return runWithErrorTrackerIdentityScope2(() => handleHttpSyncRequestScoped(args));
1644
+ }
1645
+ async function handleHttpSyncRequestScoped({
791
1646
  name,
792
1647
  cb,
793
1648
  data,
@@ -800,51 +1655,31 @@ async function handleHttpSyncRequest({
800
1655
  stream,
801
1656
  abortSignal
802
1657
  }) {
803
- if (shouldLogDev2()) {
804
- getLogger3().debug(`http sync: ${name} called`);
1658
+ if (shouldLogDev()) {
1659
+ getLogger4().debug(`http sync: ${name} called`);
805
1660
  }
806
1661
  const effectiveAbortSignal = abortSignal ?? new AbortController().signal;
807
1662
  const normalizedReceiver = typeof receiver === "string" ? receiver.trim() : "";
808
1663
  const preferredLocale = extractLanguageFromHeader2(xLanguageHeader) || extractLanguageFromHeader2(acceptLanguageHeader);
809
- const user = await getSession2(token);
1664
+ const user = await readSession2(token);
1665
+ setCurrentErrorTrackerIdentity2(user?.id ? { id: user.id, email: user.email ?? void 0, username: user.name ?? void 0 } : null);
810
1666
  let currentRouteName;
811
1667
  let currentPerRouteFormatter;
812
1668
  const buildSyncError = ({
813
1669
  response,
814
1670
  preferred,
815
1671
  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
- };
1672
+ }) => buildFormattedError({
1673
+ response,
1674
+ preferred,
1675
+ userLanguage,
1676
+ routeName: currentRouteName,
1677
+ transport: "http",
1678
+ userId: user?.id,
1679
+ perRouteFormatter: currentPerRouteFormatter
1680
+ });
846
1681
  const ioInstance = getIoInstance3();
847
- const [bodyError, bodyResult] = await tryCatch2(async () => {
1682
+ const [bodyError, bodyResult] = await tryCatch3(async () => {
848
1683
  if (!ioInstance) {
849
1684
  return buildSyncError({
850
1685
  response: { status: "error", errorCode: "sync.ioUnavailable" },
@@ -852,285 +1687,66 @@ async function handleHttpSyncRequest({
852
1687
  userLanguage: user?.language
853
1688
  });
854
1689
  }
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;
1690
+ const ctx = {
1691
+ name,
1692
+ cb,
1693
+ data,
1694
+ normalizedReceiver,
1695
+ ignoreSelf,
1696
+ token,
1697
+ requesterIp,
1698
+ stream,
1699
+ effectiveAbortSignal,
1700
+ preferredLocale,
1701
+ user,
1702
+ buildSyncError
1703
+ };
1704
+ const routeResult = await stageResolveRoute(ctx);
1705
+ if (routeResult.error) return routeResult.error;
1706
+ const { resolvedName, callbackName } = routeResult;
875
1707
  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
1708
  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
1709
  const serverSyncEntry = syncObject[`${resolvedName}_server`];
893
1710
  currentPerRouteFormatter = serverSyncEntry?.errorFormatter;
894
1711
  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
- });
1712
+ const authError = stageCheckAuth(serverSyncEntry, ctx);
1713
+ if (authError) return authError;
933
1714
  }
1715
+ const receiverError = stageAuthorizeReceiver(resolvedName, ctx);
1716
+ if (receiverError) return receiverError;
934
1717
  const rateLimitResult = await applyHttpSyncRateLimits({
935
1718
  resolvedName,
936
1719
  token,
937
1720
  requesterIp,
938
1721
  user,
939
1722
  buildSyncError,
940
- preferredLocale
1723
+ preferredLocale,
1724
+ routeLimit: serverSyncEntry?.rateLimit
941
1725
  });
942
1726
  if (rateLimitResult) return rateLimitResult;
1727
+ const authorizeHookError = await stageRunAuthorizeHooks(resolvedName, ctx);
1728
+ if (authorizeHookError) return authorizeHookError;
943
1729
  let serverOutput = {};
944
1730
  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++;
1731
+ const validationError = await stageValidateInput(resolvedName, serverSyncEntry, ctx);
1732
+ if (validationError) return validationError;
1733
+ const executeResult = await stageExecuteServer(resolvedName, serverSyncEntry, functionsObject, ctx);
1734
+ if (executeResult.error) return executeResult.error;
1735
+ serverOutput = executeResult.serverOutput;
1119
1736
  }
1120
- fanoutPayload.recipientCount = recipientCount;
1121
- await dispatchHook3("postSyncFanout", fanoutPayload);
1122
- if (shouldLogDev2()) {
1123
- getLogger3().debug(`http sync: ${resolvedName} completed`);
1737
+ await stageFanout(resolvedName, callbackName, serverOutput, syncObject, functionsObject, ctx);
1738
+ if (shouldLogDev()) {
1739
+ getLogger4().debug(`http sync: ${resolvedName} completed`);
1124
1740
  }
1125
1741
  const serverMessage = serverOutput.message;
1126
1742
  return {
1127
- ...serverOutput,
1128
1743
  status: "success",
1129
- message: typeof serverMessage === "string" ? serverMessage : `${resolvedName} sync success`
1744
+ message: typeof serverMessage === "string" ? serverMessage : `${resolvedName} sync success`,
1745
+ result: serverOutput
1130
1746
  };
1131
1747
  });
1132
1748
  if (bodyError) {
1133
- getLogger3().error(`http sync: ${name} threw`, bodyError, { sync: name });
1749
+ getLogger4().error(`http sync: ${name} threw`, bodyError, { sync: name });
1134
1750
  return buildSyncError({
1135
1751
  response: { status: "error", errorCode: "sync.serverExecutionFailed" },
1136
1752
  preferred: preferredLocale,
@@ -1145,9 +1761,9 @@ async function handleHttpSyncRequest({
1145
1761
  }
1146
1762
 
1147
1763
  // src/streamThrottle.ts
1148
- import { getProjectConfig as getProjectConfig4 } from "@luckystack/core";
1764
+ import { getProjectConfig as getProjectConfig5 } from "@luckystack/core";
1149
1765
  var createStreamThrottle = (options = {}) => {
1150
- const defaults = getProjectConfig4().sync.streamThrottle;
1766
+ const defaults = getProjectConfig5().sync.streamThrottle;
1151
1767
  const flushAtChars = options.flushAtChars ?? defaults.flushAtChars;
1152
1768
  const flushEveryMs = options.flushEveryMs ?? defaults.flushEveryMs;
1153
1769
  const field = options.field ?? defaults.field;