@spikard/node 0.11.0 → 0.13.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
@@ -548,6 +548,8 @@ var Spikard = class {
548
548
  handlers = {};
549
549
  websocketRoutes = [];
550
550
  websocketHandlers = {};
551
+ grpcMethods = [];
552
+ grpcHandlers = {};
551
553
  lifecycleHooks = {
552
554
  onRequest: [],
553
555
  preValidation: [],
@@ -591,13 +593,111 @@ var Spikard = class {
591
593
  this.websocketRoutes.push(route2);
592
594
  this.websocketHandlers[handlerName] = handlerWrapper;
593
595
  }
596
+ /**
597
+ * Register a unary gRPC method on the application.
598
+ *
599
+ * @param serviceName - Fully-qualified service name
600
+ * @param methodName - gRPC method name
601
+ * @param handler - gRPC handler implementation
602
+ * @returns The application for chaining
603
+ */
604
+ addGrpcUnary(serviceName, methodName, handler) {
605
+ if (typeof handler?.handleRequest !== "function") {
606
+ throw new TypeError("Unary handler must implement handleRequest(request)");
607
+ }
608
+ return this.registerGrpcMethod(serviceName, methodName, "unary", {
609
+ handleRequest: (request) => handler.handleRequest(request)
610
+ });
611
+ }
612
+ addGrpcServerStreaming(serviceName, methodName, handler) {
613
+ if (typeof handler?.handleServerStream !== "function") {
614
+ throw new TypeError("Server-streaming handler must implement handleServerStream(request)");
615
+ }
616
+ return this.registerGrpcMethod(serviceName, methodName, "serverStreaming", {
617
+ handleServerStream: (request) => handler.handleServerStream(request)
618
+ });
619
+ }
620
+ addGrpcClientStreaming(serviceName, methodName, handler) {
621
+ if (typeof handler?.handleClientStream !== "function") {
622
+ throw new TypeError("Client-streaming handler must implement handleClientStream(request)");
623
+ }
624
+ return this.registerGrpcMethod(serviceName, methodName, "clientStreaming", {
625
+ handleClientStream: (request) => handler.handleClientStream(request)
626
+ });
627
+ }
628
+ addGrpcBidirectionalStreaming(serviceName, methodName, handler) {
629
+ if (typeof handler?.handleBidiStream !== "function") {
630
+ throw new TypeError("Bidirectional-streaming handler must implement handleBidiStream(request)");
631
+ }
632
+ return this.registerGrpcMethod(serviceName, methodName, "bidirectionalStreaming", {
633
+ handleBidiStream: (request) => handler.handleBidiStream(request)
634
+ });
635
+ }
636
+ registerGrpcMethod(serviceName, methodName, rpcMode, handlerWrapper) {
637
+ if (!serviceName) {
638
+ throw new Error("Service name cannot be empty");
639
+ }
640
+ if (!methodName) {
641
+ throw new Error("Method name cannot be empty");
642
+ }
643
+ const previous = this.grpcMethods.find(
644
+ (entry) => entry.serviceName === serviceName && entry.methodName === methodName
645
+ );
646
+ if (previous) {
647
+ delete this.grpcHandlers[previous.handlerName];
648
+ }
649
+ const handlerName = `grpc_${this.grpcMethods.length}_${serviceName}_${methodName}`.replace(/[^a-zA-Z0-9_]/g, "_");
650
+ this.grpcHandlers[handlerName] = handlerWrapper;
651
+ this.grpcMethods = this.grpcMethods.filter(
652
+ (entry) => !(entry.serviceName === serviceName && entry.methodName === methodName)
653
+ );
654
+ this.grpcMethods.push({ serviceName, methodName, rpcMode, handlerName });
655
+ return this;
656
+ }
657
+ /**
658
+ * Mount all handlers from a gRPC service registry on the application.
659
+ *
660
+ * @param service - Registry containing one or more service methods
661
+ * @returns The application for chaining
662
+ */
663
+ useGrpc(service) {
664
+ for (const method of service.entries()) {
665
+ switch (method.rpcMode) {
666
+ case "unary":
667
+ this.addGrpcUnary(method.serviceName, method.methodName, method.handler);
668
+ break;
669
+ case "serverStreaming":
670
+ this.addGrpcServerStreaming(
671
+ method.serviceName,
672
+ method.methodName,
673
+ method.handler
674
+ );
675
+ break;
676
+ case "clientStreaming":
677
+ this.addGrpcClientStreaming(
678
+ method.serviceName,
679
+ method.methodName,
680
+ method.handler
681
+ );
682
+ break;
683
+ case "bidirectionalStreaming":
684
+ this.addGrpcBidirectionalStreaming(
685
+ method.serviceName,
686
+ method.methodName,
687
+ method.handler
688
+ );
689
+ break;
690
+ }
691
+ }
692
+ return this;
693
+ }
594
694
  /**
595
695
  * Run the server
596
696
  *
597
- * @param options - Server configuration
697
+ * @param config - Server configuration
598
698
  */
599
- run(options = {}) {
600
- runServer(this, options);
699
+ run(config = {}) {
700
+ runServer(this, config);
601
701
  }
602
702
  /**
603
703
  * Register an onRequest lifecycle hook
@@ -823,6 +923,135 @@ var GrpcError = class extends Error {
823
923
  this.name = "GrpcError";
824
924
  }
825
925
  };
926
+ var GrpcService = class {
927
+ methods = /* @__PURE__ */ new Map();
928
+ methodKey(serviceName, methodName) {
929
+ return `${serviceName}/${methodName}`;
930
+ }
931
+ registerMethod(config) {
932
+ if (!config.serviceName) {
933
+ throw new Error("Service name cannot be empty");
934
+ }
935
+ if (!config.methodName) {
936
+ throw new Error("Method name cannot be empty");
937
+ }
938
+ switch (config.rpcMode) {
939
+ case "unary":
940
+ if (typeof config.handler?.handleRequest !== "function") {
941
+ throw new TypeError("Unary handler must implement handleRequest(request)");
942
+ }
943
+ break;
944
+ case "serverStreaming":
945
+ if (typeof config.handler?.handleServerStream !== "function") {
946
+ throw new TypeError("Server-streaming handler must implement handleServerStream(request)");
947
+ }
948
+ break;
949
+ case "clientStreaming":
950
+ if (typeof config.handler?.handleClientStream !== "function") {
951
+ throw new TypeError("Client-streaming handler must implement handleClientStream(request)");
952
+ }
953
+ break;
954
+ case "bidirectionalStreaming":
955
+ if (typeof config.handler?.handleBidiStream !== "function") {
956
+ throw new TypeError("Bidirectional-streaming handler must implement handleBidiStream(request)");
957
+ }
958
+ break;
959
+ }
960
+ this.methods.set(this.methodKey(config.serviceName, config.methodName), config);
961
+ return this;
962
+ }
963
+ /**
964
+ * Register a unary handler for a fully-qualified service method.
965
+ *
966
+ * @param serviceName - Service name such as `mypackage.UserService`
967
+ * @param methodName - Method name such as `GetUser`
968
+ * @param handler - Handler implementation for that method
969
+ * @returns The registry for chaining
970
+ */
971
+ registerUnary(serviceName, methodName, handler) {
972
+ return this.registerMethod({ serviceName, methodName, rpcMode: "unary", handler });
973
+ }
974
+ registerServerStreaming(serviceName, methodName, handler) {
975
+ return this.registerMethod({ serviceName, methodName, rpcMode: "serverStreaming", handler });
976
+ }
977
+ registerClientStreaming(serviceName, methodName, handler) {
978
+ return this.registerMethod({ serviceName, methodName, rpcMode: "clientStreaming", handler });
979
+ }
980
+ registerBidirectionalStreaming(serviceName, methodName, handler) {
981
+ return this.registerMethod({ serviceName, methodName, rpcMode: "bidirectionalStreaming", handler });
982
+ }
983
+ /**
984
+ * Remove a handler from the registry.
985
+ *
986
+ * @param serviceName - Fully-qualified service name
987
+ * @param methodName - Method name
988
+ */
989
+ unregister(serviceName, methodName) {
990
+ if (!this.methods.delete(this.methodKey(serviceName, methodName))) {
991
+ throw new Error(`No handler registered for method: ${serviceName}/${methodName}`);
992
+ }
993
+ }
994
+ /**
995
+ * Get the registration for a service method.
996
+ *
997
+ * @param serviceName - Fully-qualified service name
998
+ * @param methodName - Method name
999
+ * @returns The registered method configuration, if present
1000
+ */
1001
+ getMethod(serviceName, methodName) {
1002
+ return this.methods.get(this.methodKey(serviceName, methodName));
1003
+ }
1004
+ /**
1005
+ * List all registered service names.
1006
+ *
1007
+ * @returns Fully-qualified service names
1008
+ */
1009
+ serviceNames() {
1010
+ return Array.from(new Set(Array.from(this.methods.values(), (entry) => entry.serviceName)));
1011
+ }
1012
+ methodNames(serviceName) {
1013
+ return Array.from(this.methods.values()).filter((entry) => entry.serviceName === serviceName).map((entry) => entry.methodName);
1014
+ }
1015
+ /**
1016
+ * Check whether a specific service method is registered.
1017
+ *
1018
+ * @param serviceName - Fully-qualified service name
1019
+ * @param methodName - Method name
1020
+ * @returns True when a handler is registered for the method
1021
+ */
1022
+ hasMethod(serviceName, methodName) {
1023
+ return this.methods.has(this.methodKey(serviceName, methodName));
1024
+ }
1025
+ /**
1026
+ * Return registered method entries.
1027
+ */
1028
+ entries() {
1029
+ return Array.from(this.methods.values());
1030
+ }
1031
+ /**
1032
+ * Route a unary request to the registered method handler.
1033
+ *
1034
+ * @param request - Incoming gRPC request
1035
+ * @returns Promise resolving to the handler response
1036
+ * @throws GrpcError when no service is registered
1037
+ */
1038
+ async handleRequest(request) {
1039
+ const method = this.getMethod(request.serviceName, request.methodName);
1040
+ if (!method) {
1041
+ throw new GrpcError(
1042
+ 12 /* UNIMPLEMENTED */,
1043
+ `No handler registered for method: ${request.serviceName}/${request.methodName}`
1044
+ );
1045
+ }
1046
+ if (method.rpcMode !== "unary") {
1047
+ throw new GrpcError(
1048
+ 12 /* UNIMPLEMENTED */,
1049
+ `Method ${request.serviceName}/${request.methodName} is registered as ${method.rpcMode}`
1050
+ );
1051
+ }
1052
+ return method.handler.handleRequest(request);
1053
+ }
1054
+ };
826
1055
  function createUnaryHandler(methodName, handler, requestType, responseType) {
827
1056
  return {
828
1057
  async handleRequest(request) {
@@ -903,6 +1132,36 @@ import fs from "fs/promises";
903
1132
  import { createRequire as createRequire3 } from "module";
904
1133
  import path from "path";
905
1134
  import { gunzipSync, gzipSync } from "zlib";
1135
+ var GRAPHQL_WS_TIMEOUT_MS = 2e3;
1136
+ var GRAPHQL_WS_MAX_CONTROL_MESSAGES = 32;
1137
+ var withTimeout = async (promise, timeoutMs, context) => {
1138
+ let timer;
1139
+ try {
1140
+ return await Promise.race([
1141
+ promise,
1142
+ new Promise((_resolve, reject) => {
1143
+ timer = setTimeout(() => reject(new Error(`Timed out waiting for ${context}`)), timeoutMs);
1144
+ })
1145
+ ]);
1146
+ } finally {
1147
+ if (timer) {
1148
+ clearTimeout(timer);
1149
+ }
1150
+ }
1151
+ };
1152
+ var decodeGraphqlWsMessage = (value) => {
1153
+ if (typeof value === "string") {
1154
+ const parsed = JSON.parse(value);
1155
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1156
+ return parsed;
1157
+ }
1158
+ throw new Error("Expected GraphQL WebSocket JSON object message");
1159
+ }
1160
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1161
+ return value;
1162
+ }
1163
+ throw new Error("Expected GraphQL WebSocket message object");
1164
+ };
906
1165
  var MockWebSocketConnection = class {
907
1166
  handler;
908
1167
  queue = [];
@@ -1616,6 +1875,107 @@ var TestClient = class {
1616
1875
  bodyText: response.text()
1617
1876
  };
1618
1877
  }
1878
+ /**
1879
+ * Send a GraphQL subscription over WebSocket and return the first event payload.
1880
+ */
1881
+ async graphqlSubscription(query, variables, operationName, path2 = "/graphql") {
1882
+ const operationId = "spikard-subscription-1";
1883
+ const subscriptionPayload = { query };
1884
+ if (variables !== null && variables !== void 0) {
1885
+ subscriptionPayload.variables = variables;
1886
+ }
1887
+ if (operationName !== null && operationName !== void 0) {
1888
+ subscriptionPayload.operationName = operationName;
1889
+ }
1890
+ const ws = await this.websocketConnect(path2);
1891
+ try {
1892
+ await ws.sendJson({ type: "connection_init" });
1893
+ let acknowledged = false;
1894
+ for (let i = 0; i < GRAPHQL_WS_MAX_CONTROL_MESSAGES; i++) {
1895
+ const message = decodeGraphqlWsMessage(
1896
+ await withTimeout(ws.receiveJson(), GRAPHQL_WS_TIMEOUT_MS, "GraphQL connection_ack")
1897
+ );
1898
+ const messageType = typeof message.type === "string" ? message.type : "";
1899
+ if (messageType === "connection_ack") {
1900
+ acknowledged = true;
1901
+ break;
1902
+ }
1903
+ if (messageType === "ping") {
1904
+ const pong = { type: "pong" };
1905
+ if ("payload" in message) {
1906
+ pong.payload = message.payload;
1907
+ }
1908
+ await ws.sendJson(pong);
1909
+ continue;
1910
+ }
1911
+ if (messageType === "connection_error" || messageType === "error") {
1912
+ throw new Error(`GraphQL subscription rejected during init: ${JSON.stringify(message)}`);
1913
+ }
1914
+ }
1915
+ if (!acknowledged) {
1916
+ throw new Error("No GraphQL connection_ack received");
1917
+ }
1918
+ await ws.sendJson({
1919
+ id: operationId,
1920
+ type: "subscribe",
1921
+ payload: subscriptionPayload
1922
+ });
1923
+ let event = null;
1924
+ const errors = [];
1925
+ let completeReceived = false;
1926
+ for (let i = 0; i < GRAPHQL_WS_MAX_CONTROL_MESSAGES; i++) {
1927
+ const message = decodeGraphqlWsMessage(
1928
+ await withTimeout(ws.receiveJson(), GRAPHQL_WS_TIMEOUT_MS, "GraphQL subscription message")
1929
+ );
1930
+ const messageType = typeof message.type === "string" ? message.type : "";
1931
+ const messageId = typeof message.id === "string" ? message.id : void 0;
1932
+ const idMatches = messageId === void 0 || messageId === operationId;
1933
+ if (messageType === "next" && idMatches) {
1934
+ event = "payload" in message ? message.payload : null;
1935
+ await ws.sendJson({ id: operationId, type: "complete" });
1936
+ try {
1937
+ const maybeComplete = decodeGraphqlWsMessage(
1938
+ await withTimeout(ws.receiveJson(), GRAPHQL_WS_TIMEOUT_MS, "GraphQL complete message")
1939
+ );
1940
+ const completeType = typeof maybeComplete.type === "string" ? maybeComplete.type : "";
1941
+ const completeId = typeof maybeComplete.id === "string" ? maybeComplete.id : void 0;
1942
+ if (completeType === "complete" && (completeId === void 0 || completeId === operationId)) {
1943
+ completeReceived = true;
1944
+ }
1945
+ } catch {
1946
+ }
1947
+ break;
1948
+ }
1949
+ if (messageType === "error") {
1950
+ errors.push("payload" in message ? message.payload : message);
1951
+ break;
1952
+ }
1953
+ if (messageType === "complete" && idMatches) {
1954
+ completeReceived = true;
1955
+ break;
1956
+ }
1957
+ if (messageType === "ping") {
1958
+ const pong = { type: "pong" };
1959
+ if ("payload" in message) {
1960
+ pong.payload = message.payload;
1961
+ }
1962
+ await ws.sendJson(pong);
1963
+ }
1964
+ }
1965
+ if (event === null && errors.length === 0 && !completeReceived) {
1966
+ throw new Error("No GraphQL subscription event received before timeout");
1967
+ }
1968
+ return {
1969
+ operationId,
1970
+ acknowledged,
1971
+ event,
1972
+ errors,
1973
+ completeReceived
1974
+ };
1975
+ } finally {
1976
+ await ws.close();
1977
+ }
1978
+ }
1619
1979
  /**
1620
1980
  * Cleanup resources when test client is done
1621
1981
  */
@@ -1624,6 +1984,7 @@ var TestClient = class {
1624
1984
  };
1625
1985
  export {
1626
1986
  GrpcError,
1987
+ GrpcService,
1627
1988
  GrpcStatusCode,
1628
1989
  Spikard,
1629
1990
  StreamingResponse,