@nice-code/action 0.15.0 → 0.17.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.
Files changed (44) hide show
  1. package/README.md +582 -582
  2. package/build/{ActionDevtoolsCore-9PsnscvK.mjs → ActionDevtoolsCore-BcItqP-C.mjs} +7 -7
  3. package/build/ActionDevtoolsCore-BcItqP-C.mjs.map +1 -0
  4. package/build/{ActionDevtoolsCore-CCRLYASa.d.cts → ActionDevtoolsCore-C5XrQI1K.d.mts} +3 -3
  5. package/build/{ActionDevtoolsCore-DtgXwPBZ.cjs → ActionDevtoolsCore-Cb_QR44N.cjs} +7 -7
  6. package/build/ActionDevtoolsCore-Cb_QR44N.cjs.map +1 -0
  7. package/build/{ActionDevtoolsCore-CYGD2o6C.d.mts → ActionDevtoolsCore-Dd1qJAwK.d.cts} +3 -3
  8. package/build/{ActionPayload.types-BN-rXFBK.d.cts → ActionPayload.types-BchJrBIX.d.mts} +1966 -1139
  9. package/build/{ActionPayload.types-D28ELKXC.d.mts → ActionPayload.types-snDlSIF-.d.cts} +1966 -1139
  10. package/build/devtools/browser/index.cjs +5 -5
  11. package/build/devtools/browser/index.cjs.map +1 -1
  12. package/build/devtools/browser/index.d.cts +1 -1
  13. package/build/devtools/browser/index.d.mts +1 -1
  14. package/build/devtools/browser/index.mjs +5 -5
  15. package/build/devtools/browser/index.mjs.map +1 -1
  16. package/build/devtools/server/index.cjs +1 -1
  17. package/build/devtools/server/index.cjs.map +1 -1
  18. package/build/devtools/server/index.d.cts +1 -1
  19. package/build/devtools/server/index.d.mts +1 -1
  20. package/build/devtools/server/index.mjs +1 -1
  21. package/build/devtools/server/index.mjs.map +1 -1
  22. package/build/index.cjs +2667 -1752
  23. package/build/index.cjs.map +1 -1
  24. package/build/index.d.cts +2 -2
  25. package/build/index.d.mts +2 -2
  26. package/build/index.mjs +2635 -1738
  27. package/build/index.mjs.map +1 -1
  28. package/build/platform/cloudflare/index.cjs +56 -0
  29. package/build/platform/cloudflare/index.cjs.map +1 -0
  30. package/build/platform/cloudflare/index.d.cts +67 -0
  31. package/build/platform/cloudflare/index.d.mts +67 -0
  32. package/build/platform/cloudflare/index.mjs +54 -0
  33. package/build/platform/cloudflare/index.mjs.map +1 -0
  34. package/build/react-query/index.cjs.map +1 -1
  35. package/build/react-query/index.d.cts +1 -1
  36. package/build/react-query/index.d.mts +1 -1
  37. package/build/react-query/index.mjs.map +1 -1
  38. package/build/wsAcceptorCarrier-CXGlQU_f.mjs +80 -0
  39. package/build/wsAcceptorCarrier-CXGlQU_f.mjs.map +1 -0
  40. package/build/wsAcceptorCarrier-DHRbsY1X.cjs +103 -0
  41. package/build/wsAcceptorCarrier-DHRbsY1X.cjs.map +1 -0
  42. package/package.json +15 -4
  43. package/build/ActionDevtoolsCore-9PsnscvK.mjs.map +0 -1
  44. package/build/ActionDevtoolsCore-DtgXwPBZ.cjs.map +0 -1
package/build/index.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import { n as ERunningActionState, r as ERunningActionUpdateType, t as ERunningActionFinishedType } from "./RunningAction.types-C176rqHG.mjs";
2
+ import { i as ETransportStatus, n as isExchangeAcceptorCarrier, r as ETransportShape, t as wsAcceptorCarrier } from "./wsAcceptorCarrier-CXGlQU_f.mjs";
2
3
  import { nanoid } from "nanoid";
3
4
  import { NiceError, castNiceError, err, err_nice, isNiceErrorObject } from "@nice-code/error";
4
- import { runtime } from "std-env";
5
5
  import { extractMessageFromStandardSchema } from "@nice-code/common-errors";
6
- import { pack, unpack } from "msgpackr";
6
+ import { runtime } from "std-env";
7
7
  import { ClientCryptoKeyLink, createTypedStorage } from "@nice-code/util";
8
8
  import * as v from "valibot";
9
+ import { pack, unpack } from "msgpackr";
9
10
  const UNSET_RUNTIME_ENV_ID = "_unset_";
10
11
  //#endregion
11
12
  //#region src/ActionRuntime/utils/runtimeCoordinateToStringIds.ts
@@ -635,6 +636,156 @@ const isActionPayload_Result_JsonObject = (obj) => {
635
636
  return isAction_Base_JsonObject(obj) && obj.result != null && obj.form === "data" && obj.type === "result";
636
637
  };
637
638
  //#endregion
639
+ //#region src/ActionDefinition/Schema/ActionSchema.ts
640
+ /**
641
+ * What a sender should expect back from an action — declared on its schema so both ends agree without
642
+ * any wire flag (each derives the mode from the shared `domain:id`).
643
+ *
644
+ * - `payload` — a typed output (the action has `.output(...)`); the sender awaits it.
645
+ * - `ack` — an empty success confirming receipt (no output); the sender may await it to know the
646
+ * receiver handled it (or to surface an error). This is the default for an action with no output.
647
+ * - `none` — fire-and-forget: the receiver sends no reply and the sender doesn't wait. The sender's
648
+ * running action completes as soon as the frame is on the wire (no pending reply, no timeout).
649
+ */
650
+ let EActionResponseMode = /* @__PURE__ */ function(EActionResponseMode) {
651
+ EActionResponseMode["payload"] = "payload";
652
+ EActionResponseMode["ack"] = "ack";
653
+ EActionResponseMode["none"] = "none";
654
+ return EActionResponseMode;
655
+ }({});
656
+ var ActionSchema = class {
657
+ _errorDeclarations = [];
658
+ inputOptions;
659
+ outputOptions;
660
+ _responseMode;
661
+ get inputSchema() {
662
+ return this.inputOptions?.schema;
663
+ }
664
+ get outputSchema() {
665
+ return this.outputOptions?.schema;
666
+ }
667
+ /**
668
+ * The response contract for this action. Defaults are inferred — `payload` when an output schema is
669
+ * declared, otherwise `ack` — and made explicit by {@link ack} / {@link fireAndForget}.
670
+ */
671
+ get responseMode() {
672
+ if (this._responseMode != null) return this._responseMode;
673
+ return this.outputOptions != null ? "payload" : "ack";
674
+ }
675
+ /**
676
+ * Mark this action as expecting only an acknowledgment (an empty success). Mostly for clarity — an
677
+ * output-less action already acks by default — but it documents intent and reads as the deliberate
678
+ * counterpart to {@link fireAndForget}.
679
+ */
680
+ ack() {
681
+ this._responseMode = "ack";
682
+ return this;
683
+ }
684
+ /**
685
+ * Mark this action as fire-and-forget: the receiver sends no reply, and the sender's running action
686
+ * completes the moment the frame is sent (no awaited reply, no timeout). Ideal for high-frequency
687
+ * server→client pushes (presence, ticks) where an ack would only add wire chatter.
688
+ */
689
+ fireAndForget() {
690
+ this._responseMode = "none";
691
+ return this;
692
+ }
693
+ /**
694
+ * Declare the input schema (JSON-native or with explicit SERDE type param).
695
+ * For non-JSON-native inputs, prefer the 3-argument form below to avoid
696
+ * needing explicit type parameters.
697
+ */
698
+ input(options) {
699
+ this.inputOptions = options;
700
+ return this;
701
+ }
702
+ /**
703
+ * Declare the output schema (JSON-native or with explicit SERDE type param).
704
+ * For non-JSON-native outputs, prefer the 3-argument form below to avoid
705
+ * needing explicit type parameters.
706
+ */
707
+ output(options) {
708
+ this.outputOptions = options;
709
+ return this;
710
+ }
711
+ throws(domain, ids) {
712
+ this._errorDeclarations.push({
713
+ _domain: domain,
714
+ _ids: ids
715
+ });
716
+ return this;
717
+ }
718
+ /**
719
+ * Serialize raw input to a JSON-serializable form.
720
+ * Uses the schema's serialization.serialize if defined; otherwise the input
721
+ * is already JSON-native and is returned as-is.
722
+ */
723
+ serializeInput(rawInput) {
724
+ if (this.inputOptions?.serialization) return this.inputOptions.serialization.serialize(rawInput);
725
+ return rawInput;
726
+ }
727
+ /**
728
+ * Deserialize a JSON value back into the raw input type.
729
+ * Uses serialization.deserialize if defined; otherwise the value is cast
730
+ * directly (it's already in the correct shape).
731
+ */
732
+ deserializeInput(serialized) {
733
+ if (this.inputOptions?.serialization) return this.inputOptions.serialization.deserialize(serialized);
734
+ return serialized;
735
+ }
736
+ /**
737
+ * Validate raw input against the schema defined via `.input({ schema })`.
738
+ * Throws `action_input_validation_failed` if validation fails.
739
+ * Returns the validated (and possibly coerced) value on success.
740
+ * If no input schema was declared, the value is passed through as-is.
741
+ */
742
+ validateInput(value, meta) {
743
+ if (this.inputOptions?.schema == null) return value;
744
+ const result = this.inputOptions.schema["~standard"].validate(value);
745
+ if (result instanceof Promise) throw err_nice_action.fromId("action_input_validation_promise", {
746
+ domain: meta.domain,
747
+ actionId: meta.actionId
748
+ });
749
+ if (result.issues != null) throw err_nice_action.fromId("action_input_validation_failed", {
750
+ domain: meta.domain,
751
+ actionId: meta.actionId,
752
+ validationMessage: extractMessageFromStandardSchema(result)
753
+ });
754
+ return result.value;
755
+ }
756
+ validateOutput(value, meta) {
757
+ if (this.outputOptions?.schema == null) return value;
758
+ const result = this.outputOptions.schema["~standard"].validate(value);
759
+ if (result instanceof Promise) throw err_nice_action.fromId("action_output_validation_promise", {
760
+ domain: meta.domain,
761
+ actionId: meta.actionId
762
+ });
763
+ if (result.issues != null) throw err_nice_action.fromId("action_output_validation_failed", {
764
+ domain: meta.domain,
765
+ actionId: meta.actionId,
766
+ validationMessage: extractMessageFromStandardSchema(result)
767
+ });
768
+ return result.value;
769
+ }
770
+ /**
771
+ * Serialize raw output to a JSON-serializable form.
772
+ */
773
+ serializeOutput(rawOutput) {
774
+ if (this.outputOptions?.serialization) return this.outputOptions.serialization.serialize(rawOutput);
775
+ return rawOutput;
776
+ }
777
+ /**
778
+ * Deserialize a JSON value back into the raw output type.
779
+ */
780
+ deserializeOutput(serialized) {
781
+ if (this.outputOptions?.serialization) return this.outputOptions.serialization.deserialize(serialized);
782
+ return serialized;
783
+ }
784
+ };
785
+ const actionSchema = () => {
786
+ return new ActionSchema();
787
+ };
788
+ //#endregion
638
789
  //#region src/utils/getAssumedRuntimeEnvironment.ts
