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.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
  }
@@ -102,12 +110,30 @@ function assertConsumerOptions(options) {
102
110
  if (!isNonEmptyString(options.url)) {
103
111
  throw new Error("Consumer mode requires `url`.");
104
112
  }
113
+ let parsedUrl;
114
+ try {
115
+ parsedUrl = new URL(options.url);
116
+ } catch {
117
+ throw new Error("Consumer option `url` must be a valid URL.");
118
+ }
119
+ if (parsedUrl.protocol !== "ws:" && parsedUrl.protocol !== "wss:") {
120
+ throw new Error("Consumer option `url` must use `ws://` or `wss://`.");
121
+ }
122
+ const isLoopbackHost = parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1" || parsedUrl.hostname === "::1";
123
+ if (parsedUrl.protocol === "ws:" && !isLoopbackHost && !options.allowInsecureWs) {
124
+ throw new Error(
125
+ "Insecure `ws://` is only allowed for loopback hosts by default. Use `wss://` or set `allowInsecureWs` to true."
126
+ );
127
+ }
105
128
  if (!isNonEmptyString(options.secret)) {
106
129
  throw new Error("Consumer mode requires `secret`.");
107
130
  }
108
131
  if (options.secretId !== void 0 && !isNonEmptyString(options.secretId)) {
109
132
  throw new Error("Consumer option `secretId` must be a non-empty string.");
110
133
  }
134
+ if (options.clientName !== void 0 && !isNonEmptyString(options.clientName)) {
135
+ throw new Error("Consumer option `clientName` must be a non-empty string.");
136
+ }
111
137
  if (options.requestTimeoutMs !== void 0) {
112
138
  assertPositiveNumber("requestTimeoutMs", options.requestTimeoutMs);
113
139
  }
@@ -130,9 +156,54 @@ function assertJsonPayload(kind, name, payload) {
130
156
  throw new Error(`${kind} "${name}" payload must be JSON-serializable.`);
131
157
  }
132
158
  }
159
+ function normalizeSchemaIssues(error) {
160
+ if (!error || typeof error !== "object") {
161
+ return void 0;
162
+ }
163
+ const issues = error.issues;
164
+ if (!Array.isArray(issues)) {
165
+ return void 0;
166
+ }
167
+ const normalized = issues.map((issue) => {
168
+ if (!issue || typeof issue !== "object") {
169
+ return null;
170
+ }
171
+ const message = issue.message;
172
+ const rawPath = issue.path;
173
+ if (typeof message !== "string") {
174
+ return null;
175
+ }
176
+ const path = Array.isArray(rawPath) && rawPath.length > 0 ? rawPath.map((segment) => typeof segment === "string" || typeof segment === "number" ? String(segment) : "").filter(Boolean).join(".") : "";
177
+ return { path, message };
178
+ }).filter((issue) => Boolean(issue));
179
+ return normalized.length > 0 ? normalized : void 0;
180
+ }
181
+ function parsePayloadWithSchema(schema, payload, context) {
182
+ if (!schema) {
183
+ return payload;
184
+ }
185
+ try {
186
+ return schema.parse(payload);
187
+ } catch (error) {
188
+ const message = error instanceof Error && error.message.trim().length > 0 ? error.message : `Payload validation failed for ${context.stage} "${context.name}".`;
189
+ const issues = normalizeSchemaIssues(error);
190
+ throw new PayloadValidationError(message, {
191
+ ...context,
192
+ ...issues ? { issues } : {}
193
+ });
194
+ }
195
+ }
133
196
 
134
197
  // src/consumer/index.ts
