@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.
- package/README.md +582 -582
- package/build/{ActionDevtoolsCore-9PsnscvK.mjs → ActionDevtoolsCore-BcItqP-C.mjs} +7 -7
- package/build/ActionDevtoolsCore-BcItqP-C.mjs.map +1 -0
- package/build/{ActionDevtoolsCore-CCRLYASa.d.cts → ActionDevtoolsCore-C5XrQI1K.d.mts} +3 -3
- package/build/{ActionDevtoolsCore-DtgXwPBZ.cjs → ActionDevtoolsCore-Cb_QR44N.cjs} +7 -7
- package/build/ActionDevtoolsCore-Cb_QR44N.cjs.map +1 -0
- package/build/{ActionDevtoolsCore-CYGD2o6C.d.mts → ActionDevtoolsCore-Dd1qJAwK.d.cts} +3 -3
- package/build/{ActionPayload.types-BN-rXFBK.d.cts → ActionPayload.types-BchJrBIX.d.mts} +1966 -1139
- package/build/{ActionPayload.types-D28ELKXC.d.mts → ActionPayload.types-snDlSIF-.d.cts} +1966 -1139
- package/build/devtools/browser/index.cjs +5 -5
- package/build/devtools/browser/index.cjs.map +1 -1
- package/build/devtools/browser/index.d.cts +1 -1
- package/build/devtools/browser/index.d.mts +1 -1
- package/build/devtools/browser/index.mjs +5 -5
- package/build/devtools/browser/index.mjs.map +1 -1
- package/build/devtools/server/index.cjs +1 -1
- package/build/devtools/server/index.cjs.map +1 -1
- package/build/devtools/server/index.d.cts +1 -1
- package/build/devtools/server/index.d.mts +1 -1
- package/build/devtools/server/index.mjs +1 -1
- package/build/devtools/server/index.mjs.map +1 -1
- package/build/index.cjs +2667 -1752
- package/build/index.cjs.map +1 -1
- package/build/index.d.cts +2 -2
- package/build/index.d.mts +2 -2
- package/build/index.mjs +2635 -1738
- package/build/index.mjs.map +1 -1
- package/build/platform/cloudflare/index.cjs +56 -0
- package/build/platform/cloudflare/index.cjs.map +1 -0
- package/build/platform/cloudflare/index.d.cts +67 -0
- package/build/platform/cloudflare/index.d.mts +67 -0
- package/build/platform/cloudflare/index.mjs +54 -0
- package/build/platform/cloudflare/index.mjs.map +1 -0
- package/build/react-query/index.cjs.map +1 -1
- package/build/react-query/index.d.cts +1 -1
- package/build/react-query/index.d.mts +1 -1
- package/build/react-query/index.mjs.map +1 -1
- package/build/wsAcceptorCarrier-CXGlQU_f.mjs +80 -0
- package/build/wsAcceptorCarrier-CXGlQU_f.mjs.map +1 -0
- package/build/wsAcceptorCarrier-DHRbsY1X.cjs +103 -0
- package/build/wsAcceptorCarrier-DHRbsY1X.cjs.map +1 -0
- package/package.json +15 -4
- package/build/ActionDevtoolsCore-9PsnscvK.mjs.map +0 -1
- 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 {
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
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
|
-
|
|
1149
|
+
/** Listeners installed by the runtime (`resolveIncomingActionPayload`) for inbound peer frames. */
|
|
1150
|
+
_incomingActionDataListeners = [];
|
|
1151
|
+
constructor(peerCoordinate) {
|
|
1011
1152
|
super();
|
|
1012
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1294
|
+
client: this.peerClient
|
|
1119
1295
|
};
|
|
1120
1296
|
}
|
|
1121
1297
|
toHandlerRouteItem(transport, input) {
|
|
1122
1298
|
return {
|
|
1123
1299
|
type: this.handlerType,
|
|
1124
|
-
client: this.
|
|
1300
|
+
client: this.peerClient,
|
|
1125
1301
|
transOrd: transport.transOrd,
|
|
1126
|
-
|
|
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
|
|
1137
|
-
return new
|
|
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
|
-
|
|
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
|
|
1398
|
+
const targetPeer = options?.targetPeer;
|
|
1223
1399
|
const possibleHandlers = handlers.filter((handler) => {
|
|
1224
|
-
if (handler.handlerType === "
|
|
1225
|
-
if (
|
|
1400
|
+
if (handler.handlerType === "peer") {
|
|
1401
|
+
if (targetPeer && !targetPeer.isSameFor(handler.peerClient).id) return false;
|
|
1226
1402
|
return true;
|
|
1227
1403
|
}
|
|
1228
|
-
if (
|
|
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
|
|
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"
|
|
1238
|
-
if (possibleHandler.handlerType === "
|
|
1239
|
-
const score =
|
|
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?.
|
|
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 === "
|
|
1441
|
+
if (handler.handlerType === "peer") {
|
|
1266
1442
|
handler._setIncomingActionDataListener((json) => this.resolveIncomingActionPayload(json));
|
|
1267
|
-
this.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1324
|
-
|
|
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.
|
|
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/
|
|
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/
|
|
2358
|
+
//#region src/utils/decodeActionFrame.ts
|
|
2628
2359
|
/**
|
|
2629
|
-
*
|
|
2630
|
-
*
|
|
2631
|
-
*
|
|
2632
|
-
*
|
|
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
|
-
*
|
|
2636
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
2668
|
-
|
|
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
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
if (
|
|
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
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
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
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
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
|
-
|
|
2705
|
-
|
|
2706
|
-
progress: payloadData
|
|
2445
|
+
send,
|
|
2446
|
+
decryptIncoming
|
|
2707
2447
|
};
|
|
2708
2448
|
}
|
|
2709
2449
|
//#endregion
|
|
2710
|
-
//#region src/ActionRuntime/
|
|
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
|
-
*
|
|
2735
|
-
*
|
|
2736
|
-
*
|
|
2737
|
-
*
|
|
2738
|
-
*
|
|
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
|
-
*
|
|
2748
|
-
*
|
|
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
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
if (
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
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
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
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
|
-
*
|
|
2833
|
-
*
|
|
2834
|
-
*
|
|
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
|
-
*
|
|
2838
|
-
*
|
|
2839
|
-
*
|
|
2840
|
-
*
|
|
2841
|
-
*
|
|
2842
|
-
*
|
|
2843
|
-
*
|
|
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
|
-
*
|
|
2846
|
-
*
|
|
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
|
-
*
|
|
2849
|
-
*
|
|
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
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
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-
|
|
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/
|
|
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
|
-
*
|
|
2974
|
-
*
|
|
2975
|
-
*
|
|
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
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
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
|
-
|
|
3279
|
+
return `auto:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
2984
3280
|
}
|
|
2985
|
-
/**
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
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
|
-
|
|
2992
|
-
|
|
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
|
-
|
|
2995
|
-
return `${u.host}${u.pathname}`;
|
|
3318
|
+
return JSON.parse(raw);
|
|
2996
3319
|
} catch {
|
|
2997
|
-
return
|
|
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/
|
|
3002
|
-
const
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
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
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
if (
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
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
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
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
|
-
|
|
3056
|
-
|
|
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
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
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
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
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
|
-
|
|
3204
|
-
|
|
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
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
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/
|
|
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
|
-
*
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
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
|
-
|
|
3267
|
-
options;
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
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
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
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
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
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/
|
|
3534
|
+
//#region src/ActionRuntime/Handler/PeerLink/Acceptor/Hibernation/createHibernatableWsServerAdapter.ts
|
|
3313
3535
|
/**
|
|
3314
|
-
*
|
|
3315
|
-
*
|
|
3316
|
-
*
|
|
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
|
|
3319
|
-
const {
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
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
|
-
|
|
3336
|
-
|
|
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
|
-
*
|
|
3341
|
-
*
|
|
3342
|
-
*
|
|
3343
|
-
* `
|
|
3344
|
-
*
|
|
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
|
|
3347
|
-
const
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
link,
|
|
3361
|
-
|
|
3362
|
-
|
|
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/
|
|
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
|
|
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
|
|
3372
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
3452
|
-
*
|
|
3453
|
-
*
|
|
3454
|
-
*
|
|
3455
|
-
*
|
|
3456
|
-
*
|
|
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
|
-
*
|
|
3469
|
-
*
|
|
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
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
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
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
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
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
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
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
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
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
|
|
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
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
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
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
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
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
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
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
const
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
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
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
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
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
transOrd: 0
|
|
4296
|
+
k: "act",
|
|
4297
|
+
t: state.token,
|
|
4298
|
+
c: bytesToBase64(ciphertext)
|
|
3711
4299
|
};
|
|
3712
4300
|
}
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
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
|
-
*
|
|
3733
|
-
*
|
|
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
|
-
|
|
3736
|
-
|
|
3737
|
-
return this._dispatch(runtime, connection, request, options?.timeout);
|
|
4407
|
+
getRouteInfo(input) {
|
|
4408
|
+
return this.definition?.getRouteInfo(input);
|
|
3738
4409
|
}
|
|
3739
4410
|
/**
|
|
3740
|
-
*
|
|
3741
|
-
* the
|
|
3742
|
-
* the `
|
|
3743
|
-
*
|
|
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
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
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
|
-
*
|
|
3763
|
-
*
|
|
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
|
-
|
|
3769
|
-
|
|
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
|
-
|
|
3794
|
-
const
|
|
3795
|
-
|
|
3796
|
-
|
|
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
|
-
|
|
3822
|
-
const
|
|
3823
|
-
|
|
3824
|
-
|
|
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
|
-
|
|
3837
|
-
this.
|
|
3838
|
-
this._clientByConn.set(connection, client);
|
|
4440
|
+
getTransport(input) {
|
|
4441
|
+
return this._processTransportStatus(input);
|
|
3839
4442
|
}
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
if (
|
|
3844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
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
|
-
|
|
3854
|
-
|
|
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/
|
|
3858
|
-
/**
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
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
|
-
*
|
|
3867
|
-
*
|
|
3868
|
-
*
|
|
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
|
|
3885
|
-
const
|
|
3886
|
-
const
|
|
3887
|
-
const
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
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
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3902
|
-
|
|
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/
|
|
3907
|
-
/**
|
|
3908
|
-
|
|
3909
|
-
"
|
|
3910
|
-
|
|
3911
|
-
|
|
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
|
-
*
|
|
3941
|
-
*
|
|
3942
|
-
*
|
|
3943
|
-
*
|
|
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
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
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
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
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,
|
|
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
|