639
790
  const getAssumedRuntimeInfo = () => {
640
791
  return {
@@ -670,6 +821,137 @@ function peekHandlerCuid() {
670
821
  return _stack[_stack.length - 1];
671
822
  }
672
823
  //#endregion
824
+ //#region src/ActionRuntime/Handler/PeerLink/Connector/err_nice_external_client.ts
825
+ const err_nice_external_client = err_nice_action.createChildDomain({
826
+ domain: "err_nice_external_client",
827
+ schema: {}
828
+ });
829
+ //#endregion
830
+ //#region src/ActionRuntime/Transport/err_nice_transport.ts
831
+ let EErrId_NiceTransport = /* @__PURE__ */ function(EErrId_NiceTransport) {
832
+ EErrId_NiceTransport["timeout"] = "timeout";
833
+ EErrId_NiceTransport["not_found"] = "not_found";
834
+ EErrId_NiceTransport["unsupported"] = "unsupported";
835
+ EErrId_NiceTransport["initialization_failed"] = "initialization_failed";
836
+ EErrId_NiceTransport["send_failed"] = "send_failed";
837
+ EErrId_NiceTransport["invalid_action_response"] = "invalid_action_response";
838
+ return EErrId_NiceTransport;
839
+ }({});
840
+ const err_nice_transport = err_nice_external_client.createChildDomain({
841
+ domain: "err_nice_transport",
842
+ schema: {
843
+ ["timeout"]: err({ message: ({ timeout }) => `ActionConnect transport timed out after ${timeout}ms.` }),
844
+ ["not_found"]: err({ message: ({ actionId }) => `No connected transport found for action "${actionId}".` }),
845
+ ["unsupported"]: err({ message: ({ transportShapes }) => `${transportShapes.length} Transport(s) [${transportShapes.join(", ")}] found but returned "unsupported" status.` }),
846
+ ["initialization_failed"]: err({ message: ({ actionId }) => `Transports found for action "${actionId}", but none are ready.` }),
847
+ ["send_failed"]: err({
848
+ message: ({ actionId, httpStatusCode, message }) => `Failed to send action "${actionId}" [${httpStatusCode ?? "Unknown status"}]: ${message ?? "Unknown error"}.`,
849
+ httpStatusCode: ({ httpStatusCode }) => httpStatusCode ?? 500
850
+ }),
851
+ ["invalid_action_response"]: err({ message: ({ actionId }) => `Invalid action response JSON structure for action "${actionId}"` })
852
+ }
853
+ });
854
+ //#endregion
855
+ //#region src/ActionRuntime/Transport/ConnectionTransportManager.ts
856
+ var ConnectionTransportManager = class {
857
+ _cache;
858
+ _transports = [];
859
+ constructor(_cache) {
860
+ this._cache = _cache;
861
+ }
862
+ addTransport(transport) {
863
+ this._transports.push(transport);
864
+ }
865
+ /**
866
+ * The highest-priority transport (first declared). Used to label an action's route *before* the
867
+ * transport has finished connecting — so a still-connecting action shows its (expected) destination
868
+ * instead of an "unknown" hop. {@link getReadyTransport} still decides the real winner, and the
869
+ * caller corrects the hop if a lower-priority transport ends up serving the action.
870
+ */
871
+ getPreferredTransport() {
872
+ return this._transports[0];
873
+ }
874
+ async getReadyTransport(routeActionParams) {
875
+ const action = routeActionParams.action;
876
+ const candidates = [];
877
+ const unavailableTransports = [];
878
+ for (const transport of this._transports) {
879
+ const cacheKey = transport.getCacheKey(routeActionParams);
880
+ if (cacheKey != null) {
881
+ const cached = this._cache.get(cacheKey);
882
+ if (cached != null) {
883
+ if (cached instanceof Promise) {
884
+ candidates.push(cached);
885
+ continue;
886
+ }
887
+ candidates.push(Promise.resolve({
888
+ ...cached,
889
+ transport
890
+ }));
891
+ break;
892
+ }
893
+ }
894
+ const statusInfo = transport.getTransport(routeActionParams);
895
+ if (statusInfo.status === "ready") {
896
+ const readyData = statusInfo.readyData;
897
+ if (cacheKey != null) {
898
+ const entry = {
899
+ methods: readyData,
900
+ transport
901
+ };
902
+ this._cache.set(cacheKey, entry);
903
+ readyData.addOnDisconnectListener?.(() => {
904
+ if (this._cache.get(cacheKey) === entry) this._cache.delete(cacheKey);
905
+ });
906
+ }
907
+ candidates.push(Promise.resolve({
908
+ methods: readyData,
909
+ transport
910
+ }));
911
+ break;
912
+ }
913
+ if (statusInfo.status === "unsupported") {
914
+ unavailableTransports.push(transport);
915
+ continue;
916
+ }
917
+ if (statusInfo.status === "initializing") {
918
+ const promise = statusInfo.initializationPromise.then((info) => {
919
+ if (info.status === "failed") throw info.error;
920
+ if (info.status === "unsupported") throw err_nice_transport.fromId("unsupported", { transportShapes: [transport.type] });
921
+ const readyData = info.readyData;
922
+ const entry = {
923
+ methods: readyData,
924
+ transport
925
+ };
926
+ if (cacheKey != null) if (this._cache.get(cacheKey) === promise) {
927
+ this._cache.set(cacheKey, entry);
928
+ readyData.addOnDisconnectListener?.(() => {
929
+ if (this._cache.get(cacheKey) === entry) this._cache.delete(cacheKey);
930
+ });
931
+ } else readyData.disconnect?.();
932
+ return entry;
933
+ }).catch((e) => {
934
+ if (cacheKey != null && this._cache.get(cacheKey) === promise) this._cache.delete(cacheKey);
935
+ throw e;
936
+ });
937
+ if (cacheKey != null) this._cache.set(cacheKey, promise);
938
+ candidates.push(promise);
939
+ }
940
+ }
941
+ if (candidates.length === 0) {
942
+ if (unavailableTransports.length > 0) throw err_nice_transport.fromId("unsupported", { transportShapes: unavailableTransports.map((t) => t.type) });
943
+ throw err_nice_transport.fromId("not_found", { actionId: action.id });
944
+ }
945
+ let lastError;
946
+ for (const candidate of candidates) try {
947
+ return await candidate;
948
+ } catch (e) {
949
+ lastError = e;
950
+ }
951
+ throw err_nice_transport.fromId("initialization_failed", { actionId: action.id }).withOriginError(lastError);
952
+ }
953
+ };
954
+ //#endregion
673
955
  //#region src/ActionRuntime/ActionDomainManager.ts
674
956
  var ActionDomainManager = class {
675
957
  _domains = /* @__PURE__ */ new Map();
@@ -842,183 +1124,33 @@ var ActionHandler = class {
842
1124
  }
843
1125
  };
844
1126
  //#endregion
845
- //#region src/ActionRuntime/Handler/ExternalClient/err_nice_external_client.ts
846
- const err_nice_external_client = err_nice_action.createChildDomain({
847
- domain: "err_nice_external_client",
848
- schema: {}
849
- });
850
- //#endregion
851
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/err_nice_transport.ts
852
- let EErrId_NiceTransport = /* @__PURE__ */ function(EErrId_NiceTransport) {
853
- EErrId_NiceTransport["timeout"] = "timeout";
854
- EErrId_NiceTransport["not_found"] = "not_found";
855
- EErrId_NiceTransport["unsupported"] = "unsupported";
856
- EErrId_NiceTransport["initialization_failed"] = "initialization_failed";
857
- EErrId_NiceTransport["send_failed"] = "send_failed";
858
- EErrId_NiceTransport["invalid_action_response"] = "invalid_action_response";
859
- return EErrId_NiceTransport;
860
- }({});
861
- const err_nice_transport = err_nice_external_client.createChildDomain({
862
- domain: "err_nice_transport",
863
- schema: {
864
- ["timeout"]: err({ message: ({ timeout }) => `ActionConnect transport timed out after ${timeout}ms.` }),
865
- ["not_found"]: err({ message: ({ actionId }) => `No connected transport found for action "${actionId}".` }),
866
- ["unsupported"]: err({ message: ({ transportTypes }) => `${transportTypes.length} Transport(s) [${transportTypes.join(", ")}] found but returned "unsupported" status.` }),
867
- ["initialization_failed"]: err({ message: ({ actionId }) => `Transports found for action "${actionId}", but none are ready.` }),
868
- ["send_failed"]: err({
869
- message: ({ actionId, httpStatusCode, message }) => `Failed to send action "${actionId}" [${httpStatusCode ?? "Unknown status"}]: ${message ?? "Unknown error"}.`,
870
- httpStatusCode: ({ httpStatusCode }) => httpStatusCode ?? 500
871
- }),
872
- ["invalid_action_response"]: err({ message: ({ actionId }) => `Invalid action response JSON structure for action "${actionId}"` })
873
- }
874
- });
875
- //#endregion
876
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/Transport.types.ts
877
- let ETransportType = /* @__PURE__ */ function(ETransportType) {
878
- ETransportType["ws"] = "ws";
879
- ETransportType["http"] = "http";
880
- ETransportType["custom"] = "custom";
881
- return ETransportType;
882
- }({});
1127
+ //#region src/ActionRuntime/Handler/PeerLink/PeerLinkHandler.ts
883
1128
  /**
1129
+ * Shared base for every handler that routes a domain set to/from *another runtime* (a "peer") — the
1130
+ * unified peer-link concept. Both specializations extend this as siblings, differing only in *who
1131
+ * establishes the connection*, which is a transport trait, not a routing one:
884
1132
  *
885
- * TRANSPORT READINESS RESPONSE
1133
+ * - {@link ConnectorHandler} — **dial-out**: this runtime opens connection(s) to one peer
1134
+ * over a transport stack (with caching + fallback). The classic "client → backend" link.
1135
+ * - {@link AcceptorHandler} — **accept-in**: connections are accepted from many peers and fed in
1136
+ * via `receive()`; it keeps a per-connection registry and can push to any of them.
886
1137
  *
1138
+ * To the runtime there is no "client" vs "server" — both are peer-link handlers (`handlerType =
1139
+ * external`) keyed to a peer coordinate, chosen by the return-path dispatch via {@link sendReturnPayload}.
887
1140
  */
888
- let ETransportStatus = /* @__PURE__ */ function(ETransportStatus) {
889
- ETransportStatus["uninitialized"] = "uninitialized";
890
- ETransportStatus["unsupported"] = "unsupported";
891
- ETransportStatus["initializing"] = "initializing";
892
- ETransportStatus["ready"] = "ready";
893
- ETransportStatus["failed"] = "failed";
894
- return ETransportStatus;
895
- }({});
896
- //#endregion
897
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/ConnectionTransportManager.ts
898
- var ConnectionTransportManager = class {
899
- _cache;
900
- _transports = [];
901
- constructor(_cache) {
902
- this._cache = _cache;
903
- }
904
- addTransport(transport) {
905
- this._transports.push(transport);
906
- }
907
- /**
908
- * The highest-priority transport (first declared). Used to label an action's route *before* the
909
- * transport has finished connecting — so a still-connecting action shows its (expected) destination
910
- * instead of an "unknown" hop. {@link getReadyTransport} still decides the real winner, and the
911
- * caller corrects the hop if a lower-priority transport ends up serving the action.
912
- */
913
- getPreferredTransport() {
914
- return this._transports[0];
915
- }
916
- async getReadyTransport(routeActionParams) {
917
- const action = routeActionParams.action;
918
- const candidates = [];
919
- const unavailableTransports = [];
920
- for (const transport of this._transports) {
921
- const cacheKey = transport.getCacheKey(routeActionParams);
922
- if (cacheKey != null) {
923
- const cached = this._cache.get(cacheKey);
924
- if (cached != null) {
925
- if (cached instanceof Promise) {
926
- candidates.push(cached);
927
- continue;
928
- }
929
- candidates.push(Promise.resolve({
930
- ...cached,
931
- transport
932
- }));
933
- break;
934
- }
935
- }
936
- const statusInfo = transport.getTransport(routeActionParams);
937
- if (statusInfo.status === "ready") {
938
- const readyData = statusInfo.readyData;
939
- if (cacheKey != null) {
940
- const entry = {
941
- methods: readyData,
942
- transport
943
- };
944
- this._cache.set(cacheKey, entry);
945
- readyData.addOnDisconnectListener?.(() => {
946
- if (this._cache.get(cacheKey) === entry) this._cache.delete(cacheKey);
947
- });
948
- }
949
- candidates.push(Promise.resolve({
950
- methods: readyData,
951
- transport
952
- }));
953
- break;
954
- }
955
- if (statusInfo.status === "unsupported") {
956
- unavailableTransports.push(transport);
957
- continue;
958
- }
959
- if (statusInfo.status === "initializing") {
960
- const promise = statusInfo.initializationPromise.then((info) => {
961
- if (info.status === "failed") throw info.error;
962
- if (info.status === "unsupported") throw err_nice_transport.fromId("unsupported", { transportTypes: [transport.type] });
963
- const readyData = info.readyData;
964
- const entry = {
965
- methods: readyData,
966
- transport
967
- };
968
- if (cacheKey != null) if (this._cache.get(cacheKey) === promise) {
969
- this._cache.set(cacheKey, entry);
970
- readyData.addOnDisconnectListener?.(() => {
971
- if (this._cache.get(cacheKey) === entry) this._cache.delete(cacheKey);
972
- });
973
- } else readyData.disconnect?.();
974
- return entry;
975
- }).catch((e) => {
976
- if (cacheKey != null && this._cache.get(cacheKey) === promise) this._cache.delete(cacheKey);
977
- throw e;
978
- });
979
- if (cacheKey != null) this._cache.set(cacheKey, promise);
980
- candidates.push(promise);
981
- }
982
- }
983
- if (candidates.length === 0) {
984
- if (unavailableTransports.length > 0) throw err_nice_transport.fromId("unsupported", { transportTypes: unavailableTransports.map((t) => t.type) });
985
- throw err_nice_transport.fromId("not_found", { actionId: action.id });
986
- }
987
- let lastError;
988
- for (const candidate of candidates) try {
989
- return await candidate;
990
- } catch (e) {
991
- lastError = e;
992
- }
993
- throw err_nice_transport.fromId("initialization_failed", { actionId: action.id }).withOriginError(lastError);
994
- }
995
- };
996
- //#endregion
997
- //#region src/ActionRuntime/Handler/ExternalClient/ActionExternalClientHandler.ts
998
- var ActionExternalClientHandler = class extends ActionHandler {
999
- externalClient;
1000
- handlerType = "external";
1001
- cuid;
1002
- _defaultTimeout;
1003
- _transportCache = /* @__PURE__ */ new Map();
1004
- transportManager = new ConnectionTransportManager(this._transportCache);
1005
- _incomingActionDataListeners = [];
1141
+ var PeerLinkHandler = class extends ActionHandler {
1142
+ /** The peer runtime this handler links to (an env-only coordinate for an accept-in handler). */
1143
+ peerClient;
1144
+ handlerType = "peer";
1006
1145
  actionRouter = new ActionRouter({
1007
1146
  contextType: "handler_route",
1008
1147
  handler: this
1009
1148
  });
1010
- constructor({ runtimeCoordinate: externalClientSpecifier, transports, defaultTimeout }) {
1149
+ /** Listeners installed by the runtime (`resolveIncomingActionPayload`) for inbound peer frames. */
1150
+ _incomingActionDataListeners = [];
1151
+ constructor(peerCoordinate) {
1011
1152
  super();
1012
- this.externalClient = externalClientSpecifier;
1013
- this.cuid = nanoid();
1014
- this._defaultTimeout = defaultTimeout ?? 1e4;
1015
- for (const transport of transports) {
1016
- const connection = transport._createConnection({ resolvers: { onIncomingActionDataJson: (json) => {
1017
- for (const l of this._incomingActionDataListeners) l(json);
1018
- } } });
1019
- connection.definition = transport;
1020
- this.transportManager.addTransport(connection);
1021
- }
1153
+ this.peerClient = peerCoordinate;
1022
1154
  }
1023
1155
  forDomain(domain) {
1024
1156
  this.actionRouter.forDomain(domain, true);
@@ -1035,6 +1167,49 @@ var ActionExternalClientHandler = class extends ActionHandler {
1035
1167
  _setIncomingActionDataListener(listener) {
1036
1168
  this._incomingActionDataListeners.push(listener);
1037
1169
  }
1170
+ /** Hand a decoded inbound frame to the runtime (called by each specialization's receive path). */
1171
+ _emitIncoming(json) {
1172
+ for (const listener of this._incomingActionDataListeners) listener(json);
1173
+ }
1174
+ /**
1175
+ * Whether this handler currently holds a *live* connection bound to `origin`. The runtime's return-path
1176
+ * dispatch ({@link ActionRuntime.getReturnHandlerForOrigin}) prefers a handler that owns the origin's
1177
+ * connection over a mere coordinate match, so with several duplex acceptors a result/push routes back
1178
+ * over the carrier the client connected on. Defaults to `false`; an acceptor overrides it from its
1179
+ * connection registry.
1180
+ */
1181
+ ownsLiveConnectionFor(_origin) {
1182
+ return false;
1183
+ }
1184
+ /** Release any long-lived connections this handler owns (a teardown). No-op by default. */
1185
+ clearTransportCache() {}
1186
+ };
1187
+ //#endregion
1188
+ //#region src/ActionRuntime/Handler/PeerLink/Connector/ConnectorHandler.ts
1189
+ /**
1190
+ * Dial-out peer link: this runtime opens connection(s) to one peer over a transport stack (cached, with
1191
+ * preference-ordered fallback). The classic "client → backend" handler — but to the runtime it's just a
1192
+ * {@link PeerLinkHandler} like the accept-in server one.
1193
+ */
1194
+ var ConnectorHandler = class extends PeerLinkHandler {
1195
+ /**
1196
+ * Dial-out can receive (and so return) an unsolicited push only over a duplex transport. With every
1197
+ * transport exchange-only (HTTP), the peer can never push to us — so this link can't deliver one back.
1198
+ */
1199
+ canPush;
1200
+ _defaultTimeout;
1201
+ _transportCache = /* @__PURE__ */ new Map();
1202
+ transportManager = new ConnectionTransportManager(this._transportCache);
1203
+ constructor({ runtimeCoordinate: peerSpecifier, transports, defaultTimeout }) {
1204
+ super(peerSpecifier);
1205
+ this._defaultTimeout = defaultTimeout ?? 1e4;
1206
+ this.canPush = transports.some((transport) => transport.type === "duplex");
1207
+ for (const transport of transports) {
1208
+ const connection = transport._createConnection({ resolvers: { onIncomingActionDataJson: (json) => this._emitIncoming(json) } });
1209
+ connection.definition = transport;
1210
+ this.transportManager.addTransport(connection);
1211
+ }
1212
+ }
1038
1213
  async handleActionRequest(action, config) {
1039
1214
  const localRuntime = config?.targetLocalRuntime ?? ActionRuntime.getDefault();
1040
1215
  const localClient = localRuntime.coordinate;
@@ -1044,7 +1219,7 @@ var ActionExternalClientHandler = class extends ActionHandler {
1044
1219
  const routeParams = {
1045
1220
  action,
1046
1221
  localClient,
1047
- externalClient: this.externalClient
1222
+ externalClient: this.peerClient
1048
1223
  };
1049
1224
  const preferredTransport = this.transportManager.getPreferredTransport();
1050
1225
  const routeItem = preferredTransport != null ? {
@@ -1083,6 +1258,7 @@ var ActionExternalClientHandler = class extends ActionHandler {
1083
1258
  };
1084
1259
  if (action.type === "request" && methods.updateRunConfig != null) sendInput.timeout = methods.updateRunConfig(sendInput)?.timeout ?? incomingTimeout;
1085
1260
  methods.sendActionData(sendInput);
1261
+ if (action.type === "request" && action.schema.responseMode === "none") runningAction._completeWithResult(action.successResult(void 0));
1086
1262
  } catch (err) {
1087
1263
  runningAction._abort(err);
1088
1264
  }
@@ -1100,12 +1276,12 @@ var ActionExternalClientHandler = class extends ActionHandler {
1100
1276
  const { methods } = await this.transportManager.getReadyTransport({
1101
1277
  action: payload,
1102
1278
  localClient,
1103
- externalClient: this.externalClient
1279
+ externalClient: this.peerClient
1104
1280
  });
1105
1281
  if (methods.sendReturnData == null) return false;
1106
1282
  methods.sendReturnData(payload, {
1107
1283
  localClient,
1108
- externalClient: this.externalClient
1284
+ externalClient: this.peerClient
1109
1285
  });
1110
1286
  return true;
1111
1287
  } catch {
@@ -1115,15 +1291,15 @@ var ActionExternalClientHandler = class extends ActionHandler {
1115
1291
  toJsonObject() {
1116
1292
  return {
1117
1293
  type: this.handlerType,
1118
- client: this.externalClient
1294
+ client: this.peerClient
1119
1295
  };
1120
1296
  }
1121
1297
  toHandlerRouteItem(transport, input) {
1122
1298
  return {
1123
1299
  type: this.handlerType,
1124
- client: this.externalClient,
1300
+ client: this.peerClient,
1125
1301
  transOrd: transport.transOrd,
1126
- transType: transport.type,
1302
+ transShape: transport.type,
1127
1303
  transInfo: transport.getRouteInfo(input)
1128
1304
  };
1129
1305
  }
@@ -1133,8 +1309,8 @@ var ActionExternalClientHandler = class extends ActionHandler {
1133
1309
  this._transportCache.clear();
1134
1310
  }
1135
1311
  };
1136
- const createExternalClientHandler = (config) => {
1137
- return new ActionExternalClientHandler(config);
1312
+ const createConnectorHandler = (config) => {
1313
+ return new ConnectorHandler(config);
1138
1314
  };
1139
1315
  //#endregion
1140
1316
  //#region src/ActionRuntime/ActionRuntime.ts
@@ -1144,7 +1320,7 @@ var ActionRuntime = class {
1144
1320
  runtimeInfo = getAssumedRuntimeInfo();
1145
1321
  actionRouter;
1146
1322
  _pendingRunningActions = /* @__PURE__ */ new Map();
1147
- _registeredExternalHandlers = [];
1323
+ _registeredPeerHandlers = [];
1148
1324
  _applied = false;
1149
1325
  static getDefault() {
1150
1326
  return getDefaultActionRuntime();
@@ -1219,24 +1395,24 @@ var ActionRuntime = class {
1219
1395
  */
1220
1396
  _getHandlerForAction(action, options) {
1221
1397
  const handlers = this.actionRouter.getRouteDataEntriesForAction(action);
1222
- const targetExternalClient = options?.targetExternalClient;
1398
+ const targetPeer = options?.targetPeer;
1223
1399
  const possibleHandlers = handlers.filter((handler) => {
1224
- if (handler.handlerType === "external") {
1225
- if (targetExternalClient && !targetExternalClient.isSameFor(handler.externalClient).id) return false;
1400
+ if (handler.handlerType === "peer") {
1401
+ if (targetPeer && !targetPeer.isSameFor(handler.peerClient).id) return false;
1226
1402
  return true;
1227
1403
  }
1228
- if (targetExternalClient != null) return false;
1404
+ if (targetPeer != null) return false;
1229
1405
  if (action.type === "request") return true;
1230
1406
  return false;
1231
1407
  });
1232
1408
  if (possibleHandlers.length === 0) return;
1233
- const scoringExternalClient = targetExternalClient ?? RuntimeCoordinate.unknown;
1409
+ const scoringPeer = targetPeer ?? RuntimeCoordinate.unknown;
1234
1410
  let handlerScore = -1;
1235
1411
  let handler;
1236
1412
  for (const possibleHandler of possibleHandlers) {
1237
- if (possibleHandler.handlerType === "local" && handler == null) return possibleHandler;
1238
- if (possibleHandler.handlerType === "external") {
1239
- const score = scoringExternalClient.similarityLevel(possibleHandler.externalClient);
1413
+ if (possibleHandler.handlerType === "local") return possibleHandler;
1414
+ if (possibleHandler.handlerType === "peer") {
1415
+ const score = scoringPeer.similarityLevel(possibleHandler.peerClient);
1240
1416
  if (score > handlerScore) {
1241
1417
  handlerScore = score;
1242
1418
  handler = possibleHandler;
@@ -1250,7 +1426,7 @@ var ActionRuntime = class {
1250
1426
  if (handler == null) throw err_nice_action.fromId("no_action_execution_handler", {
1251
1427
  actionId: action.id,
1252
1428
  domain: action.domain,
1253
- specifiedClient: options?.targetExternalClient
1429
+ specifiedClient: options?.targetPeer
1254
1430
  });
1255
1431
  return handler;
1256
1432
  }
@@ -1262,9 +1438,9 @@ var ActionRuntime = class {
1262
1438
  */
1263
1439
  addHandlers(handlers) {
1264
1440
  for (const handler of handlers) {
1265
- if (handler.handlerType === "external") {
1441
+ if (handler.handlerType === "peer") {
1266
1442
  handler._setIncomingActionDataListener((json) => this.resolveIncomingActionPayload(json));
1267
- this._registeredExternalHandlers.push(handler);
1443
+ this._registeredPeerHandlers.push(handler);
1268
1444
  }
1269
1445
  const handlerRouter = handler.getActionRouter();
1270
1446
  this.actionRouter.addDomainsFromOther(handlerRouter);
@@ -1275,18 +1451,18 @@ var ActionRuntime = class {
1275
1451
  }
1276
1452
  /**
1277
1453
  * Declare an external "backend client" in one call: build an
1278
- * {@link ActionExternalClientHandler} for `externalCoordinate` carrying the given
1454
+ * {@link ConnectorHandler} for `externalCoordinate` carrying the given
1279
1455
  * `transports`, route the listed `domains`/`actions` to it, register it (plus any
1280
1456
  * `localHandlers` — e.g. server→client push handlers that share the same channel)
1281
1457
  * on this runtime, and `apply()`. Returns the external handler so the caller can
1282
1458
  * later `clearTransportCache()` it.
1283
1459
  *
1284
- * Sugar over `new ActionExternalClientHandler(...).forDomain(...)` + `addHandlers([...])`,
1460
+ * Sugar over `new ConnectorHandler(...).forDomain(...)` + `addHandlers([...])`,
1285
1461
  * so a single runtime can host one handler per backend target with its transports
1286
1462
  * declared once and reused across every action routed to that backend.
1287
1463
  */
1288
1464
  connectTo(externalCoordinate, options) {
1289
- const handler = new ActionExternalClientHandler({
1465
+ const handler = new ConnectorHandler({
1290
1466
  runtimeCoordinate: externalCoordinate,
1291
1467
  transports: options.transports,
1292
1468
  defaultTimeout: options.defaultTimeout
@@ -1315,25 +1491,40 @@ var ActionRuntime = class {
1315
1491
  * Find the best registered external handler that can reach `originClient` directly.
1316
1492
  * Used to locate the return-path channel for dispatching results back to the action origin.
1317
1493
  * Returns `undefined` if no handler matches (score > 0 required, i.e. at least id must match).
1494
+ *
1495
+ * A handler that currently holds the origin's *live* connection always wins over a mere coordinate
1496
+ * match — so with several duplex acceptors (e.g. WS + WebRTC) a result/push routes back over the carrier
1497
+ * the client actually connected on, never a same-coordinate sibling that lacks the socket. Only when no
1498
+ * handler owns a live connection do we fall back to the plain best-coordinate-score pick (the
1499
+ * single-acceptor and connector-only cases, unchanged).
1318
1500
  */
1319
1501
  getReturnHandlerForOrigin(originClient) {
1320
1502
  if (originClient.envId === "_unset_") return void 0;
1321
1503
  let bestScore = -1;
1322
1504
  let bestHandler;
1323
- for (const handler of this._registeredExternalHandlers) {
1324
- const score = originClient.similarityLevel(handler.externalClient);
1505
+ let bestOwnedScore = -1;
1506
+ let bestOwnedHandler;
1507
+ for (const handler of this._registeredPeerHandlers) {
1508
+ if (!handler.canPush) continue;
1509
+ const score = originClient.similarityLevel(handler.peerClient);
1325
1510
  if (score > bestScore) {
1326
1511
  bestScore = score;
1327
1512
  bestHandler = handler;
1328
1513
  }
1514
+ if (score > bestOwnedScore && handler.ownsLiveConnectionFor(originClient)) {
1515
+ bestOwnedScore = score;
1516
+ bestOwnedHandler = handler;
1517
+ }
1329
1518
  }
1519
+ if (bestOwnedHandler != null && bestOwnedScore > 0) return bestOwnedHandler;
1330
1520
  return bestScore > 0 ? bestHandler : void 0;
1331
1521
  }
1332
1522
  resetRuntime() {
1333
1523
  for (const ra of this._pendingRunningActions.values()) ra._abort(err_nice_action.fromId("runtime_reset"));
1334
- for (const handler of this._registeredExternalHandlers) handler.clearTransportCache();
1524
+ for (const handler of this._registeredPeerHandlers) handler.clearTransportCache();
1335
1525
  }
1336
1526
  _trySetupReturnDispatch(runningAction) {
1527
+ if (runningAction.context.schema.responseMode === "none") return;
1337
1528
  const originClient = runningAction.context.originClient;
1338
1529
  if (originClient.envId === "_unset_" || originClient.isSameFor(this._coordinate).id) return;
1339
1530
  runningAction.addUpdateListeners([(update) => {
@@ -1812,467 +2003,7 @@ const createActionRootDomain = (definition) => {
1812
2003
  return new ActionRootDomain(definition);
1813
2004
  };
1814
2005
  //#endregion
1815
- //#region src/ActionDefinition/Schema/ActionSchema.ts
1816
- var ActionSchema = class {
1817
- _errorDeclarations = [];
1818
- inputOptions;
1819
- outputOptions;
1820
- get inputSchema() {
1821
- return this.inputOptions?.schema;
1822
- }
1823
- get outputSchema() {
1824
- return this.outputOptions?.schema;
1825
- }
1826
- /**
1827
- * Declare the input schema (JSON-native or with explicit SERDE type param).
1828
- * For non-JSON-native inputs, prefer the 3-argument form below to avoid
1829
- * needing explicit type parameters.
1830
- */
1831
- input(options) {
1832
- this.inputOptions = options;
1833
- return this;
1834
- }
1835
- /**
1836
- * Declare the output schema (JSON-native or with explicit SERDE type param).
1837
- * For non-JSON-native outputs, prefer the 3-argument form below to avoid
1838
- * needing explicit type parameters.
1839
- */
1840
- output(options) {
1841
- this.outputOptions = options;
1842
- return this;
1843
- }
1844
- throws(domain, ids) {
1845
- this._errorDeclarations.push({
1846
- _domain: domain,
1847
- _ids: ids
1848
- });
1849
- return this;
1850
- }
1851
- /**
1852
- * Serialize raw input to a JSON-serializable form.
1853
- * Uses the schema's serialization.serialize if defined; otherwise the input
1854
- * is already JSON-native and is returned as-is.
1855
- */
1856
- serializeInput(rawInput) {
1857
- if (this.inputOptions?.serialization) return this.inputOptions.serialization.serialize(rawInput);
1858
- return rawInput;
1859
- }
1860
- /**
1861
- * Deserialize a JSON value back into the raw input type.
1862
- * Uses serialization.deserialize if defined; otherwise the value is cast
1863
- * directly (it's already in the correct shape).
1864
- */
1865
- deserializeInput(serialized) {
1866
- if (this.inputOptions?.serialization) return this.inputOptions.serialization.deserialize(serialized);
1867
- return serialized;
1868
- }
1869
- /**
1870
- * Validate raw input against the schema defined via `.input({ schema })`.
1871
- * Throws `action_input_validation_failed` if validation fails.
1872
- * Returns the validated (and possibly coerced) value on success.
1873
- * If no input schema was declared, the value is passed through as-is.
1874
- */
1875
- validateInput(value, meta) {
1876
- if (this.inputOptions?.schema == null) return value;
1877
- const result = this.inputOptions.schema["~standard"].validate(value);
1878
- if (result instanceof Promise) throw err_nice_action.fromId("action_input_validation_promise", {
1879
- domain: meta.domain,
1880
- actionId: meta.actionId
1881
- });
1882
- if (result.issues != null) throw err_nice_action.fromId("action_input_validation_failed", {
1883
- domain: meta.domain,
1884
- actionId: meta.actionId,
1885
- validationMessage: extractMessageFromStandardSchema(result)
1886
- });
1887
- return result.value;
1888
- }
1889
- validateOutput(value, meta) {
1890
- if (this.outputOptions?.schema == null) return value;
1891
- const result = this.outputOptions.schema["~standard"].validate(value);
1892
- if (result instanceof Promise) throw err_nice_action.fromId("action_output_validation_promise", {
1893
- domain: meta.domain,
1894
- actionId: meta.actionId
1895
- });
1896
- if (result.issues != null) throw err_nice_action.fromId("action_output_validation_failed", {
1897
- domain: meta.domain,
1898
- actionId: meta.actionId,
1899
- validationMessage: extractMessageFromStandardSchema(result)
1900
- });
1901
- return result.value;
1902
- }
1903
- /**
1904
- * Serialize raw output to a JSON-serializable form.
1905
- */
1906
- serializeOutput(rawOutput) {
1907
- if (this.outputOptions?.serialization) return this.outputOptions.serialization.serialize(rawOutput);
1908
- return rawOutput;
1909
- }
1910
- /**
1911
- * Deserialize a JSON value back into the raw output type.
1912
- */
1913
- deserializeOutput(serialized) {
1914
- if (this.outputOptions?.serialization) return this.outputOptions.serialization.deserialize(serialized);
1915
- return serialized;
1916
- }
1917
- };
1918
- const actionSchema = () => {
1919
- return new ActionSchema();
1920
- };
1921
- //#endregion
1922
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/Transport.ts
1923
- /**
1924
- * Reusable transport definition. Devs construct these (`HttpTransport.create({ createRequest })`,
1925
- * `WebSocketTransport.create({ createWebSocket })`, …) and pass them to an
1926
- * `ActionExternalClientHandler`. A single
1927
- * definition can be shared across multiple handlers — each handler builds its own live
1928
- * {@link TransportConnection} via {@link TransportConnection._createConnection}.
1929
- */
1930
- var Transport = class {};
1931
- //#endregion
1932
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/helpers/addTransportStatusMetadata.ts
1933
- function addTransportStatusMetadata(transportStatus) {
1934
- if (transportStatus.status === "ready") return {
1935
- status: "ready",
1936
- readyData: transportStatus.readyData
1937
- };
1938
- if (transportStatus.status === "initializing") return {
1939
- status: "initializing",
1940
- initializationPromise: transportStatus.initializationPromise,
1941
- timeStarted: Date.now()
1942
- };
1943
- if (transportStatus.status === "failed") return {
1944
- status: "failed",
1945
- error: transportStatus.error,
1946
- timeFailed: Date.now()
1947
- };
1948
- if (transportStatus.status === "unsupported") return { status: "unsupported" };
1949
- return { status: "uninitialized" };
1950
- }
1951
- //#endregion
1952
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/TransportConnection.ts
1953
- let transportOrd = 0;
1954
- /**
1955
- * Live, per-handler transport runtime built from a reusable {@link Transport} definition. Holds the
1956
- * connection-scoped state (ordinal, initialized config, sockets / abort sets) that must not be shared
1957
- * across handlers. Construct these via `definition._createConnection(...)`, never directly.
1958
- */
1959
- var TransportConnection = class {
1960
- def;
1961
- transOrd = transportOrd++;
1962
- type;
1963
- initialized;
1964
- /** Backref to the public definition that created this connection (used for devtools route info). */
1965
- definition;
1966
- constructor(def) {
1967
- this.def = def;
1968
- this.type = def.type;
1969
- this.initialized = def.initialize();
1970
- }
1971
- /**
1972
- * Devtools route info for an action routed through this live connection. Defaults to the stateless
1973
- * {@link definition}'s info; connections override to enrich it from live state (e.g. the actual
1974
- * resolved socket URL) when the definition couldn't resolve it on its own.
1975
- */
1976
- getRouteInfo(input) {
1977
- return this.definition?.getRouteInfo(input);
1978
- }
1979
- _getCacheKey(input) {
1980
- const parts = this.initialized.getTransportCacheKey?.(input);
1981
- if (parts == null) return null;
1982
- return parts.join("\0");
1983
- }
1984
- getCacheKey(input) {
1985
- const inner = this._getCacheKey(input);
1986
- if (inner == null) return null;
1987
- return `${this.transOrd}:${inner}`;
1988
- }
1989
- getTransport(input) {
1990
- return this._processTransportStatus(input);
1991
- }
1992
- _processTransportStatus(input) {
1993
- const transportStatusInfo = addTransportStatusMetadata(this.initialized.getTransport(input));
1994
- if (transportStatusInfo.status !== "initializing" && transportStatusInfo.status !== "ready") return transportStatusInfo;
1995
- if (transportStatusInfo.status === "initializing") {
1996
- const promiseForReadyData = transportStatusInfo.initializationPromise.then((result) => {
1997
- if (result.status === "ready") return {
1998
- status: "ready",
1999
- readyData: this._finalizeTransportMethods(result.readyData)
2000
- };
2001
- return result;
2002
- });
2003
- return {
2004
- status: "initializing",
2005
- timeStarted: transportStatusInfo.timeStarted,
2006
- initializationPromise: promiseForReadyData
2007
- };
2008
- }
2009
- return {
2010
- status: "ready",
2011
- readyData: this._finalizeTransportMethods(transportStatusInfo.readyData)
2012
- };
2013
- }
2014
- };
2015
- //#endregion
2016
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/Custom/CustomConnection.ts
2017
- var CustomConnection = class extends TransportConnection {
2018
- constructor(def) {
2019
- super({
2020
- ...def,
2021
- type: "custom"
2022
- });
2023
- }
2024
- _finalizeTransportMethods(inputs) {
2025
- return {
2026
- sendActionData: inputs.sendActionData,
2027
- sendReturnData: inputs.sendReturnData,
2028
- updateRunConfig: inputs.updateRunConfig
2029
- };
2030
- }
2031
- };
2032
- //#endregion
2033
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/Custom/CustomTransport.ts
2034
- /**
2035
- * Reusable custom transport definition for channels nice-action doesn't model natively. Create one
2036
- * with `CustomTransport.create({ sendActionData })` for the simple case, or
2037
- * `CustomTransport.createAdvanced({ getTransport })` for full control over the lifecycle.
2038
- */
2039
- var CustomTransport = class CustomTransport extends Transport {
2040
- options;
2041
- type = "custom";
2042
- constructor(options) {
2043
- super();
2044
- this.options = options;
2045
- }
2046
- static create(options) {
2047
- return new CustomTransport({
2048
- ...options,
2049
- mode: "send"
2050
- });
2051
- }
2052
- static createAdvanced(options) {
2053
- return new CustomTransport({
2054
- ...options,
2055
- mode: "advanced"
2056
- });
2057
- }
2058
- _createConnection(_ctx) {
2059
- const options = this.options;
2060
- let getTransport;
2061
- if (options.mode === "advanced") getTransport = options.getTransport;
2062
- else getTransport = () => ({
2063
- status: "ready",
2064
- readyData: {
2065
- sendActionData: options.sendActionData,
2066
- sendReturnData: options.sendReturnData,
2067
- updateRunConfig: options.updateRunConfig,
2068
- closeTransport: options.closeTransport ?? (() => {})
2069
- }
2070
- });
2071
- return new CustomConnection({ initialize: () => ({
2072
- getTransportCacheKey: options.getTransportCacheKey,
2073
- getTransport
2074
- }) });
2075
- }
2076
- getRouteInfo(input) {
2077
- if (this.options.getRouteInfo != null) return this.options.getRouteInfo(input);
2078
- return {
2079
- type: "custom",
2080
- summary: this.options.label ?? "custom"
2081
- };
2082
- }
2083
- };
2084
- //#endregion
2085
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/err_nice_transport_ws.ts
2086
- let EErrId_NiceTransport_WebSocket = /* @__PURE__ */ function(EErrId_NiceTransport_WebSocket) {
2087
- EErrId_NiceTransport_WebSocket["ws_disconnected"] = "ws_disconnected";
2088
- EErrId_NiceTransport_WebSocket["ws_create_failed"] = "ws_create_failed";
2089
- EErrId_NiceTransport_WebSocket["ws_error"] = "ws_error";
2090
- return EErrId_NiceTransport_WebSocket;
2091
- }({});
2092
- const err_nice_transport_ws = err_nice_transport.createChildDomain({
2093
- domain: "ws_transport",
2094
- schema: {
2095
- ["ws_disconnected"]: err({ message: () => `WebSocket transport disconnected.` }),
2096
- ["ws_create_failed"]: err({ message: ({ originalError }) => `Failed to create WebSocket transport.${originalError ? ` Original error: ${originalError.message}` : ""}` }),
2097
- ["ws_error"]: err({ message: ({ originalError }) => `WebSocket transport error.${originalError ? ` Original error: ${originalError.message}` : ""}` })
2098
- }
2099
- });
2100
- //#endregion
2101
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/Http/HttpConnection.ts
2102
- var HttpConnection = class extends TransportConnection {
2103
- constructor(def) {
2104
- super({
2105
- ...def,
2106
- type: "http"
2107
- });
2108
- }
2109
- _finalizeTransportMethods(methods) {
2110
- return {
2111
- sendActionData: (input) => {
2112
- const request = methods.createRequest(input);
2113
- this.send({
2114
- ...input,
2115
- params: { request },
2116
- runningAction: input.runningAction
2117
- }).catch((err) => input.runningAction._abort(err));
2118
- },
2119
- updateRunConfig: methods.updateRunConfig
2120
- };
2121
- }
2122
- async send(input) {
2123
- const { action, runningAction, timeout, params: { request } } = input;
2124
- const wire = action.toJsonObject();
2125
- const ac = new AbortController();
2126
- let timedOut = false;
2127
- const timeoutId = setTimeout(() => {
2128
- timedOut = true;
2129
- ac.abort();
2130
- }, timeout);
2131
- const unsubscribe = input.runningAction.addUpdateListeners([(update) => {
2132
- if (update.type === "finished") {
2133
- clearTimeout(timeoutId);
2134
- ac.abort();
2135
- }
2136
- }]);
2137
- try {
2138
- const res = await fetch(request.url, {
2139
- method: "POST",
2140
- headers: {
2141
- "Content-Type": "application/json",
2142
- ...request.headers
2143
- },
2144
- body: request.body ?? JSON.stringify(wire),
2145
- signal: ac.signal
2146
- });
2147
- if (!res.ok) {
2148
- if (action.type === "request") try {
2149
- const jsonData = await res.json();
2150
- if (isActionPayload_Result_JsonObject(jsonData)) runningAction._completeWithResult(action._domain.hydrateResultPayload(jsonData));
2151
- else if (isNiceErrorObject(jsonData)) runningAction._completeWithResult(action.errorResult(castNiceError(jsonData)));
2152
- else runningAction._completeWithResult(action.errorResult(err_nice_transport.fromId("invalid_action_response", { actionId: action.id })));
2153
- } catch (e) {
2154
- throw err_nice_transport.fromId("send_failed", {
2155
- actionState: action.type,
2156
- actionId: action.id,
2157
- httpStatusCode: res.status,
2158
- message: e.message
2159
- }).withOriginError(e);
2160
- }
2161
- else {
2162
- let text;
2163
- try {
2164
- text = await res.text();
2165
- } catch (e) {
2166
- console.warn(`Failed to read error response body for failed HTTP request in HttpConnection:`, e);
2167
- }
2168
- throw err_nice_transport.fromId("send_failed", {
2169
- actionState: action.type,
2170
- actionId: action.id,
2171
- httpStatusCode: res.status,
2172
- message: text ?? `HTTP error with status ${res.status}`
2173
- });
2174
- }
2175
- return;
2176
- }
2177
- if (action.type === "request") {
2178
- const json = await res.json();
2179
- if (!isActionPayload_Result_JsonObject(json)) throw err_nice_transport.fromId("invalid_action_response", { actionId: action.id });
2180
- runningAction._completeWithResult(action._domain.hydrateResultPayload(json));
2181
- }
2182
- } catch (err) {
2183
- if (timedOut) throw err_nice_transport.fromId("timeout", { timeout });
2184
- if (err instanceof NiceError) throw err;
2185
- throw err_nice_transport.fromId("send_failed", {
2186
- actionState: action.type,
2187
- actionId: action.id,
2188
- message: err instanceof Error ? err.message : String(err)
2189
- }).withOriginError(err instanceof Error ? err : void 0);
2190
- } finally {
2191
- clearTimeout(timeoutId);
2192
- unsubscribe();
2193
- }
2194
- }
2195
- };
2196
- //#endregion
2197
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/Http/HttpTransport.ts
2198
- function shortPath(url) {
2199
- try {
2200
- return new URL(url).pathname || url;
2201
- } catch {
2202
- return url;
2203
- }
2204
- }
2205
- /**
2206
- * Reusable HTTP transport definition. Create one with `HttpTransport.create({ createRequest })` — the
2207
- * single `createRequest` function lets you keep it simple (`() => ({ url })`) or derive the request
2208
- * per action.
2209
- */
2210
- var HttpTransport = class HttpTransport extends Transport {
2211
- options;
2212
- type = "http";
2213
- constructor(options) {
2214
- super();
2215
- this.options = options;
2216
- }
2217
- static create(options) {
2218
- return new HttpTransport(options);
2219
- }
2220
- _createConnection(_ctx) {
2221
- return new HttpConnection({ initialize: () => ({
2222
- getTransportCacheKey: this.options.getTransportCacheKey,
2223
- getTransport: () => ({
2224
- status: "ready",
2225
- readyData: {
2226
- createRequest: this.options.createRequest,
2227
- updateRunConfig: this.options.updateRunConfig
2228
- }
2229
- })
2230
- }) });
2231
- }
2232
- getRouteInfo(input) {
2233
- const { url } = this.options.createRequest(input);
2234
- return {
2235
- type: "http",
2236
- method: "POST",
2237
- url,
2238
- summary: `POST ${shortPath(url)}`
2239
- };
2240
- }
2241
- };
2242
- //#endregion
2243
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/actionFrameCrypto.ts
2244
- const ENCRYPTED_ENVELOPE_LENGTH = 2;
2245
- /**
2246
- * Build the encrypt/decrypt transform for a connection whose handshake settled on the `encrypted`
2247
- * level. Keyed by the link + `linkedClientId`, so it reuses the cached shared AES-GCM key.
2248
- */
2249
- function createActionFrameCrypto({ link, linkedClientId }) {
2250
- return {
2251
- async encryptFrame(frame) {
2252
- const { nonce, ciphertext } = await link.encryptBytesForLinkedClient({
2253
- linkedClientId,
2254
- dataToEncrypt: frame
2255
- });
2256
- return pack([nonce, ciphertext]);
2257
- },
2258
- async decryptFrame(frame) {
2259
- if (typeof frame === "string") throw new Error("[ws-crypto] expected an encrypted binary frame, received text");
2260
- const envelope = unpack(frame instanceof ArrayBuffer ? new Uint8Array(frame) : frame);
2261
- if (!Array.isArray(envelope) || envelope.length !== ENCRYPTED_ENVELOPE_LENGTH) throw new Error("[ws-crypto] malformed encrypted frame envelope");
2262
- const [nonce, ciphertext] = envelope;
2263
- if (!(nonce instanceof Uint8Array) || !(ciphertext instanceof Uint8Array)) throw new Error("[ws-crypto] malformed encrypted frame fields");
2264
- return await link.decryptBytesFromLinkedClient({
2265
- linkedClientId,
2266
- dataToDecrypt: {
2267
- nonce,
2268
- ciphertext
2269
- }
2270
- });
2271
- }
2272
- };
2273
- }
2274
- //#endregion
2275
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/actionWsHandshake.ts
2006
+ //#region src/ActionRuntime/Transport/crypto/actionHandshake.ts
2276
2007
  /**
2277
2008
  * Authenticated handshake for the WebSocket channel. Run once per connection, before any action
2278
2009
  * frames flow, it:
@@ -2624,250 +2355,840 @@ function createServerHandshake(config) {
2624
2355
  };
2625
2356
  }
2626
2357
  //#endregion
2627
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/actionWireCodec.ts
2358
+ //#region src/utils/decodeActionFrame.ts
2628
2359
  /**
2629
- * Shared building blocks for the binary action codecs (the stateless {@link createBinaryWsAdapter} and
2630
- * the per-connection `createBinaryWsSessionFactory`). Both map a `domain:id` route to a tiny integer
2631
- * and reduce the verbose JSON wire to a positional tuple — they only differ in how much context they
2632
- * carry per frame, so the dictionary + payload (de)assembly live here.
2360
+ * Decode a single inbound channel frame (text or binary) into validated action wire JSON, or
2361
+ * `undefined` if it isn't a recognisable action payload.
2362
+ *
2363
+ * Shared by the WebSocket transport's message listener and the server-side `AcceptorHandler` so
2364
+ * both decode identically: a binary `decoder.incoming` (e.g. msgpackr) takes precedence, and plain
2365
+ * text frames fall back to JSON — keeping binary and JSON clients interoperable on one channel.
2633
2366
  */
2367
+ function decodeActionFrame(frame, decoder) {
2368
+ const decoded = decoder?.incoming?.(frame) ?? (typeof frame === "string" ? parseJsonActionFrame(frame) : void 0);
2369
+ return decoded != null && isActionPayload_Any_JsonObject(decoded) ? decoded : void 0;
2370
+ }
2371
+ function parseJsonActionFrame(message) {
2372
+ try {
2373
+ const json = JSON.parse(message);
2374
+ return isActionPayload_Any_JsonObject(json) ? json : void 0;
2375
+ } catch {
2376
+ return;
2377
+ }
2378
+ }
2379
+ //#endregion
2380
+ //#region src/ActionRuntime/Transport/crypto/actionFrameCrypto.ts
2381
+ const ENCRYPTED_ENVELOPE_LENGTH = 2;
2634
2382
  /**
2635
- * Tiny integer codes for the payload type, so the verbose `"request"`/`"result"`/`"progress"`
2636
- * strings never hit the wire. The index in {@link ReversePayloadType} must line up with the value.
2383
+ * Build the encrypt/decrypt transform for a connection whose handshake settled on the `encrypted`
2384
+ * level. Keyed by the link + `linkedClientId`, so it reuses the cached shared AES-GCM key.
2637
2385
  */
2638
- const PayloadTypeToInt = {
2639
- ["request"]: 0,
2640
- ["result"]: 1,
2641
- ["progress"]: 2
2642
- };
2643
- const ReversePayloadType = [
2644
- "request",
2645
- "result",
2646
- "progress"
2647
- ];
2648
- /**
2649
- * Build the positional `domain:id` ↔ integer dictionary. Both ends of a channel MUST build it from
2650
- * the same domains in the same order — the mapping is positional, so a mismatch routes to the wrong
2651
- * action. Add new transported domains to the end of the list.
2652
- */
2653
- function buildActionRouteDictionary(domains) {
2654
- const routeToInt = /* @__PURE__ */ new Map();
2655
- const intToRoute = [];
2656
- for (const dom of domains) for (const actionId of Object.keys(dom.actionSchema)) {
2657
- const routeKey = `${dom.domain}:${actionId}`;
2658
- if (routeToInt.has(routeKey)) continue;
2659
- routeToInt.set(routeKey, intToRoute.length);
2660
- intToRoute.push({
2661
- domain: dom.domain,
2662
- id: actionId,
2663
- allDomains: dom.allDomains
2664
- });
2665
- }
2386
+ function createActionFrameCrypto({ link, linkedClientId }) {
2666
2387
  return {
2667
- routeToInt,
2668
- intToRoute
2388
+ async encryptFrame(frame) {
2389
+ const { nonce, ciphertext } = await link.encryptBytesForLinkedClient({
2390
+ linkedClientId,
2391
+ dataToEncrypt: frame
2392
+ });
2393
+ return pack([nonce, ciphertext]);
2394
+ },
2395
+ async decryptFrame(frame) {
2396
+ if (typeof frame === "string") throw new Error("[ws-crypto] expected an encrypted binary frame, received text");
2397
+ const envelope = unpack(frame instanceof ArrayBuffer ? new Uint8Array(frame) : frame);
2398
+ if (!Array.isArray(envelope) || envelope.length !== ENCRYPTED_ENVELOPE_LENGTH) throw new Error("[ws-crypto] malformed encrypted frame envelope");
2399
+ const [nonce, ciphertext] = envelope;
2400
+ if (!(nonce instanceof Uint8Array) || !(ciphertext instanceof Uint8Array)) throw new Error("[ws-crypto] malformed encrypted frame fields");
2401
+ return await link.decryptBytesFromLinkedClient({
2402
+ linkedClientId,
2403
+ dataToDecrypt: {
2404
+ nonce,
2405
+ ciphertext
2406
+ }
2407
+ });
2408
+ }
2669
2409
  };
2670
2410
  }
2671
- /** Pull the type-specific payload (`input` / `result` / `progress`) out of a wire JSON object. */
2672
- function extractWirePayload(json) {
2673
- if (json.type === "request") return json.input;
2674
- if (json.type === "result") return json.result;
2675
- if (json.type === "progress") return json.progress;
2411
+ //#endregion
2412
+ //#region src/ActionRuntime/Transport/crypto/frameBytes.ts
2413
+ /** Normalize any frame form to bytes (for the AES-GCM layer, which works on `Uint8Array`). */
2414
+ function toFrameBytes(frame) {
2415
+ if (typeof frame === "string") return new TextEncoder().encode(frame);
2416
+ if (frame instanceof ArrayBuffer) return new Uint8Array(frame);
2417
+ return frame;
2676
2418
  }
2677
- /**
2678
- * Reassemble a full wire JSON object from its decoded parts. `inputHash`/`outputHash` are emitted
2679
- * empty — the hydration constructors recompute them — and the result still satisfies
2680
- * `isActionPayload_Any_JsonObject` so it flows through validation like a JSON frame.
2681
- */
2682
- function assembleWireJson(routeMeta, payloadType, time, context, payloadData) {
2683
- const base = {
2684
- form: "data",
2685
- domain: routeMeta.domain,
2686
- id: routeMeta.id,
2687
- allDomains: routeMeta.allDomains,
2688
- time,
2689
- context
2690
- };
2691
- if (payloadType === "request") return {
2692
- ...base,
2693
- type: "request",
2694
- input: payloadData,
2695
- inputHash: ""
2419
+ //#endregion
2420
+ //#region src/ActionRuntime/Transport/SecureSession/frameCryptoPipe.ts
2421
+ function createFrameCryptoPipe(config) {
2422
+ const { write, isOpen, crypto, label = "link" } = config;
2423
+ let sendChain = Promise.resolve();
2424
+ const send = (frame) => {
2425
+ if (isOpen != null && !isOpen()) return;
2426
+ if (crypto == null) {
2427
+ write(frame);
2428
+ return;
2429
+ }
2430
+ const bytes = toFrameBytes(frame);
2431
+ sendChain = sendChain.then(() => crypto).then((c) => c.encryptFrame(bytes)).then((encrypted) => {
2432
+ if (isOpen == null || isOpen()) write(encrypted);
2433
+ }).catch((err) => console.error(`[${label}] failed to encrypt/send frame`, err));
2696
2434
  };
2697
- if (payloadType === "result") return {
2698
- ...base,
2699
- type: "result",
2700
- result: payloadData,
2701
- outputHash: ""
2435
+ const decryptIncoming = async (frame) => {
2436
+ if (crypto == null) return frame;
2437
+ try {
2438
+ return await (await crypto).decryptFrame(frame);
2439
+ } catch (err) {
2440
+ console.error(`[${label}] failed to decrypt incoming frame`, err);
2441
+ return;
2442
+ }
2702
2443
  };
2703
2444
  return {
2704
- ...base,
2705
- type: "progress",
2706
- progress: payloadData
2445
+ send,
2446
+ decryptIncoming
2707
2447
  };
2708
2448
  }
2709
2449
  //#endregion
2710
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/createBinaryWsAdapter.ts
2711
- /**
2712
- * Positional layout of the stateless binary envelope. A flat tuple (rather than an object) strips the
2713
- * repeated `domain`/`id`/`form`/`type` and context key names from every frame, and we carry only the
2714
- * context fields the receiver can't recompute: `cuid` (correlation) and `originClient` (return
2715
- * routing).
2716
- *
2717
- * [ routeInt, typeInt, time, cuid, originClient, payloadData ]
2718
- *
2719
- * Dropped vs the JSON wire: `form`/`type` strings, `inputHash`/`outputHash` (recomputed on hydrate),
2720
- * `context.timeCreated` (reconstructed from `time`) and `context.routing` (rebuilt empty — the
2721
- * receiver re-stamps its own route items as it handles the action). For the leanest possible frames
2722
- * (integer correlation, identity dropped after a handshake), use `createBinaryWsSessionFactory`.
2723
- */
2724
- const ENVELOPE$1 = {
2725
- route: 0,
2726
- type: 1,
2727
- time: 2,
2728
- cuid: 3,
2729
- originClient: 4,
2730
- payload: 5
2731
- };
2732
- const ENVELOPE_LENGTH$1 = 6;
2450
+ //#region src/ActionRuntime/Transport/SecureSession/acceptorSecureSession.ts
2733
2451
  /**
2734
- * Builds a *stateless* `formatMessage` pipeline for {@link WebSocketTransport}, packing action
2735
- * payloads into a compact msgpackr binary frame instead of JSON. The `domain`/`id` route collapses to
2736
- * a single integer drawn from a shared dictionary; `form`/`type`, the recomputable
2737
- * `inputHash`/`outputHash`, and the per-frame `context.routing`/`context.timeCreated` are all dropped
2738
- * (see {@link ENVELOPE}).
2739
- *
2740
- * No validation runs here: `incoming` blindly reconstructs the wire JSON shape and hands it back to
2741
- * the connection, which flows into `ActionRuntime` → `domain.hydrateAnyAction()` where the Valibot
2742
- * schemas validate it exactly as they would for a JSON frame.
2743
- *
2744
- * Both ends of the socket MUST construct the adapter with the same domains in the same order — the
2745
- * integer dictionary is positional. Mismatched dictionaries will route to the wrong action.
2452
+ * The acceptor (accept-in) counterpart to the connector's `establishLinkSession`: one connection's
2453
+ * secure session the server-side handshake driving, the ordered frame crypto, and the per-connection
2454
+ * phase (undecided plain | authenticated). The crypto itself (ordered encrypt-send / decrypt) lives in
2455
+ * the shared {@link createFrameCryptoPipe}, the same primitive the connector uses — so the secure logic
2456
+ * lives once for both link roles.
2746
2457
  *
2747
- * Because `incoming` returns `undefined` for text frames, a binary server can still serve plain-JSON
2748
- * clients on the same runtime (the connection falls back to its built-in JSON parser).
2458
+ * The handler owns one of these per accepted connection and feeds it inbound frames via {@link receive};
2459
+ * identity binding, persistence, codec, and return routing stay in the handler (driven through the
2460
+ * config callbacks). Inbound processing is serialized per connection because the handshake and decryption
2461
+ * are async — this keeps handshake ordering and frame order intact.
2749
2462
  */
2750
- function createBinaryWsAdapter(domains) {
2751
- const { routeToInt, intToRoute } = buildActionRouteDictionary(domains);
2752
- return {
2753
- outgoing: (input) => {
2754
- const json = input.action.toJsonObject();
2755
- const routeKey = `${json.domain}:${json.id}`;
2756
- const routeInt = routeToInt.get(routeKey);
2757
- if (routeInt == null) throw new Error(`[binary-ws] Cannot pack unregistered action route: ${routeKey}`);
2758
- const envelope = new Array(ENVELOPE_LENGTH$1);
2759
- envelope[ENVELOPE$1.route] = routeInt;
2760
- envelope[ENVELOPE$1.type] = PayloadTypeToInt[json.type];
2761
- envelope[ENVELOPE$1.time] = json.time;
2762
- envelope[ENVELOPE$1.cuid] = json.context.cuid;
2763
- envelope[ENVELOPE$1.originClient] = json.context.originClient;
2764
- envelope[ENVELOPE$1.payload] = extractWirePayload(json);
2765
- return pack(envelope);
2766
- },
2767
- incoming: (frame) => {
2768
- let buffer;
2769
- if (frame instanceof ArrayBuffer) buffer = new Uint8Array(frame);
2770
- else if (frame instanceof Uint8Array) buffer = frame;
2771
- else return;
2772
- try {
2773
- const envelope = unpack(buffer);
2774
- if (!Array.isArray(envelope) || envelope.length !== ENVELOPE_LENGTH$1) return void 0;
2775
- const routeMeta = intToRoute[envelope[ENVELOPE$1.route]];
2776
- const payloadType = ReversePayloadType[envelope[ENVELOPE$1.type]];
2777
- if (routeMeta == null || payloadType == null) return void 0;
2778
- const time = envelope[ENVELOPE$1.time];
2779
- return assembleWireJson(routeMeta, payloadType, time, {
2780
- cuid: envelope[ENVELOPE$1.cuid],
2781
- timeCreated: time,
2782
- routing: [],
2783
- originClient: envelope[ENVELOPE$1.originClient]
2784
- }, envelope[ENVELOPE$1.payload]);
2785
- } catch (e) {
2786
- console.error("[binary-ws] Failed to unpack binary action frame", e);
2463
+ var AcceptorSecureSession = class {
2464
+ config;
2465
+ _handshake;
2466
+ /** The ordered encrypt-send / decrypt pipe — present only for an `encrypted` connection. */
2467
+ _pipe;
2468
+ _authed = false;
2469
+ _plain = false;
2470
+ /** Serializes inbound processing (handshake + decryption are async). */
2471
+ _inboundChain = Promise.resolve();
2472
+ constructor(config) {
2473
+ this.config = config;
2474
+ }
2475
+ /** Feed one inbound frame. Serialized per connection; routes back through the config callbacks. */
2476
+ receive(frame) {
2477
+ this._inboundChain = this._inboundChain.then(() => this._receive(frame)).catch((err) => console.error("[ws-server] failed to process inbound frame", err));
2478
+ }
2479
+ async _receive(frame) {
2480
+ if (this._plain) {
2481
+ this.config.routePlain(frame);
2482
+ return;
2483
+ }
2484
+ if (!this._authed) {
2485
+ const message = typeof frame === "string" ? decodeHandshakeMessage(frame) : void 0;
2486
+ if (message == null) {
2487
+ if (this.config.noneAllowed) {
2488
+ this._plain = true;
2489
+ this.config.routePlain(frame);
2490
+ }
2787
2491
  return;
2788
2492
  }
2493
+ await this.config.link.initialize();
2494
+ if (this._handshake == null) this._handshake = createServerHandshake({
2495
+ link: this.config.link,
2496
+ localCoordinate: this.config.localCoordinate,
2497
+ dictionaryVersion: this.config.dictionaryVersion,
2498
+ securityLevel: this.config.securityLevel,
2499
+ verifyKeyResolver: this.config.verifyKeyResolver
2500
+ });
2501
+ if (message.t === "hello") this.config.send(encodeHandshakeMessage(await this._handshake.onHello(message)));
2502
+ else if (message.t === "prove") {
2503
+ const reply = await this._handshake.onProve(message);
2504
+ this.config.send(encodeHandshakeMessage(reply));
2505
+ const result = this._handshake.getResult();
2506
+ if (reply.t === "accept" && result != null) this._complete(result);
2507
+ }
2508
+ return;
2789
2509
  }
2790
- };
2791
- }
2792
- //#endregion
2793
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/createBinaryWsSessionFactory.ts
2794
- /**
2795
- * Positional layout of the *session* binary envelope — the leanest frame. Compared to the stateless
2796
- * adapter it replaces the 21-char `cuid` with a small per-connection integer and only carries
2797
- * `originClient` on the very first request of each direction (the peer remembers it afterwards).
2798
- *
2799
- * [ routeInt, typeInt, corrId, time, originClient?, payloadData ]
2800
- */
2801
- const ENVELOPE = {
2802
- route: 0,
2803
- type: 1,
2804
- corr: 2,
2805
- time: 3,
2806
- originClient: 4,
2807
- payload: 5
2808
- };
2809
- const ENVELOPE_LENGTH = 6;
2810
- /**
2811
- * How long a pending correlation entry is kept before it's swept. A correlation only matters until its
2812
- * action resolves or times out, so anything older than the longest realistic action timeout can be
2813
- * dropped — this bounds memory when requests time out or a connection dies mid-flight (their replies
2814
- * would never arrive, leaving the entry orphaned). Generous default so live correlations are never
2815
- * pruned (the default transport timeout is 10s).
2816
- */
2817
- const DEFAULT_CORRELATION_TTL_MS = 5 * 6e4;
2818
- function isKnownIdentity(coordinate) {
2819
- return coordinate != null && coordinate.envId !== "_unset_";
2820
- }
2821
- /**
2822
- * Drop entries older than `ttlMs`. Maps keep insertion order and entries are inserted in time order,
2823
- * so the oldest are first — stop sweeping at the first live entry.
2824
- */
2825
- function pruneExpired(map, now, ttlMs) {
2826
- for (const [key, entry] of map) {
2827
- if (now - entry.time <= ttlMs) break;
2828
- map.delete(key);
2510
+ const bytes = this._pipe != null ? await this._pipe.decryptIncoming(frame) : frame;
2511
+ if (bytes === void 0) return;
2512
+ this.config.routeAction(bytes);
2513
+ }
2514
+ _complete(result) {
2515
+ this._authed = true;
2516
+ this._handshake = void 0;
2517
+ if (result.securityLevel === "encrypted") this._pipe = this._buildPipe(createActionFrameCrypto({
2518
+ link: this.config.link,
2519
+ linkedClientId: result.linkedClientId
2520
+ }));
2521
+ this.config.onAuthenticated({
2522
+ client: new RuntimeCoordinate(result.remote),
2523
+ securityLevel: result.securityLevel,
2524
+ linkedClientId: result.linkedClientId,
2525
+ keyMaterial: result.encryptionKeyMaterial
2526
+ });
2829
2527
  }
2830
- }
2528
+ /**
2529
+ * Restore an already-authenticated session after eviction — no handshake. For an `encrypted`
2530
+ * connection the shared key is re-derived asynchronously (the acceptor re-links the client off its own
2531
+ * persisted identity); the pipe's crypto IS that promise, so the connection's first in/out frame
2532
+ * naturally waits for the key before encrypt/decrypt — no separate gate.
2533
+ */
2534
+ rehydrate(state) {
2535
+ this._authed = true;
2536
+ if (state.securityLevel !== "encrypted" || state.keyMaterial == null) return;
2537
+ const { link } = this.config;
2538
+ const { linkedClientId, keyMaterial } = state;
2539
+ const cryptoReady = link.initialize().then(() => link.linkClient({
2540
+ linkedClientId,
2541
+ verifyPublicKey: keyMaterial.verifyPublicKey,
2542
+ exchangePublicKey: keyMaterial.exchangePublicKey,
2543
+ saltString: keyMaterial.saltString,
2544
+ infoString: keyMaterial.infoString,
2545
+ bindVerifyKeysIntoDerivation: keyMaterial.bindVerifyKeysIntoDerivation
2546
+ })).then(() => createActionFrameCrypto({
2547
+ link,
2548
+ linkedClientId
2549
+ }));
2550
+ cryptoReady.catch((err) => console.error("[ws-server] failed to restore encrypted session", err));
2551
+ this._pipe = this._buildPipe(cryptoReady);
2552
+ }
2553
+ /** Send an outbound frame: through the encrypt pipe when encrypted, otherwise raw. */
2554
+ send(frame) {
2555
+ if (this._pipe != null) {
2556
+ this._pipe.send(frame);
2557
+ return;
2558
+ }
2559
+ this.config.send(frame);
2560
+ }
2561
+ _buildPipe(crypto) {
2562
+ return createFrameCryptoPipe({
2563
+ write: this.config.send,
2564
+ crypto,
2565
+ label: "ws-server"
2566
+ });
2567
+ }
2568
+ };
2569
+ //#endregion
2570
+ //#region src/ActionRuntime/Handler/PeerLink/Acceptor/AcceptorHandler.ts
2831
2571
  /**
2832
- * Builds a factory of *stateful, per-connection* codecs for {@link WebSocketTransport} /
2833
- * `ActionServerHandler` the maximally compact binary wire. Call the returned factory once per live
2834
- * connection (each socket on the client, each accepted connection on the server) so every channel
2835
- * gets its own correlation + identity state.
2572
+ * Server-side handler for backends that accept many client connections over a single open channel
2573
+ * (WebSockets, Durable Objects, …). It is transport-agnostic: you feed it inbound frames with
2574
+ * {@link receive} and tell it how to write outbound frames via the `send` option.
2836
2575
  *
2837
- * On top of everything {@link createBinaryWsAdapter} drops, a session also drops:
2838
- * - **`cuid`** — replaced by a per-connection integer correlation id. The initiator maps it to its
2839
- * real cuid; the responder echoes it; each side reconstructs the cuid from its own map. Correlation
2840
- * only needs to be unique per socket, so a counter suffices.
2841
- * - **`originClient` after the first request** the first request each side sends carries its
2842
- * identity; the peer remembers it and injects it into later frames. Replies omit it entirely (a
2843
- * reply carries the initiator's own origin, which the initiator already knows).
2576
+ * Add it alongside your local execution handler:
2577
+ * ```ts
2578
+ * const serverHandler = createAcceptorHandler({ clientEnv, formatMessage, send: (ws, f) => ws.send(f) });
2579
+ * runtime.addHandlers([localHandler, serverHandler]);
2580
+ * // per inbound message (e.g. a Durable Object's webSocketMessage):
2581
+ * serverHandler.receive(ws, message);
2582
+ * ```
2844
2583
  *
2845
- * Both ends MUST build the factory from the same domains in the same order (positional dictionary).
2846
- * Text frames still return `undefined` from `incoming`, so JSON clients remain interoperable.
2584
+ * Inbound requests route to your local handler; the runtime's return dispatch then calls this
2585
+ * handler back (it is an external handler keyed to `clientEnv`) to send the result to the originating
2586
+ * connection. The handler keeps a per-connection identity registry so each result lands on the right
2587
+ * socket, and remembers each connection's encoding so binary and JSON clients can share the channel.
2847
2588
  *
2848
- * Hibernation note: after a server connection is evicted its session resets, so a still-connected
2849
- * client (whose session persists) will keep omitting `originClient`. The server must therefore restore
2850
- * the connection→client binding from its own store (see `ActionServerHandler.rehydrateConnection`) and
2851
- * inject `originClient` from there — the session alone can't recover it.
2589
+ * It registers an empty action router, so it is never chosen to *execute* an inbound request — only
2590
+ * to ferry results/pushes back out.
2852
2591
  */
2853
- function createBinaryWsSessionFactory(domains, options) {
2854
- const { routeToInt, intToRoute } = buildActionRouteDictionary(domains);
2855
- const unknownIdentity = RuntimeCoordinate.unknown.toJsonObject();
2856
- const ttlMs = options?.correlationTtlMs ?? DEFAULT_CORRELATION_TTL_MS;
2857
- return () => {
2858
- let outCounter = 0;
2859
- const corrToCuid = /* @__PURE__ */ new Map();
2860
- const cuidToCorr = /* @__PURE__ */ new Map();
2861
- let selfIdentity;
2862
- let peerIdentity;
2863
- return {
2864
- outgoing: (input) => {
2865
- const json = input.action.toJsonObject();
2866
- const routeKey = `${json.domain}:${json.id}`;
2867
- const routeInt = routeToInt.get(routeKey);
2868
- if (routeInt == null) throw new Error(`[binary-ws] Cannot pack unregistered action route: ${routeKey}`);
2869
- const now = Date.now();
2870
- pruneExpired(corrToCuid, now, ttlMs);
2592
+ var AcceptorHandler = class extends PeerLinkHandler {
2593
+ /** Accept-in over a live (duplex) connection registry — it pushes results/broadcasts to bound sockets. */
2594
+ canPush = true;
2595
+ _formatMessage;
2596
+ _createFormatMessage;
2597
+ _send;
2598
+ _runtime;
2599
+ _serverTimeout;
2600
+ _onConnectionBound;
2601
+ _security;
2602
+ /** Normalized accepted levels; whether `none` (plain) is allowed; whether any level needs a handshake. */
2603
+ _allowedLevels;
2604
+ _noneAllowed;
2605
+ _handshakeMode;
2606
+ _connByClient = /* @__PURE__ */ new Map();
2607
+ _clientByConn = /* @__PURE__ */ new Map();
2608
+ _connEncoding = /* @__PURE__ */ new Map();
2609
+ _codecByConn = /* @__PURE__ */ new Map();
2610
+ _sessionByConn = /* @__PURE__ */ new Map();
2611
+ constructor(options) {
2612
+ super(options.clientEnv);
2613
+ this._formatMessage = options.formatMessage;
2614
+ this._createFormatMessage = options.createFormatMessage;
2615
+ this._send = options.send;
2616
+ this._runtime = options.runtime;
2617
+ this._serverTimeout = options.defaultTimeout ?? 1e4;
2618
+ this._onConnectionBound = options.onConnectionBound;
2619
+ this._security = options.security;
2620
+ this._allowedLevels = options.security == null ? [] : Array.isArray(options.security.securityLevel) ? options.security.securityLevel : [options.security.securityLevel];
2621
+ this._noneAllowed = this._allowedLevels.includes("none");
2622
+ this._handshakeMode = this._allowedLevels.some((level) => level !== "none");
2623
+ }
2624
+ /**
2625
+ * The codec for a connection: a per-connection session (cached) when a factory was provided, else
2626
+ * the single shared `formatMessage`.
2627
+ */
2628
+ _codecFor(connection) {
2629
+ if (this._createFormatMessage != null) {
2630
+ let codec = this._codecByConn.get(connection);
2631
+ if (codec == null) {
2632
+ codec = this._createFormatMessage();
2633
+ this._codecByConn.set(connection, codec);
2634
+ }
2635
+ return codec;
2636
+ }
2637
+ if (this._formatMessage != null) return this._formatMessage;
2638
+ throw err_nice_transport.fromId("not_found", { actionId: "server-handler-codec (provide formatMessage or createFormatMessage)" });
2639
+ }
2640
+ /**
2641
+ * Register (or replace) the connection-bound persistence callback after construction. Used by
2642
+ * lifecycle helpers like {@link createHibernatableWsServerAdapter} so persistence and replay are
2643
+ * owned by one place instead of being split across the constructor options.
2644
+ */
2645
+ setOnConnectionBound(onConnectionBound) {
2646
+ this._onConnectionBound = onConnectionBound;
2647
+ }
2648
+ /**
2649
+ * Feed one inbound frame from a connection into the runtime. Decodes text or binary, binds the
2650
+ * connection to the requesting client's identity, then routes it (requests execute locally;
2651
+ * results/progress resolve pending server-initiated actions).
2652
+ */
2653
+ receive(connection, frame) {
2654
+ const security = this._security;
2655
+ if (security == null || !this._handshakeMode) {
2656
+ this._receivePlain(connection, frame);
2657
+ return;
2658
+ }
2659
+ this._sessionFor(connection, security).receive(frame);
2660
+ }
2661
+ _receivePlain(connection, frame) {
2662
+ const wire = decodeActionFrame(frame, this._codecFor(connection));
2663
+ if (wire == null) return;
2664
+ const encoding = typeof frame === "string" ? "json" : "binary";
2665
+ this._connEncoding.set(connection, encoding);
2666
+ if (wire.type === "request") this._resolveRequestIdentity(connection, wire, encoding);
2667
+ this._emitIncoming(wire);
2668
+ }
2669
+ /**
2670
+ * The secure session for a connection (built lazily on its first secure-mode frame), with the
2671
+ * handler-owned effects — raw send, identity binding + persistence, and inbound routing — wired in as
2672
+ * callbacks. The session owns all crypto/handshake/chain state; the handler keeps only the registry.
2673
+ */
2674
+ _sessionFor(connection, security) {
2675
+ let session = this._sessionByConn.get(connection);
2676
+ if (session == null) {
2677
+ session = new AcceptorSecureSession({
2678
+ link: security.link,
2679
+ localCoordinate: security.localCoordinate,
2680
+ dictionaryVersion: security.dictionaryVersion,
2681
+ securityLevel: security.securityLevel,
2682
+ verifyKeyResolver: security.verifyKeyResolver,
2683
+ noneAllowed: this._noneAllowed,
2684
+ send: (frame) => this._send(connection, frame),
2685
+ onAuthenticated: (auth) => this._onConnectionAuthenticated(connection, auth),
2686
+ routePlain: (frame) => this._receivePlain(connection, frame),
2687
+ routeAction: (bytes) => this._routeAuthedActionBytes(connection, bytes)
2688
+ });
2689
+ this._sessionByConn.set(connection, session);
2690
+ }
2691
+ return session;
2692
+ }
2693
+ /** Bind + persist a connection's authenticated identity once its handshake completes. */
2694
+ _onConnectionAuthenticated(connection, auth) {
2695
+ this._bindConnection(connection, auth.client);
2696
+ this._connEncoding.set(connection, "binary");
2697
+ this._onConnectionBound?.(connection, {
2698
+ client: auth.client.toJsonObject(),
2699
+ encoding: "binary",
2700
+ secure: {
2701
+ securityLevel: auth.securityLevel,
2702
+ linkedClientId: auth.linkedClientId,
2703
+ keyMaterial: auth.keyMaterial
2704
+ }
2705
+ });
2706
+ }
2707
+ /** Decode a decrypted authenticated frame, inject the *authenticated* identity, and route it. */
2708
+ _routeAuthedActionBytes(connection, bytes) {
2709
+ const wire = decodeActionFrame(bytes, this._codecFor(connection));
2710
+ if (wire == null) return;
2711
+ if (wire.type === "request") {
2712
+ const bound = this._clientByConn.get(connection);
2713
+ if (bound != null) wire.context.originClient = bound.toJsonObject();
2714
+ }
2715
+ this._emitIncoming(wire);
2716
+ }
2717
+ /**
2718
+ * Ensure an inbound request carries the client's identity and that this connection is bound to it,
2719
+ * so its result can be routed back. A session codec omits `originClient` after the first request, so
2720
+ * when it's missing we restore it from the (possibly rehydrated) binding instead. (Plain mode only;
2721
+ * secure mode binds the authenticated coordinate at handshake time.)
2722
+ */
2723
+ _resolveRequestIdentity(connection, wire, encoding) {
2724
+ const wireOrigin = wire.context.originClient;
2725
+ if (wireOrigin != null && wireOrigin.envId !== "_unset_") {
2726
+ const clientCoord = new RuntimeCoordinate(wireOrigin);
2727
+ const isNewBinding = this._clientByConn.get(connection)?.stringId !== clientCoord.stringId;
2728
+ this._bindConnection(connection, clientCoord);
2729
+ if (isNewBinding) this._onConnectionBound?.(connection, {
2730
+ client: clientCoord.toJsonObject(),
2731
+ encoding
2732
+ });
2733
+ return;
2734
+ }
2735
+ const bound = this._clientByConn.get(connection);
2736
+ if (bound != null) wire.context.originClient = bound.toJsonObject();
2737
+ }
2738
+ /**
2739
+ * Restore a connection→client binding without an inbound frame — for transports that resume after
2740
+ * eviction. Pair it with the {@link IAcceptorHandlerOptions.onConnectionBound} hook: persist
2741
+ * the binding there, then replay each live connection here when the channel comes back (e.g. a
2742
+ * Durable Object iterating `ctx.getWebSockets()` as it wakes from hibernation).
2743
+ */
2744
+ rehydrateConnection(connection, binding) {
2745
+ this._bindConnection(connection, new RuntimeCoordinate(binding.client));
2746
+ this._connEncoding.set(connection, binding.encoding);
2747
+ const secure = binding.secure;
2748
+ const security = this._security;
2749
+ if (secure == null || security == null) return;
2750
+ this._sessionFor(connection, security).rehydrate(secure);
2751
+ }
2752
+ toJsonObject() {
2753
+ return {
2754
+ type: this.handlerType,
2755
+ client: this.peerClient
2756
+ };
2757
+ }
2758
+ toHandlerRouteItem() {
2759
+ return {
2760
+ type: this.handlerType,
2761
+ client: this.peerClient,
2762
+ transShape: "duplex",
2763
+ transOrd: 0
2764
+ };
2765
+ }
2766
+ /** Forget a connection (call on socket close) so stale entries don't misroute later results. */
2767
+ dropConnection(connection) {
2768
+ const coord = this._clientByConn.get(connection);
2769
+ if (coord != null && this._connByClient.get(coord.stringId) === connection) this._connByClient.delete(coord.stringId);
2770
+ this._clientByConn.delete(connection);
2771
+ this._connEncoding.delete(connection);
2772
+ this._codecByConn.delete(connection);
2773
+ this._sessionByConn.delete(connection);
2774
+ }
2775
+ /** Live connection for a client coordinate, if currently registered. */
2776
+ getConnectionForClient(client) {
2777
+ return this._connByClient.get(client.stringId);
2778
+ }
2779
+ /** This acceptor owns the origin's return path when it currently holds a live connection bound to it. */
2780
+ ownsLiveConnectionFor(origin) {
2781
+ return this._connByClient.has(origin.stringId);
2782
+ }
2783
+ /** Whether this acceptor currently tracks `connection` — used to pick the owning handler among several. */
2784
+ hasConnection(connection) {
2785
+ return this._clientByConn.has(connection);
2786
+ }
2787
+ /**
2788
+ * Send (and optionally await) a server-initiated action to a specific connected client. Pass the
2789
+ * connection token directly (e.g. the `ws`) or a client `RuntimeCoordinate` to look one up.
2790
+ */
2791
+ pushToClient(runtime, target, request, options) {
2792
+ const connection = this._resolveConnection(target);
2793
+ return this._dispatch(runtime, connection, request, options?.timeout);
2794
+ }
2795
+ /**
2796
+ * Build a local handler whose cases are connection-aware: each case receives the primed request and
2797
+ * the originating client's live connection (resolved from `originClient`), so handlers don't repeat
2798
+ * the `getConnectionForClient(action.context.originClient)` lookup. Cases may return raw output or
2799
+ * nothing, just like {@link ActionLocalHandler.forDomainActionCases}. Add the returned handler to the
2800
+ * runtime alongside this server handler:
2801
+ * ```ts
2802
+ * runtime.addHandlers([serverHandler.forConnectionDomainCases(domain, { … }), serverHandler]);
2803
+ * ```
2804
+ */
2805
+ forConnectionDomainCases(domain, cases) {
2806
+ return this.forConnectionDomainCasesMulti([domain], cases);
2807
+ }
2808
+ /**
2809
+ * Like {@link forConnectionDomainCases} but spanning several domains with one merged case map — used
2810
+ * by channel-derived wiring (`acceptChannelConnections`) where the channel's `toAcceptor` domains are
2811
+ * served together. Each domain takes only the cases whose ids it owns, so a single map can cover
2812
+ * several domains and unrelated ids are ignored.
2813
+ */
2814
+ forConnectionDomainCasesMulti(domains, cases) {
2815
+ const handler = new ActionLocalHandler();
2816
+ for (const domain of domains) {
2817
+ const ownedIds = new Set(Object.keys(domain.actionsMap()));
2818
+ const wrapped = {};
2819
+ for (const id in cases) {
2820
+ if (!ownedIds.has(id)) continue;
2821
+ const caseFn = cases[id];
2822
+ if (caseFn == null) continue;
2823
+ wrapped[id] = (request) => caseFn(request, this.getConnectionForClient(request.context.originClient));
2824
+ }
2825
+ handler.forDomainActionCases(domain, wrapped);
2826
+ }
2827
+ return handler;
2828
+ }
2829
+ /**
2830
+ * Fan a server-initiated request out to every currently-bound connection. A fresh request is built
2831
+ * per connection (each push mutates its own action context) and dispatched fire-and-forget. Pass
2832
+ * `except` to skip the originating socket and `where` to filter by connection (e.g. read its
2833
+ * attachment for a role). Iterating bound connections (rather than every accepted socket) skips
2834
+ * sockets that are still mid-handshake and so can't yet receive a frame.
2835
+ */
2836
+ broadcast(makeRequest, options) {
2837
+ const runtime = options?.runtime ?? this._runtime;
2838
+ if (runtime == null) throw err_nice_transport.fromId("not_found", { actionId: "server-handler-runtime (construct with `runtime` or pass `options.runtime`)" });
2839
+ for (const connection of this._clientByConn.keys()) {
2840
+ if (options?.except != null && connection === options.except) continue;
2841
+ if (options?.where != null && !options.where(connection)) continue;
2842
+ try {
2843
+ this.pushToClient(runtime, connection, makeRequest(), { timeout: options?.timeout });
2844
+ } catch (error) {
2845
+ if (options?.onError != null) options.onError(error, connection);
2846
+ else console.error("[ws-server] broadcast push failed", error);
2847
+ }
2848
+ }
2849
+ }
2850
+ async sendReturnPayload(payload, config) {
2851
+ const connection = this._connByClient.get(payload.context.originClient.stringId);
2852
+ if (connection == null) return false;
2853
+ this._sendPayload(connection, payload, config.targetLocalRuntime.coordinate);
2854
+ return true;
2855
+ }
2856
+ async handleActionRequest(action, config) {
2857
+ const runtime = config?.targetLocalRuntime ?? ActionRuntime.getDefault();
2858
+ const connection = this._resolveSingleConnection();
2859
+ return this._dispatch(runtime, connection, action, config?.timeout);
2860
+ }
2861
+ _dispatch(runtime, connection, action, timeout) {
2862
+ const timeoutMs = timeout ?? this._serverTimeout;
2863
+ action.context._setOriginClient(runtime.coordinate);
2864
+ action.context.addRouteItem({
2865
+ runtime: runtime.coordinate,
2866
+ handler: this.toHandlerRouteItem(),
2867
+ time: Date.now()
2868
+ });
2869
+ const runningAction = new RunningAction({
2870
+ context: action.context,
2871
+ request: action,
2872
+ parentCuid: peekHandlerCuid(),
2873
+ callSite: action._callSite
2874
+ });
2875
+ runtime.registerRunningAction(runningAction);
2876
+ if (action.schema.responseMode === "none") {
2877
+ try {
2878
+ this._sendPayload(connection, action, runtime.coordinate);
2879
+ runningAction._completeWithResult(action.successResult(void 0));
2880
+ } catch (err) {
2881
+ runningAction._abort(err);
2882
+ }
2883
+ return runningAction;
2884
+ }
2885
+ const timeoutId = setTimeout(() => {
2886
+ runningAction._abort(err_nice_transport.fromId("timeout", { timeout: timeoutMs }));
2887
+ }, timeoutMs);
2888
+ runningAction.addUpdateListeners([(update) => {
2889
+ if (update.type === "finished") clearTimeout(timeoutId);
2890
+ }]);
2891
+ try {
2892
+ this._sendPayload(connection, action, runtime.coordinate);
2893
+ } catch (err) {
2894
+ runningAction._abort(err);
2895
+ }
2896
+ return runningAction;
2897
+ }
2898
+ _sendPayload(connection, payload, localClient) {
2899
+ const frame = (this._connEncoding.get(connection) ?? "binary") === "json" ? JSON.stringify(payload.toJsonObject()) : this._codecFor(connection).outgoing({
2900
+ action: payload,
2901
+ localClient,
2902
+ externalClient: this.peerClient
2903
+ });
2904
+ const session = this._sessionByConn.get(connection);
2905
+ if (session == null) {
2906
+ this._send(connection, frame);
2907
+ return;
2908
+ }
2909
+ session.send(frame);
2910
+ }
2911
+ _bindConnection(connection, client) {
2912
+ this._connByClient.set(client.stringId, connection);
2913
+ this._clientByConn.set(connection, client);
2914
+ }
2915
+ _resolveConnection(target) {
2916
+ if (target instanceof RuntimeCoordinate) {
2917
+ const connection = this._connByClient.get(target.stringId);
2918
+ if (connection == null) throw err_nice_transport.fromId("not_found", { actionId: target.stringId });
2919
+ return connection;
2920
+ }
2921
+ return target;
2922
+ }
2923
+ _resolveSingleConnection() {
2924
+ if (this._clientByConn.size !== 1) throw err_nice_transport.fromId("not_found", { actionId: "server-handler-target (use pushToClient with an explicit connection or client coordinate)" });
2925
+ return this._clientByConn.keys().next().value;
2926
+ }
2927
+ };
2928
+ const createAcceptorHandler = (options) => {
2929
+ return new AcceptorHandler(options);
2930
+ };
2931
+ //#endregion
2932
+ //#region src/ActionRuntime/Handler/PeerLink/Acceptor/createSecureActionServer.ts
2933
+ /** Default accepted set: negotiate per connection to whatever the client picks. */
2934
+ const DEFAULT_SERVER_SECURITY_LEVELS$1 = [
2935
+ "none",
2936
+ "authenticated",
2937
+ "encrypted"
2938
+ ];
2939
+ /**
2940
+ * Build an {@link AcceptorHandler} for the secure binary channel with the boilerplate folded in:
2941
+ * it creates the {@link ClientCryptoKeyLink} and the storage-backed TOFU resolver from a single
2942
+ * `storageAdapter`, installs the channel's per-connection codec, and assembles the `security` block
2943
+ * from the runtime coordinate + channel version (accepting all three levels by default).
2944
+ *
2945
+ * For a hibernatable transport (e.g. a Durable Object), pair it with
2946
+ * {@link createHibernatableWsServerAdapter} to wire persistence + replay.
2947
+ */
2948
+ function createSecureAcceptorHandler(options) {
2949
+ const link = options.link ?? new ClientCryptoKeyLink({ storageAdapter: options.storageAdapter });
2950
+ return new AcceptorHandler({
2951
+ clientEnv: options.clientEnv,
2952
+ createFormatMessage: options.channel.createCodec,
2953
+ send: options.send,
2954
+ runtime: options.runtime,
2955
+ defaultTimeout: options.defaultTimeout,
2956
+ security: {
2957
+ securityLevel: options.securityLevel ?? DEFAULT_SERVER_SECURITY_LEVELS$1,
2958
+ link,
2959
+ localCoordinate: options.runtime.coordinate.toJsonObject(),
2960
+ dictionaryVersion: options.channel.dictionaryVersion,
2961
+ verifyKeyResolver: options.verifyKeyResolver ?? createStorageTofuVerifyKeyResolver(options.storageAdapter)
2962
+ }
2963
+ });
2964
+ }
2965
+ //#endregion
2966
+ //#region src/ActionRuntime/Channel/ActionChannel.ts
2967
+ /**
2968
+ * Declare a transport-agnostic channel by role. Use it for HTTP, custom transports, or as the routing
2969
+ * half of a richer channel. The order of each list is part of the contract for wire formats that pack
2970
+ * positionally (see `defineSecureChannel`) — add new domains to the end of their list. (`domains` is
2971
+ * accepted as a legacy alias for `toAcceptor`.)
2972
+ */
2973
+ function defineChannel(options) {
2974
+ return {
2975
+ toAcceptorDomains: options.toAcceptor,
2976
+ toConnectorDomains: options.toConnector
2977
+ };
2978
+ }
2979
+ /**
2980
+ * Wire a connection to the acceptor straight from a channel: route the channel's `toAcceptor` domains to
2981
+ * the acceptor over `transports`, and register local handlers for its `toConnector` pushes from
2982
+ * `onPush`. The channel is the single source of truth for *what* is routed in each direction — the
2983
+ * caller only supplies the transport(s) and the push handlers, never restated domain lists. Pass several
2984
+ * transports to make the connector→acceptor path transport-agnostic (e.g. secure WS preferred, HTTP
2985
+ * fallback).
2986
+ *
2987
+ * Sugar over {@link ActionRuntime.connectTo}. Returns the acceptor handler so the caller can later
2988
+ * `clearTransportCache()` it.
2989
+ */
2990
+ function connectChannel(runtime, acceptorCoordinate, options) {
2991
+ const pushHandlers = options.onPush != null ? options.channel.toConnectorDomains.map((domain) => domain.wrapAsPartialLocalHandler(options.onPush)) : [];
2992
+ return runtime.connectTo(acceptorCoordinate, {
2993
+ transports: options.transports,
2994
+ domains: [...options.channel.toAcceptorDomains],
2995
+ localHandlers: pushHandlers,
2996
+ defaultTimeout: options.defaultTimeout
2997
+ });
2998
+ }
2999
+ /**
3000
+ * Register an acceptor handler's execution for a channel straight from its definition: the channel's
3001
+ * `toAcceptor` domains are served together with one merged, connection-aware case map (each case gets
3002
+ * the primed request + the originating connection, as with
3003
+ * {@link AcceptorHandler.forConnectionDomainCases}). The domain list is taken from the channel,
3004
+ * never restated. Add the returned handler to the runtime alongside the acceptor handler:
3005
+ * ```ts
3006
+ * runtime.addHandlers([acceptChannelConnections(serverHandler, channel, { … }), serverHandler]);
3007
+ * ```
3008
+ */
3009
+ function acceptChannelConnections(serverHandler, channel, cases) {
3010
+ return serverHandler.forConnectionDomainCasesMulti(channel.toAcceptorDomains, cases);
3011
+ }
3012
+ /**
3013
+ * Build the secure {@link AcceptorHandler} for a channel — the accept-in counterpart to
3014
+ * {@link connectChannel}. It folds in the same boilerplate as {@link createSecureAcceptorHandler} (the
3015
+ * `ClientCryptoKeyLink` + storage-backed TOFU resolver from one `storageAdapter`, the channel's codec +
3016
+ * dictionary version, the `security` block from the runtime coordinate) but takes the `(runtime, channel,
3017
+ * options)` shape of the channel family. Pair it with {@link acceptChannelConnections} for execution:
3018
+ * ```ts
3019
+ * const acceptor = acceptChannel(runtime, gameChannel, { clientEnv, storageAdapter, send });
3020
+ * runtime.addHandlers([acceptChannelConnections(acceptor, gameChannel, { … }), acceptor]);
3021
+ * ```
3022
+ */
3023
+ function acceptChannel(runtime, channel, options) {
3024
+ return createSecureAcceptorHandler({
3025
+ channel,
3026
+ runtime,
3027
+ clientEnv: options.clientEnv,
3028
+ storageAdapter: options.storageAdapter,
3029
+ link: options.link,
3030
+ send: options.send,
3031
+ securityLevel: options.securityLevel,
3032
+ verifyKeyResolver: options.verifyKeyResolver,
3033
+ defaultTimeout: options.defaultTimeout
3034
+ });
3035
+ }
3036
+ //#endregion
3037
+ //#region src/ActionRuntime/Transport/codec/actionWireCodec.ts
3038
+ /**
3039
+ * Tiny integer codes for the payload type, so the verbose `"request"`/`"result"`/`"progress"`
3040
+ * strings never hit the wire. The index in {@link ReversePayloadType} must line up with the value.
3041
+ */
3042
+ const PayloadTypeToInt = {
3043
+ ["request"]: 0,
3044
+ ["result"]: 1,
3045
+ ["progress"]: 2
3046
+ };
3047
+ const ReversePayloadType = [
3048
+ "request",
3049
+ "result",
3050
+ "progress"
3051
+ ];
3052
+ /**
3053
+ * Build the positional `domain:id` ↔ integer dictionary. Both ends of a channel MUST build it from
3054
+ * the same domains in the same order — the mapping is positional, so a mismatch routes to the wrong
3055
+ * action. Add new transported domains to the end of the list.
3056
+ */
3057
+ function buildActionRouteDictionary(domains) {
3058
+ const routeToInt = /* @__PURE__ */ new Map();
3059
+ const intToRoute = [];
3060
+ for (const dom of domains) for (const actionId of Object.keys(dom.actionSchema)) {
3061
+ const routeKey = `${dom.domain}:${actionId}`;
3062
+ if (routeToInt.has(routeKey)) continue;
3063
+ routeToInt.set(routeKey, intToRoute.length);
3064
+ intToRoute.push({
3065
+ domain: dom.domain,
3066
+ id: actionId,
3067
+ allDomains: dom.allDomains
3068
+ });
3069
+ }
3070
+ return {
3071
+ routeToInt,
3072
+ intToRoute
3073
+ };
3074
+ }
3075
+ /** Pull the type-specific payload (`input` / `result` / `progress`) out of a wire JSON object. */
3076
+ function extractWirePayload(json) {
3077
+ if (json.type === "request") return json.input;
3078
+ if (json.type === "result") return json.result;
3079
+ if (json.type === "progress") return json.progress;
3080
+ }
3081
+ /**
3082
+ * Reassemble a full wire JSON object from its decoded parts. `inputHash`/`outputHash` are emitted
3083
+ * empty — the hydration constructors recompute them — and the result still satisfies
3084
+ * `isActionPayload_Any_JsonObject` so it flows through validation like a JSON frame.
3085
+ */
3086
+ function assembleWireJson(routeMeta, payloadType, time, context, payloadData) {
3087
+ const base = {
3088
+ form: "data",
3089
+ domain: routeMeta.domain,
3090
+ id: routeMeta.id,
3091
+ allDomains: routeMeta.allDomains,
3092
+ time,
3093
+ context
3094
+ };
3095
+ if (payloadType === "request") return {
3096
+ ...base,
3097
+ type: "request",
3098
+ input: payloadData,
3099
+ inputHash: ""
3100
+ };
3101
+ if (payloadType === "result") return {
3102
+ ...base,
3103
+ type: "result",
3104
+ result: payloadData,
3105
+ outputHash: ""
3106
+ };
3107
+ return {
3108
+ ...base,
3109
+ type: "progress",
3110
+ progress: payloadData
3111
+ };
3112
+ }
3113
+ //#endregion
3114
+ //#region src/ActionRuntime/Transport/codec/createBinaryWireSessionFactory.ts
3115
+ /**
3116
+ * Positional layout of the *session* binary envelope — the leanest frame. Compared to the stateless
3117
+ * adapter it replaces the 21-char `cuid` with a small per-connection integer and only carries
3118
+ * `originClient` on the very first request of each direction (the peer remembers it afterwards).
3119
+ *
3120
+ * [ routeInt, typeInt, corrId, time, originClient?, payloadData ]
3121
+ */
3122
+ const ENVELOPE$1 = {
3123
+ route: 0,
3124
+ type: 1,
3125
+ corr: 2,
3126
+ time: 3,
3127
+ originClient: 4,
3128
+ payload: 5
3129
+ };
3130
+ const ENVELOPE_LENGTH$1 = 6;
3131
+ /**
3132
+ * How long a pending correlation entry is kept before it's swept. A correlation only matters until its
3133
+ * action resolves or times out, so anything older than the longest realistic action timeout can be
3134
+ * dropped — this bounds memory when requests time out or a connection dies mid-flight (their replies
3135
+ * would never arrive, leaving the entry orphaned). Generous default so live correlations are never
3136
+ * pruned (the default transport timeout is 10s).
3137
+ */
3138
+ const DEFAULT_CORRELATION_TTL_MS = 5 * 6e4;
3139
+ function isKnownIdentity(coordinate) {
3140
+ return coordinate != null && coordinate.envId !== "_unset_";
3141
+ }
3142
+ /**
3143
+ * Drop entries older than `ttlMs`. Maps keep insertion order and entries are inserted in time order,
3144
+ * so the oldest are first — stop sweeping at the first live entry.
3145
+ */
3146
+ function pruneExpired(map, now, ttlMs) {
3147
+ for (const [key, entry] of map) {
3148
+ if (now - entry.time <= ttlMs) break;
3149
+ map.delete(key);
3150
+ }
3151
+ }
3152
+ /**
3153
+ * Builds a factory of *stateful, per-connection* codecs for {@link LinkTransport} /
3154
+ * `AcceptorHandler` — the maximally compact binary wire. Call the returned factory once per live
3155
+ * connection (each socket on the client, each accepted connection on the server) so every channel
3156
+ * gets its own correlation + identity state.
3157
+ *
3158
+ * On top of everything {@link createBinaryWireAdapter} drops, a session also drops:
3159
+ * - **`cuid`** — replaced by a per-connection integer correlation id. The initiator maps it to its
3160
+ * real cuid; the responder echoes it; each side reconstructs the cuid from its own map. Correlation
3161
+ * only needs to be unique per socket, so a counter suffices.
3162
+ * - **`originClient` after the first request** — the first request each side sends carries its
3163
+ * identity; the peer remembers it and injects it into later frames. Replies omit it entirely (a
3164
+ * reply carries the initiator's own origin, which the initiator already knows).
3165
+ *
3166
+ * Both ends MUST build the factory from the same domains in the same order (positional dictionary).
3167
+ * Text frames still return `undefined` from `incoming`, so JSON clients remain interoperable.
3168
+ *
3169
+ * Hibernation note: after a server connection is evicted its session resets, so a still-connected
3170
+ * client (whose session persists) will keep omitting `originClient`. The server must therefore restore
3171
+ * the connection→client binding from its own store (see `AcceptorHandler.rehydrateConnection`) and
3172
+ * inject `originClient` from there — the session alone can't recover it.
3173
+ */
3174
+ function createBinaryWireSessionFactory(domains, options) {
3175
+ const { routeToInt, intToRoute } = buildActionRouteDictionary(domains);
3176
+ const unknownIdentity = RuntimeCoordinate.unknown.toJsonObject();
3177
+ const ttlMs = options?.correlationTtlMs ?? DEFAULT_CORRELATION_TTL_MS;
3178
+ return () => {
3179
+ let outCounter = 0;
3180
+ const corrToCuid = /* @__PURE__ */ new Map();
3181
+ const cuidToCorr = /* @__PURE__ */ new Map();
3182
+ let selfIdentity;
3183
+ let peerIdentity;
3184
+ return {
3185
+ outgoing: (input) => {
3186
+ const json = input.action.toJsonObject();
3187
+ const routeKey = `${json.domain}:${json.id}`;
3188
+ const routeInt = routeToInt.get(routeKey);
3189
+ if (routeInt == null) throw new Error(`[binary-wire] Cannot pack unregistered action route: ${routeKey}`);
3190
+ const now = Date.now();
3191
+ pruneExpired(corrToCuid, now, ttlMs);
2871
3192
  pruneExpired(cuidToCorr, now, ttlMs);
2872
3193
  let corr;
2873
3194
  let wireIdentity;
@@ -2885,13 +3206,13 @@ function createBinaryWsSessionFactory(domains, options) {
2885
3206
  corr = cuidToCorr.get(json.context.cuid)?.value ?? -1;
2886
3207
  if (json.type === "result") cuidToCorr.delete(json.context.cuid);
2887
3208
  }
2888
- const envelope = new Array(ENVELOPE_LENGTH);
2889
- envelope[ENVELOPE.route] = routeInt;
2890
- envelope[ENVELOPE.type] = PayloadTypeToInt[json.type];
2891
- envelope[ENVELOPE.corr] = corr;
2892
- envelope[ENVELOPE.time] = json.time;
2893
- envelope[ENVELOPE.originClient] = wireIdentity;
2894
- envelope[ENVELOPE.payload] = extractWirePayload(json);
3209
+ const envelope = new Array(ENVELOPE_LENGTH$1);
3210
+ envelope[ENVELOPE$1.route] = routeInt;
3211
+ envelope[ENVELOPE$1.type] = PayloadTypeToInt[json.type];
3212
+ envelope[ENVELOPE$1.corr] = corr;
3213
+ envelope[ENVELOPE$1.time] = json.time;
3214
+ envelope[ENVELOPE$1.originClient] = wireIdentity;
3215
+ envelope[ENVELOPE$1.payload] = extractWirePayload(json);
2895
3216
  return pack(envelope);
2896
3217
  },
2897
3218
  incoming: (frame) => {
@@ -2901,16 +3222,16 @@ function createBinaryWsSessionFactory(domains, options) {
2901
3222
  else return;
2902
3223
  try {
2903
3224
  const envelope = unpack(buffer);
2904
- if (!Array.isArray(envelope) || envelope.length !== ENVELOPE_LENGTH) return void 0;
2905
- const routeMeta = intToRoute[envelope[ENVELOPE.route]];
2906
- const payloadType = ReversePayloadType[envelope[ENVELOPE.type]];
3225
+ if (!Array.isArray(envelope) || envelope.length !== ENVELOPE_LENGTH$1) return void 0;
3226
+ const routeMeta = intToRoute[envelope[ENVELOPE$1.route]];
3227
+ const payloadType = ReversePayloadType[envelope[ENVELOPE$1.type]];
2907
3228
  if (routeMeta == null || payloadType == null) return void 0;
2908
3229
  const now = Date.now();
2909
3230
  pruneExpired(corrToCuid, now, ttlMs);
2910
3231
  pruneExpired(cuidToCorr, now, ttlMs);
2911
- const corr = envelope[ENVELOPE.corr];
2912
- const time = envelope[ENVELOPE.time];
2913
- const wireIdentity = envelope[ENVELOPE.originClient];
3232
+ const corr = envelope[ENVELOPE$1.corr];
3233
+ const time = envelope[ENVELOPE$1.time];
3234
+ const wireIdentity = envelope[ENVELOPE$1.originClient];
2914
3235
  let cuid;
2915
3236
  let originClient;
2916
3237
  if (payloadType === "request") {
@@ -2931,9 +3252,9 @@ function createBinaryWsSessionFactory(domains, options) {
2931
3252
  timeCreated: time,
2932
3253
  routing: [],
2933
3254
  originClient
2934
- }, envelope[ENVELOPE.payload]);
3255
+ }, envelope[ENVELOPE$1.payload]);
2935
3256
  } catch (e) {
2936
- console.error("[binary-ws] Failed to unpack binary action session frame", e);
3257
+ console.error("[binary-wire] Failed to unpack binary action session frame", e);
2937
3258
  return;
2938
3259
  }
2939
3260
  }
@@ -2941,438 +3262,433 @@ function createBinaryWsSessionFactory(domains, options) {
2941
3262
  };
2942
3263
  }
2943
3264
  //#endregion
2944
- //#region src/utils/decodeActionFrame.ts
2945
- /**
2946
- * Decode a single inbound channel frame (text or binary) into validated action wire JSON, or
2947
- * `undefined` if it isn't a recognisable action payload.
2948
- *
2949
- * Shared by the WebSocket transport's message listener and the server-side `ActionServerHandler` so
2950
- * both decode identically: a binary `decoder.incoming` (e.g. msgpackr) takes precedence, and plain
2951
- * text frames fall back to JSON — keeping binary and JSON clients interoperable on one channel.
2952
- */
2953
- function decodeActionFrame(frame, decoder) {
2954
- const decoded = decoder?.incoming?.(frame) ?? (typeof frame === "string" ? parseJsonActionFrame(frame) : void 0);
2955
- return decoded != null && isActionPayload_Any_JsonObject(decoded) ? decoded : void 0;
2956
- }
2957
- function parseJsonActionFrame(message) {
2958
- try {
2959
- const json = JSON.parse(message);
2960
- return isActionPayload_Any_JsonObject(json) ? json : void 0;
2961
- } catch {
2962
- return;
2963
- }
2964
- }
2965
- //#endregion
2966
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/helpers/createUnsetTransportResolvers.ts
2967
- const createUnsetTransportResolvers = (type) => ({ onIncomingActionDataJson: (json) => {
2968
- console.warn(`Received incoming action JSON [${json.domain}:${json.id}] on Transport [${type}] but no incoming data listener has been set.`);
2969
- } });
2970
- //#endregion
2971
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/ws_util.ts
3265
+ //#region src/ActionRuntime/Channel/secureChannel.ts
2972
3266
  /**
2973
- * Send a text or binary frame over a socket. A binary formatter may hand back a `Uint8Array` whose
2974
- * backing buffer is typed as `ArrayBufferLike` (msgpackr pools buffers / may be `SharedArrayBuffer`),
2975
- * which `WebSocket.send`'s `BufferSource` parameter rejects copy it into a fresh `ArrayBuffer`-backed
2976
- * view so the type (and the bytes) are safe to send.
3267
+ * Derive a stable wire-dictionary version from the ordered route list (FNV-1a over `domain:id,…`), so
3268
+ * the version moves automatically whenever the transported domains change a stale peer is then
3269
+ * rejected by the handshake instead of silently misrouting a positionally-packed frame.
2977
3270
  */
2978
- function sendFrame(ws, data) {
2979
- if (typeof data === "string" || data instanceof ArrayBuffer) {
2980
- ws.send(data);
2981
- return;
3271
+ function deriveDictionaryVersion(domains) {
3272
+ const { intToRoute } = buildActionRouteDictionary(domains);
3273
+ const signature = intToRoute.map((route) => `${route.domain}:${route.id}`).join(",");
3274
+ let hash = 2166136261;
3275
+ for (let i = 0; i < signature.length; i++) {
3276
+ hash ^= signature.charCodeAt(i);
3277
+ hash = Math.imul(hash, 16777619);
2982
3278
  }
2983
- ws.send(new Uint8Array(data));
3279
+ return `auto:${(hash >>> 0).toString(16).padStart(8, "0")}`;
2984
3280
  }
2985
- /** Normalize any frame form to bytes (for the AES-GCM layer, which works on `Uint8Array`). */
2986
- function toFrameBytes(frame) {
2987
- if (typeof frame === "string") return new TextEncoder().encode(frame);
2988
- if (frame instanceof ArrayBuffer) return new Uint8Array(frame);
2989
- return frame;
3281
+ /**
3282
+ * Bundle a secure channel's shared identity from its transported domains. Both ends MUST call this
3283
+ * with the same domains in the same order (the binary wire dictionary is positional). The
3284
+ * `dictionaryVersion` is derived from those domains unless you pin an explicit one.
3285
+ *
3286
+ * Declare the domains *by role* — `toAcceptor` (connector→acceptor requests) and `toConnector`
3287
+ * (acceptor→connector pushes) — so the routing for both ends is derived from the channel (see
3288
+ * {@link connectChannel} and `acceptChannelConnections`) instead of being restated at each end. The
3289
+ * wire dictionary spans `[...toAcceptor, ...toConnector]` in that order; add new domains to the end of
3290
+ * their list to keep older peers compatible. (`domains` is still accepted as a legacy alias for
3291
+ * `toAcceptor`.)
3292
+ */
3293
+ function defineSecureChannel(options) {
3294
+ const base = defineChannel({
3295
+ toAcceptor: options.toAcceptor,
3296
+ toConnector: options.toConnector
3297
+ });
3298
+ const allDomains = [...base.toAcceptorDomains, ...base.toConnectorDomains];
3299
+ return {
3300
+ ...base,
3301
+ dictionaryVersion: options.dictionaryVersion ?? deriveDictionaryVersion(allDomains),
3302
+ createCodec: createBinaryWireSessionFactory(allDomains, options.sessionOptions)
3303
+ };
2990
3304
  }
2991
- /** Compact a WebSocket URL to `host/pathname` for devtools display, falling back to the raw url. */
2992
- function shortWs(url) {
3305
+ //#endregion
3306
+ //#region src/ActionRuntime/Transport/SecureSession/exchangeProtocol.ts
3307
+ function encodeExchange(envelope) {
3308
+ return JSON.stringify(envelope);
3309
+ }
3310
+ function decodeExchangeRequest(raw) {
3311
+ return parse(raw);
3312
+ }
3313
+ function decodeExchangeReply(raw) {
3314
+ return parse(raw);
3315
+ }
3316
+ function parse(raw) {
2993
3317
  try {
2994
- const u = new URL(url);
2995
- return `${u.host}${u.pathname}`;
3318
+ return JSON.parse(raw);
2996
3319
  } catch {
2997
- return url;
3320
+ return;
2998
3321
  }
2999
3322
  }
3323
+ function bytesToBase64(bytes) {
3324
+ let binary = "";
3325
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
3326
+ return btoa(binary);
3327
+ }
3328
+ function base64ToBytes(base64) {
3329
+ const binary = atob(base64);
3330
+ const bytes = new Uint8Array(binary.length);
3331
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
3332
+ return bytes;
3333
+ }
3000
3334
  //#endregion
3001
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/WebSocketConnection.ts
3002
- const HANDSHAKE_TIMEOUT_MS = 15e3;
3003
- var WebSocketConnection = class extends TransportConnection {
3004
- resolvers;
3005
- /** URL of the most recently resolved live socket surfaced to devtools when the definition can't. */
3006
- _liveSocketUrl;
3007
- /** Sockets we closed on purpose (via `disconnect`), so their `close` event stays quiet. */
3008
- _intentionalCloses = /* @__PURE__ */ new WeakSet();
3009
- constructor(def, resolvers) {
3010
- super({
3011
- ...def,
3012
- type: "ws"
3013
- });
3014
- this.resolvers = resolvers ?? createUnsetTransportResolvers("ws");
3015
- }
3016
- _getCacheKey(_input) {
3017
- return this.initialized.getTransportCacheKey?.(_input).join("\0") ?? "";
3335
+ //#region src/ActionRuntime/Transport/SecureSession/exchangeAcceptor.ts
3336
+ const textEncoder$1 = new TextEncoder();
3337
+ const textDecoder$1 = new TextDecoder();
3338
+ /**
3339
+ * Acceptor (accept-in) side of the secure exchange protocol — the HTTP counterpart to
3340
+ * {@link AcceptorSecureSession}. Each POST body is one {@link decodeExchangeRequest} envelope; the
3341
+ * acceptor drives the server handshake over the two `hs` POSTs (correlated by `hsid`, since stateless
3342
+ * requests can't rely on channel ordering), mints a session **token** on accept, and on every later `act`
3343
+ * POST resolves the session by token, decrypts the body (at `encrypted`), routes it through the runtime,
3344
+ * and returns the (encrypted) result inline as the reply.
3345
+ *
3346
+ * Sessions and in-flight handshakes are held in memory — fine for a single-instance server. (Surviving a
3347
+ * Durable-Object eviction would persist each token's `keyMaterial` and re-derive the key on a miss, the
3348
+ * same primitive `AcceptorSecureSession.rehydrate` uses; left as a follow-up.)
3349
+ */
3350
+ var ExchangeAcceptor = class {
3351
+ _security;
3352
+ _runtime;
3353
+ _allowedLevels;
3354
+ _noneAllowed;
3355
+ _pendingHandshakes = /* @__PURE__ */ new Map();
3356
+ _sessions = /* @__PURE__ */ new Map();
3357
+ constructor(config) {
3358
+ this._security = config.security;
3359
+ this._runtime = config.runtime;
3360
+ this._allowedLevels = Array.isArray(config.security.securityLevel) ? config.security.securityLevel : [config.security.securityLevel];
3361
+ this._noneAllowed = this._allowedLevels.includes("none");
3018
3362
  }
3019
- _processTransportStatus(input) {
3020
- const transportStatusInfo = addTransportStatusMetadata(this.initialized.getTransport(input));
3021
- if (transportStatusInfo.status !== "initializing" && transportStatusInfo.status !== "ready") return transportStatusInfo;
3022
- if (transportStatusInfo.status === "ready") {
3023
- const ws = transportStatusInfo.readyData.ws;
3024
- if (ws.readyState !== WebSocket.OPEN || this._isSecure(transportStatusInfo.readyData)) {
3025
- const readyData = transportStatusInfo.readyData;
3026
- const initialization = async () => {
3027
- await this._awaitOpen(ws);
3028
- return {
3029
- status: "ready",
3030
- readyData: await this._finalize(readyData)
3031
- };
3032
- };
3363
+ /** Process one POST body (an exchange envelope), returning the reply body to send back. */
3364
+ async handlePost(body) {
3365
+ const request = decodeExchangeRequest(body);
3366
+ if (request == null) return this._err("malformed exchange request");
3367
+ if (request.k === "hs") return encodeExchange(await this._handleHandshake(request));
3368
+ return encodeExchange(await this._handleAction(request));
3369
+ }
3370
+ async _handleHandshake(request) {
3371
+ const message = decodeHandshakeMessage(request.m);
3372
+ if (message == null) return {
3373
+ k: "err",
3374
+ message: "malformed handshake message"
3375
+ };
3376
+ const security = this._security;
3377
+ await security.link.initialize();
3378
+ let handshake = this._pendingHandshakes.get(request.hsid);
3379
+ if (handshake == null) {
3380
+ handshake = createServerHandshake({
3381
+ link: security.link,
3382
+ localCoordinate: security.localCoordinate,
3383
+ dictionaryVersion: security.dictionaryVersion,
3384
+ securityLevel: security.securityLevel,
3385
+ verifyKeyResolver: security.verifyKeyResolver
3386
+ });
3387
+ this._pendingHandshakes.set(request.hsid, handshake);
3388
+ }
3389
+ if (message.t === "hello") return {
3390
+ k: "hs",
3391
+ m: encodeHandshakeMessage(await handshake.onHello(message))
3392
+ };
3393
+ if (message.t === "prove") {
3394
+ const reply = await handshake.onProve(message);
3395
+ this._pendingHandshakes.delete(request.hsid);
3396
+ const result = handshake.getResult();
3397
+ if (reply.t === "accept" && result != null) {
3398
+ const token = nanoid();
3399
+ this._sessions.set(token, {
3400
+ client: new RuntimeCoordinate(result.remote),
3401
+ securityLevel: result.securityLevel,
3402
+ crypto: result.securityLevel === "encrypted" ? createActionFrameCrypto({
3403
+ link: security.link,
3404
+ linkedClientId: result.linkedClientId
3405
+ }) : void 0
3406
+ });
3033
3407
  return {
3034
- status: "initializing",
3035
- timeStarted: Date.now(),
3036
- initializationPromise: initialization()
3408
+ k: "hs",
3409
+ m: encodeHandshakeMessage(reply),
3410
+ t: token
3037
3411
  };
3038
3412
  }
3413
+ return {
3414
+ k: "hs",
3415
+ m: encodeHandshakeMessage(reply)
3416
+ };
3039
3417
  }
3040
- if (transportStatusInfo.status === "initializing") return {
3041
- status: "initializing",
3042
- initializationPromise: transportStatusInfo.initializationPromise.then(async (result) => {
3043
- if (result.status === "ready") {
3044
- await this._awaitOpen(result.readyData.ws);
3045
- return {
3046
- status: "ready",
3047
- readyData: await this._finalize(result.readyData)
3048
- };
3049
- }
3050
- return result;
3051
- }),
3052
- timeStarted: transportStatusInfo.timeStarted
3053
- };
3054
3418
  return {
3055
- status: "ready",
3056
- readyData: this._finalizeTransportMethods(transportStatusInfo.readyData)
3057
- };
3058
- }
3059
- getRouteInfo(input) {
3060
- const base = this.definition?.getRouteInfo(input);
3061
- if (base?.url != null || this._liveSocketUrl == null) return base;
3062
- return {
3063
- type: "ws",
3064
- ...base,
3065
- url: this._liveSocketUrl,
3066
- summary: `ws ${shortWs(this._liveSocketUrl)}`
3419
+ k: "err",
3420
+ message: `unexpected handshake message ${message.t}`
3067
3421
  };
3068
3422
  }
3069
- _isSecure(wsData) {
3070
- return wsData.secureChannel != null && wsData.secureChannel.securityLevel !== "none";
3071
- }
3072
- _awaitOpen(ws) {
3073
- if (ws.readyState === WebSocket.OPEN) return Promise.resolve();
3074
- return new Promise((resolve, reject) => {
3075
- ws.addEventListener("open", () => resolve(), { once: true });
3076
- ws.addEventListener("error", (event) => reject(event), { once: true });
3077
- ws.addEventListener("close", (event) => reject(/* @__PURE__ */ new Error(`WebSocket closed before open: code=${event.code}`)), { once: true });
3078
- });
3079
- }
3080
- /** Non-secure connections finalize synchronously; secure ones run the handshake first. */
3081
- _finalize(wsData) {
3082
- return this._isSecure(wsData) ? this._finalizeSecureMethods(wsData) : this._finalizeTransportMethods(wsData);
3083
- }
3084
- _finalizeTransportMethods(wsData) {
3085
- const ws = wsData.ws;
3086
- const disconnectListeners = [];
3087
- const abortSet = /* @__PURE__ */ new Set();
3088
- this._captureSocketUrl(ws);
3089
- this._attachLifecycle(ws, disconnectListeners, abortSet);
3090
- ws.addEventListener("message", async (event) => {
3091
- const frame = await this._normalizeFrame(event.data);
3092
- if (frame !== void 0) await this._handleIncomingActionFrame(frame, wsData, void 0);
3093
- });
3094
- return this._buildSendMethods(ws, wsData, void 0, disconnectListeners, abortSet);
3095
- }
3096
- /**
3097
- * Secure path: a single message listener feeds the handshake until it completes, then routes action
3098
- * frames (decrypting for the `encrypted` level). Frames that arrive in the gap between accept and
3099
- * activation are buffered and flushed, so nothing is lost.
3100
- */
3101
- async _finalizeSecureMethods(wsData) {
3102
- const ws = wsData.ws;
3103
- const disconnectListeners = [];
3104
- const abortSet = /* @__PURE__ */ new Set();
3105
- this._captureSocketUrl(ws);
3106
- this._attachLifecycle(ws, disconnectListeners, abortSet);
3107
- let active = false;
3108
- let crypto;
3109
- const handshakeQueue = [];
3110
- const handshakeWaiters = [];
3111
- const pendingActionFrames = [];
3112
- ws.addEventListener("message", async (event) => {
3113
- const frame = await this._normalizeFrame(event.data);
3114
- if (frame === void 0) return;
3115
- if (active) {
3116
- await this._handleIncomingActionFrame(frame, wsData, crypto);
3117
- return;
3118
- }
3119
- if (typeof frame === "string") {
3120
- const message = decodeHandshakeMessage(frame);
3121
- if (message != null) {
3122
- const waiter = handshakeWaiters.shift();
3123
- if (waiter != null) waiter(message);
3124
- else handshakeQueue.push(message);
3125
- return;
3126
- }
3127
- }
3128
- pendingActionFrames.push(frame);
3129
- });
3130
- const nextHandshakeMessage = () => {
3131
- const queued = handshakeQueue.shift();
3132
- if (queued != null) return Promise.resolve(queued);
3133
- return new Promise((resolve, reject) => {
3134
- const timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("[ws-handshake] timed out waiting for server reply")), HANDSHAKE_TIMEOUT_MS);
3135
- handshakeWaiters.push((message) => {
3136
- clearTimeout(timeout);
3137
- resolve(message);
3138
- });
3139
- });
3140
- };
3141
- crypto = await this._runClientHandshake(ws, wsData.secureChannel, nextHandshakeMessage);
3142
- active = true;
3143
- for (const frame of pendingActionFrames) await this._handleIncomingActionFrame(frame, wsData, crypto);
3144
- pendingActionFrames.length = 0;
3145
- return this._buildSendMethods(ws, wsData, crypto, disconnectListeners, abortSet);
3146
- }
3147
- async _runClientHandshake(ws, secure, nextHandshakeMessage) {
3148
- await secure.link.initialize();
3149
- const handshake = createClientHandshake({
3150
- link: secure.link,
3151
- localCoordinate: secure.localCoordinate,
3152
- dictionaryVersion: secure.dictionaryVersion,
3153
- securityLevel: secure.securityLevel
3154
- });
3155
- sendFrame(ws, encodeHandshakeMessage(await handshake.createHello()));
3156
- const welcome = await nextHandshakeMessage();
3157
- if (welcome.t === "reject") throw new Error(`[ws-handshake] rejected by server: ${welcome.reason}`);
3158
- if (welcome.t !== "welcome") throw new Error(`[ws-handshake] expected welcome, got ${welcome.t}`);
3159
- sendFrame(ws, encodeHandshakeMessage(await handshake.onWelcome(welcome)));
3160
- const accept = await nextHandshakeMessage();
3161
- if (accept.t === "reject") throw new Error(`[ws-handshake] rejected by server: ${accept.reason}`);
3162
- if (accept.t !== "accept") throw new Error(`[ws-handshake] expected accept, got ${accept.t}`);
3163
- const result = await handshake.onAccept(accept);
3164
- return result.securityLevel === "encrypted" ? createActionFrameCrypto({
3165
- link: secure.link,
3166
- linkedClientId: result.linkedClientId
3167
- }) : void 0;
3168
- }
3169
- _buildSendMethods(ws, wsData, crypto, disconnectListeners, abortSet) {
3170
- let sendChain = Promise.resolve();
3171
- const enqueueSend = (frame) => {
3172
- if (ws.readyState !== WebSocket.OPEN) return;
3173
- if (crypto == null) {
3174
- sendFrame(ws, frame);
3175
- return;
3176
- }
3177
- const bytes = toFrameBytes(frame);
3178
- sendChain = sendChain.then(() => crypto.encryptFrame(bytes)).then((encrypted) => {
3179
- if (ws.readyState === WebSocket.OPEN) sendFrame(ws, encrypted);
3180
- }).catch((err) => console.error("[ws] failed to encrypt/send frame", err));
3423
+ async _handleAction(request) {
3424
+ let session;
3425
+ let candidate;
3426
+ if (request.t != null) {
3427
+ session = this._sessions.get(request.t);
3428
+ if (session == null) return {
3429
+ k: "err",
3430
+ message: "unknown or expired session token"
3431
+ };
3432
+ if ("c" in request) {
3433
+ if (session.crypto == null) return {
3434
+ k: "err",
3435
+ message: "session is not encrypted"
3436
+ };
3437
+ const plain = await session.crypto.decryptFrame(base64ToBytes(request.c));
3438
+ candidate = JSON.parse(textDecoder$1.decode(plain));
3439
+ } else candidate = request.w;
3440
+ } else {
3441
+ if (!this._noneAllowed || "c" in request) return {
3442
+ k: "err",
3443
+ message: "missing session token"
3444
+ };
3445
+ candidate = request.w;
3446
+ }
3447
+ if (!isActionPayload_Any_JsonObject(candidate)) return {
3448
+ k: "err",
3449
+ message: "malformed action wire"
3181
3450
  };
3182
- const sendActionData = (inputs) => {
3183
- const { action, runningAction, timeout } = inputs;
3184
- if (ws.readyState !== WebSocket.OPEN) {
3185
- if (action.type === "request") runningAction._abort(err_nice_transport_ws.fromId("ws_disconnected"));
3186
- return;
3187
- }
3188
- if (action.type === "request") {
3189
- abortSet.add(runningAction);
3190
- const timeoutId = setTimeout(() => {
3191
- runningAction._abort(err_nice_transport.fromId("timeout", { timeout }));
3192
- }, timeout);
3193
- runningAction.addUpdateListeners([(update) => {
3194
- if (update.type === "finished") {
3195
- clearTimeout(timeoutId);
3196
- abortSet.delete(runningAction);
3197
- }
3198
- }]);
3199
- }
3200
- enqueueSend(wsData.formatMessage?.outgoing(inputs) ?? JSON.stringify(inputs.action.toJsonObject()));
3451
+ const wire = candidate;
3452
+ if (session != null && wire.type === "request") wire.context.originClient = session.client.toJsonObject();
3453
+ const resultWire = (await (await this._runtime.handleActionPayloadWire(wire)).waitForResultPayload()).toJsonObject();
3454
+ if (session?.crypto != null) return {
3455
+ k: "act",
3456
+ c: bytesToBase64(await session.crypto.encryptFrame(textEncoder$1.encode(JSON.stringify(resultWire))))
3201
3457
  };
3202
3458
  return {
3203
- sendActionData,
3204
- updateRunConfig: wsData.updateRunConfig,
3205
- addOnDisconnectListener: (cb) => {
3206
- disconnectListeners.push(cb);
3207
- },
3208
- disconnect: () => {
3209
- this._intentionalCloses.add(ws);
3210
- try {
3211
- ws.close();
3212
- } catch {}
3213
- },
3214
- sendReturnData: (payload, clients) => {
3215
- enqueueSend((clients != null ? wsData.formatMessage?.outgoing({
3216
- action: payload,
3217
- ...clients
3218
- }) : void 0) ?? JSON.stringify(payload.toJsonObject()));
3219
- }
3459
+ k: "act",
3460
+ w: resultWire
3220
3461
  };
3221
3462
  }
3222
- /** Decode (and, when encrypted, decrypt) one inbound action frame and hand it to the runtime. */
3223
- async _handleIncomingActionFrame(frame, wsData, crypto) {
3224
- let decoded = frame;
3225
- if (crypto != null) try {
3226
- decoded = await crypto.decryptFrame(frame);
3227
- } catch (err) {
3228
- console.error("[ws] failed to decrypt incoming frame", err);
3229
- return;
3230
- }
3231
- const rawJson = decodeActionFrame(decoded, wsData.formatMessage);
3232
- if (rawJson != null) this.resolvers.onIncomingActionDataJson(rawJson);
3233
- }
3234
- /** Accept text + binary frames (ArrayBuffer / Uint8Array / Blob); Blobs are converted to a buffer. */
3235
- async _normalizeFrame(data) {
3236
- if (typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array) return data;
3237
- if (typeof Blob !== "undefined" && data instanceof Blob) return await data.arrayBuffer();
3238
- }
3239
- _captureSocketUrl(ws) {
3240
- if (ws.url != null && ws.url !== "") this._liveSocketUrl = ws.url;
3241
- }
3242
- _attachLifecycle(ws, disconnectListeners, abortSet) {
3243
- ws.addEventListener("close", (event) => {
3244
- if (!this._intentionalCloses.has(ws)) console.error("WebSocket closed:", event);
3245
- for (const cb of disconnectListeners) cb();
3246
- this._abortAll(abortSet, err_nice_transport_ws.fromId("ws_disconnected"));
3463
+ _err(message) {
3464
+ return encodeExchange({
3465
+ k: "err",
3466
+ message
3247
3467
  });
3248
- ws.addEventListener("error", (event) => {
3249
- console.error("WebSocket error:", event);
3250
- for (const cb of disconnectListeners) cb();
3251
- this._abortAll(abortSet, err_nice_transport_ws.fromId("ws_error", { originalError: event instanceof Error ? event : void 0 }));
3252
- });
3253
- }
3254
- _abortAll(abortSet, error) {
3255
- const snapshot = [...abortSet];
3256
- for (const ra of snapshot) ra._abort(error);
3257
3468
  }
3258
3469
  };
3259
3470
  //#endregion
3260
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/WebSocketTransport.ts
3471
+ //#region src/ActionRuntime/Handler/PeerLink/Acceptor/createActionFetchHandler.ts
3472
+ /** Permissive defaults — fine for a public action endpoint; override (or disable) via `cors`. */
3473
+ const DEFAULT_CORS_HEADERS = {
3474
+ "Access-Control-Allow-Origin": "*",
3475
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
3476
+ "Access-Control-Allow-Headers": "Content-Type",
3477
+ "Access-Control-Max-Age": "86400"
3478
+ };
3261
3479
  /**
3262
- * Reusable WebSocket transport definition. Create one with `WebSocketTransport.create({ createWebSocket })`
3263
- * for the common case, or `WebSocketTransport.createAdvanced({ getTransport })` for full control over
3264
- * readiness. The underlying socket is cached (via `getTransportCacheKey`) and reused across actions.
3480
+ * Build the `fetch` handler a server/Durable-Object exposes for action traffic, folding in the
3481
+ * boilerplate every endpoint repeats: CORS (incl. the `OPTIONS` preflight), routing the `/action`
3482
+ * `POST` body through the runtime (`handleActionPayloadWire` `waitForResultPayload`
3483
+ * `toHttpResponse`), an optional WebSocket-upgrade hook, and a `404` fallback.
3484
+ *
3485
+ * It only touches web-standard `Request`/`Response`, so it stays transport-agnostic — the one
3486
+ * environment-specific bit (the WS upgrade) is injected via {@link IActionFetchHandlerOptions.onWebSocketUpgrade}:
3487
+ * ```ts
3488
+ * this.fetchHandler = createActionFetchHandler(this.runtime, {
3489
+ * onWebSocketUpgrade: () => {
3490
+ * const pair = new WebSocketPair();
3491
+ * this.ctx.acceptWebSocket(pair[1]);
3492
+ * return new Response(null, { status: 101, webSocket: pair[0] });
3493
+ * },
3494
+ * });
3495
+ * // async fetch(request) { return this.fetchHandler(request); }
3496
+ * ```
3265
3497
  */
3266
- var WebSocketTransport = class WebSocketTransport extends Transport {
3267
- options;
3268
- type = "ws";
3269
- constructor(options) {
3270
- super();
3271
- this.options = options;
3272
- }
3273
- static create(options) {
3274
- return new WebSocketTransport({
3275
- ...options,
3276
- mode: "socket"
3277
- });
3278
- }
3279
- static createAdvanced(options) {
3280
- return new WebSocketTransport({
3281
- ...options,
3282
- mode: "advanced"
3498
+ function createActionFetchHandler(runtime, options = {}) {
3499
+ const corsHeaders = options.cors === false ? {} : options.cors ?? DEFAULT_CORS_HEADERS;
3500
+ const isActionPath = options.isActionPath ?? ((url) => url.pathname.endsWith("/action"));
3501
+ const isWebSocketPath = options.isWebSocketPath ?? ((url) => url.pathname.endsWith("/ws"));
3502
+ const exchangeAcceptor = options.security != null ? new ExchangeAcceptor({
3503
+ runtime,
3504
+ security: options.security
3505
+ }) : void 0;
3506
+ const withCors = (response) => {
3507
+ if (options.cors === false) return response;
3508
+ const headers = new Headers(response.headers);
3509
+ for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
3510
+ return new Response(response.body, {
3511
+ status: response.status,
3512
+ headers
3283
3513
  });
3284
- }
3285
- _createConnection(ctx) {
3286
- const options = this.options;
3287
- let getTransport;
3288
- if (options.mode === "advanced") getTransport = options.getTransport;
3289
- else getTransport = (input) => ({
3290
- status: "ready",
3291
- readyData: {
3292
- ws: options.createWebSocket(input),
3293
- formatMessage: options.createFormatMessage?.() ?? options.formatMessage,
3294
- updateRunConfig: options.updateRunConfig,
3295
- secureChannel: options.security
3514
+ };
3515
+ return async (request) => {
3516
+ if (request.method === "OPTIONS") return withCors(new Response(null, { status: 204 }));
3517
+ const url = new URL(request.url);
3518
+ const isWebSocketUpgrade = options.isWebSocketUpgrade ?? ((req, u) => req.headers.get("Upgrade") === "websocket" && isWebSocketPath(u));
3519
+ if (options.onWebSocketUpgrade != null && isWebSocketUpgrade(request, url)) return options.onWebSocketUpgrade(request, url);
3520
+ if (request.method === "POST" && isActionPath(url)) {
3521
+ if (exchangeAcceptor != null) {
3522
+ const reply = await exchangeAcceptor.handlePost(await request.text());
3523
+ return withCors(new Response(reply, {
3524
+ status: 200,
3525
+ headers: { "Content-Type": "application/json" }
3526
+ }));
3296
3527
  }
3297
- });
3298
- return new WebSocketConnection({ initialize: () => ({
3299
- getTransportCacheKey: options.getTransportCacheKey,
3300
- getTransport
3301
- }) }, ctx.resolvers);
3302
- }
3303
- getRouteInfo(input) {
3304
- if (this.options.getRouteInfo != null) return this.options.getRouteInfo(input);
3305
- return {
3306
- type: "ws",
3307
- summary: "ws"
3308
- };
3309
- }
3310
- };
3528
+ return withCors((await (await runtime.handleActionPayloadWire(await request.json())).waitForResultPayload()).toHttpResponse({ useErrorStatus: options.useErrorStatus }));
3529
+ }
3530
+ return withCors(new Response("Not found", { status: 404 }));
3531
+ };
3532
+ }
3311
3533
  //#endregion
3312
- //#region src/ActionRuntime/Handler/ExternalClient/Transport/WebSocket/secureWsChannel.ts
3534
+ //#region src/ActionRuntime/Handler/PeerLink/Acceptor/Hibernation/createHibernatableWsServerAdapter.ts
3313
3535
  /**
3314
- * Derive a stable wire-dictionary version from the ordered route list (FNV-1a over `domain:id,…`), so
3315
- * the version moves automatically whenever the transported domains change a stale peer is then
3316
- * rejected by the handshake instead of silently misrouting a positionally-packed frame.
3536
+ * Wire the hibernation lifecycle for an acceptor handler on a transport whose connections outlive process
3537
+ * eviction (e.g. a Durable Object's hibernatable WebSockets). It owns persistence end to end:
3538
+ * registers `setAttachment` as the handler's connection-bound callback and immediately replays every
3539
+ * live connection's stored binding via `getAttachment`, so results/pushes still route after a wake.
3540
+ *
3541
+ * Layered on top of the generic {@link AcceptorHandler} — it touches only the handler's neutral
3542
+ * `setOnConnectionBound` / `rehydrateConnection` / `receive` / `dropConnection` surface, so no
3543
+ * hibernation concern leaks into the handler itself.
3544
+ *
3545
+ * Construct it once when the handler is built, then forward connection events:
3546
+ * ```ts
3547
+ * const duplex = createHibernatableWsServerAdapter({ handler, getConnections, getAttachment, setAttachment });
3548
+ * // webSocketMessage(ws, msg) => duplex.receive(ws, msg);
3549
+ * // webSocketClose/Error(ws) => duplex.drop(ws);
3550
+ * ```
3317
3551
  */
3318
- function deriveDictionaryVersion(domains) {
3319
- const { intToRoute } = buildActionRouteDictionary(domains);
3320
- const signature = intToRoute.map((route) => `${route.domain}:${route.id}`).join(",");
3321
- let hash = 2166136261;
3322
- for (let i = 0; i < signature.length; i++) {
3323
- hash ^= signature.charCodeAt(i);
3324
- hash = Math.imul(hash, 16777619);
3552
+ function createHibernatableWsServerAdapter(options) {
3553
+ const { handler, getConnections, getAttachment, setAttachment } = options;
3554
+ handler.setOnConnectionBound(setAttachment);
3555
+ for (const connection of getConnections()) {
3556
+ const binding = getAttachment(connection);
3557
+ if (binding != null) handler.rehydrateConnection(connection, binding);
3325
3558
  }
3326
- return `auto:${(hash >>> 0).toString(16).padStart(8, "0")}`;
3327
- }
3328
- /**
3329
- * Bundle a secure channel's shared identity from its transported domains. Both ends MUST call this
3330
- * with the same domains in the same order (the binary wire dictionary is positional). The
3331
- * `dictionaryVersion` is derived from those domains unless you pin an explicit one.
3332
- */
3333
- function defineSecureWsChannel(options) {
3334
3559
  return {
3335
- dictionaryVersion: options.dictionaryVersion ?? deriveDictionaryVersion(options.domains),
3336
- createCodec: createBinaryWsSessionFactory(options.domains, options.sessionOptions)
3560
+ receive: (connection, frame) => handler.receive(connection, frame),
3561
+ drop: (connection) => handler.dropConnection(connection)
3337
3562
  };
3338
3563
  }
3564
+ //#endregion
3565
+ //#region src/ActionRuntime/Channel/serveChannel.ts
3566
+ /** Default accepted set, shared by every carrier: negotiate per connection to whatever the client picks. */
3567
+ const DEFAULT_SERVER_SECURITY_LEVELS = [
3568
+ "none",
3569
+ "authenticated",
3570
+ "encrypted"
3571
+ ];
3339
3572
  /**
3340
- * Build a {@link WebSocketTransport} for the secure binary channel with the boilerplate folded in: it
3341
- * creates the {@link ClientCryptoKeyLink} from `storageAdapter`, opens an `arraybuffer` socket to
3342
- * `url`, caches it per endpoint, installs the channel's per-connection codec, and assembles the
3343
- * `security` block from the runtime coordinate + channel version. Pass `createWebSocket` /
3344
- * `getTransportCacheKey` to take over those bits when you need to.
3573
+ * Serve a secure channel over one or more carriers from a single call — the accept-in dual of
3574
+ * `connectChannel`. It builds the crypto identity (a {@link ClientCryptoKeyLink} + a storage-backed TOFU
3575
+ * resolver) and the security block (coordinate, dictionary version, accepted levels) *once* from
3576
+ * `(runtime, channel)` and fans them across every carrier, so the WebSocket and the secure-HTTP endpoint
3577
+ * can never drift apart. It registers your handlers (plus the duplex acceptor it builds) on the runtime,
3578
+ * wires hibernation when the duplex carrier declares it, and returns a single {@link IChannelServer} whose
3579
+ * `fetch` / `duplex` / `pushToClient` you forward straight to the host:
3580
+ * ```ts
3581
+ * const server = serveChannel(runtime, channel, {
3582
+ * clientEnv, storage,
3583
+ * carriers: [wsAcceptorCarrier({ send, upgrade, hibernation }), httpAcceptorCarrier()],
3584
+ * handlers: [localHandler],
3585
+ * });
3586
+ * // fetch(req) => server.fetch(req)
3587
+ * // webSocketMessage(conn, m) => server.duplex?.receive(conn, m)
3588
+ * // webSocketClose/Error(conn) => server.duplex?.drop(conn)
3589
+ * ```
3590
+ *
3591
+ * `TConn` (the live-connection token a duplex carrier hands back through `send`/`receive`/`drop`) is
3592
+ * inferred from the carriers — `WebSocket` for `wsAcceptorCarrier`, the data-channel type for a WebRTC
3593
+ * carrier, and so on — so it stays carrier-agnostic.
3345
3594
  */
3346
- function createSecureWebSocketTransport(options) {
3347
- const link = new ClientCryptoKeyLink({ storageAdapter: options.storageAdapter });
3348
- return WebSocketTransport.create({
3349
- createWebSocket: options.createWebSocket ?? (() => {
3350
- const ws = new WebSocket(options.url);
3351
- ws.binaryType = "arraybuffer";
3352
- return ws;
3353
- }),
3354
- getTransportCacheKey: options.getTransportCacheKey ?? (() => [options.url]),
3355
- createFormatMessage: options.channel.createCodec,
3356
- updateRunConfig: options.updateRunConfig,
3357
- getRouteInfo: options.getRouteInfo,
3358
- security: {
3359
- securityLevel: options.securityLevel,
3360
- link,
3361
- localCoordinate: options.runtime.coordinate.toJsonObject(),
3362
- dictionaryVersion: options.channel.dictionaryVersion
3363
- }
3595
+ function serveChannel(runtime, channel, options) {
3596
+ const duplexCarriers = options.carriers.filter((carrier) => !isExchangeAcceptorCarrier(carrier));
3597
+ const exchangeCarriers = options.carriers.filter(isExchangeAcceptorCarrier);
3598
+ if (exchangeCarriers.length > 1) throw new Error("serveChannel: at most one exchange carrier is supported");
3599
+ const exchangeCarrier = exchangeCarriers[0];
3600
+ const exchangeSecure = exchangeCarrier != null && (exchangeCarrier.secure ?? true);
3601
+ const anyDuplexSecure = duplexCarriers.some((carrier) => carrier.secure ?? true);
3602
+ const securityLevel = options.securityLevel ?? DEFAULT_SERVER_SECURITY_LEVELS;
3603
+ let secure;
3604
+ if (anyDuplexSecure || exchangeSecure) {
3605
+ const storage = options.storage;
3606
+ if (storage == null) throw new Error("serveChannel: a secure carrier requires `storage`. Pass it, or set `secure: false` on the carrier for a plain endpoint.");
3607
+ secure = {
3608
+ storage,
3609
+ link: options.link ?? new ClientCryptoKeyLink({ storageAdapter: storage }),
3610
+ verifyKeyResolver: options.verifyKeyResolver ?? createStorageTofuVerifyKeyResolver(storage)
3611
+ };
3612
+ }
3613
+ const handlers = [];
3614
+ for (const carrier of duplexCarriers) {
3615
+ const handler = (carrier.secure ?? true) && secure != null ? acceptChannel(runtime, channel, {
3616
+ clientEnv: options.clientEnv,
3617
+ storageAdapter: secure.storage,
3618
+ link: secure.link,
3619
+ verifyKeyResolver: secure.verifyKeyResolver,
3620
+ securityLevel,
3621
+ send: carrier.send,
3622
+ defaultTimeout: options.defaultTimeout
3623
+ }) : createAcceptorHandler({
3624
+ clientEnv: options.clientEnv,
3625
+ createFormatMessage: channel.createCodec,
3626
+ send: carrier.send,
3627
+ runtime,
3628
+ defaultTimeout: options.defaultTimeout
3629
+ });
3630
+ const router = carrier.hibernation != null ? createHibernatableWsServerAdapter({
3631
+ handler,
3632
+ ...carrier.hibernation
3633
+ }) : {
3634
+ receive: (connection, frame) => handler.receive(connection, frame),
3635
+ drop: (connection) => handler.dropConnection(connection)
3636
+ };
3637
+ carrier._activate(router);
3638
+ handlers.push(handler);
3639
+ }
3640
+ runtime.addHandlers([...options.handlers ?? [], ...handlers]);
3641
+ const exchangeSecurity = exchangeSecure && secure != null ? {
3642
+ link: secure.link,
3643
+ verifyKeyResolver: secure.verifyKeyResolver,
3644
+ localCoordinate: runtime.coordinate.toJsonObject(),
3645
+ dictionaryVersion: channel.dictionaryVersion,
3646
+ securityLevel
3647
+ } : void 0;
3648
+ const defaultIsUpgrade = (request) => request.headers.get("Upgrade") === "websocket";
3649
+ const upgraders = [];
3650
+ for (const carrier of duplexCarriers) {
3651
+ if (carrier.upgrade == null) continue;
3652
+ upgraders.push({
3653
+ isUpgrade: carrier.isUpgrade ?? defaultIsUpgrade,
3654
+ upgrade: carrier.upgrade
3655
+ });
3656
+ }
3657
+ const fetch = createActionFetchHandler(runtime, {
3658
+ cors: exchangeCarrier?.cors,
3659
+ onWebSocketUpgrade: upgraders.length === 0 ? void 0 : (request, url) => (upgraders.find((u) => u.isUpgrade(request, url)) ?? upgraders[0]).upgrade(request, url),
3660
+ isWebSocketUpgrade: upgraders.length === 0 ? void 0 : (request, url) => upgraders.some((u) => u.isUpgrade(request, url)),
3661
+ isActionPath: exchangeCarrier != null ? exchangeCarrier.isActionPath ?? (() => true) : () => false,
3662
+ security: exchangeSecurity,
3663
+ useErrorStatus: exchangeCarrier?.useErrorStatus
3364
3664
  });
3665
+ const duplex = duplexCarriers.length === 1 ? duplexCarriers[0] : void 0;
3666
+ const pushToClient = (target, request, pushOptions) => {
3667
+ const owner = target instanceof RuntimeCoordinate ? handlers.find((handler) => handler.ownsLiveConnectionFor(target)) : handlers.find((handler) => handler.hasConnection(target));
3668
+ if (owner == null) throw new Error("serveChannel: no duplex carrier holds a connection for the push target");
3669
+ return owner.pushToClient(runtime, target, request, pushOptions);
3670
+ };
3671
+ return {
3672
+ handlers,
3673
+ fetch,
3674
+ duplex,
3675
+ pushToClient
3676
+ };
3365
3677
  }
3366
3678
  //#endregion
3367
- //#region src/ActionRuntime/Handler/Server/WsConnectionStateStore.ts
3679
+ //#region src/ActionRuntime/Handler/PeerLink/Acceptor/Hibernation/ConnectionStateStore.ts
3368
3680
  /**
3369
- * A typed per-connection state store that co-owns the app state and the server handler's routing
3681
+ * A typed per-connection state store that co-owns the app state and the acceptor handler's routing
3370
3682
  * binding in one attachment, so neither the consumer nor the handler has to hand-merge the two. Create
3371
- * it through {@link ActionServerHandler.createConnectionState} (which also wires binding persistence and
3372
- * replays surviving connections after a wake), then `get`/`set`/`clearApp` the app state directly.
3683
+ * it through {@link createConnectionStateStore} (which also wires binding persistence and replays
3684
+ * surviving connections after a wake), then `get`/`set`/`clearApp` the app state directly.
3685
+ *
3686
+ * The mechanism is carrier-neutral — it only needs read/write/enumerate callbacks for the connection's
3687
+ * attachment — but it pays off on transports whose connections outlive process eviction (e.g. a
3688
+ * Durable Object's hibernatable WebSockets), which is why it lives beside the hibernation adapter.
3373
3689
  *
3374
3690
  * ```ts
3375
- * const players = serverHandler.createConnectionState({
3691
+ * const players = createConnectionStateStore(serverHandler, {
3376
3692
  * schema: vs_player,
3377
3693
  * read: (ws) => ws.deserializeAttachment(),
3378
3694
  * write: (ws, v) => ws.serializeAttachment(v),
@@ -3382,7 +3698,7 @@ function createSecureWebSocketTransport(options) {
3382
3698
  * const player = players.get(ws);
3383
3699
  * ```
3384
3700
  */
3385
- var WsConnectionStateStore = class {
3701
+ var ConnectionStateStore = class {
3386
3702
  options;
3387
3703
  constructor(options) {
3388
3704
  this.options = options;
@@ -3445,523 +3761,1104 @@ var WsConnectionStateStore = class {
3445
3761
  return result.value;
3446
3762
  }
3447
3763
  };
3448
- //#endregion
3449
- //#region src/ActionRuntime/Handler/Server/ActionServerHandler.ts
3450
3764
  /**
3451
- * Server-side handler for backends that accept many client connections over a single open channel
3452
- * (WebSockets, Durable Objects, …). It is transport-agnostic: you feed it inbound frames with
3453
- * {@link receive} and tell it how to write outbound frames via the `send` option.
3454
- *
3455
- * Add it alongside your local execution handler:
3456
- * ```ts
3457
- * const serverHandler = createServerHandler({ clientEnv, formatMessage, send: (ws, f) => ws.send(f) });
3458
- * runtime.addHandlers([localHandler, serverHandler]);
3459
- * // per inbound message (e.g. a Durable Object's webSocketMessage):
3460
- * serverHandler.receive(ws, message);
3461
- * ```
3462
- *
3463
- * Inbound requests route to your local handler; the runtime's return dispatch then calls this
3464
- * handler back (it is an external handler keyed to `clientEnv`) to send the result to the originating
3465
- * connection. The handler keeps a per-connection identity registry so each result lands on the right
3466
- * socket, and remembers each connection's encoding so binary and JSON clients can share the channel.
3765
+ * Build a per-connection {@link ConnectionStateStore} bound to an {@link AcceptorHandler}: it registers
3766
+ * itself as the handler's connection-bound persistence callback (so bindings are written without
3767
+ * overwriting app state) and immediately replays every live connection's stored binding via
3768
+ * {@link AcceptorHandler.rehydrateConnection} — so on a transport that resumes after eviction (e.g. a
3769
+ * Durable Object waking from hibernation) both the app identity and the action routing come back from a
3770
+ * single attachment, with no storage reads and no hand-rolled merge.
3467
3771
  *
3468
- * It registers an empty action router, so it is never chosen to *execute* an inbound request — only
3469
- * to ferry results/pushes back out.
3772
+ * Lives outside the handler so the generic {@link AcceptorHandler} stays free of any attachment/
3773
+ * hibernation concern it exposes only the neutral `setOnConnectionBound` + `rehydrateConnection`
3774
+ * hooks this builder drives.
3470
3775
  */
3471
- var ActionServerHandler = class extends ActionExternalClientHandler {
3472
- _formatMessage;
3473
- _createFormatMessage;
3474
- _send;
3475
- _runtime;
3476
- _serverTimeout;
3477
- _onConnectionBound;
3478
- /** Incoming-data listeners installed by the runtime (`resolveIncomingActionPayload`). */
3479
- _incomingListeners = [];
3480
- _security;
3481
- /** Normalized accepted levels; whether `none` (plain) is allowed; whether any level needs a handshake. */
3482
- _allowedLevels;
3483
- _noneAllowed;
3484
- _handshakeMode;
3485
- _connByClient = /* @__PURE__ */ new Map();
3486
- _clientByConn = /* @__PURE__ */ new Map();
3487
- _connEncoding = /* @__PURE__ */ new Map();
3488
- _codecByConn = /* @__PURE__ */ new Map();
3489
- _handshakeByConn = /* @__PURE__ */ new Map();
3490
- _cryptoByConn = /* @__PURE__ */ new Map();
3491
- _authedConns = /* @__PURE__ */ new Set();
3492
- _plainConns = /* @__PURE__ */ new Set();
3493
- _inboundChainByConn = /* @__PURE__ */ new Map();
3494
- _outboundChainByConn = /* @__PURE__ */ new Map();
3495
- constructor(options) {
3496
- super({
3497
- runtimeCoordinate: options.clientEnv,
3498
- transports: []
3499
- });
3500
- this._formatMessage = options.formatMessage;
3501
- this._createFormatMessage = options.createFormatMessage;
3502
- this._send = options.send;
3503
- this._runtime = options.runtime;
3504
- this._serverTimeout = options.defaultTimeout ?? 1e4;
3505
- this._onConnectionBound = options.onConnectionBound;
3506
- this._security = options.security;
3507
- this._allowedLevels = options.security == null ? [] : Array.isArray(options.security.securityLevel) ? options.security.securityLevel : [options.security.securityLevel];
3508
- this._noneAllowed = this._allowedLevels.includes("none");
3509
- this._handshakeMode = this._allowedLevels.some((level) => level !== "none");
3776
+ function createConnectionStateStore(handler, options) {
3777
+ const store = new ConnectionStateStore(options);
3778
+ handler.setOnConnectionBound((connection, binding) => store._persistBinding(connection, binding));
3779
+ for (const connection of options.getConnections()) {
3780
+ const binding = store._readBinding(connection);
3781
+ if (binding != null) handler.rehydrateConnection(connection, binding);
3510
3782
  }
3511
- /**
3512
- * The codec for a connection: a per-connection session (cached) when a factory was provided, else
3513
- * the single shared `formatMessage`.
3514
- */
3515
- _codecFor(connection) {
3516
- if (this._createFormatMessage != null) {
3517
- let codec = this._codecByConn.get(connection);
3518
- if (codec == null) {
3519
- codec = this._createFormatMessage();
3520
- this._codecByConn.set(connection, codec);
3783
+ return store;
3784
+ }
3785
+ //#endregion
3786
+ //#region src/ActionRuntime/Transport/Carrier/duplex/inMemory/createInMemoryChannel.ts
3787
+ /**
3788
+ * Two cross-wired in-process byte channels — a loopback carrier with no socket. The client end is a
3789
+ * {@link IDuplexCarrier} you hand to a {@link LinkTransport}; the server end plugs into an
3790
+ * `AcceptorHandler` (`send: (_, f) => serverEndpoint.send(f)`, and `serverEndpoint.onMessage(f =>
3791
+ * handler.receive(conn, f))`). Frames are delivered on a microtask, so each side observes the other
3792
+ * asynchronously — exactly like a real transport — which makes this ideal for tests and for running
3793
+ * two runtimes in one process (or proving a non-WS carrier end to end).
3794
+ */
3795
+ function createInMemoryChannelPair() {
3796
+ let clientMessage;
3797
+ let clientClose;
3798
+ let serverMessage;
3799
+ let serverClose;
3800
+ let open = true;
3801
+ const closeBoth = () => {
3802
+ if (!open) return;
3803
+ open = false;
3804
+ queueMicrotask(() => {
3805
+ clientClose?.();
3806
+ serverClose?.();
3807
+ });
3808
+ };
3809
+ return {
3810
+ clientChannel: {
3811
+ ready: Promise.resolve(),
3812
+ isOpen: () => open,
3813
+ send: (frame) => {
3814
+ if (!open) return;
3815
+ queueMicrotask(() => serverMessage?.(frame));
3816
+ },
3817
+ attach: ({ onMessage, onClose }) => {
3818
+ clientMessage = onMessage;
3819
+ clientClose = onClose;
3820
+ },
3821
+ close: closeBoth,
3822
+ label: "in-memory"
3823
+ },
3824
+ serverEndpoint: {
3825
+ send: (frame) => {
3826
+ if (!open) return;
3827
+ queueMicrotask(() => clientMessage?.(frame));
3828
+ },
3829
+ onMessage: (handler) => {
3830
+ serverMessage = handler;
3831
+ },
3832
+ onClose: (handler) => {
3833
+ serverClose = handler;
3834
+ },
3835
+ close: closeBoth
3836
+ }
3837
+ };
3838
+ }
3839
+ //#endregion
3840
+ //#region src/ActionRuntime/Transport/Carrier/duplex/inMemory/inMemoryCarrier.ts
3841
+ /**
3842
+ * A loopback duplex carrier with no socket — two cross-wired in-process ends. The connector end is an
3843
+ * {@link IDuplexCarrierSource} for {@link secureTransport}; the acceptor end plugs into an
3844
+ * `AcceptorHandler`. Ideal for tests and for running two runtimes in one process, or proving a
3845
+ * non-WS carrier end to end.
3846
+ */
3847
+ function inMemoryCarrier() {
3848
+ const { clientChannel, serverEndpoint } = createInMemoryChannelPair();
3849
+ return {
3850
+ carrier: {
3851
+ carrierLabel: "memory",
3852
+ open: () => clientChannel,
3853
+ getCacheKey: () => ["memory"]
3854
+ },
3855
+ serverEndpoint
3856
+ };
3857
+ }
3858
+ //#endregion
3859
+ //#region src/ActionRuntime/Transport/Carrier/duplex/rtc/rtcDataChannelByteChannel.ts
3860
+ /**
3861
+ * Adapt a WebRTC `RTCDataChannel` to the carrier-agnostic {@link IDuplexCarrier}, so two browsers
3862
+ * (or two mobile apps) linked peer-to-peer — no server in the middle — run the *same* secure session as
3863
+ * a WebSocket. Hand it to `createSecureLinkTransport({ openChannel: () => rtcDataChannelByteChannel(dc) })`.
3864
+ *
3865
+ * The data channel must already be created (its negotiation/signaling is the app's concern); this only
3866
+ * drives bytes over it. Binary frames are requested as `ArrayBuffer` so the binary session codec unpacks
3867
+ * them synchronously; a `Blob` (if the channel hands one back) is normalized to a buffer.
3868
+ */
3869
+ function rtcDataChannelByteChannel(dc) {
3870
+ dc.binaryType = "arraybuffer";
3871
+ let intentional = false;
3872
+ let onMessage;
3873
+ let onClose;
3874
+ const preAttach = [];
3875
+ const deliver = (frame) => {
3876
+ if (onMessage != null) onMessage(frame);
3877
+ else preAttach.push(frame);
3878
+ };
3879
+ dc.addEventListener("message", async (event) => {
3880
+ const frame = await normalizeFrame$1(event.data);
3881
+ if (frame !== void 0) deliver(frame);
3882
+ });
3883
+ dc.addEventListener("close", () => {
3884
+ if (!intentional) console.error("RTCDataChannel closed");
3885
+ onClose?.();
3886
+ });
3887
+ dc.addEventListener("error", (event) => {
3888
+ console.error("RTCDataChannel error:", event);
3889
+ onClose?.();
3890
+ });
3891
+ return {
3892
+ ready: new Promise((resolve, reject) => {
3893
+ if (dc.readyState === "open") {
3894
+ resolve();
3895
+ return;
3521
3896
  }
3522
- return codec;
3897
+ dc.addEventListener("open", () => resolve(), { once: true });
3898
+ dc.addEventListener("error", (event) => reject(event), { once: true });
3899
+ dc.addEventListener("close", () => reject(/* @__PURE__ */ new Error("RTCDataChannel closed before open")), { once: true });
3900
+ }),
3901
+ isOpen: () => dc.readyState === "open",
3902
+ send: (frame) => {
3903
+ if (typeof frame === "string" || frame instanceof ArrayBuffer) dc.send(frame);
3904
+ else dc.send(new Uint8Array(frame));
3905
+ },
3906
+ attach: (handlers) => {
3907
+ onMessage = handlers.onMessage;
3908
+ onClose = handlers.onClose;
3909
+ for (const frame of preAttach) handlers.onMessage(frame);
3910
+ preAttach.length = 0;
3911
+ },
3912
+ close: () => {
3913
+ intentional = true;
3914
+ try {
3915
+ dc.close();
3916
+ } catch {}
3917
+ },
3918
+ get label() {
3919
+ return dc.label != null && dc.label !== "" ? dc.label : void 0;
3523
3920
  }
3524
- if (this._formatMessage != null) return this._formatMessage;
3525
- throw err_nice_transport.fromId("not_found", { actionId: "server-handler-codec (provide formatMessage or createFormatMessage)" });
3526
- }
3527
- _setIncomingActionDataListener(listener) {
3528
- this._incomingListeners.push(listener);
3921
+ };
3922
+ }
3923
+ async function normalizeFrame$1(data) {
3924
+ if (typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array) return data;
3925
+ if (typeof Blob !== "undefined" && data instanceof Blob) return await data.arrayBuffer();
3926
+ }
3927
+ //#endregion
3928
+ //#region src/ActionRuntime/Transport/Carrier/duplex/rtc/rtcCarrier.ts
3929
+ /**
3930
+ * A WebRTC {@link IDuplexCarrierSource} over an already-negotiated `RTCDataChannel` (signaling is the
3931
+ * app's concern). Hand it to {@link secureTransport} so two browsers/apps linked peer-to-peer run the
3932
+ * identical secure session as a WebSocket.
3933
+ */
3934
+ function rtcCarrier(dataChannel, options = {}) {
3935
+ return {
3936
+ carrierLabel: "webrtc",
3937
+ open: () => rtcDataChannelByteChannel(dataChannel),
3938
+ getCacheKey: options.getTransportCacheKey ?? (() => ["webrtc"]),
3939
+ getRouteInfo: options.getRouteInfo
3940
+ };
3941
+ }
3942
+ //#endregion
3943
+ //#region src/ActionRuntime/Transport/Carrier/duplex/ws/err_nice_transport_ws.ts
3944
+ let EErrId_NiceTransport_WebSocket = /* @__PURE__ */ function(EErrId_NiceTransport_WebSocket) {
3945
+ EErrId_NiceTransport_WebSocket["ws_disconnected"] = "ws_disconnected";
3946
+ EErrId_NiceTransport_WebSocket["ws_create_failed"] = "ws_create_failed";
3947
+ EErrId_NiceTransport_WebSocket["ws_error"] = "ws_error";
3948
+ return EErrId_NiceTransport_WebSocket;
3949
+ }({});
3950
+ const err_nice_transport_ws = err_nice_transport.createChildDomain({
3951
+ domain: "ws_transport",
3952
+ schema: {
3953
+ ["ws_disconnected"]: err({ message: () => `WebSocket transport disconnected.` }),
3954
+ ["ws_create_failed"]: err({ message: ({ originalError }) => `Failed to create WebSocket transport.${originalError ? ` Original error: ${originalError.message}` : ""}` }),
3955
+ ["ws_error"]: err({ message: ({ originalError }) => `WebSocket transport error.${originalError ? ` Original error: ${originalError.message}` : ""}` })
3529
3956
  }
3530
- /**
3531
- * Register (or replace) the connection-bound persistence callback after construction. Used by
3532
- * lifecycle helpers like {@link createHibernatableWsServerAdapter} so persistence and replay are
3533
- * owned by one place instead of being split across the constructor options.
3534
- */
3535
- setOnConnectionBound(onConnectionBound) {
3536
- this._onConnectionBound = onConnectionBound;
3957
+ });
3958
+ //#endregion
3959
+ //#region src/ActionRuntime/Transport/Carrier/duplex/ws/ws_util.ts
3960
+ /**
3961
+ * Send a text or binary frame over a socket. A binary formatter may hand back a `Uint8Array` whose
3962
+ * backing buffer is typed as `ArrayBufferLike` (msgpackr pools buffers / may be `SharedArrayBuffer`),
3963
+ * which `WebSocket.send`'s `BufferSource` parameter rejects — copy it into a fresh `ArrayBuffer`-backed
3964
+ * view so the type (and the bytes) are safe to send.
3965
+ */
3966
+ function sendFrame(ws, data) {
3967
+ if (typeof data === "string" || data instanceof ArrayBuffer) {
3968
+ ws.send(data);
3969
+ return;
3537
3970
  }
3538
- /**
3539
- * Create a typed per-connection state store that co-owns the consumer's app state and this handler's
3540
- * routing binding in one attachment. It registers itself as the connection-bound persistence callback
3541
- * (so bindings are written without overwriting app state) and immediately replays every live
3542
- * connection's stored binding via {@link rehydrateConnection} — so on a transport that resumes after
3543
- * eviction (e.g. a Durable Object waking from hibernation) both the app identity and the action
3544
- * routing come back from a single attachment, with no storage reads and no hand-rolled merge.
3545
- *
3546
- * This supersedes {@link createHibernatableWsServerAdapter} for app code that also pins its own state
3547
- * to the connection. Construct it once when the handler is built, then `get`/`set` app state directly.
3548
- */
3549
- createConnectionState(options) {
3550
- const store = new WsConnectionStateStore(options);
3551
- this.setOnConnectionBound((connection, binding) => store._persistBinding(connection, binding));
3552
- for (const connection of options.getConnections()) {
3553
- const binding = store._readBinding(connection);
3554
- if (binding != null) this.rehydrateConnection(connection, binding);
3555
- }
3556
- return store;
3971
+ ws.send(new Uint8Array(data));
3972
+ }
3973
+ /** Compact a WebSocket URL to `host/pathname` for devtools display, falling back to the raw url. */
3974
+ function shortWs(url) {
3975
+ try {
3976
+ const u = new URL(url);
3977
+ return `${u.host}${u.pathname}`;
3978
+ } catch {
3979
+ return url;
3557
3980
  }
3558
- /**
3559
- * Feed one inbound frame from a connection into the runtime. Decodes text or binary, binds the
3560
- * connection to the requesting client's identity, then routes it (requests execute locally;
3561
- * results/progress resolve pending server-initiated actions).
3562
- */
3563
- receive(connection, frame) {
3564
- if (this._security == null || !this._handshakeMode) {
3565
- this._receivePlain(connection, frame);
3566
- return;
3981
+ }
3982
+ //#endregion
3983
+ //#region src/ActionRuntime/Transport/Carrier/duplex/ws/webSocketByteChannel.ts
3984
+ /**
3985
+ * Adapt a `WebSocket` to the carrier-agnostic {@link IDuplexCarrier}, so the WebSocket becomes
3986
+ * "just another carrier" under the shared secure session. It owns every WebSocket-specific concern —
3987
+ * awaiting `open`, normalizing `Blob` frames to bytes, suppressing the log on a deliberate `close`, and
3988
+ * buffering any frame that arrives before the session attaches its handler — leaving the session itself
3989
+ * carrier-neutral.
3990
+ */
3991
+ function webSocketByteChannel(ws) {
3992
+ let intentional = false;
3993
+ let onMessage;
3994
+ let onClose;
3995
+ const preAttach = [];
3996
+ const deliver = (frame) => {
3997
+ if (onMessage != null) onMessage(frame);
3998
+ else preAttach.push(frame);
3999
+ };
4000
+ ws.addEventListener("message", async (event) => {
4001
+ const frame = await normalizeFrame(event.data);
4002
+ if (frame !== void 0) deliver(frame);
4003
+ });
4004
+ ws.addEventListener("close", (event) => {
4005
+ if (!intentional) console.error("WebSocket closed:", event);
4006
+ onClose?.();
4007
+ });
4008
+ ws.addEventListener("error", (event) => {
4009
+ console.error("WebSocket error:", event);
4010
+ onClose?.();
4011
+ });
4012
+ return {
4013
+ ready: new Promise((resolve, reject) => {
4014
+ if (ws.readyState === WebSocket.OPEN) {
4015
+ resolve();
4016
+ return;
4017
+ }
4018
+ ws.addEventListener("open", () => resolve(), { once: true });
4019
+ ws.addEventListener("error", (event) => reject(event), { once: true });
4020
+ ws.addEventListener("close", (event) => reject(/* @__PURE__ */ new Error(`WebSocket closed before open: code=${event.code}`)), { once: true });
4021
+ }),
4022
+ isOpen: () => ws.readyState === WebSocket.OPEN,
4023
+ send: (frame) => sendFrame(ws, frame),
4024
+ attach: (handlers) => {
4025
+ onMessage = handlers.onMessage;
4026
+ onClose = handlers.onClose;
4027
+ for (const frame of preAttach) handlers.onMessage(frame);
4028
+ preAttach.length = 0;
4029
+ },
4030
+ close: () => {
4031
+ intentional = true;
4032
+ try {
4033
+ ws.close();
4034
+ } catch {}
4035
+ },
4036
+ get label() {
4037
+ return ws.url != null && ws.url !== "" ? ws.url : void 0;
3567
4038
  }
3568
- const next = (this._inboundChainByConn.get(connection) ?? Promise.resolve()).then(() => this._receiveSecure(connection, frame)).catch((err) => console.error("[ws-server] failed to process inbound frame", err));
3569
- this._inboundChainByConn.set(connection, next);
3570
- }
3571
- _receivePlain(connection, frame) {
3572
- const wire = decodeActionFrame(frame, this._codecFor(connection));
3573
- if (wire == null) return;
3574
- const encoding = typeof frame === "string" ? "json" : "binary";
3575
- this._connEncoding.set(connection, encoding);
3576
- if (wire.type === "request") this._resolveRequestIdentity(connection, wire, encoding);
3577
- for (const listener of this._incomingListeners) listener(wire);
4039
+ };
4040
+ }
4041
+ /** Accept text + binary frames (ArrayBuffer / Uint8Array / Blob); Blobs are converted to a buffer. */
4042
+ async function normalizeFrame(data) {
4043
+ if (typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array) return data;
4044
+ if (typeof Blob !== "undefined" && data instanceof Blob) return await data.arrayBuffer();
4045
+ }
4046
+ //#endregion
4047
+ //#region src/ActionRuntime/Transport/Carrier/duplex/ws/wsCarrier.ts
4048
+ /**
4049
+ * A WebSocket {@link IDuplexCarrierSource}: opens an `arraybuffer` socket to `url` (cached per endpoint)
4050
+ * and adapts it to a carrier. Hand it to {@link secureTransport} (or `LinkTransport`) — the WebSocket is
4051
+ * now "just another carrier" under the shared secure session, with no WS-specific transport class.
4052
+ */
4053
+ function wsCarrier(url, options = {}) {
4054
+ return {
4055
+ carrierLabel: "ws",
4056
+ open: (input) => webSocketByteChannel(options.createWebSocket?.(input) ?? defaultWebSocket(url)),
4057
+ getCacheKey: options.getTransportCacheKey ?? (() => [url]),
4058
+ getRouteInfo: options.getRouteInfo ?? (() => ({
4059
+ carrierLabel: "ws",
4060
+ url,
4061
+ summary: `ws ${shortWs(url)}`
4062
+ }))
4063
+ };
4064
+ }
4065
+ function defaultWebSocket(url) {
4066
+ const ws = new WebSocket(url);
4067
+ ws.binaryType = "arraybuffer";
4068
+ return ws;
4069
+ }
4070
+ //#endregion
4071
+ //#region src/ActionRuntime/Transport/Carrier/exchange/http/httpAcceptorCarrier.ts
4072
+ /**
4073
+ * An HTTP {@link IExchangeAcceptorCarrier}: the accept-in dual of {@link httpCarrier}. It serves the
4074
+ * secure exchange protocol (handshake → token session → encrypted frames) over web-standard
4075
+ * `Request`/`Response`. The crypto identity, runtime coordinate, dictionary version, and accepted security
4076
+ * levels are all supplied centrally by `serveChannel`, so this only needs to say which requests carry an
4077
+ * action envelope and how to answer CORS.
4078
+ */
4079
+ function httpAcceptorCarrier(options = {}) {
4080
+ return {
4081
+ shape: "exchange",
4082
+ carrierLabel: options.carrierLabel ?? "http",
4083
+ secure: options.secure,
4084
+ isActionPath: options.isActionPath,
4085
+ cors: options.cors,
4086
+ useErrorStatus: options.useErrorStatus
4087
+ };
4088
+ }
4089
+ //#endregion
4090
+ //#region src/ActionRuntime/Transport/Carrier/exchange/http/httpCarrier.ts
4091
+ function shortPath(url) {
4092
+ try {
4093
+ return new URL(url).pathname || url;
4094
+ } catch {
4095
+ return url;
3578
4096
  }
3579
- async _receiveSecure(connection, frame) {
3580
- const security = this._security;
3581
- if (security == null) return;
3582
- if (this._plainConns.has(connection)) {
3583
- this._receivePlain(connection, frame);
3584
- return;
3585
- }
3586
- if (!this._authedConns.has(connection)) {
3587
- const message = typeof frame === "string" ? decodeHandshakeMessage(frame) : void 0;
3588
- if (message == null) {
3589
- if (this._noneAllowed) {
3590
- this._plainConns.add(connection);
3591
- this._receivePlain(connection, frame);
4097
+ }
4098
+ /**
4099
+ * An HTTP {@link IExchangeCarrierSource}: each `exchange` POSTs one frame body to the action endpoint and
4100
+ * resolves with the response body as the single correlated reply. Hand it to {@link secureTransport} —
4101
+ * HTTP then runs the *same* secure session as a duplex carrier (handshake → token → encrypted frames),
4102
+ * the request/reply correlation provided for free by the HTTP transaction.
4103
+ *
4104
+ * `createRequest` derives the URL/headers per action (keep it simple with `() => ({ url })`). The body is
4105
+ * the session's responsibility, so it is never built here.
4106
+ */
4107
+ function httpCarrier(createRequest, options = {}) {
4108
+ const doFetch = options.fetch ?? fetch;
4109
+ return {
4110
+ shape: "exchange",
4111
+ carrierLabel: "http",
4112
+ open: (input) => {
4113
+ const request = createRequest(input);
4114
+ return {
4115
+ label: request.url,
4116
+ exchange: async (frame, opts) => {
4117
+ return await (await doFetch(request.url, {
4118
+ method: "POST",
4119
+ headers: {
4120
+ "Content-Type": "application/json",
4121
+ ...request.headers
4122
+ },
4123
+ body: typeof frame === "string" ? frame : new Uint8Array(frame),
4124
+ signal: opts?.signal
4125
+ })).text();
3592
4126
  }
4127
+ };
4128
+ },
4129
+ getCacheKey: options.getTransportCacheKey ?? ((input) => [createRequest(input).url]),
4130
+ getRouteInfo: options.getRouteInfo ?? ((input) => {
4131
+ const { url } = createRequest(input);
4132
+ return {
4133
+ carrierLabel: "http",
4134
+ method: "POST",
4135
+ url,
4136
+ summary: `POST ${shortPath(url)}`
4137
+ };
4138
+ })
4139
+ };
4140
+ }
4141
+ //#endregion
4142
+ //#region src/ActionRuntime/Transport/codec/createBinaryWireAdapter.ts
4143
+ /**
4144
+ * Positional layout of the stateless binary envelope. A flat tuple (rather than an object) strips the
4145
+ * repeated `domain`/`id`/`form`/`type` and context key names from every frame, and we carry only the
4146
+ * context fields the receiver can't recompute: `cuid` (correlation) and `originClient` (return
4147
+ * routing).
4148
+ *
4149
+ * [ routeInt, typeInt, time, cuid, originClient, payloadData ]
4150
+ *
4151
+ * Dropped vs the JSON wire: `form`/`type` strings, `inputHash`/`outputHash` (recomputed on hydrate),
4152
+ * `context.timeCreated` (reconstructed from `time`) and `context.routing` (rebuilt empty — the
4153
+ * receiver re-stamps its own route items as it handles the action). For the leanest possible frames
4154
+ * (integer correlation, identity dropped after a handshake), use `createBinaryWireSessionFactory`.
4155
+ */
4156
+ const ENVELOPE = {
4157
+ route: 0,
4158
+ type: 1,
4159
+ time: 2,
4160
+ cuid: 3,
4161
+ originClient: 4,
4162
+ payload: 5
4163
+ };
4164
+ const ENVELOPE_LENGTH = 6;
4165
+ /**
4166
+ * Builds a *stateless* `formatMessage` pipeline for {@link LinkTransport}, packing action
4167
+ * payloads into a compact msgpackr binary frame instead of JSON. The `domain`/`id` route collapses to
4168
+ * a single integer drawn from a shared dictionary; `form`/`type`, the recomputable
4169
+ * `inputHash`/`outputHash`, and the per-frame `context.routing`/`context.timeCreated` are all dropped
4170
+ * (see {@link ENVELOPE}).
4171
+ *
4172
+ * No validation runs here: `incoming` blindly reconstructs the wire JSON shape and hands it back to
4173
+ * the connection, which flows into `ActionRuntime` → `domain.hydrateAnyAction()` where the Valibot
4174
+ * schemas validate it exactly as they would for a JSON frame.
4175
+ *
4176
+ * Both ends of the socket MUST construct the adapter with the same domains in the same order — the
4177
+ * integer dictionary is positional. Mismatched dictionaries will route to the wrong action.
4178
+ *
4179
+ * Because `incoming` returns `undefined` for text frames, a binary server can still serve plain-JSON
4180
+ * clients on the same runtime (the connection falls back to its built-in JSON parser).
4181
+ */
4182
+ function createBinaryWireAdapter(domains) {
4183
+ const { routeToInt, intToRoute } = buildActionRouteDictionary(domains);
4184
+ return {
4185
+ outgoing: (input) => {
4186
+ const json = input.action.toJsonObject();
4187
+ const routeKey = `${json.domain}:${json.id}`;
4188
+ const routeInt = routeToInt.get(routeKey);
4189
+ if (routeInt == null) throw new Error(`[binary-wire] Cannot pack unregistered action route: ${routeKey}`);
4190
+ const envelope = new Array(ENVELOPE_LENGTH);
4191
+ envelope[ENVELOPE.route] = routeInt;
4192
+ envelope[ENVELOPE.type] = PayloadTypeToInt[json.type];
4193
+ envelope[ENVELOPE.time] = json.time;
4194
+ envelope[ENVELOPE.cuid] = json.context.cuid;
4195
+ envelope[ENVELOPE.originClient] = json.context.originClient;
4196
+ envelope[ENVELOPE.payload] = extractWirePayload(json);
4197
+ return pack(envelope);
4198
+ },
4199
+ incoming: (frame) => {
4200
+ let buffer;
4201
+ if (frame instanceof ArrayBuffer) buffer = new Uint8Array(frame);
4202
+ else if (frame instanceof Uint8Array) buffer = frame;
4203
+ else return;
4204
+ try {
4205
+ const envelope = unpack(buffer);
4206
+ if (!Array.isArray(envelope) || envelope.length !== ENVELOPE_LENGTH) return void 0;
4207
+ const routeMeta = intToRoute[envelope[ENVELOPE.route]];
4208
+ const payloadType = ReversePayloadType[envelope[ENVELOPE.type]];
4209
+ if (routeMeta == null || payloadType == null) return void 0;
4210
+ const time = envelope[ENVELOPE.time];
4211
+ return assembleWireJson(routeMeta, payloadType, time, {
4212
+ cuid: envelope[ENVELOPE.cuid],
4213
+ timeCreated: time,
4214
+ routing: [],
4215
+ originClient: envelope[ENVELOPE.originClient]
4216
+ }, envelope[ENVELOPE.payload]);
4217
+ } catch (e) {
4218
+ console.error("[binary-wire] Failed to unpack binary action frame", e);
3593
4219
  return;
3594
4220
  }
3595
- await security.link.initialize();
3596
- let handshake = this._handshakeByConn.get(connection);
3597
- if (handshake == null) {
3598
- handshake = createServerHandshake({
3599
- link: security.link,
3600
- localCoordinate: security.localCoordinate,
3601
- dictionaryVersion: security.dictionaryVersion,
3602
- securityLevel: security.securityLevel,
3603
- verifyKeyResolver: security.verifyKeyResolver
3604
- });
3605
- this._handshakeByConn.set(connection, handshake);
3606
- }
3607
- if (message.t === "hello") this._send(connection, encodeHandshakeMessage(await handshake.onHello(message)));
3608
- else if (message.t === "prove") {
3609
- const reply = await handshake.onProve(message);
3610
- this._send(connection, encodeHandshakeMessage(reply));
3611
- const result = handshake.getResult();
3612
- if (reply.t === "accept" && result != null) this._completeServerHandshake(connection, result);
3613
- }
3614
- return;
3615
- }
3616
- let bytes = frame;
3617
- const cryptoReady = this._cryptoByConn.get(connection);
3618
- if (cryptoReady != null) try {
3619
- bytes = await (await cryptoReady).decryptFrame(frame);
3620
- } catch (err) {
3621
- console.error("[ws-server] failed to decrypt inbound frame", err);
3622
- return;
3623
4221
  }
3624
- const wire = decodeActionFrame(bytes, this._codecFor(connection));
3625
- if (wire == null) return;
3626
- if (wire.type === "request") {
3627
- const bound = this._clientByConn.get(connection);
3628
- if (bound != null) wire.context.originClient = bound.toJsonObject();
4222
+ };
4223
+ }
4224
+ //#endregion
4225
+ //#region src/ActionRuntime/Transport/Transport.ts
4226
+ /**
4227
+ * Reusable transport definition. Devs construct these (`secureTransport({ carrier: wsCarrier(url) })`,
4228
+ * `plainTransport({ carrier: httpCarrier(...) })`, …) and pass them to a
4229
+ * `ConnectorHandler`. A single
4230
+ * definition can be shared across multiple handlers — each handler builds its own live
4231
+ * {@link TransportConnection} via {@link TransportConnection._createConnection}.
4232
+ */
4233
+ var Transport = class {};
4234
+ //#endregion
4235
+ //#region src/ActionRuntime/Transport/SecureSession/establishExchangeSession.ts
4236
+ const textEncoder = new TextEncoder();
4237
+ const textDecoder = new TextDecoder();
4238
+ /** Plain path (no handshake/token): every action rides a bare `act` envelope, plaintext both ways. */
4239
+ function finalizePlainExchangeMethods(ctx) {
4240
+ return buildExchangeMethods(ctx, {});
4241
+ }
4242
+ /** Secure path: run the handshake (two exchanges) once at bring-up, then reuse the token + crypto. */
4243
+ async function finalizeSecureExchangeMethods(ctx) {
4244
+ return buildExchangeMethods(ctx, await runConnectorExchangeHandshake(ctx.carrier, ctx.secure));
4245
+ }
4246
+ function buildExchangeMethods(ctx, state) {
4247
+ const sendActionData = (inputs) => {
4248
+ runExchange(ctx.carrier, state, inputs).catch((err) => inputs.runningAction._abort(err));
4249
+ };
4250
+ return {
4251
+ sendActionData,
4252
+ updateRunConfig: ctx.updateRunConfig
4253
+ };
4254
+ }
4255
+ async function runExchange(carrier, state, inputs) {
4256
+ const { action, runningAction, timeout } = inputs;
4257
+ const ac = new AbortController();
4258
+ let timedOut = false;
4259
+ const timeoutId = setTimeout(() => {
4260
+ timedOut = true;
4261
+ ac.abort();
4262
+ }, timeout);
4263
+ const unsubscribe = runningAction.addUpdateListeners([(update) => {
4264
+ if (update.type === "finished") {
4265
+ clearTimeout(timeoutId);
4266
+ ac.abort();
3629
4267
  }
3630
- for (const listener of this._incomingListeners) listener(wire);
3631
- }
3632
- _completeServerHandshake(connection, result) {
3633
- const clientCoord = new RuntimeCoordinate(result.remote);
3634
- this._bindConnection(connection, clientCoord);
3635
- this._connEncoding.set(connection, "binary");
3636
- this._authedConns.add(connection);
3637
- this._handshakeByConn.delete(connection);
3638
- if (result.securityLevel === "encrypted" && this._security != null) this._cryptoByConn.set(connection, Promise.resolve(createActionFrameCrypto({
3639
- link: this._security.link,
3640
- linkedClientId: result.linkedClientId
3641
- })));
3642
- this._onConnectionBound?.(connection, {
3643
- client: clientCoord.toJsonObject(),
3644
- encoding: "binary",
3645
- secure: {
3646
- securityLevel: result.securityLevel,
3647
- linkedClientId: result.linkedClientId,
3648
- keyMaterial: result.encryptionKeyMaterial
3649
- }
4268
+ }]);
4269
+ try {
4270
+ const request = await buildRequestEnvelope(state, action);
4271
+ const replyRaw = await carrier.exchange(encodeExchange(request), { signal: ac.signal });
4272
+ if (action.type !== "request") return;
4273
+ const reply = decodeExchangeReply(asString(replyRaw));
4274
+ if (reply == null) throw err_nice_transport.fromId("invalid_action_response", { actionId: action.id });
4275
+ if (reply.k === "err") throw err_nice_transport.fromId("send_failed", {
4276
+ actionState: action.type,
4277
+ actionId: action.id,
4278
+ message: reply.message
3650
4279
  });
4280
+ const wire = await extractReplyWire(state, reply);
4281
+ if (wire == null || !isActionPayload_Result_JsonObject(wire)) throw err_nice_transport.fromId("invalid_action_response", { actionId: action.id });
4282
+ runningAction._completeWithResult(action._domain.hydrateResultPayload(wire));
4283
+ } catch (err) {
4284
+ if (timedOut) throw err_nice_transport.fromId("timeout", { timeout });
4285
+ throw err;
4286
+ } finally {
4287
+ clearTimeout(timeoutId);
4288
+ unsubscribe();
3651
4289
  }
3652
- /**
3653
- * Ensure an inbound request carries the client's identity and that this connection is bound to it,
3654
- * so its result can be routed back. A session codec omits `originClient` after the first request, so
3655
- * when it's missing we restore it from the (possibly rehydrated) binding instead. (Plain mode only;
3656
- * secure mode binds the authenticated coordinate at handshake time.)
3657
- */
3658
- _resolveRequestIdentity(connection, wire, encoding) {
3659
- const wireOrigin = wire.context.originClient;
3660
- if (wireOrigin != null && wireOrigin.envId !== "_unset_") {
3661
- const clientCoord = new RuntimeCoordinate(wireOrigin);
3662
- const isNewBinding = this._clientByConn.get(connection)?.stringId !== clientCoord.stringId;
3663
- this._bindConnection(connection, clientCoord);
3664
- if (isNewBinding) this._onConnectionBound?.(connection, {
3665
- client: clientCoord.toJsonObject(),
3666
- encoding
3667
- });
3668
- return;
3669
- }
3670
- const bound = this._clientByConn.get(connection);
3671
- if (bound != null) wire.context.originClient = bound.toJsonObject();
3672
- }
3673
- /**
3674
- * Restore a connection→client binding without an inbound frame — for transports that resume after
3675
- * eviction. Pair it with the {@link IActionServerHandlerOptions.onConnectionBound} hook: persist
3676
- * the binding there, then replay each live connection here when the channel comes back (e.g. a
3677
- * Durable Object iterating `ctx.getWebSockets()` as it wakes from hibernation).
3678
- */
3679
- rehydrateConnection(connection, binding) {
3680
- this._bindConnection(connection, new RuntimeCoordinate(binding.client));
3681
- this._connEncoding.set(connection, binding.encoding);
3682
- const secure = binding.secure;
3683
- if (secure == null) return;
3684
- this._authedConns.add(connection);
3685
- if (secure.securityLevel === "encrypted" && secure.keyMaterial != null && this._security != null) {
3686
- const security = this._security;
3687
- const { linkedClientId, keyMaterial } = secure;
3688
- const cryptoReady = security.link.initialize().then(() => security.link.linkClient({
3689
- linkedClientId,
3690
- verifyPublicKey: keyMaterial.verifyPublicKey,
3691
- exchangePublicKey: keyMaterial.exchangePublicKey,
3692
- saltString: keyMaterial.saltString,
3693
- infoString: keyMaterial.infoString,
3694
- bindVerifyKeysIntoDerivation: keyMaterial.bindVerifyKeysIntoDerivation
3695
- })).then(() => createActionFrameCrypto({
3696
- link: security.link,
3697
- linkedClientId
3698
- }));
3699
- this._cryptoByConn.set(connection, cryptoReady);
3700
- const gate = cryptoReady.then(() => {}, (err) => console.error("[ws-server] failed to restore encrypted session", err));
3701
- this._inboundChainByConn.set(connection, gate);
3702
- this._outboundChainByConn.set(connection, gate);
3703
- }
3704
- }
3705
- toHandlerRouteItem() {
4290
+ }
4291
+ async function buildRequestEnvelope(state, action) {
4292
+ const wire = action.toJsonObject();
4293
+ if (state.crypto != null) {
4294
+ const ciphertext = await state.crypto.encryptFrame(textEncoder.encode(JSON.stringify(wire)));
3706
4295
  return {
3707
- type: this.handlerType,
3708
- client: this.externalClient,
3709
- transType: "custom",
3710
- transOrd: 0
4296
+ k: "act",
4297
+ t: state.token,
4298
+ c: bytesToBase64(ciphertext)
3711
4299
  };
3712
4300
  }
3713
- /** Forget a connection (call on socket close) so stale entries don't misroute later results. */
3714
- dropConnection(connection) {
3715
- const coord = this._clientByConn.get(connection);
3716
- if (coord != null && this._connByClient.get(coord.stringId) === connection) this._connByClient.delete(coord.stringId);
3717
- this._clientByConn.delete(connection);
3718
- this._connEncoding.delete(connection);
3719
- this._codecByConn.delete(connection);
3720
- this._handshakeByConn.delete(connection);
3721
- this._cryptoByConn.delete(connection);
3722
- this._authedConns.delete(connection);
3723
- this._plainConns.delete(connection);
3724
- this._inboundChainByConn.delete(connection);
3725
- this._outboundChainByConn.delete(connection);
3726
- }
3727
- /** Live connection for a client coordinate, if currently registered. */
3728
- getConnectionForClient(client) {
3729
- return this._connByClient.get(client.stringId);
4301
+ return {
4302
+ k: "act",
4303
+ t: state.token,
4304
+ w: wire
4305
+ };
4306
+ }
4307
+ async function extractReplyWire(state, reply) {
4308
+ if (reply.k !== "act") return void 0;
4309
+ if ("c" in reply) {
4310
+ if (state.crypto == null) return void 0;
4311
+ const plain = await state.crypto.decryptFrame(base64ToBytes(reply.c));
4312
+ return JSON.parse(textDecoder.decode(plain));
4313
+ }
4314
+ return reply.w;
4315
+ }
4316
+ async function runConnectorExchangeHandshake(carrier, secure) {
4317
+ await secure.link.initialize();
4318
+ const handshake = createClientHandshake({
4319
+ link: secure.link,
4320
+ localCoordinate: secure.localCoordinate,
4321
+ dictionaryVersion: secure.dictionaryVersion,
4322
+ securityLevel: secure.securityLevel
4323
+ });
4324
+ const hsid = nanoid();
4325
+ const hello = await handshake.createHello();
4326
+ const welcomeReply = decodeExchangeReply(asString(await carrier.exchange(encodeExchange({
4327
+ k: "hs",
4328
+ hsid,
4329
+ m: encodeHandshakeMessage(hello)
4330
+ }))));
4331
+ if (welcomeReply?.k !== "hs") throw new Error("[exchange-handshake] expected a welcome reply");
4332
+ const welcome = decodeHandshakeMessage(welcomeReply.m);
4333
+ if (welcome == null) throw new Error("[exchange-handshake] malformed welcome");
4334
+ if (welcome.t === "reject") throw new Error(`[exchange-handshake] rejected by peer: ${welcome.reason}`);
4335
+ if (welcome.t !== "welcome") throw new Error(`[exchange-handshake] expected welcome, got ${welcome.t}`);
4336
+ const prove = await handshake.onWelcome(welcome);
4337
+ const acceptReply = decodeExchangeReply(asString(await carrier.exchange(encodeExchange({
4338
+ k: "hs",
4339
+ hsid,
4340
+ m: encodeHandshakeMessage(prove)
4341
+ }))));
4342
+ if (acceptReply?.k !== "hs") throw new Error("[exchange-handshake] expected an accept reply");
4343
+ const accept = decodeHandshakeMessage(acceptReply.m);
4344
+ if (accept == null) throw new Error("[exchange-handshake] malformed accept");
4345
+ if (accept.t === "reject") throw new Error(`[exchange-handshake] rejected by peer: ${accept.reason}`);
4346
+ if (accept.t !== "accept") throw new Error(`[exchange-handshake] expected accept, got ${accept.t}`);
4347
+ if (acceptReply.t == null) throw new Error("[exchange-handshake] accept missing session token");
4348
+ const result = await handshake.onAccept(accept);
4349
+ const crypto = result.securityLevel === "encrypted" ? createActionFrameCrypto({
4350
+ link: secure.link,
4351
+ linkedClientId: result.linkedClientId
4352
+ }) : void 0;
4353
+ return {
4354
+ token: acceptReply.t,
4355
+ crypto
4356
+ };
4357
+ }
4358
+ function asString(frame) {
4359
+ if (typeof frame === "string") return frame;
4360
+ return textDecoder.decode(frame instanceof ArrayBuffer ? new Uint8Array(frame) : frame);
4361
+ }
4362
+ //#endregion
4363
+ //#region src/ActionRuntime/Transport/helpers/addTransportStatusMetadata.ts
4364
+ function addTransportStatusMetadata(transportStatus) {
4365
+ if (transportStatus.status === "ready") return {
4366
+ status: "ready",
4367
+ readyData: transportStatus.readyData
4368
+ };
4369
+ if (transportStatus.status === "initializing") return {
4370
+ status: "initializing",
4371
+ initializationPromise: transportStatus.initializationPromise,
4372
+ timeStarted: Date.now()
4373
+ };
4374
+ if (transportStatus.status === "failed") return {
4375
+ status: "failed",
4376
+ error: transportStatus.error,
4377
+ timeFailed: Date.now()
4378
+ };
4379
+ if (transportStatus.status === "unsupported") return { status: "unsupported" };
4380
+ return { status: "uninitialized" };
4381
+ }
4382
+ //#endregion
4383
+ //#region src/ActionRuntime/Transport/TransportConnection.ts
4384
+ let transportOrd = 0;
4385
+ /**
4386
+ * Live, per-handler transport runtime built from a reusable {@link Transport} definition. Holds the
4387
+ * connection-scoped state (ordinal, initialized config, sockets / abort sets) that must not be shared
4388
+ * across handlers. Construct these via `definition._createConnection(...)`, never directly.
4389
+ */
4390
+ var TransportConnection = class {
4391
+ def;
4392
+ transOrd = transportOrd++;
4393
+ type;
4394
+ initialized;
4395
+ /** Backref to the public definition that created this connection (used for devtools route info). */
4396
+ definition;
4397
+ constructor(def) {
4398
+ this.def = def;
4399
+ this.type = def.type;
4400
+ this.initialized = def.initialize();
3730
4401
  }
3731
4402
  /**
3732
- * Send (and optionally await) a server-initiated action to a specific connected client. Pass the
3733
- * connection token directly (e.g. the `ws`) or a client `RuntimeCoordinate` to look one up.
4403
+ * Devtools route info for an action routed through this live connection. Defaults to the stateless
4404
+ * {@link definition}'s info; connections override to enrich it from live state (e.g. the actual
4405
+ * resolved socket URL) when the definition couldn't resolve it on its own.
3734
4406
  */
3735
- pushToClient(runtime, target, request, options) {
3736
- const connection = this._resolveConnection(target);
3737
- return this._dispatch(runtime, connection, request, options?.timeout);
4407
+ getRouteInfo(input) {
4408
+ return this.definition?.getRouteInfo(input);
3738
4409
  }
3739
4410
  /**
3740
- * Build a local handler whose cases are connection-aware: each case receives the primed request and
3741
- * the originating client's live connection (resolved from `originClient`), so handlers don't repeat
3742
- * the `getConnectionForClient(action.context.originClient)` lookup. Cases may return raw output or
3743
- * nothing, just like {@link ActionLocalHandler.forDomainActionCases}. Add the returned handler to the
3744
- * runtime alongside this server handler:
3745
- * ```ts
3746
- * runtime.addHandlers([serverHandler.forConnectionDomainCases(domain, { … }), serverHandler]);
3747
- * ```
4411
+ * Whether a `ready`-status transport still needs asynchronous bring-up before its methods exist
4412
+ * awaiting the carrier to open and/or running a handshake. Default `false`: a stateless transport
4413
+ * (HTTP) is usable the instant `getTransport` reports `ready`, so it stays a terminal *synchronous*
4414
+ * fallback in {@link ConnectionTransportManager}. Stream carriers (Link/WS) override to `true`.
3748
4415
  */
3749
- forConnectionDomainCases(domain, cases) {
3750
- const handler = new ActionLocalHandler();
3751
- const executor = cases;
3752
- const wrapped = {};
3753
- const wrappedRecord = wrapped;
3754
- for (const id in cases) {
3755
- const caseFn = executor[id];
3756
- if (caseFn == null) continue;
3757
- wrappedRecord[id] = (request) => caseFn(request, this.getConnectionForClient(request.context.originClient));
3758
- }
3759
- return handler.forDomainActionCases(domain, wrapped);
4416
+ _needsAsyncBringUp(_readyData) {
4417
+ return false;
4418
+ }
4419
+ /** Await the carrier becoming ready to send (e.g. a socket `open`). Default: nothing to await. */
4420
+ _awaitCarrierReady(_readyData) {
4421
+ return Promise.resolve();
3760
4422
  }
3761
4423
  /**
3762
- * Fan a server-initiated request out to every currently-bound connection. A fresh request is built
3763
- * per connection (each push mutates its own action context) and dispatched fire-and-forget. Pass
3764
- * `except` to skip the originating socket and `where` to filter by connection (e.g. read its
3765
- * attachment for a role). Iterating bound connections (rather than every accepted socket) skips
3766
- * sockets that are still mid-handshake and so can't yet receive a frame.
4424
+ * Finalize during async bring-up may run a handshake, so it can be async. Defaults to the
4425
+ * synchronous {@link _finalizeTransportMethods}; secure stream carriers override to branch plain/secure.
3767
4426
  */
3768
- broadcast(makeRequest, options) {
3769
- const runtime = options?.runtime ?? this._runtime;
3770
- if (runtime == null) throw err_nice_transport.fromId("not_found", { actionId: "server-handler-runtime (construct with `runtime` or pass `options.runtime`)" });
3771
- for (const connection of this._clientByConn.keys()) {
3772
- if (options?.except != null && connection === options.except) continue;
3773
- if (options?.where != null && !options.where(connection)) continue;
3774
- try {
3775
- this.pushToClient(runtime, connection, makeRequest(), { timeout: options?.timeout });
3776
- } catch (error) {
3777
- if (options?.onError != null) options.onError(error, connection);
3778
- else console.error("[ws-server] broadcast push failed", error);
3779
- }
3780
- }
3781
- }
3782
- async sendReturnPayload(payload, config) {
3783
- const connection = this._connByClient.get(payload.context.originClient.stringId);
3784
- if (connection == null) return false;
3785
- this._sendPayload(connection, payload, config.targetLocalRuntime.coordinate);
3786
- return true;
3787
- }
3788
- async handleActionRequest(action, config) {
3789
- const runtime = config?.targetLocalRuntime ?? ActionRuntime.getDefault();
3790
- const connection = this._resolveSingleConnection();
3791
- return this._dispatch(runtime, connection, action, config?.timeout);
4427
+ _finalizeReady(readyData) {
4428
+ return this._finalizeTransportMethods(readyData);
3792
4429
  }
3793
- _dispatch(runtime, connection, action, timeout) {
3794
- const timeoutMs = timeout ?? this._serverTimeout;
3795
- action.context._setOriginClient(runtime.coordinate);
3796
- action.context.addRouteItem({
3797
- runtime: runtime.coordinate,
3798
- handler: this.toHandlerRouteItem(),
3799
- time: Date.now()
3800
- });
3801
- const runningAction = new RunningAction({
3802
- context: action.context,
3803
- request: action,
3804
- parentCuid: peekHandlerCuid(),
3805
- callSite: action._callSite
3806
- });
3807
- runtime.registerRunningAction(runningAction);
3808
- const timeoutId = setTimeout(() => {
3809
- runningAction._abort(err_nice_transport.fromId("timeout", { timeout: timeoutMs }));
3810
- }, timeoutMs);
3811
- runningAction.addUpdateListeners([(update) => {
3812
- if (update.type === "finished") clearTimeout(timeoutId);
3813
- }]);
3814
- try {
3815
- this._sendPayload(connection, action, runtime.coordinate);
3816
- } catch (err) {
3817
- runningAction._abort(err);
3818
- }
3819
- return runningAction;
4430
+ _getCacheKey(input) {
4431
+ const parts = this.initialized.getTransportCacheKey?.(input);
4432
+ if (parts == null) return null;
4433
+ return parts.join("\0");
3820
4434
  }
3821
- _sendPayload(connection, payload, localClient) {
3822
- const frame = (this._connEncoding.get(connection) ?? "binary") === "json" ? JSON.stringify(payload.toJsonObject()) : this._codecFor(connection).outgoing({
3823
- action: payload,
3824
- localClient,
3825
- externalClient: this.externalClient
3826
- });
3827
- const cryptoReady = this._cryptoByConn.get(connection);
3828
- if (cryptoReady == null) {
3829
- this._send(connection, frame);
3830
- return;
3831
- }
3832
- const bytes = toFrameBytes(frame);
3833
- const next = (this._outboundChainByConn.get(connection) ?? Promise.resolve()).then(() => cryptoReady).then((crypto) => crypto.encryptFrame(bytes)).then((encrypted) => this._send(connection, encrypted)).catch((err) => console.error("[ws-server] failed to encrypt/send frame", err));
3834
- this._outboundChainByConn.set(connection, next);
4435
+ getCacheKey(input) {
4436
+ const inner = this._getCacheKey(input);
4437
+ if (inner == null) return null;
4438
+ return `${this.transOrd}:${inner}`;
3835
4439
  }
3836
- _bindConnection(connection, client) {
3837
- this._connByClient.set(client.stringId, connection);
3838
- this._clientByConn.set(connection, client);
4440
+ getTransport(input) {
4441
+ return this._processTransportStatus(input);
3839
4442
  }
3840
- _resolveConnection(target) {
3841
- if (target instanceof RuntimeCoordinate) {
3842
- const connection = this._connByClient.get(target.stringId);
3843
- if (connection == null) throw err_nice_transport.fromId("not_found", { actionId: target.stringId });
3844
- return connection;
4443
+ _processTransportStatus(input) {
4444
+ const statusInfo = addTransportStatusMetadata(this.initialized.getTransport(input));
4445
+ if (statusInfo.status === "ready") {
4446
+ if (!this._needsAsyncBringUp(statusInfo.readyData)) return {
4447
+ status: "ready",
4448
+ readyData: this._finalizeTransportMethods(statusInfo.readyData)
4449
+ };
4450
+ return {
4451
+ status: "initializing",
4452
+ timeStarted: Date.now(),
4453
+ initializationPromise: this._bringUp(statusInfo.readyData)
4454
+ };
3845
4455
  }
3846
- return target;
4456
+ if (statusInfo.status === "initializing") {
4457
+ const initializationPromise = statusInfo.initializationPromise.then((result) => result.status === "ready" ? this._bringUp(result.readyData) : result);
4458
+ return {
4459
+ status: "initializing",
4460
+ timeStarted: statusInfo.timeStarted,
4461
+ initializationPromise
4462
+ };
4463
+ }
4464
+ return statusInfo;
3847
4465
  }
3848
- _resolveSingleConnection() {
3849
- if (this._clientByConn.size !== 1) throw err_nice_transport.fromId("not_found", { actionId: "server-handler-target (use pushToClient with an explicit connection or client coordinate)" });
3850
- return this._clientByConn.keys().next().value;
4466
+ /** Await carrier readiness, then finalize (possibly running a handshake) into the live methods. */
4467
+ async _bringUp(readyData) {
4468
+ await this._awaitCarrierReady(readyData);
4469
+ return {
4470
+ status: "ready",
4471
+ readyData: await this._finalizeReady(readyData)
4472
+ };
3851
4473
  }
3852
4474
  };
3853
- const createServerHandler = (options) => {
3854
- return new ActionServerHandler(options);
4475
+ //#endregion
4476
+ //#region src/ActionRuntime/Transport/Exchange/ExchangeConnection.ts
4477
+ /**
4478
+ * Carrier-agnostic live connection for the exchange (request → single reply) shape — the HTTP
4479
+ * counterpart to {@link LinkConnection}. It owns only the bring-up (run the secure handshake on first
4480
+ * use); the request/reply lifecycle + crypto live in the shared `establishExchangeSession`.
4481
+ */
4482
+ var ExchangeConnection = class extends TransportConnection {
4483
+ constructor(def) {
4484
+ super({
4485
+ ...def,
4486
+ type: "exchange"
4487
+ });
4488
+ }
4489
+ _getCacheKey(input) {
4490
+ return this.initialized.getTransportCacheKey?.(input).join("\0") ?? "";
4491
+ }
4492
+ _needsAsyncBringUp(data) {
4493
+ return data.secureChannel != null && data.secureChannel.securityLevel !== "none";
4494
+ }
4495
+ _finalizeReady(data) {
4496
+ const secure = data.secureChannel;
4497
+ if (secure != null && secure.securityLevel !== "none") return finalizeSecureExchangeMethods({
4498
+ ...this._sessionContext(data),
4499
+ secure
4500
+ });
4501
+ return this._finalizeTransportMethods(data);
4502
+ }
4503
+ _finalizeTransportMethods(data) {
4504
+ return finalizePlainExchangeMethods(this._sessionContext(data));
4505
+ }
4506
+ _sessionContext(data) {
4507
+ return {
4508
+ carrier: data.carrier,
4509
+ updateRunConfig: data.updateRunConfig,
4510
+ secure: data.secureChannel
4511
+ };
4512
+ }
3855
4513
  };
3856
4514
  //#endregion
3857
- //#region src/ActionRuntime/Handler/Server/createActionFetchHandler.ts
3858
- /** Permissive defaults — fine for a public action endpoint; override (or disable) via `cors`. */
3859
- const DEFAULT_CORS_HEADERS = {
3860
- "Access-Control-Allow-Origin": "*",
3861
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
3862
- "Access-Control-Allow-Headers": "Content-Type",
3863
- "Access-Control-Max-Age": "86400"
4515
+ //#region src/ActionRuntime/Transport/Exchange/ExchangeTransport.ts
4516
+ /**
4517
+ * A carrier-agnostic exchange (request → single reply) transport: it drives nice-action's secure session
4518
+ * over any {@link IExchangeCarrier} (HTTP being the one built-in). The duplex counterpart is
4519
+ * {@link LinkTransport}; this is the no-push half — its reply rides the response to its own request, so it
4520
+ * can't deliver an unsolicited frame (the runtime never picks it for the return path).
4521
+ */
4522
+ var ExchangeTransport = class ExchangeTransport extends Transport {
4523
+ options;
4524
+ type = "exchange";
4525
+ constructor(options) {
4526
+ super();
4527
+ this.options = options;
4528
+ }
4529
+ static create(options) {
4530
+ return new ExchangeTransport(options);
4531
+ }
4532
+ _createConnection(_ctx) {
4533
+ const options = this.options;
4534
+ return new ExchangeConnection({ initialize: () => ({
4535
+ getTransportCacheKey: options.getTransportCacheKey,
4536
+ getTransport: (input) => ({
4537
+ status: "ready",
4538
+ readyData: {
4539
+ carrier: options.openCarrier(input),
4540
+ secureChannel: options.security,
4541
+ updateRunConfig: options.updateRunConfig
4542
+ }
4543
+ })
4544
+ }) });
4545
+ }
4546
+ getRouteInfo(input) {
4547
+ if (this.options.getRouteInfo != null) return this.options.getRouteInfo(input);
4548
+ return {
4549
+ carrierLabel: this.options.label ?? "exchange",
4550
+ summary: this.options.label ?? "exchange"
4551
+ };
4552
+ }
3864
4553
  };
4554
+ //#endregion
4555
+ //#region src/ActionRuntime/Transport/helpers/createUnsetTransportResolvers.ts
4556
+ const createUnsetTransportResolvers = (transportLabel) => ({ onIncomingActionDataJson: (json) => {
4557
+ console.warn(`Received incoming action JSON [${json.domain}:${json.id}] on Transport [${transportLabel}] but no incoming data listener has been set.`);
4558
+ } });
4559
+ //#endregion
4560
+ //#region src/ActionRuntime/Transport/SecureSession/establishLinkSession.ts
4561
+ const HANDSHAKE_TIMEOUT_MS = 15e3;
4562
+ /** Plain path (no handshake): route every inbound frame to the runtime; send without crypto. */
4563
+ function finalizePlainLinkMethods(ctx) {
4564
+ const disconnectListeners = [];
4565
+ const abortSet = /* @__PURE__ */ new Set();
4566
+ const pipe = makePipe(ctx, void 0);
4567
+ ctx.channel.attach({
4568
+ onMessage: (frame) => void handleIncomingActionFrame(ctx, pipe, frame),
4569
+ onClose: () => onChannelClosed(ctx, disconnectListeners, abortSet),
4570
+ onError: () => onChannelClosed(ctx, disconnectListeners, abortSet)
4571
+ });
4572
+ return buildSendMethods(ctx, pipe, disconnectListeners, abortSet);
4573
+ }
3865
4574
  /**
3866
- * Build the `fetch` handler a server/Durable-Object exposes for action traffic, folding in the
3867
- * boilerplate every endpoint repeats: CORS (incl. the `OPTIONS` preflight), routing the `/action`
3868
- * `POST` body through the runtime (`handleActionPayloadWire` `waitForResultPayload`
3869
- * `toHttpResponse`), an optional WebSocket-upgrade hook, and a `404` fallback.
3870
- *
3871
- * It only touches web-standard `Request`/`Response`, so it stays transport-agnostic — the one
3872
- * environment-specific bit (the WS upgrade) is injected via {@link IActionFetchHandlerOptions.onWebSocketUpgrade}:
3873
- * ```ts
3874
- * this.fetchHandler = createActionFetchHandler(this.runtime, {
3875
- * onWebSocketUpgrade: () => {
3876
- * const pair = new WebSocketPair();
3877
- * this.ctx.acceptWebSocket(pair[1]);
3878
- * return new Response(null, { status: 101, webSocket: pair[0] });
3879
- * },
3880
- * });
3881
- * // async fetch(request) { return this.fetchHandler(request); }
3882
- * ```
4575
+ * Secure path: a single message handler feeds the handshake until it completes, then routes action
4576
+ * frames (decrypting at the `encrypted` level). Frames that race ahead of activation are buffered and
4577
+ * flushed once the handshake lands, so nothing is lost.
3883
4578
  */
3884
- function createActionFetchHandler(runtime, options = {}) {
3885
- const corsHeaders = options.cors === false ? {} : options.cors ?? DEFAULT_CORS_HEADERS;
3886
- const isActionPath = options.isActionPath ?? ((url) => url.pathname.endsWith("/action"));
3887
- const isWebSocketPath = options.isWebSocketPath ?? ((url) => url.pathname.endsWith("/ws"));
3888
- const withCors = (response) => {
3889
- if (options.cors === false) return response;
3890
- const headers = new Headers(response.headers);
3891
- for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
3892
- return new Response(response.body, {
3893
- status: response.status,
3894
- headers
4579
+ async function finalizeSecureLinkMethods(ctx) {
4580
+ const disconnectListeners = [];
4581
+ const abortSet = /* @__PURE__ */ new Set();
4582
+ const session = {};
4583
+ let active = false;
4584
+ const handshakeQueue = [];
4585
+ const handshakeWaiters = [];
4586
+ const pendingActionFrames = [];
4587
+ ctx.channel.attach({
4588
+ onMessage: (frame) => {
4589
+ if (active && session.pipe != null) {
4590
+ handleIncomingActionFrame(ctx, session.pipe, frame);
4591
+ return;
4592
+ }
4593
+ if (typeof frame === "string") {
4594
+ const message = decodeHandshakeMessage(frame);
4595
+ if (message != null) {
4596
+ const waiter = handshakeWaiters.shift();
4597
+ if (waiter != null) waiter(message);
4598
+ else handshakeQueue.push(message);
4599
+ return;
4600
+ }
4601
+ }
4602
+ pendingActionFrames.push(frame);
4603
+ },
4604
+ onClose: () => onChannelClosed(ctx, disconnectListeners, abortSet),
4605
+ onError: () => onChannelClosed(ctx, disconnectListeners, abortSet)
4606
+ });
4607
+ const nextHandshakeMessage = () => {
4608
+ const queued = handshakeQueue.shift();
4609
+ if (queued != null) return Promise.resolve(queued);
4610
+ return new Promise((resolve, reject) => {
4611
+ const timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("[link-handshake] timed out waiting for peer reply")), HANDSHAKE_TIMEOUT_MS);
4612
+ handshakeWaiters.push((message) => {
4613
+ clearTimeout(timeout);
4614
+ resolve(message);
4615
+ });
3895
4616
  });
3896
4617
  };
3897
- return async (request) => {
3898
- if (request.method === "OPTIONS") return withCors(new Response(null, { status: 204 }));
3899
- const url = new URL(request.url);
3900
- if (options.onWebSocketUpgrade != null && request.headers.get("Upgrade") === "websocket" && isWebSocketPath(url)) return options.onWebSocketUpgrade(request, url);
3901
- if (request.method === "POST" && isActionPath(url)) return withCors((await (await runtime.handleActionPayloadWire(await request.json())).waitForResultPayload()).toHttpResponse({ useErrorStatus: options.useErrorStatus }));
3902
- return withCors(new Response("Not found", { status: 404 }));
4618
+ const pipe = makePipe(ctx, await runClientHandshake(ctx.channel, ctx.secure, nextHandshakeMessage));
4619
+ session.pipe = pipe;
4620
+ active = true;
4621
+ for (const frame of pendingActionFrames) await handleIncomingActionFrame(ctx, pipe, frame);
4622
+ pendingActionFrames.length = 0;
4623
+ return buildSendMethods(ctx, pipe, disconnectListeners, abortSet);
4624
+ }
4625
+ function makePipe(ctx, crypto) {
4626
+ return createFrameCryptoPipe({
4627
+ write: (frame) => ctx.channel.send(frame),
4628
+ isOpen: () => ctx.channel.isOpen(),
4629
+ crypto
4630
+ });
4631
+ }
4632
+ async function runClientHandshake(channel, secure, nextHandshakeMessage) {
4633
+ await secure.link.initialize();
4634
+ const handshake = createClientHandshake({
4635
+ link: secure.link,
4636
+ localCoordinate: secure.localCoordinate,
4637
+ dictionaryVersion: secure.dictionaryVersion,
4638
+ securityLevel: secure.securityLevel
4639
+ });
4640
+ channel.send(encodeHandshakeMessage(await handshake.createHello()));
4641
+ const welcome = await nextHandshakeMessage();
4642
+ if (welcome.t === "reject") throw new Error(`[link-handshake] rejected by peer: ${welcome.reason}`);
4643
+ if (welcome.t !== "welcome") throw new Error(`[link-handshake] expected welcome, got ${welcome.t}`);
4644
+ channel.send(encodeHandshakeMessage(await handshake.onWelcome(welcome)));
4645
+ const accept = await nextHandshakeMessage();
4646
+ if (accept.t === "reject") throw new Error(`[link-handshake] rejected by peer: ${accept.reason}`);
4647
+ if (accept.t !== "accept") throw new Error(`[link-handshake] expected accept, got ${accept.t}`);
4648
+ const result = await handshake.onAccept(accept);
4649
+ return result.securityLevel === "encrypted" ? createActionFrameCrypto({
4650
+ link: secure.link,
4651
+ linkedClientId: result.linkedClientId
4652
+ }) : void 0;
4653
+ }
4654
+ function buildSendMethods(ctx, pipe, disconnectListeners, abortSet) {
4655
+ const channel = ctx.channel;
4656
+ const sendActionData = (inputs) => {
4657
+ const { action, runningAction, timeout } = inputs;
4658
+ if (!channel.isOpen()) {
4659
+ if (action.type === "request") runningAction._abort(ctx.makeDisconnectError(action.id));
4660
+ return;
4661
+ }
4662
+ if (action.type === "request") {
4663
+ abortSet.add(runningAction);
4664
+ const timeoutId = setTimeout(() => {
4665
+ runningAction._abort(err_nice_transport.fromId("timeout", { timeout }));
4666
+ }, timeout);
4667
+ runningAction.addUpdateListeners([(update) => {
4668
+ if (update.type === "finished") {
4669
+ clearTimeout(timeoutId);
4670
+ abortSet.delete(runningAction);
4671
+ }
4672
+ }]);
4673
+ }
4674
+ pipe.send(ctx.formatMessage?.outgoing(inputs) ?? JSON.stringify(inputs.action.toJsonObject()));
4675
+ };
4676
+ return {
4677
+ sendActionData,
4678
+ updateRunConfig: ctx.updateRunConfig,
4679
+ addOnDisconnectListener: (cb) => {
4680
+ disconnectListeners.push(cb);
4681
+ },
4682
+ disconnect: () => {
4683
+ try {
4684
+ channel.close();
4685
+ } catch {}
4686
+ },
4687
+ sendReturnData: (payload, clients) => {
4688
+ const formatted = clients != null ? ctx.formatMessage?.outgoing({
4689
+ action: payload,
4690
+ ...clients
4691
+ }) : void 0;
4692
+ pipe.send(formatted ?? JSON.stringify(payload.toJsonObject()));
4693
+ }
3903
4694
  };
3904
4695
  }
4696
+ async function handleIncomingActionFrame(ctx, pipe, frame) {
4697
+ const decoded = await pipe.decryptIncoming(frame);
4698
+ if (decoded === void 0) return;
4699
+ const rawJson = decodeActionFrame(decoded, ctx.formatMessage);
4700
+ if (rawJson != null) ctx.resolvers.onIncomingActionDataJson(rawJson);
4701
+ }
4702
+ function onChannelClosed(ctx, disconnectListeners, abortSet) {
4703
+ for (const cb of disconnectListeners) cb();
4704
+ const error = ctx.makeDisconnectError("—");
4705
+ for (const ra of [...abortSet]) ra._abort(error);
4706
+ }
3905
4707
  //#endregion
3906
- //#region src/ActionRuntime/Handler/Server/createSecureActionServer.ts
3907
- /** Default accepted set: negotiate per connection to whatever the client picks. */
3908
- const DEFAULT_SERVER_SECURITY_LEVELS = [
3909
- "none",
3910
- "authenticated",
3911
- "encrypted"
3912
- ];
3913
- /**
3914
- * Build an {@link ActionServerHandler} for the secure binary channel with the boilerplate folded in:
3915
- * it creates the {@link ClientCryptoKeyLink} and the storage-backed TOFU resolver from a single
3916
- * `storageAdapter`, installs the channel's per-connection codec, and assembles the `security` block
3917
- * from the runtime coordinate + channel version (accepting all three levels by default).
3918
- *
3919
- * For a hibernatable transport (e.g. a Durable Object), pair it with
3920
- * {@link createHibernatableWsServerAdapter} to wire persistence + replay.
3921
- */
3922
- function createSecureActionServerHandler(options) {
3923
- const link = new ClientCryptoKeyLink({ storageAdapter: options.storageAdapter });
3924
- return new ActionServerHandler({
3925
- clientEnv: options.clientEnv,
3926
- createFormatMessage: options.channel.createCodec,
3927
- send: options.send,
3928
- runtime: options.runtime,
3929
- defaultTimeout: options.defaultTimeout,
3930
- security: {
3931
- securityLevel: options.securityLevel ?? DEFAULT_SERVER_SECURITY_LEVELS,
3932
- link,
3933
- localCoordinate: options.runtime.coordinate.toJsonObject(),
3934
- dictionaryVersion: options.channel.dictionaryVersion,
3935
- verifyKeyResolver: options.verifyKeyResolver ?? createStorageTofuVerifyKeyResolver(options.storageAdapter)
3936
- }
4708
+ //#region src/ActionRuntime/Transport/Link/LinkConnection.ts
4709
+ /** Abort error for a closed link channel (carrier-neutral the carrier itself isn't named). */
4710
+ function linkDisconnectError(actionId) {
4711
+ return err_nice_transport.fromId("send_failed", {
4712
+ actionId,
4713
+ actionState: "request",
4714
+ message: "link channel disconnected"
3937
4715
  });
3938
4716
  }
3939
4717
  /**
3940
- * Wire the hibernation lifecycle for a server handler on a transport whose sockets outlive process
3941
- * eviction (e.g. a Durable Object's hibernatable WebSockets). It owns persistence end to end:
3942
- * registers `setAttachment` as the handler's connection-bound callback and immediately replays every
3943
- * live connection's stored binding via `getAttachment`, so results/pushes still route after a wake.
3944
- *
3945
- * Construct it once when the handler is built, then forward socket events:
3946
- * ```ts
3947
- * const wsServer = createHibernatableWsServerAdapter({ handler, getWebSockets, getAttachment, setAttachment });
3948
- * // webSocketMessage(ws, msg) => wsServer.receive(ws, msg);
3949
- * // webSocketClose/Error(ws) => wsServer.drop(ws);
3950
- * ```
4718
+ * Carrier-agnostic live connection. It owns only the *bring-up* (open the carrier, then run the secure
4719
+ * session); the session itself handshake, frame crypto, codec, send/receive lives in the shared
4720
+ * {@link finalizeSecureLinkMethods}/{@link finalizePlainLinkMethods}, so a WebSocket, a WebRTC data
4721
+ * channel, a Bluetooth characteristic, and an in-memory pipe all run the identical secure layer.
3951
4722
  */
3952
- function createHibernatableWsServerAdapter(options) {
3953
- const { handler, getWebSockets, getAttachment, setAttachment } = options;
3954
- handler.setOnConnectionBound(setAttachment);
3955
- for (const connection of getWebSockets()) {
3956
- const binding = getAttachment(connection);
3957
- if (binding != null) handler.rehydrateConnection(connection, binding);
4723
+ var LinkConnection = class extends TransportConnection {
4724
+ resolvers;
4725
+ constructor(def, resolvers) {
4726
+ super({
4727
+ ...def,
4728
+ type: "duplex"
4729
+ });
4730
+ this.resolvers = resolvers ?? createUnsetTransportResolvers("link");
3958
4731
  }
3959
- return {
3960
- receive: (connection, frame) => handler.receive(connection, frame),
3961
- drop: (connection) => handler.dropConnection(connection)
4732
+ _getCacheKey(input) {
4733
+ return this.initialized.getTransportCacheKey?.(input).join("\0") ?? "";
4734
+ }
4735
+ _needsAsyncBringUp() {
4736
+ return true;
4737
+ }
4738
+ _awaitCarrierReady(data) {
4739
+ return data.channel.ready;
4740
+ }
4741
+ _finalizeReady(data) {
4742
+ const secure = data.secureChannel;
4743
+ if (secure != null && secure.securityLevel !== "none") return finalizeSecureLinkMethods({
4744
+ ...this._sessionContext(data),
4745
+ secure
4746
+ });
4747
+ return this._finalizeTransportMethods(data);
4748
+ }
4749
+ _sessionContext(data) {
4750
+ return {
4751
+ channel: data.channel,
4752
+ resolvers: this.resolvers,
4753
+ formatMessage: data.formatMessage,
4754
+ updateRunConfig: data.updateRunConfig,
4755
+ makeDisconnectError: linkDisconnectError
4756
+ };
4757
+ }
4758
+ _finalizeTransportMethods(data) {
4759
+ return finalizePlainLinkMethods(this._sessionContext(data));
4760
+ }
4761
+ };
4762
+ //#endregion
4763
+ //#region src/ActionRuntime/Transport/Link/LinkTransport.ts
4764
+ /**
4765
+ * A carrier-agnostic transport: it drives nice-action's secure session + action routing over any
4766
+ * {@link IDuplexCarrier}. The WebSocket transport is the special case that opens a `WebSocket`;
4767
+ * this opens whatever `openChannel` returns, so the identical secure layer works over WebRTC, Bluetooth,
4768
+ * or an in-memory pipe. Reported with an overridable carrier label in the devtools (defaults to "link").
4769
+ */
4770
+ var LinkTransport = class LinkTransport extends Transport {
4771
+ options;
4772
+ type = "duplex";
4773
+ constructor(options) {
4774
+ super();
4775
+ this.options = options;
4776
+ }
4777
+ static create(options) {
4778
+ return new LinkTransport(options);
4779
+ }
4780
+ _createConnection(ctx) {
4781
+ const options = this.options;
4782
+ return new LinkConnection({ initialize: () => ({
4783
+ getTransportCacheKey: options.getTransportCacheKey,
4784
+ getTransport: (input) => ({
4785
+ status: "ready",
4786
+ readyData: {
4787
+ channel: options.openChannel(input),
4788
+ formatMessage: options.createFormatMessage?.() ?? options.formatMessage,
4789
+ updateRunConfig: options.updateRunConfig,
4790
+ secureChannel: options.security
4791
+ }
4792
+ })
4793
+ }) }, ctx.resolvers);
4794
+ }
4795
+ getRouteInfo(input) {
4796
+ if (this.options.getRouteInfo != null) return this.options.getRouteInfo(input);
4797
+ return {
4798
+ carrierLabel: this.options.label ?? "link",
4799
+ summary: this.options.label ?? "link"
4800
+ };
4801
+ }
4802
+ };
4803
+ //#endregion
4804
+ //#region src/ActionRuntime/Transport/Carrier/Carrier.types.ts
4805
+ /**
4806
+ * Narrow a carrier source to the exchange shape via its `shape` discriminant — the one branch the
4807
+ * transport factories ({@link secureTransport}, {@link plainTransport}) use to pick the duplex vs
4808
+ * exchange transport. A duplex source carries no `shape`, so the `else` branch is the duplex one.
4809
+ */
4810
+ function isExchangeCarrierSource(carrier) {
4811
+ return "shape" in carrier && carrier.shape === "exchange";
4812
+ }
4813
+ //#endregion
4814
+ //#region src/ActionRuntime/Transport/plainTransport.ts
4815
+ function plainTransport(options) {
4816
+ const carrier = options.carrier;
4817
+ if (isExchangeCarrierSource(carrier)) return ExchangeTransport.create({
4818
+ openCarrier: carrier.open,
4819
+ getTransportCacheKey: carrier.getCacheKey,
4820
+ getRouteInfo: carrier.getRouteInfo,
4821
+ label: options.label ?? carrier.carrierLabel,
4822
+ updateRunConfig: options.updateRunConfig
4823
+ });
4824
+ return LinkTransport.create({
4825
+ openChannel: carrier.open,
4826
+ formatMessage: options.formatMessage,
4827
+ createFormatMessage: options.createFormatMessage,
4828
+ getTransportCacheKey: carrier.getCacheKey,
4829
+ getRouteInfo: carrier.getRouteInfo,
4830
+ label: options.label ?? carrier.carrierLabel,
4831
+ updateRunConfig: options.updateRunConfig
4832
+ });
4833
+ }
4834
+ //#endregion
4835
+ //#region src/ActionRuntime/Transport/secureTransport.ts
4836
+ function secureTransport(options) {
4837
+ const link = new ClientCryptoKeyLink({ storageAdapter: options.storageAdapter });
4838
+ const security = {
4839
+ securityLevel: options.securityLevel,
4840
+ link,
4841
+ localCoordinate: options.runtime.coordinate.toJsonObject(),
4842
+ dictionaryVersion: options.channel.dictionaryVersion
3962
4843
  };
4844
+ const carrier = options.carrier;
4845
+ if (isExchangeCarrierSource(carrier)) return ExchangeTransport.create({
4846
+ openCarrier: carrier.open,
4847
+ getTransportCacheKey: carrier.getCacheKey,
4848
+ getRouteInfo: carrier.getRouteInfo,
4849
+ label: carrier.carrierLabel,
4850
+ security
4851
+ });
4852
+ return LinkTransport.create({
4853
+ openChannel: carrier.open,
4854
+ createFormatMessage: options.channel.createCodec,
4855
+ getTransportCacheKey: carrier.getCacheKey,
4856
+ getRouteInfo: carrier.getRouteInfo,
4857
+ label: carrier.carrierLabel,
4858
+ security
4859
+ });
3963
4860
  }
3964
4861
  //#endregion
3965
- export { ActionCore, ActionDomain, ActionExternalClientHandler, ActionLocalHandler, ActionRootDomain, ActionRuntime, ActionSchema, ActionServerHandler, CustomTransport, EActionPayloadType, EActionProgressType, EErrId_NiceAction, EErrId_NiceTransport, EErrId_NiceTransport_WebSocket, EHandshakeMessageType, ERunningActionFinishedType, ERunningActionState, ERunningActionUpdateType, ESecurityLevel, ETransportStatus, ETransportType, HttpTransport, RunningAction, RuntimeCoordinate, Transport, WebSocketTransport, WsConnectionStateStore, actionSchema, createActionFetchHandler, createActionFrameCrypto, createActionRootDomain, createBinaryWsAdapter, createBinaryWsSessionFactory, createClientHandshake, createExternalClientHandler, createHibernatableWsServerAdapter, createInMemoryTofuVerifyKeyResolver, createLocalHandler, createSecureActionServerHandler, createSecureWebSocketTransport, createServerHandler, createServerHandshake, createStorageTofuVerifyKeyResolver, decodeActionFrame, decodeHandshakeMessage, defineSecureWsChannel, encodeHandshakeMessage, err_nice_action, err_nice_external_client, err_nice_transport, err_nice_transport_ws, isActionPayload_Any_JsonObject, isActionPayload_Request_JsonObject, isActionPayload_Result_JsonObject, runtimeLinkId };
4862
+ export { AcceptorHandler, ActionCore, ActionDomain, ActionLocalHandler, ActionRootDomain, ActionRuntime, ActionSchema, ConnectionStateStore, ConnectorHandler, EActionPayloadType, EActionProgressType, EActionResponseMode, EErrId_NiceAction, EErrId_NiceTransport, EErrId_NiceTransport_WebSocket, EHandshakeMessageType, ERunningActionFinishedType, ERunningActionState, ERunningActionUpdateType, ESecurityLevel, ETransportShape, ETransportStatus, ExchangeAcceptor, ExchangeTransport, LinkTransport, PeerLinkHandler, RunningAction, RuntimeCoordinate, Transport, acceptChannel, acceptChannelConnections, actionSchema, connectChannel, createAcceptorHandler, createActionFetchHandler, createActionFrameCrypto, createActionRootDomain, createBinaryWireAdapter, createBinaryWireSessionFactory, createClientHandshake, createConnectionStateStore, createConnectorHandler, createHibernatableWsServerAdapter, createInMemoryChannelPair, createInMemoryTofuVerifyKeyResolver, createLocalHandler, createSecureAcceptorHandler, createServerHandshake, createStorageTofuVerifyKeyResolver, decodeActionFrame, decodeHandshakeMessage, defineChannel, defineSecureChannel, encodeHandshakeMessage, err_nice_action, err_nice_external_client, err_nice_transport, err_nice_transport_ws, httpAcceptorCarrier, httpCarrier, inMemoryCarrier, isActionPayload_Any_JsonObject, isActionPayload_Request_JsonObject, isActionPayload_Result_JsonObject, isExchangeAcceptorCarrier, plainTransport, rtcCarrier, rtcDataChannelByteChannel, runtimeLinkId, secureTransport, serveChannel, wsAcceptorCarrier, wsCarrier };
3966
4863
 
3967
4864
  //# sourceMappingURL=index.mjs.map