shardwire 0.0.1 → 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/dist/index.js CHANGED
@@ -30,7 +30,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- createShardwire: () => createShardwire
33
+ createShardwire: () => createShardwire,
34
+ fromSafeParseSchema: () => fromSafeParseSchema,
35
+ fromZodSchema: () => fromZodSchema
34
36
  });
35
37
  module.exports = __toCommonJS(index_exports);
36
38
 
@@ -100,6 +102,14 @@ function stringifyEnvelope(envelope) {
100
102
  }
101
103
 
102
104
  // src/runtime/validation.ts
105
+ var PayloadValidationError = class extends Error {
106
+ constructor(message, details) {
107
+ super(message);
108
+ this.details = details;
109
+ this.name = "PayloadValidationError";
110
+ }
111
+ details;
112
+ };
103
113
  function isNonEmptyString(value) {
104
114
  return typeof value === "string" && value.trim().length > 0;
105
115
  }
@@ -113,8 +123,16 @@ function assertHostOptions(options) {
113
123
  throw new Error("Host mode requires a server configuration.");
114
124
  }
115
125
  assertPositiveNumber("server.port", options.server.port);
116
- if (!isNonEmptyString(options.server.secret)) {
117
- throw new Error("server.secret is required.");
126
+ if (!Array.isArray(options.server.secrets) || options.server.secrets.length === 0) {
127
+ throw new Error("server.secrets must contain at least one secret.");
128
+ }
129
+ for (const [index, secret] of options.server.secrets.entries()) {
130
+ if (!isNonEmptyString(secret)) {
131
+ throw new Error(`server.secrets[${index}] must be a non-empty string.`);
132
+ }
133
+ }
134
+ if (options.server.primarySecretId !== void 0 && !options.server.secrets.some((_, index) => options.server.primarySecretId === `s${index}`)) {
135
+ throw new Error("server.primarySecretId must reference an existing secret id.");
118
136
  }
119
137
  if (options.server.heartbeatMs !== void 0) {
120
138
  assertPositiveNumber("server.heartbeatMs", options.server.heartbeatMs);
@@ -133,6 +151,12 @@ function assertConsumerOptions(options) {
133
151
  if (!isNonEmptyString(options.secret)) {
134
152
  throw new Error("Consumer mode requires `secret`.");
135
153
  }
154
+ if (options.secretId !== void 0 && !isNonEmptyString(options.secretId)) {
155
+ throw new Error("Consumer option `secretId` must be a non-empty string.");
156
+ }
157
+ if (options.clientName !== void 0 && !isNonEmptyString(options.clientName)) {
158
+ throw new Error("Consumer option `clientName` must be a non-empty string.");
159
+ }
136
160
  if (options.requestTimeoutMs !== void 0) {
137
161
  assertPositiveNumber("requestTimeoutMs", options.requestTimeoutMs);
138
162
  }
@@ -155,9 +179,54 @@ function assertJsonPayload(kind, name, payload) {
155
179
  throw new Error(`${kind} "${name}" payload must be JSON-serializable.`);
156
180
  }
157
181
  }
182
+ function normalizeSchemaIssues(error) {
183
+ if (!error || typeof error !== "object") {
184
+ return void 0;
185
+ }
186
+ const issues = error.issues;
187
+ if (!Array.isArray(issues)) {
188
+ return void 0;
189
+ }
190
+ const normalized = issues.map((issue) => {
191
+ if (!issue || typeof issue !== "object") {
192
+ return null;
193
+ }
194
+ const message = issue.message;
195
+ const rawPath = issue.path;
196
+ if (typeof message !== "string") {
197
+ return null;
198
+ }
199
+ const path = Array.isArray(rawPath) && rawPath.length > 0 ? rawPath.map((segment) => typeof segment === "string" || typeof segment === "number" ? String(segment) : "").filter(Boolean).join(".") : "";
200
+ return { path, message };
201
+ }).filter((issue) => Boolean(issue));
202
+ return normalized.length > 0 ? normalized : void 0;
203
+ }
204
+ function parsePayloadWithSchema(schema, payload, context) {
205
+ if (!schema) {
206
+ return payload;
207
+ }
208
+ try {
209
+ return schema.parse(payload);
210
+ } catch (error) {
211
+ const message = error instanceof Error && error.message.trim().length > 0 ? error.message : `Payload validation failed for ${context.stage} "${context.name}".`;
212
+ const issues = normalizeSchemaIssues(error);
213
+ throw new PayloadValidationError(message, {
214
+ ...context,
215
+ ...issues ? { issues } : {}
216
+ });
217
+ }
218
+ }
158
219
 
159
220
  // src/consumer/index.ts
160
221
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
222
+ var CommandRequestError = class extends Error {
223
+ constructor(code, message) {
224
+ super(message);
225
+ this.code = code;
226
+ this.name = "CommandRequestError";
227
+ }
228
+ code;
229
+ };
161
230
  function createConsumerShardwire(options) {
162
231
  const logger = withLogger(options.logger);
163
232
  const reconnectEnabled = options.reconnect?.enabled ?? true;
@@ -174,8 +243,12 @@ function createConsumerShardwire(options) {
174
243
  let connectResolve = null;
175
244
  let connectReject = null;
176
245
  let authTimeoutTimer = null;
246
+ let currentConnectionId = null;
177
247
  const pendingRequests = /* @__PURE__ */ new Map();
178
248
  const eventHandlers = /* @__PURE__ */ new Map();
249
+ const connectedHandlers = /* @__PURE__ */ new Set();
250
+ const disconnectedHandlers = /* @__PURE__ */ new Set();
251
+ const reconnectingHandlers = /* @__PURE__ */ new Set();
179
252
  function clearAuthTimeout() {
180
253
  if (authTimeoutTimer) {
181
254
  clearTimeout(authTimeoutTimer);
@@ -204,10 +277,10 @@ function createConsumerShardwire(options) {
204
277
  }
205
278
  socket.send(data);
206
279
  }
207
- function rejectAllPending(reason) {
280
+ function rejectAllPending(code, reason) {
208
281
  for (const [requestId, pending] of pendingRequests.entries()) {
209
282
  clearTimeout(pending.timer);
210
- pending.reject(new Error(reason));
283
+ pending.reject(new CommandRequestError(code, reason));
211
284
  pendingRequests.delete(requestId);
212
285
  }
213
286
  }
@@ -217,6 +290,13 @@ function createConsumerShardwire(options) {
217
290
  }
218
291
  const delay = getBackoffDelay(reconnectAttempts, { initialDelayMs, maxDelayMs, jitter });
219
292
  reconnectAttempts += 1;
293
+ for (const handler of reconnectingHandlers) {
294
+ try {
295
+ handler({ attempt: reconnectAttempts, delayMs: delay, at: Date.now() });
296
+ } catch (error) {
297
+ logger.warn("Reconnect handler threw an error.", { error: String(error) });
298
+ }
299
+ }
220
300
  reconnectTimer = setTimeout(() => {
221
301
  reconnectTimer = null;
222
302
  void connect().catch((error) => {
@@ -255,7 +335,12 @@ function createConsumerShardwire(options) {
255
335
  socket.on("open", () => {
256
336
  reconnectAttempts = 0;
257
337
  isAuthed = false;
258
- const hello = makeEnvelope("auth.hello", { secret: options.secret });
338
+ currentConnectionId = null;
339
+ const hello = makeEnvelope("auth.hello", {
340
+ secret: options.secret,
341
+ secretId: options.secretId,
342
+ clientName: options.clientName
343
+ });
259
344
  socket?.send(stringifyEnvelope(hello));
260
345
  authTimeoutTimer = setTimeout(() => {
261
346
  if (!isAuthed) {
@@ -271,6 +356,18 @@ function createConsumerShardwire(options) {
271
356
  switch (envelope.type) {
272
357
  case "auth.ok":
273
358
  isAuthed = true;
359
+ if (envelope.payload && typeof envelope.payload === "object" && "connectionId" in envelope.payload && typeof envelope.payload.connectionId === "string") {
360
+ currentConnectionId = envelope.payload.connectionId;
361
+ }
362
+ if (currentConnectionId) {
363
+ for (const handler of connectedHandlers) {
364
+ try {
365
+ handler({ connectionId: currentConnectionId, connectedAt: Date.now() });
366
+ } catch (error) {
367
+ logger.warn("Connected handler threw an error.", { error: String(error) });
368
+ }
369
+ }
370
+ }
274
371
  resolveConnect();
275
372
  break;
276
373
  case "auth.error": {
@@ -280,7 +377,7 @@ function createConsumerShardwire(options) {
280
377
  message: payload.message
281
378
  });
282
379
  rejectConnect(payload.message);
283
- rejectAllPending("Shardwire authentication failed.");
380
+ rejectAllPending("UNAUTHORIZED", "Shardwire authentication failed.");
284
381
  socket?.close();
285
382
  break;
286
383
  }
@@ -319,8 +416,22 @@ function createConsumerShardwire(options) {
319
416
  }
320
417
  });
321
418
  socket.on("close", () => {
419
+ const willReconnect = !isClosed && reconnectEnabled;
322
420
  rejectConnect("Shardwire connection closed.");
323
421
  isAuthed = false;
422
+ currentConnectionId = null;
423
+ rejectAllPending("DISCONNECTED", "Shardwire connection closed before command completed.");
424
+ for (const handler of disconnectedHandlers) {
425
+ try {
426
+ handler({
427
+ reason: "Shardwire connection closed.",
428
+ at: Date.now(),
429
+ willReconnect
430
+ });
431
+ } catch (error) {
432
+ logger.warn("Disconnected handler threw an error.", { error: String(error) });
433
+ }
434
+ }
324
435
  if (!isClosed) {
325
436
  scheduleReconnect();
326
437
  }
@@ -347,7 +458,7 @@ function createConsumerShardwire(options) {
347
458
  requestId: sendOptions?.requestId ?? "unknown",
348
459
  ts: Date.now(),
349
460
  error: {
350
- code: "AUTH_ERROR",
461
+ code: "UNAUTHORIZED",
351
462
  message: error instanceof Error ? error.message : "Failed to authenticate."
352
463
  }
353
464
  };
@@ -359,7 +470,7 @@ function createConsumerShardwire(options) {
359
470
  requestId: sendOptions?.requestId ?? "unknown",
360
471
  ts: Date.now(),
361
472
  error: {
362
- code: "TIMEOUT",
473
+ code: "DISCONNECTED",
363
474
  message: "Not connected to Shardwire host."
364
475
  }
365
476
  };
@@ -369,9 +480,13 @@ function createConsumerShardwire(options) {
369
480
  const promise = new Promise((resolve, reject) => {
370
481
  const timer = setTimeout(() => {
371
482
  pendingRequests.delete(requestId);
372
- reject(new Error(`Command "${name}" timed out after ${timeoutMs}ms.`));
483
+ reject(new CommandRequestError("TIMEOUT", `Command "${name}" timed out after ${timeoutMs}ms.`));
373
484
  }, timeoutMs);
374
- pendingRequests.set(requestId, { resolve, reject, timer });
485
+ pendingRequests.set(requestId, {
486
+ resolve,
487
+ reject: (error) => reject(error),
488
+ timer
489
+ });
375
490
  });
376
491
  sendRaw(
377
492
  stringifyEnvelope(
@@ -388,13 +503,14 @@ function createConsumerShardwire(options) {
388
503
  try {
389
504
  return await promise;
390
505
  } catch (error) {
506
+ const failureCode = error instanceof CommandRequestError ? error.code : !socket || socket.readyState !== 1 ? "DISCONNECTED" : "TIMEOUT";
391
507
  return {
392
508
  ok: false,
393
509
  requestId,
394
510
  ts: Date.now(),
395
511
  error: {
396
- code: "TIMEOUT",
397
- message: error instanceof Error ? error.message : "Command request timeout."
512
+ code: failureCode,
513
+ message: error instanceof Error ? error.message : "Command request failed."
398
514
  }
399
515
  };
400
516
  }
@@ -430,18 +546,43 @@ function createConsumerShardwire(options) {
430
546
  eventHandlers.delete(name);
431
547
  }
432
548
  },
549
+ onConnected(handler) {
550
+ connectedHandlers.add(handler);
551
+ return () => {
552
+ connectedHandlers.delete(handler);
553
+ };
554
+ },
555
+ onDisconnected(handler) {
556
+ disconnectedHandlers.add(handler);
557
+ return () => {
558
+ disconnectedHandlers.delete(handler);
559
+ };
560
+ },
561
+ onReconnecting(handler) {
562
+ reconnectingHandlers.add(handler);
563
+ return () => {
564
+ reconnectingHandlers.delete(handler);
565
+ };
566
+ },
567
+ ready() {
568
+ return connect();
569
+ },
433
570
  connected() {
434
571
  return Boolean(socket && socket.readyState === 1 && isAuthed);
435
572
  },
573
+ connectionId() {
574
+ return currentConnectionId;
575
+ },
436
576
  async close() {
437
577
  isClosed = true;
438
578
  isAuthed = false;
579
+ currentConnectionId = null;
439
580
  rejectConnect("Shardwire consumer has been closed.");
440
581
  if (reconnectTimer) {
441
582
  clearTimeout(reconnectTimer);
442
583
  reconnectTimer = null;
443
584
  }
444
- rejectAllPending("Shardwire consumer has been closed.");
585
+ rejectAllPending("DISCONNECTED", "Shardwire consumer has been closed.");
445
586
  if (!socket) {
446
587
  return;
447
588
  }
@@ -525,6 +666,9 @@ function isSecretValid(provided, expected) {
525
666
  }
526
667
  return (0, import_node_crypto2.timingSafeEqual)(providedBuffer, expectedBuffer);
527
668
  }
669
+ function getSecretId(secretIndex) {
670
+ return `s${secretIndex}`;
671
+ }
528
672
 
529
673
  // src/transport/ws/host-server.ts
530
674
  var CLOSE_AUTH_REQUIRED = 4001;
@@ -643,13 +787,33 @@ var HostWebSocketServer = class {
643
787
  return;
644
788
  }
645
789
  const payload = envelope.payload;
646
- if (!payload?.secret || !isSecretValid(payload.secret, this.config.options.server.secret)) {
790
+ const providedSecret = payload?.secret;
791
+ const knownSecrets = this.config.options.server.secrets;
792
+ const secretId = payload?.secretId;
793
+ let authReason = null;
794
+ if (!providedSecret) {
795
+ authReason = "invalid_secret";
796
+ } else if (secretId) {
797
+ const secretIndex = knownSecrets.findIndex((_, index) => getSecretId(index) === secretId);
798
+ if (secretIndex < 0) {
799
+ authReason = "unknown_secret_id";
800
+ } else {
801
+ const expectedSecret = knownSecrets[secretIndex];
802
+ if (!expectedSecret || !isSecretValid(providedSecret, expectedSecret)) {
803
+ authReason = "invalid_secret";
804
+ }
805
+ }
806
+ } else if (!knownSecrets.some((secret) => isSecretValid(providedSecret, secret))) {
807
+ authReason = "invalid_secret";
808
+ }
809
+ if (authReason) {
647
810
  this.safeSend(
648
811
  state.socket,
649
812
  stringifyEnvelope(
650
813
  makeEnvelope("auth.error", {
651
- code: "AUTH_ERROR",
652
- message: "Invalid shared secret."
814
+ code: "UNAUTHORIZED",
815
+ reason: authReason,
816
+ message: "Authentication failed."
653
817
  })
654
818
  )
655
819
  );
@@ -758,21 +922,44 @@ function createHostShardwire(options, runtimeHooks) {
758
922
  context.source = source;
759
923
  }
760
924
  try {
761
- const maybePromise = Promise.resolve(handler(payload.data, context));
925
+ const commandValidation = options.validation?.commands?.[payload.name];
926
+ const validatedRequest = parsePayloadWithSchema(commandValidation?.request, payload.data, {
927
+ name: payload.name,
928
+ stage: "command.request"
929
+ });
930
+ const maybePromise = Promise.resolve(handler(validatedRequest, context));
762
931
  const value = await withTimeout(
763
932
  maybePromise,
764
933
  commandTimeoutMs,
765
934
  `Command "${payload.name}" timed out after ${commandTimeoutMs}ms.`
766
935
  );
936
+ const validatedResponse = parsePayloadWithSchema(commandValidation?.response, value, {
937
+ name: payload.name,
938
+ stage: "command.response"
939
+ });
767
940
  const success = {
768
941
  ok: true,
769
942
  requestId,
770
943
  ts: Date.now(),
771
- data: value
944
+ data: validatedResponse
772
945
  };
773
946
  dedupeCache.set(cacheKey, success);
774
947
  return success;
775
948
  } catch (error) {
949
+ if (error instanceof PayloadValidationError) {
950
+ const failure2 = {
951
+ ok: false,
952
+ requestId,
953
+ ts: Date.now(),
954
+ error: {
955
+ code: "VALIDATION_ERROR",
956
+ message: error.message,
957
+ details: error.details
958
+ }
959
+ };
960
+ dedupeCache.set(cacheKey, failure2);
961
+ return failure2;
962
+ }
776
963
  const isTimeout = error instanceof Error && /timed out/i.test(error.message);
777
964
  const failure = {
778
965
  ok: false,
@@ -799,13 +986,21 @@ function createHostShardwire(options, runtimeHooks) {
799
986
  },
800
987
  emitEvent(name, payload) {
801
988
  assertMessageName("event", name);
802
- assertJsonPayload("event", name, payload);
803
- hostServer.emitEvent(name, payload, options.name);
989
+ const validatedPayload = parsePayloadWithSchema(options.validation?.events?.[name], payload, {
990
+ name,
991
+ stage: "event.emit"
992
+ });
993
+ assertJsonPayload("event", name, validatedPayload);
994
+ hostServer.emitEvent(name, validatedPayload, options.name);
804
995
  },
805
996
  broadcast(name, payload) {
806
997
  assertMessageName("event", name);
807
- assertJsonPayload("event", name, payload);
808
- hostServer.emitEvent(name, payload, options.name);
998
+ const validatedPayload = parsePayloadWithSchema(options.validation?.events?.[name], payload, {
999
+ name,
1000
+ stage: "event.emit"
1001
+ });
1002
+ assertJsonPayload("event", name, validatedPayload);
1003
+ hostServer.emitEvent(name, validatedPayload, options.name);
809
1004
  },
810
1005
  close() {
811
1006
  return hostServer.close().then(async () => {
@@ -815,6 +1010,52 @@ function createHostShardwire(options, runtimeHooks) {
815
1010
  };
816
1011
  }
817
1012
 
1013
+ // src/schema/index.ts
1014
+ function fromSafeParseSchema(schema) {
1015
+ return {
1016
+ parse(value) {
1017
+ const result = schema.safeParse(value);
1018
+ if (result.success) {
1019
+ return result.data;
1020
+ }
1021
+ const error = new Error(result.error.message);
1022
+ if (result.error.issues) {
1023
+ error.issues = result.error.issues;
1024
+ }
1025
+ throw error;
1026
+ }
1027
+ };
1028
+ }
1029
+
1030
+ // src/schema/zod.ts
1031
+ function normalizeZodPath(path) {
1032
+ if (path.length === 0) {
1033
+ return "";
1034
+ }
1035
+ return path.filter((segment) => typeof segment === "string" || typeof segment === "number").map((segment) => String(segment)).join(".");
1036
+ }
1037
+ function fromZodSchema(schema) {
1038
+ return fromSafeParseSchema({
1039
+ safeParse(value) {
1040
+ const result = schema.safeParse(value);
1041
+ if (result.success) {
1042
+ return { success: true, data: result.data };
1043
+ }
1044
+ const issues = result.error.issues.map((issue) => ({
1045
+ path: normalizeZodPath(issue.path),
1046
+ message: issue.message
1047
+ }));
1048
+ return {
1049
+ success: false,
1050
+ error: {
1051
+ message: "Schema validation failed.",
1052
+ issues
1053
+ }
1054
+ };
1055
+ }
1056
+ });
1057
+ }
1058
+
818
1059
  // src/index.ts
819
1060
  function isHostOptions(options) {
820
1061
  return "server" in options;
@@ -850,6 +1091,8 @@ function createShardwire(options) {
850
1091
  }
851
1092
  // Annotate the CommonJS export names for ESM import in node:
852
1093
  0 && (module.exports = {
853
- createShardwire
1094
+ createShardwire,
1095
+ fromSafeParseSchema,
1096
+ fromZodSchema
854
1097
  });
855
1098
  //# sourceMappingURL=index.js.map