shardwire 0.0.3 → 0.2.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
  }
@@ -138,12 +148,30 @@ function assertConsumerOptions(options) {
138
148
  if (!isNonEmptyString(options.url)) {
139
149
  throw new Error("Consumer mode requires `url`.");
140
150
  }
151
+ let parsedUrl;
152
+ try {
153
+ parsedUrl = new URL(options.url);
154
+ } catch {
155
+ throw new Error("Consumer option `url` must be a valid URL.");
156
+ }
157
+ if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") {
158
+ throw new Error("Consumer option `url` must use `ws://` or `wss://`.");
159
+ }
160
+ const isLoopbackHost = parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1" || parsedUrl.hostname === "::1";
161
+ if (parsedUrl.protocol === "ws:" && !isLoopbackHost && !options.allowInsecureWs) {
162
+ throw new Error(
163
+ "Insecure `ws://` is only allowed for loopback hosts by default. Use `wss://` or set `allowInsecureWs` to true."
164
+ );
165
+ }
141
166
  if (!isNonEmptyString(options.secret)) {
142
167
  throw new Error("Consumer mode requires `secret`.");
143
168
  }
144
169
  if (options.secretId !== void 0 && !isNonEmptyString(options.secretId)) {
145
170
  throw new Error("Consumer option `secretId` must be a non-empty string.");
146
171
  }
172
+ if (options.clientName !== void 0 && !isNonEmptyString(options.clientName)) {
173
+ throw new Error("Consumer option `clientName` must be a non-empty string.");
174
+ }
147
175
  if (options.requestTimeoutMs !== void 0) {
148
176
  assertPositiveNumber("requestTimeoutMs", options.requestTimeoutMs);
149
177
  }
@@ -166,9 +194,54 @@ function assertJsonPayload(kind, name, payload) {
166
194
  throw new Error(`${kind} "${name}" payload must be JSON-serializable.`);
167
195
  }
168
196
  }
197
+ function normalizeSchemaIssues(error) {
198
+ if (!error || typeof error !== "object") {
199
+ return void 0;
200
+ }
201
+ const issues = error.issues;
202
+ if (!Array.isArray(issues)) {
203
+ return void 0;
204
+ }
205
+ const normalized = issues.map((issue) => {
206
+ if (!issue || typeof issue !== "object") {
207
+ return null;
208
+ }
209
+ const message = issue.message;
210
+ const rawPath = issue.path;
211
+ if (typeof message !== "string") {
212
+ return null;
213
+ }
214
+ const path = Array.isArray(rawPath) && rawPath.length > 0 ? rawPath.map((segment) => typeof segment === "string" || typeof segment === "number" ? String(segment) : "").filter(Boolean).join(".") : "";
215
+ return { path, message };
216
+ }).filter((issue) => Boolean(issue));
217
+ return normalized.length > 0 ? normalized : void 0;
218
+ }
219
+ function parsePayloadWithSchema(schema, payload, context) {
220
+ if (!schema) {
221
+ return payload;
222
+ }
223
+ try {
224
+ return schema.parse(payload);
225
+ } catch (error) {
226
+ const message = error instanceof Error && error.message.trim().length > 0 ? error.message : `Payload validation failed for ${context.stage} "${context.name}".`;
227
+ const issues = normalizeSchemaIssues(error);
228
+ throw new PayloadValidationError(message, {
229
+ ...context,
230
+ ...issues ? { issues } : {}
231
+ });
232
+ }
233
+ }
169
234
 
170
235
  // src/consumer/index.ts