135
198
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
199
+ var CommandRequestError = class extends Error {
200
+ constructor(code, message) {
201
+ super(message);
202
+ this.code = code;
203
+ this.name = "CommandRequestError";
204
+ }
205
+ code;
206
+ };
136
207
  function createConsumerShardwire(options) {
137
208
  const logger = withLogger(options.logger);
138
209
  const reconnectEnabled = options.reconnect?.enabled ?? true;
@@ -149,8 +220,12 @@ function createConsumerShardwire(options) {
149
220
  let connectResolve = null;
150
221
  let connectReject = null;
151
222
  let authTimeoutTimer = null;
223
+ let currentConnectionId = null;
152
224
  const pendingRequests = /* @__PURE__ */ new Map();
153
225
  const eventHandlers = /* @__PURE__ */ new Map();
226
+ const connectedHandlers = /* @__PURE__ */ new Set();
227
+ const disconnectedHandlers = /* @__PURE__ */ new Set();
228
+ const reconnectingHandlers = /* @__PURE__ */ new Set();
154
229
  function clearAuthTimeout() {
155
230
  if (authTimeoutTimer) {
156
231
  clearTimeout(authTimeoutTimer);
@@ -179,10 +254,10 @@ function createConsumerShardwire(options) {
179
254
  }
180
255
  socket.send(data);
181
256
  }
182
- function rejectAllPending(reason) {
257
+ function rejectAllPending(code, reason) {
183
258
  for (const [requestId, pending] of pendingRequests.entries()) {
184
259
  clearTimeout(pending.timer);
185
- pending.reject(new Error(reason));
260
+ pending.reject(new CommandRequestError(code, reason));
186
261
  pendingRequests.delete(requestId);
187
262
  }
188
263
  }
@@ -192,6 +267,13 @@ function createConsumerShardwire(options) {
192
267
  }
193
268
  const delay = getBackoffDelay(reconnectAttempts, { initialDelayMs, maxDelayMs, jitter });
194
269
  reconnectAttempts += 1;
270
+ for (const handler of reconnectingHandlers) {
271
+ try {
272
+ handler({ attempt: reconnectAttempts, delayMs: delay, at: Date.now() });
273
+ } catch (error) {
274
+ logger.warn("Reconnect handler threw an error.", { error: String(error) });
275
+ }
276
+ }
195
277
  reconnectTimer = setTimeout(() => {
196
278
  reconnectTimer = null;
197
279
  void connect().catch((error) => {
@@ -230,9 +312,11 @@ function createConsumerShardwire(options) {
230
312
  socket.on("open", () => {
231
313
  reconnectAttempts = 0;
232
314
  isAuthed = false;
315
+ currentConnectionId = null;
233
316
  const hello = makeEnvelope("auth.hello", {
234
317
  secret: options.secret,
235
- secretId: options.secretId
318
+ secretId: options.secretId,
319
+ clientName: options.clientName
236
320
  });
237
321
  socket?.send(stringifyEnvelope(hello));
238
322
  authTimeoutTimer = setTimeout(() => {
@@ -249,6 +333,18 @@ function createConsumerShardwire(options) {
249
333
  switch (envelope.type) {
250
334
  case "auth.ok":
251
335
  isAuthed = true;
336
+ if (envelope.payload && typeof envelope.payload === "object" && "connectionId" in envelope.payload && typeof envelope.payload.connectionId === "string") {
337
+ currentConnectionId = envelope.payload.connectionId;
338
+ }
339
+ if (currentConnectionId) {
340
+ for (const handler of connectedHandlers) {
341
+ try {
342
+ handler({ connectionId: currentConnectionId, connectedAt: Date.now() });
343
+ } catch (error) {
344
+ logger.warn("Connected handler threw an error.", { error: String(error) });
345
+ }
346
+ }
347
+ }
252
348
  resolveConnect();
253
349
  break;
254
350
  case "auth.error": {
@@ -258,7 +354,7 @@ function createConsumerShardwire(options) {
258
354
  message: payload.message
259
355
  });
260
356
  rejectConnect(payload.message);
261
- rejectAllPending("Shardwire authentication failed.");
357
+ rejectAllPending("UNAUTHORIZED", "Shardwire authentication failed.");
262
358
  socket?.close();
263
359
  break;
264
360
  }
@@ -297,8 +393,22 @@ function createConsumerShardwire(options) {
297
393
  }
298
394
  });
299
395
  socket.on("close", () => {
396
+ const willReconnect = !isClosed && reconnectEnabled;
300
397
  rejectConnect("Shardwire connection closed.");
301
398
  isAuthed = false;
399
+ currentConnectionId = null;
400
+ rejectAllPending("DISCONNECTED", "Shardwire connection closed before command completed.");
401
+ for (const handler of disconnectedHandlers) {
402
+ try {
403
+ handler({
404
+ reason: "Shardwire connection closed.",
405
+ at: Date.now(),
406
+ willReconnect
407
+ });
408
+ } catch (error) {
409
+ logger.warn("Disconnected handler threw an error.", { error: String(error) });
410
+ }
411
+ }
302
412
  if (!isClosed) {
303
413
  scheduleReconnect();
304
414
  }
@@ -337,7 +447,7 @@ function createConsumerShardwire(options) {
337
447
  requestId: sendOptions?.requestId ?? "unknown",
338
448
  ts: Date.now(),
339
449
  error: {
340
- code: "TIMEOUT",
450
+ code: "DISCONNECTED",
341
451
  message: "Not connected to Shardwire host."
342
452
  }
343
453
  };
@@ -347,9 +457,13 @@ function createConsumerShardwire(options) {
347
457
  const promise = new Promise((resolve, reject) => {
348
458
  const timer = setTimeout(() => {
349
459
  pendingRequests.delete(requestId);
350
- reject(new Error(`Command "${name}" timed out after ${timeoutMs}ms.`));
460
+ reject(new CommandRequestError("TIMEOUT", `Command "${name}" timed out after ${timeoutMs}ms.`));
351
461
  }, timeoutMs);
352
- pendingRequests.set(requestId, { resolve, reject, timer });
462
+ pendingRequests.set(requestId, {
463
+ resolve,
464
+ reject: (error) => reject(error),
465
+ timer
466
+ });
353
467
  });
354
468
  sendRaw(
355
469
  stringifyEnvelope(
@@ -366,13 +480,14 @@ function createConsumerShardwire(options) {
366
480
  try {
367
481
  return await promise;
368
482
  } catch (error) {
483
+ const failureCode = error instanceof CommandRequestError ? error.code : !socket || socket.readyState !== 1 ? "DISCONNECTED" : "TIMEOUT";
369
484
  return {
370
485
  ok: false,
371
486
  requestId,
372
487
  ts: Date.now(),
373
488
  error: {
374
- code: "TIMEOUT",
375
- message: error instanceof Error ? error.message : "Command request timeout."
489
+ code: failureCode,
490
+ message: error instanceof Error ? error.message : "Command request failed."
376
491
  }
377
492
  };
378
493
  }
@@ -408,18 +523,43 @@ function createConsumerShardwire(options) {
408
523
  eventHandlers.delete(name);
409
524
  }
410
525
  },
526
+ onConnected(handler) {
527
+ connectedHandlers.add(handler);
528
+ return () => {
529
+ connectedHandlers.delete(handler);
530
+ };
531
+ },
532
+ onDisconnected(handler) {
533
+ disconnectedHandlers.add(handler);
534
+ return () => {
535
+ disconnectedHandlers.delete(handler);
536
+ };
537
+ },
538
+ onReconnecting(handler) {
539
+ reconnectingHandlers.add(handler);
540
+ return () => {
541
+ reconnectingHandlers.delete(handler);
542
+ };
543
+ },
544
+ ready() {
545
+ return connect();
546
+ },
411
547
  connected() {
412
548
  return Boolean(socket && socket.readyState === 1 && isAuthed);
413
549
  },
550
+ connectionId() {
551
+ return currentConnectionId;
552
+ },
414
553
  async close() {
415
554
  isClosed = true;
416
555
  isAuthed = false;
556
+ currentConnectionId = null;
417
557
  rejectConnect("Shardwire consumer has been closed.");
418
558
  if (reconnectTimer) {
419
559
  clearTimeout(reconnectTimer);
420
560
  reconnectTimer = null;
421
561
  }
422
- rejectAllPending("Shardwire consumer has been closed.");
562
+ rejectAllPending("DISCONNECTED", "Shardwire consumer has been closed.");
423
563
  if (!socket) {
424
564
  return;
425
565
  }
@@ -464,7 +604,7 @@ async function withTimeout(promise, timeoutMs, timeoutMessage = "Operation timed
464
604
  resolve(value);
465
605
  }).catch((error) => {
466
606
  clearTimeout(timeout);
467
- reject(error);
607
+ reject(error instanceof Error ? error : new Error(String(error)));
468
608
  });
469
609
  });
470
610
  }
@@ -590,14 +730,25 @@ var HostWebSocketServer = class {
590
730
  socket.close(CLOSE_AUTH_REQUIRED, "Authentication required.");
591
731
  }
592
732
  }, this.authTimeoutMs);
593
- socket.on("message", async (raw) => {
733
+ socket.on("message", (raw) => {
734
+ const serialized = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : void 0;
735
+ if (!serialized) {
736
+ this.logger.warn("Invalid message payload from client.", { error: "Unsupported payload type." });
737
+ socket.close(CLOSE_INVALID_PAYLOAD, "Invalid payload.");
738
+ return;
739
+ }
740
+ let parsed;
594
741
  try {
595
- const parsed = parseEnvelope(raw.toString());
596
- await this.handleMessage(state, parsed);
742
+ parsed = parseEnvelope(serialized);
597
743
  } catch (error) {
598
744
  this.logger.warn("Invalid message payload from client.", { error: String(error) });
599
745
  socket.close(CLOSE_INVALID_PAYLOAD, "Invalid payload.");
746
+ return;
600
747
  }
748
+ void this.handleMessage(state, parsed).catch((error) => {
749
+ this.logger.warn("Invalid message payload from client.", { error: String(error) });
750
+ socket.close(CLOSE_INVALID_PAYLOAD, "Invalid payload.");
751
+ });
601
752
  });
602
753
  socket.on("close", () => {
603
754
  clearTimeout(authTimer);
@@ -731,7 +882,7 @@ function createHostShardwire(options, runtimeHooks) {
731
882
  const hostServer = new HostWebSocketServer({
732
883
  options,
733
884
  onCommandRequest: async (connection, payload, requestId, source) => {
734
- const cacheKey = `${requestId}:${payload.name}`;
885
+ const cacheKey = `${connection.id}:${requestId}:${payload.name}`;
735
886
  const cached = dedupeCache.get(cacheKey);
736
887
  if (cached) {
737
888
  return cached;
@@ -759,21 +910,44 @@ function createHostShardwire(options, runtimeHooks) {
759
910
  context.source = source;
760
911
  }
761
912
  try {
762
- const maybePromise = Promise.resolve(handler(payload.data, context));
913
+ const commandValidation = options.validation?.commands?.[payload.name];
914
+ const validatedRequest = parsePayloadWithSchema(commandValidation?.request, payload.data, {
915
+ name: payload.name,
916
+ stage: "command.request"
917
+ });
918
+ const maybePromise = Promise.resolve(handler(validatedRequest, context));
763
919
  const value = await withTimeout(
764
920
  maybePromise,
765
921
  commandTimeoutMs,
766
922
  `Command "${payload.name}" timed out after ${commandTimeoutMs}ms.`
767
923
  );
924
+ const validatedResponse = parsePayloadWithSchema(commandValidation?.response, value, {
925
+ name: payload.name,
926
+ stage: "command.response"
927
+ });
768
928
  const success = {
769
929
  ok: true,
770
930
  requestId,
771
931
  ts: Date.now(),
772
- data: value
932
+ data: validatedResponse
773
933
  };
774
934
  dedupeCache.set(cacheKey, success);
775
935
  return success;
776
936
  } catch (error) {
937
+ if (error instanceof PayloadValidationError) {
938
+ const failure2 = {
939
+ ok: false,
940
+ requestId,
941
+ ts: Date.now(),
942
+ error: {
943
+ code: "VALIDATION_ERROR",
944
+ message: error.message,
945
+ details: error.details
946
+ }
947
+ };
948
+ dedupeCache.set(cacheKey, failure2);
949
+ return failure2;
950
+ }
777
951
  const isTimeout = error instanceof Error && /timed out/i.test(error.message);
778
952
  const failure = {
779
953
  ok: false,
@@ -800,13 +974,21 @@ function createHostShardwire(options, runtimeHooks) {
800
974
  },
801
975
  emitEvent(name, payload) {
802
976
  assertMessageName("event", name);
803
- assertJsonPayload("event", name, payload);
804
- hostServer.emitEvent(name, payload, options.name);
977
+ const validatedPayload = parsePayloadWithSchema(options.validation?.events?.[name], payload, {
978
+ name,
979
+ stage: "event.emit"
980
+ });
981
+ assertJsonPayload("event", name, validatedPayload);
982
+ hostServer.emitEvent(name, validatedPayload, options.name);
805
983
  },
806
984
  broadcast(name, payload) {
807
985
  assertMessageName("event", name);
808
- assertJsonPayload("event", name, payload);
809
- hostServer.emitEvent(name, payload, options.name);
986
+ const validatedPayload = parsePayloadWithSchema(options.validation?.events?.[name], payload, {
987
+ name,
988
+ stage: "event.emit"
989
+ });
990
+ assertJsonPayload("event", name, validatedPayload);
991
+ hostServer.emitEvent(name, validatedPayload, options.name);
810
992
  },
811
993
  close() {
812
994
  return hostServer.close().then(async () => {
@@ -816,6 +998,52 @@ function createHostShardwire(options, runtimeHooks) {
816
998
  };
817
999
  }
818
1000
 
1001
+ // src/schema/index.ts
1002
+ function fromSafeParseSchema(schema) {
1003
+ return {
1004
+ parse(value) {
1005
+ const result = schema.safeParse(value);
1006
+ if (result.success) {
1007
+ return result.data;
1008
+ }
1009
+ const error = new Error(result.error.message);
1010
+ if (result.error.issues) {
1011
+ error.issues = result.error.issues;
1012
+ }
1013
+ throw error;
1014
+ }
1015
+ };
1016
+ }
1017
+
1018
+ // src/schema/zod.ts
1019
+ function normalizeZodPath(path) {
1020
+ if (path.length === 0) {
1021
+ return "";
1022
+ }
1023
+ return path.filter((segment) => typeof segment === "string" || typeof segment === "number").map((segment) => String(segment)).join(".");
1024
+ }
1025
+ function fromZodSchema(schema) {
1026
+ return fromSafeParseSchema({
1027
+ safeParse(value) {
1028
+ const result = schema.safeParse(value);
1029
+ if (result.success) {
1030
+ return { success: true, data: result.data };
1031
+ }
1032
+ const issues = result.error.issues.map((issue) => ({
1033
+ path: normalizeZodPath(issue.path),
1034
+ message: issue.message
1035
+ }));
1036
+ return {
1037
+ success: false,
1038
+ error: {
1039
+ message: "Schema validation failed.",
1040
+ issues
1041
+ }
1042
+ };
1043
+ }
1044
+ });
1045
+ }
1046
+
819
1047
  // src/index.ts
820
1048
  function isHostOptions(options) {
821
1049
  return "server" in options;
@@ -850,6 +1078,8 @@ function createShardwire(options) {
850
1078
  });
851
1079
  }
852
1080
  export {
853
- createShardwire
1081
+ createShardwire,
1082
+ fromSafeParseSchema,
1083
+ fromZodSchema
854
1084
  };
855
1085
  //# sourceMappingURL=index.mjs.map