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.mjs CHANGED
@@ -64,6 +64,14 @@ function stringifyEnvelope(envelope) {
64
64
  }
65
65
 
66
66
  // src/runtime/validation.ts
67
+ var PayloadValidationError = class extends Error {
68
+ constructor(message, details) {
69
+ super(message);
70
+ this.details = details;
71
+ this.name = "PayloadValidationError";
72
+ }
73
+ details;
74
+ };
67
75
  function isNonEmptyString(value) {
68
76
  return typeof value === "string" && value.trim().length > 0;
69
77
  }
@@ -77,8 +85,16 @@ function assertHostOptions(options) {
77
85
  throw new Error("Host mode requires a server configuration.");
78
86
  }
79
87
  assertPositiveNumber("server.port", options.server.port);
80
- if (!isNonEmptyString(options.server.secret)) {
81
- throw new Error("server.secret is required.");
88
+ if (!Array.isArray(options.server.secrets) || options.server.secrets.length === 0) {
89
+ throw new Error("server.secrets must contain at least one secret.");
90
+ }
91
+ for (const [index, secret] of options.server.secrets.entries()) {
92
+ if (!isNonEmptyString(secret)) {
93
+ throw new Error(`server.secrets[${index}] must be a non-empty string.`);
94
+ }
95
+ }
96
+ if (options.server.primarySecretId !== void 0 && !options.server.secrets.some((_, index) => options.server.primarySecretId === `s${index}`)) {
97
+ throw new Error("server.primarySecretId must reference an existing secret id.");
82
98
  }
83
99
  if (options.server.heartbeatMs !== void 0) {
84
100
  assertPositiveNumber("server.heartbeatMs", options.server.heartbeatMs);
@@ -97,6 +113,12 @@ function assertConsumerOptions(options) {
97
113
  if (!isNonEmptyString(options.secret)) {
98
114
  throw new Error("Consumer mode requires `secret`.");
99
115
  }
116
+ if (options.secretId !== void 0 && !isNonEmptyString(options.secretId)) {
117
+ throw new Error("Consumer option `secretId` must be a non-empty string.");
118
+ }
119
+ if (options.clientName !== void 0 && !isNonEmptyString(options.clientName)) {
120
+ throw new Error("Consumer option `clientName` must be a non-empty string.");
121
+ }
100
122
  if (options.requestTimeoutMs !== void 0) {
101
123
  assertPositiveNumber("requestTimeoutMs", options.requestTimeoutMs);
102
124
  }
@@ -119,9 +141,54 @@ function assertJsonPayload(kind, name, payload) {
119
141
  throw new Error(`${kind} "${name}" payload must be JSON-serializable.`);
120
142
  }
121
143
  }
144
+ function normalizeSchemaIssues(error) {
145
+ if (!error || typeof error !== "object") {
146
+ return void 0;
147
+ }
148
+ const issues = error.issues;
149
+ if (!Array.isArray(issues)) {
150
+ return void 0;
151
+ }
152
+ const normalized = issues.map((issue) => {
153
+ if (!issue || typeof issue !== "object") {
154
+ return null;
155
+ }
156
+ const message = issue.message;
157
+ const rawPath = issue.path;
158
+ if (typeof message !== "string") {
159
+ return null;
160
+ }
161
+ const path = Array.isArray(rawPath) && rawPath.length > 0 ? rawPath.map((segment) => typeof segment === "string" || typeof segment === "number" ? String(segment) : "").filter(Boolean).join(".") : "";
162
+ return { path, message };
163
+ }).filter((issue) => Boolean(issue));
164
+ return normalized.length > 0 ? normalized : void 0;
165
+ }
166
+ function parsePayloadWithSchema(schema, payload, context) {
167
+ if (!schema) {
168
+ return payload;
169
+ }
170
+ try {
171
+ return schema.parse(payload);
172
+ } catch (error) {
173
+ const message = error instanceof Error && error.message.trim().length > 0 ? error.message : `Payload validation failed for ${context.stage} "${context.name}".`;
174
+ const issues = normalizeSchemaIssues(error);
175
+ throw new PayloadValidationError(message, {
176
+ ...context,
177
+ ...issues ? { issues } : {}
178
+ });
179
+ }
180
+ }
122
181
 
123
182
  // src/consumer/index.ts
124
183
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
184
+ var CommandRequestError = class extends Error {
185
+ constructor(code, message) {
186
+ super(message);
187
+ this.code = code;
188
+ this.name = "CommandRequestError";
189
+ }
190
+ code;
191
+ };
125
192
  function createConsumerShardwire(options) {
126
193
  const logger = withLogger(options.logger);
127
194
  const reconnectEnabled = options.reconnect?.enabled ?? true;
@@ -138,8 +205,12 @@ function createConsumerShardwire(options) {
138
205
  let connectResolve = null;
139
206
  let connectReject = null;
140
207
  let authTimeoutTimer = null;
208
+ let currentConnectionId = null;
141
209
  const pendingRequests = /* @__PURE__ */ new Map();
142
210
  const eventHandlers = /* @__PURE__ */ new Map();
211
+ const connectedHandlers = /* @__PURE__ */ new Set();
212
+ const disconnectedHandlers = /* @__PURE__ */ new Set();
213
+ const reconnectingHandlers = /* @__PURE__ */ new Set();
143
214
  function clearAuthTimeout() {
144
215
  if (authTimeoutTimer) {
145
216
  clearTimeout(authTimeoutTimer);
@@ -168,10 +239,10 @@ function createConsumerShardwire(options) {
168
239
  }
169
240
  socket.send(data);
170
241
  }
171
- function rejectAllPending(reason) {
242
+ function rejectAllPending(code, reason) {
172
243
  for (const [requestId, pending] of pendingRequests.entries()) {
173
244
  clearTimeout(pending.timer);
174
- pending.reject(new Error(reason));
245
+ pending.reject(new CommandRequestError(code, reason));
175
246
  pendingRequests.delete(requestId);
176
247
  }
177
248
  }
@@ -181,6 +252,13 @@ function createConsumerShardwire(options) {
181
252
  }
182
253
  const delay = getBackoffDelay(reconnectAttempts, { initialDelayMs, maxDelayMs, jitter });
183
254
  reconnectAttempts += 1;
255
+ for (const handler of reconnectingHandlers) {
256
+ try {
257
+ handler({ attempt: reconnectAttempts, delayMs: delay, at: Date.now() });
258
+ } catch (error) {
259
+ logger.warn("Reconnect handler threw an error.", { error: String(error) });
260
+ }
261
+ }
184
262
  reconnectTimer = setTimeout(() => {
185
263
  reconnectTimer = null;
186
264
  void connect().catch((error) => {
@@ -219,7 +297,12 @@ function createConsumerShardwire(options) {
219
297
  socket.on("open", () => {
220
298
  reconnectAttempts = 0;
221
299
  isAuthed = false;
222
- const hello = makeEnvelope("auth.hello", { secret: options.secret });
300
+ currentConnectionId = null;
301
+ const hello = makeEnvelope("auth.hello", {
302
+ secret: options.secret,
303
+ secretId: options.secretId,
304
+ clientName: options.clientName
305
+ });
223
306
  socket?.send(stringifyEnvelope(hello));
224
307
  authTimeoutTimer = setTimeout(() => {
225
308
  if (!isAuthed) {
@@ -235,6 +318,18 @@ function createConsumerShardwire(options) {
235
318
  switch (envelope.type) {
236
319
  case "auth.ok":
237
320
  isAuthed = true;
321
+ if (envelope.payload && typeof envelope.payload === "object" && "connectionId" in envelope.payload && typeof envelope.payload.connectionId === "string") {
322
+ currentConnectionId = envelope.payload.connectionId;
323
+ }
324
+ if (currentConnectionId) {
325
+ for (const handler of connectedHandlers) {
326
+ try {
327
+ handler({ connectionId: currentConnectionId, connectedAt: Date.now() });
328
+ } catch (error) {
329
+ logger.warn("Connected handler threw an error.", { error: String(error) });
330
+ }
331
+ }
332
+ }
238
333
  resolveConnect();
239
334
  break;
240
335
  case "auth.error": {
@@ -244,7 +339,7 @@ function createConsumerShardwire(options) {
244
339
  message: payload.message
245
340
  });
246
341
  rejectConnect(payload.message);
247
- rejectAllPending("Shardwire authentication failed.");
342
+ rejectAllPending("UNAUTHORIZED", "Shardwire authentication failed.");
248
343
  socket?.close();
249
344
  break;
250
345
  }
@@ -283,8 +378,22 @@ function createConsumerShardwire(options) {
283
378
  }
284
379
  });
285
380
  socket.on("close", () => {
381
+ const willReconnect = !isClosed && reconnectEnabled;
286
382
  rejectConnect("Shardwire connection closed.");
287
383
  isAuthed = false;
384
+ currentConnectionId = null;
385
+ rejectAllPending("DISCONNECTED", "Shardwire connection closed before command completed.");
386
+ for (const handler of disconnectedHandlers) {
387
+ try {
388
+ handler({
389
+ reason: "Shardwire connection closed.",
390
+ at: Date.now(),
391
+ willReconnect
392
+ });
393
+ } catch (error) {
394
+ logger.warn("Disconnected handler threw an error.", { error: String(error) });
395
+ }
396
+ }
288
397
  if (!isClosed) {
289
398
  scheduleReconnect();
290
399
  }
@@ -311,7 +420,7 @@ function createConsumerShardwire(options) {
311
420
  requestId: sendOptions?.requestId ?? "unknown",
312
421
  ts: Date.now(),
313
422
  error: {
314
- code: "AUTH_ERROR",
423
+ code: "UNAUTHORIZED",
315
424
  message: error instanceof Error ? error.message : "Failed to authenticate."
316
425
  }
317
426
  };
@@ -323,7 +432,7 @@ function createConsumerShardwire(options) {
323
432
  requestId: sendOptions?.requestId ?? "unknown",
324
433
  ts: Date.now(),
325
434
  error: {
326
- code: "TIMEOUT",
435
+ code: "DISCONNECTED",
327
436
  message: "Not connected to Shardwire host."
328
437
  }
329
438
  };
@@ -333,9 +442,13 @@ function createConsumerShardwire(options) {
333
442
  const promise = new Promise((resolve, reject) => {
334
443
  const timer = setTimeout(() => {
335
444
  pendingRequests.delete(requestId);
336
- reject(new Error(`Command "${name}" timed out after ${timeoutMs}ms.`));
445
+ reject(new CommandRequestError("TIMEOUT", `Command "${name}" timed out after ${timeoutMs}ms.`));
337
446
  }, timeoutMs);
338
- pendingRequests.set(requestId, { resolve, reject, timer });
447
+ pendingRequests.set(requestId, {
448
+ resolve,
449
+ reject: (error) => reject(error),
450
+ timer
451
+ });
339
452
  });
340
453
  sendRaw(
341
454
  stringifyEnvelope(
@@ -352,13 +465,14 @@ function createConsumerShardwire(options) {
352
465
  try {
353
466
  return await promise;
354
467
  } catch (error) {
468
+ const failureCode = error instanceof CommandRequestError ? error.code : !socket || socket.readyState !== 1 ? "DISCONNECTED" : "TIMEOUT";
355
469
  return {
356
470
  ok: false,
357
471
  requestId,
358
472
  ts: Date.now(),
359
473
  error: {
360
- code: "TIMEOUT",
361
- message: error instanceof Error ? error.message : "Command request timeout."
474
+ code: failureCode,
475
+ message: error instanceof Error ? error.message : "Command request failed."
362
476
  }
363
477
  };
364
478
  }
@@ -394,18 +508,43 @@ function createConsumerShardwire(options) {
394
508
  eventHandlers.delete(name);
395
509
  }
396
510
  },
511
+ onConnected(handler) {
512
+ connectedHandlers.add(handler);
513
+ return () => {
514
+ connectedHandlers.delete(handler);
515
+ };
516
+ },
517
+ onDisconnected(handler) {
518
+ disconnectedHandlers.add(handler);
519
+ return () => {
520
+ disconnectedHandlers.delete(handler);
521
+ };
522
+ },
523
+ onReconnecting(handler) {
524
+ reconnectingHandlers.add(handler);
525
+ return () => {
526
+ reconnectingHandlers.delete(handler);
527
+ };
528
+ },
529
+ ready() {
530
+ return connect();
531
+ },
397
532
  connected() {
398
533
  return Boolean(socket && socket.readyState === 1 && isAuthed);
399
534
  },
535
+ connectionId() {
536
+ return currentConnectionId;
537
+ },
400
538
  async close() {
401
539
  isClosed = true;
402
540
  isAuthed = false;
541
+ currentConnectionId = null;
403
542
  rejectConnect("Shardwire consumer has been closed.");
404
543
  if (reconnectTimer) {
405
544
  clearTimeout(reconnectTimer);
406
545
  reconnectTimer = null;
407
546
  }
408
- rejectAllPending("Shardwire consumer has been closed.");
547
+ rejectAllPending("DISCONNECTED", "Shardwire consumer has been closed.");
409
548
  if (!socket) {
410
549
  return;
411
550
  }
@@ -489,6 +628,9 @@ function isSecretValid(provided, expected) {
489
628
  }
490
629
  return timingSafeEqual(providedBuffer, expectedBuffer);
491
630
  }
631
+ function getSecretId(secretIndex) {
632
+ return `s${secretIndex}`;
633
+ }
492
634
 
493
635
  // src/transport/ws/host-server.ts
494
636
  var CLOSE_AUTH_REQUIRED = 4001;
@@ -607,13 +749,33 @@ var HostWebSocketServer = class {
607
749
  return;
608
750
  }
609
751
  const payload = envelope.payload;
610
- if (!payload?.secret || !isSecretValid(payload.secret, this.config.options.server.secret)) {
752
+ const providedSecret = payload?.secret;
753
+ const knownSecrets = this.config.options.server.secrets;
754
+ const secretId = payload?.secretId;
755
+ let authReason = null;
756
+ if (!providedSecret) {
757
+ authReason = "invalid_secret";
758
+ } else if (secretId) {
759
+ const secretIndex = knownSecrets.findIndex((_, index) => getSecretId(index) === secretId);
760
+ if (secretIndex < 0) {
761
+ authReason = "unknown_secret_id";
762
+ } else {
763
+ const expectedSecret = knownSecrets[secretIndex];
764
+ if (!expectedSecret || !isSecretValid(providedSecret, expectedSecret)) {
765
+ authReason = "invalid_secret";
766
+ }
767
+ }
768
+ } else if (!knownSecrets.some((secret) => isSecretValid(providedSecret, secret))) {
769
+ authReason = "invalid_secret";
770
+ }
771
+ if (authReason) {
611
772
  this.safeSend(
612
773
  state.socket,
613
774
  stringifyEnvelope(
614
775
  makeEnvelope("auth.error", {
615
- code: "AUTH_ERROR",
616
- message: "Invalid shared secret."
776
+ code: "UNAUTHORIZED",
777
+ reason: authReason,
778
+ message: "Authentication failed."
617
779
  })
618
780
  )
619
781
  );
@@ -722,21 +884,44 @@ function createHostShardwire(options, runtimeHooks) {
722
884
  context.source = source;
723
885
  }
724
886
  try {
725
- const maybePromise = Promise.resolve(handler(payload.data, context));
887
+ const commandValidation = options.validation?.commands?.[payload.name];
888
+ const validatedRequest = parsePayloadWithSchema(commandValidation?.request, payload.data, {
889
+ name: payload.name,
890
+ stage: "command.request"
891
+ });
892
+ const maybePromise = Promise.resolve(handler(validatedRequest, context));
726
893
  const value = await withTimeout(
727
894
  maybePromise,
728
895
  commandTimeoutMs,
729
896
  `Command "${payload.name}" timed out after ${commandTimeoutMs}ms.`
730
897
  );
898
+ const validatedResponse = parsePayloadWithSchema(commandValidation?.response, value, {
899
+ name: payload.name,
900
+ stage: "command.response"
901
+ });
731
902
  const success = {
732
903
  ok: true,
733
904
  requestId,
734
905
  ts: Date.now(),
735
- data: value
906
+ data: validatedResponse
736
907
  };
737
908
  dedupeCache.set(cacheKey, success);
738
909
  return success;
739
910
  } catch (error) {
911
+ if (error instanceof PayloadValidationError) {
912
+ const failure2 = {
913
+ ok: false,
914
+ requestId,
915
+ ts: Date.now(),
916
+ error: {
917
+ code: "VALIDATION_ERROR",
918
+ message: error.message,
919
+ details: error.details
920
+ }
921
+ };
922
+ dedupeCache.set(cacheKey, failure2);
923
+ return failure2;
924
+ }
740
925
  const isTimeout = error instanceof Error && /timed out/i.test(error.message);
741
926
  const failure = {
742
927
  ok: false,
@@ -763,13 +948,21 @@ function createHostShardwire(options, runtimeHooks) {
763
948
  },
764
949
  emitEvent(name, payload) {
765
950
  assertMessageName("event", name);
766
- assertJsonPayload("event", name, payload);
767
- hostServer.emitEvent(name, payload, options.name);
951
+ const validatedPayload = parsePayloadWithSchema(options.validation?.events?.[name], payload, {
952
+ name,
953
+ stage: "event.emit"
954
+ });
955
+ assertJsonPayload("event", name, validatedPayload);
956
+ hostServer.emitEvent(name, validatedPayload, options.name);
768
957
  },
769
958
  broadcast(name, payload) {
770
959
  assertMessageName("event", name);
771
- assertJsonPayload("event", name, payload);
772
- hostServer.emitEvent(name, payload, options.name);
960
+ const validatedPayload = parsePayloadWithSchema(options.validation?.events?.[name], payload, {
961
+ name,
962
+ stage: "event.emit"
963
+ });
964
+ assertJsonPayload("event", name, validatedPayload);
965
+ hostServer.emitEvent(name, validatedPayload, options.name);
773
966
  },
774
967
  close() {
775
968
  return hostServer.close().then(async () => {
@@ -779,6 +972,52 @@ function createHostShardwire(options, runtimeHooks) {
779
972
  };
780
973
  }
781
974
 
975
+ // src/schema/index.ts
976
+ function fromSafeParseSchema(schema) {
977
+ return {
978
+ parse(value) {
979
+ const result = schema.safeParse(value);
980
+ if (result.success) {
981
+ return result.data;
982
+ }
983
+ const error = new Error(result.error.message);
984
+ if (result.error.issues) {
985
+ error.issues = result.error.issues;
986
+ }
987
+ throw error;
988
+ }
989
+ };
990
+ }
991
+
992
+ // src/schema/zod.ts
993
+ function normalizeZodPath(path) {
994
+ if (path.length === 0) {
995
+ return "";
996
+ }
997
+ return path.filter((segment) => typeof segment === "string" || typeof segment === "number").map((segment) => String(segment)).join(".");
998
+ }
999
+ function fromZodSchema(schema) {
1000
+ return fromSafeParseSchema({
1001
+ safeParse(value) {
1002
+ const result = schema.safeParse(value);
1003
+ if (result.success) {
1004
+ return { success: true, data: result.data };
1005
+ }
1006
+ const issues = result.error.issues.map((issue) => ({
1007
+ path: normalizeZodPath(issue.path),
1008
+ message: issue.message
1009
+ }));
1010
+ return {
1011
+ success: false,
1012
+ error: {
1013
+ message: "Schema validation failed.",
1014
+ issues
1015
+ }
1016
+ };
1017
+ }
1018
+ });
1019
+ }
1020
+
782
1021
  // src/index.ts
783
1022
  function isHostOptions(options) {
784
1023
  return "server" in options;
@@ -813,6 +1052,8 @@ function createShardwire(options) {
813
1052
  });
814
1053
  }
815
1054
  export {
816
- createShardwire
1055
+ createShardwire,
1056
+ fromSafeParseSchema,
1057
+ fromZodSchema
817
1058
  };
818
1059
  //# sourceMappingURL=index.mjs.map