171
236
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
237
+ var CommandRequestError = class extends Error {
238
+ constructor(code, message) {
239
+ super(message);
240
+ this.code = code;
241
+ this.name = "CommandRequestError";
242
+ }
243
+ code;
244
+ };
172
245
  function createConsumerShardwire(options) {
173
246
  const logger = withLogger(options.logger);
174
247
  const reconnectEnabled = options.reconnect?.enabled ?? true;
@@ -185,8 +258,12 @@ function createConsumerShardwire(options) {
185
258
  let connectResolve = null;
186
259
  let connectReject = null;
187
260
  let authTimeoutTimer = null;
261
+ let currentConnectionId = null;
188
262
  const pendingRequests = /* @__PURE__ */ new Map();
189
263
  const eventHandlers = /* @__PURE__ */ new Map();
264
+ const connectedHandlers = /* @__PURE__ */ new Set();
265
+ const disconnectedHandlers = /* @__PURE__ */ new Set();
266
+ const reconnectingHandlers = /* @__PURE__ */ new Set();
190
267
  function clearAuthTimeout() {
191
268
  if (authTimeoutTimer) {
192
269
  clearTimeout(authTimeoutTimer);
@@ -215,10 +292,10 @@ function createConsumerShardwire(options) {
215
292
  }
216
293
  socket.send(data);
217
294
  }
218
- function rejectAllPending(reason) {
295
+ function rejectAllPending(code, reason) {
219
296
  for (const [requestId, pending] of pendingRequests.entries()) {
220
297
  clearTimeout(pending.timer);
221
- pending.reject(new Error(reason));
298
+ pending.reject(new CommandRequestError(code, reason));
222
299
  pendingRequests.delete(requestId);
223
300
  }
224
301
  }
@@ -228,6 +305,13 @@ function createConsumerShardwire(options) {
228
305
  }
229
306
  const delay = getBackoffDelay(reconnectAttempts, { initialDelayMs, maxDelayMs, jitter });
230
307
  reconnectAttempts += 1;
308
+ for (const handler of reconnectingHandlers) {
309
+ try {
310
+ handler({ attempt: reconnectAttempts, delayMs: delay, at: Date.now() });
311
+ } catch (error) {
312
+ logger.warn("Reconnect handler threw an error.", { error: String(error) });
313
+ }
314
+ }
231
315
  reconnectTimer = setTimeout(() => {
232
316
  reconnectTimer = null;
233
317
  void connect().catch((error) => {
@@ -266,9 +350,11 @@ function createConsumerShardwire(options) {
266
350
  socket.on("open", () => {
267
351
  reconnectAttempts = 0;
268
352
  isAuthed = false;
353
+ currentConnectionId = null;
269
354
  const hello = makeEnvelope("auth.hello", {
270
355
  secret: options.secret,
271
- secretId: options.secretId
356
+ secretId: options.secretId,
357
+ clientName: options.clientName
272
358
  });
273
359
  socket?.send(stringifyEnvelope(hello));
274
360
  authTimeoutTimer = setTimeout(() => {
@@ -285,6 +371,18 @@ function createConsumerShardwire(options) {
285
371
  switch (envelope.type) {
286
372
  case "auth.ok":
287
373
  isAuthed = true;
374
+ if (envelope.payload && typeof envelope.payload === "object" && "connectionId" in envelope.payload && typeof envelope.payload.connectionId === "string") {
375
+ currentConnectionId = envelope.payload.connectionId;
376
+ }
377
+ if (currentConnectionId) {
378
+ for (const handler of connectedHandlers) {
379
+ try {
380
+ handler({ connectionId: currentConnectionId, connectedAt: Date.now() });
381
+ } catch (error) {
382
+ logger.warn("Connected handler threw an error.", { error: String(error) });
383
+ }
384
+ }
385
+ }
288
386
  resolveConnect();
289
387
  break;
290
388
  case "auth.error": {
@@ -294,7 +392,7 @@ function createConsumerShardwire(options) {
294
392
  message: payload.message
295
393
  });
296
394
  rejectConnect(payload.message);
297
- rejectAllPending("Shardwire authentication failed.");
395
+ rejectAllPending("UNAUTHORIZED", "Shardwire authentication failed.");
298
396
  socket?.close();
299
397
  break;
300
398
  }
@@ -333,8 +431,22 @@ function createConsumerShardwire(options) {
333
431
  }
334
432
  });
335
433
  socket.on("close", () => {
434
+ const willReconnect = !isClosed && reconnectEnabled;
336
435
  rejectConnect("Shardwire connection closed.");
337
436
  isAuthed = false;
437
+ currentConnectionId = null;
438
+ rejectAllPending("DISCONNECTED", "Shardwire connection closed before command completed.");
439
+ for (const handler of disconnectedHandlers) {
440
+ try {
441
+ handler({
442
+ reason: "Shardwire connection closed.",
443
+ at: Date.now(),
444
+ willReconnect
445
+ });
446
+ } catch (error) {
447
+ logger.warn("Disconnected handler threw an error.", { error: String(error) });
448
+ }
449
+ }
338
450
  if (!isClosed) {
339
451
  scheduleReconnect();
340
452
  }
@@ -373,7 +485,7 @@ function createConsumerShardwire(options) {
373
485
  requestId: sendOptions?.requestId ?? "unknown",
374
486
  ts: Date.now(),
375
487
  error: {
376
- code: "TIMEOUT",
488
+ code: "DISCONNECTED",
377
489
  message: "Not connected to Shardwire host."
378
490
  }
379
491
  };
@@ -383,9 +495,13 @@ function createConsumerShardwire(options) {
383
495
  const promise = new Promise((resolve, reject) => {
384
496
  const timer = setTimeout(() => {
385
497
  pendingRequests.delete(requestId);
386
- reject(new Error(`Command "${name}" timed out after ${timeoutMs}ms.`));
498
+ reject(new CommandRequestError("TIMEOUT", `Command "${name}" timed out after ${timeoutMs}ms.`));
387
499
  }, timeoutMs);
388
- pendingRequests.set(requestId, { resolve, reject, timer });
500
+ pendingRequests.set(requestId, {
501
+ resolve,
502
+ reject: (error) => reject(error),
503
+ timer
504
+ });
389
505
  });
390
506
  sendRaw(
391
507
  stringifyEnvelope(
@@ -402,13 +518,14 @@ function createConsumerShardwire(options) {
402
518
  try {
403
519
  return await promise;
404
520
  } catch (error) {
521
+ const failureCode = error instanceof CommandRequestError ? error.code : !socket || socket.readyState !== 1 ? "DISCONNECTED" : "TIMEOUT";
405
522
  return {
406
523
  ok: false,
407
524
  requestId,
408
525
  ts: Date.now(),
409
526
  error: {
410
- code: "TIMEOUT",
411
- message: error instanceof Error ? error.message : "Command request timeout."
527
+ code: failureCode,
528
+ message: error instanceof Error ? error.message : "Command request failed."
412
529
  }
413
530
  };
414
531
  }
@@ -444,18 +561,43 @@ function createConsumerShardwire(options) {
444
561
  eventHandlers.delete(name);
445
562
  }
446
563
  },
564
+ onConnected(handler) {
565
+ connectedHandlers.add(handler);
566
+ return () => {
567
+ connectedHandlers.delete(handler);
568
+ };
569
+ },
570
+ onDisconnected(handler) {
571
+ disconnectedHandlers.add(handler);
572
+ return () => {
573
+ disconnectedHandlers.delete(handler);
574
+ };
575
+ },
576
+ onReconnecting(handler) {
577
+ reconnectingHandlers.add(handler);
578
+ return () => {
579
+ reconnectingHandlers.delete(handler);
580
+ };
581
+ },
582
+ ready() {
583
+ return connect();
584
+ },
447
585
  connected() {
448
586
  return Boolean(socket && socket.readyState === 1 && isAuthed);
449
587
  },
588
+ connectionId() {
589
+ return currentConnectionId;
590
+ },
450
591
  async close() {
451
592
  isClosed = true;
452
593
  isAuthed = false;
594
+ currentConnectionId = null;
453
595
  rejectConnect("Shardwire consumer has been closed.");
454
596
  if (reconnectTimer) {
455
597
  clearTimeout(reconnectTimer);
456
598
  reconnectTimer = null;
457
599
  }
458
- rejectAllPending("Shardwire consumer has been closed.");
600
+ rejectAllPending("DISCONNECTED", "Shardwire consumer has been closed.");
459
601
  if (!socket) {
460
602
  return;
461
603
  }
@@ -500,7 +642,7 @@ async function withTimeout(promise, timeoutMs, timeoutMessage = "Operation timed
500
642
  resolve(value);
501
643
  }).catch((error) => {
502
644
  clearTimeout(timeout);
503
- reject(error);
645
+ reject(error instanceof Error ? error : new Error(String(error)));
504
646
  });
505
647
  });
506
648
  }
@@ -626,14 +768,25 @@ var HostWebSocketServer = class {
626
768
  socket.close(CLOSE_AUTH_REQUIRED, "Authentication required.");
627
769
  }
628
770
  }, this.authTimeoutMs);
629
- socket.on("message", async (raw) => {
771
+ socket.on("message", (raw) => {
772
+ const serialized = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : void 0;
773
+ if (!serialized) {
774
+ this.logger.warn("Invalid message payload from client.", { error: "Unsupported payload type." });
775
+ socket.close(CLOSE_INVALID_PAYLOAD, "Invalid payload.");
776
+ return;
777
+ }
778
+ let parsed;
630
779
  try {
631
- const parsed = parseEnvelope(raw.toString());
632
- await this.handleMessage(state, parsed);
780
+ parsed = parseEnvelope(serialized);
633
781
  } catch (error) {
634
782
  this.logger.warn("Invalid message payload from client.", { error: String(error) });
635
783
  socket.close(CLOSE_INVALID_PAYLOAD, "Invalid payload.");
784
+ return;
636
785
  }
786
+ void this.handleMessage(state, parsed).catch((error) => {
787
+ this.logger.warn("Invalid message payload from client.", { error: String(error) });
788
+ socket.close(CLOSE_INVALID_PAYLOAD, "Invalid payload.");
789
+ });
637
790
  });
638
791
  socket.on("close", () => {
639
792
  clearTimeout(authTimer);
@@ -767,7 +920,7 @@ function createHostShardwire(options, runtimeHooks) {
767
920
  const hostServer = new HostWebSocketServer({
768
921
  options,
769
922
  onCommandRequest: async (connection, payload, requestId, source) => {
770
- const cacheKey = `${requestId}:${payload.name}`;
923
+ const cacheKey = `${connection.id}:${requestId}:${payload.name}`;
771
924
  const cached = dedupeCache.get(cacheKey);
772
925
  if (cached) {
773
926
  return cached;
@@ -795,21 +948,44 @@ function createHostShardwire(options, runtimeHooks) {
795
948
  context.source = source;
796
949
  }
797
950
  try {
798
- const maybePromise = Promise.resolve(handler(payload.data, context));
951
+ const commandValidation = options.validation?.commands?.[payload.name];
952
+ const validatedRequest = parsePayloadWithSchema(commandValidation?.request, payload.data, {
953
+ name: payload.name,
954
+ stage: "command.request"
955
+ });
956
+ const maybePromise = Promise.resolve(handler(validatedRequest, context));
799
957
  const value = await withTimeout(
800
958
  maybePromise,
801
959
  commandTimeoutMs,
802
960
  `Command "${payload.name}" timed out after ${commandTimeoutMs}ms.`
803
961
  );
962
+ const validatedResponse = parsePayloadWithSchema(commandValidation?.response, value, {
963
+ name: payload.name,
964
+ stage: "command.response"
965
+ });
804
966
  const success = {
805
967
  ok: true,
806
968
  requestId,
807
969
  ts: Date.now(),
808
- data: value
970
+ data: validatedResponse
809
971
  };
810
972
  dedupeCache.set(cacheKey, success);
811
973
  return success;
812
974
  } catch (error) {
975
+ if (error instanceof PayloadValidationError) {
976
+ const failure2 = {
977
+ ok: false,
978
+ requestId,
979
+ ts: Date.now(),
980
+ error: {
981
+ code: "VALIDATION_ERROR",
982
+ message: error.message,
983
+ details: error.details
984
+ }
985
+ };
986
+ dedupeCache.set(cacheKey, failure2);
987
+ return failure2;
988
+ }
813
989
  const isTimeout = error instanceof Error && /timed out/i.test(error.message);
814
990
  const failure = {
815
991
  ok: false,
@@ -836,13 +1012,21 @@ function createHostShardwire(options, runtimeHooks) {
836
1012
  },
837
1013
  emitEvent(name, payload) {
838
1014
  assertMessageName("event", name);
839
- assertJsonPayload("event", name, payload);
840
- hostServer.emitEvent(name, payload, options.name);
1015
+ const validatedPayload = parsePayloadWithSchema(options.validation?.events?.[name], payload, {
1016
+ name,
1017
+ stage: "event.emit"
1018
+ });
1019
+ assertJsonPayload("event", name, validatedPayload);
1020
+ hostServer.emitEvent(name, validatedPayload, options.name);
841
1021
  },
842
1022
  broadcast(name, payload) {
843
1023
  assertMessageName("event", name);
844
- assertJsonPayload("event", name, payload);
845
- hostServer.emitEvent(name, payload, options.name);
1024
+ const validatedPayload = parsePayloadWithSchema(options.validation?.events?.[name], payload, {
1025
+ name,
1026
+ stage: "event.emit"
1027
+ });
1028
+ assertJsonPayload("event", name, validatedPayload);
1029
+ hostServer.emitEvent(name, validatedPayload, options.name);
846
1030
  },
847
1031
  close() {
848
1032
  return hostServer.close().then(async () => {
@@ -852,6 +1036,52 @@ function createHostShardwire(options, runtimeHooks) {
852
1036
  };
853
1037
  }
854
1038
 
1039
+ // src/schema/index.ts
1040
+ function fromSafeParseSchema(schema) {
1041
+ return {
1042
+ parse(value) {
1043
+ const result = schema.safeParse(value);
1044
+ if (result.success) {
1045
+ return result.data;
1046
+ }
1047
+ const error = new Error(result.error.message);
1048
+ if (result.error.issues) {
1049
+ error.issues = result.error.issues;
1050
+ }
1051
+ throw error;
1052
+ }
1053
+ };
1054
+ }
1055
+
1056
+ // src/schema/zod.ts
1057
+ function normalizeZodPath(path) {
1058
+ if (path.length === 0) {
1059
+ return "";
1060
+ }
1061
+ return path.filter((segment) => typeof segment === "string" || typeof segment === "number").map((segment) => String(segment)).join(".");
1062
+ }
1063
+ function fromZodSchema(schema) {
1064
+ return fromSafeParseSchema({
1065
+ safeParse(value) {
1066
+ const result = schema.safeParse(value);
1067
+ if (result.success) {
1068
+ return { success: true, data: result.data };
1069
+ }
1070
+ const issues = result.error.issues.map((issue) => ({
1071
+ path: normalizeZodPath(issue.path),
1072
+ message: issue.message
1073
+ }));
1074
+ return {
1075
+ success: false,
1076
+ error: {
1077
+ message: "Schema validation failed.",
1078
+ issues
1079
+ }
1080
+ };
1081
+ }
1082
+ });
1083
+ }
1084
+
855
1085
  // src/index.ts
856
1086
  function isHostOptions(options) {
857
1087
  return "server" in options;
@@ -887,6 +1117,8 @@ function createShardwire(options) {
887
1117
  }
888
1118
  // Annotate the CommonJS export names for ESM import in node:
889
1119
  0 && (module.exports = {
890
- createShardwire
1120
+ createShardwire,
1121
+ fromSafeParseSchema,
1122
+ fromZodSchema
891
1123
  });
892
1124
  //# sourceMappingURL=index.js.map