@nice-code/action 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +698 -666
  2. package/build/{ActionDevtoolsCore-CCZXQBAo.d.cts → ActionDevtoolsCore-baIHExfj.d.mts} +2 -2
  3. package/build/{ActionDevtoolsCore-bjYQ8O2_.d.mts → ActionDevtoolsCore-dApyYvTS.d.cts} +2 -2
  4. package/build/{ActionPayload.types-Bmkzw2df.d.mts → ActionPayload.types-CQM1HRw_.d.cts} +320 -298
  5. package/build/{ActionPayload.types-CdHOGGZK.d.cts → ActionPayload.types-DXGiw1SF.d.mts} +320 -298
  6. package/build/devtools/browser/index.d.cts +1 -1
  7. package/build/devtools/browser/index.d.mts +1 -1
  8. package/build/devtools/server/index.d.cts +1 -1
  9. package/build/devtools/server/index.d.mts +1 -1
  10. package/build/index.cjs +1768 -1718
  11. package/build/index.cjs.map +1 -1
  12. package/build/index.d.cts +2 -2
  13. package/build/index.d.mts +2 -2
  14. package/build/index.mjs +1769 -1717
  15. package/build/index.mjs.map +1 -1
  16. package/build/platform/cloudflare/index.cjs +8 -4
  17. package/build/platform/cloudflare/index.cjs.map +1 -1
  18. package/build/platform/cloudflare/index.d.cts +8 -3
  19. package/build/platform/cloudflare/index.d.mts +8 -3
  20. package/build/platform/cloudflare/index.mjs +8 -4
  21. package/build/platform/cloudflare/index.mjs.map +1 -1
  22. package/build/react-query/index.d.cts +1 -1
  23. package/build/react-query/index.d.mts +1 -1
  24. package/build/{wsAcceptorCarrier-DHRbsY1X.cjs → wsAcceptorCarrier-BDJRIPfu.cjs} +2 -2
  25. package/build/wsAcceptorCarrier-BDJRIPfu.cjs.map +1 -0
  26. package/build/{wsAcceptorCarrier-CXGlQU_f.mjs → wsAcceptorCarrier-CW2qX25W.mjs} +2 -2
  27. package/build/wsAcceptorCarrier-CW2qX25W.mjs.map +1 -0
  28. package/package.json +4 -4
  29. package/build/wsAcceptorCarrier-CXGlQU_f.mjs.map +0 -1
  30. package/build/wsAcceptorCarrier-DHRbsY1X.cjs.map +0 -1
package/build/index.mjs CHANGED
@@ -1,5 +1,5 @@
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
+ import { i as ETransportStatus, n as isExchangeAcceptorCarrier, r as ETransportShape, t as wsAcceptorCarrier } from "./wsAcceptorCarrier-CW2qX25W.mjs";
3
3
  import { nanoid } from "nanoid";
4
4
  import { NiceError, castNiceError, err, err_nice, isNiceErrorObject } from "@nice-code/error";
5
5
  import { extractMessageFromStandardSchema } from "@nice-code/common-errors";
@@ -1454,16 +1454,16 @@ var ActionRuntime = class {
1454
1454
  return this;
1455
1455
  }
1456
1456
  /**
1457
+ * @internal Low-level primitive — the public way to open a connection is `connectChannel`, which
1458
+ * derives routing from a channel and binds the crypto identity for you. This stays as the raw building
1459
+ * block it sits on (it restates domain lists by hand) and is not part of the supported surface.
1460
+ *
1457
1461
  * Declare an external "backend client" in one call: build an
1458
1462
  * {@link ConnectorHandler} for `externalCoordinate` carrying the given
1459
1463
  * `transports`, route the listed `domains`/`actions` to it, register it (plus any
1460
1464
  * `localHandlers` — e.g. server→client push handlers that share the same channel)
1461
1465
  * on this runtime, and `apply()`. Returns the external handler so the caller can
1462
1466
  * later `clearTransportCache()` it.
1463
- *
1464
- * Sugar over `new ConnectorHandler(...).forDomain(...)` + `addHandlers([...])`,
1465
- * so a single runtime can host one handler per backend target with its transports
1466
- * declared once and reused across every action routed to that backend.
1467
1467
  */
1468
1468
  connectTo(externalCoordinate, options) {
1469
1469
  const handler = new ConnectorHandler({
@@ -2967,1917 +2967,1969 @@ function createSecureAcceptorHandler(options) {
2967
2967
  });
2968
2968
  }
2969
2969
  //#endregion
2970
- //#region src/ActionRuntime/Channel/ActionChannel.ts
2970
+ //#region src/ActionRuntime/Transport/Carrier/Carrier.types.ts
2971
2971
  /**
2972
- * Declare a transport-agnostic channel by role. Use it for HTTP, custom transports, or as the routing
2973
- * half of a richer channel. The order of each list is part of the contract for wire formats that pack
2974
- * positionally (see `defineSecureChannel`) add new domains to the end of their list. (`domains` is
2975
- * accepted as a legacy alias for `toAcceptor`.)
2972
+ * Narrow a carrier source to the exchange shape via its `shape` discriminant the one branch the
2973
+ * transport factories ({@link secureTransport}, {@link plainTransport}) use to pick the duplex vs
2974
+ * exchange transport. A duplex source carries no `shape`, so the `else` branch is the duplex one.
2976
2975
  */
2977
- function defineChannel(options) {
2978
- return {
2979
- toAcceptorDomains: options.toAcceptor,
2980
- toConnectorDomains: options.toConnector
2981
- };
2976
+ function isExchangeCarrierSource(carrier) {
2977
+ return "shape" in carrier && carrier.shape === "exchange";
2982
2978
  }
2979
+ //#endregion
2980
+ //#region src/ActionRuntime/Transport/Transport.ts
2983
2981
  /**
2984
- * Wire a connection to the acceptor straight from a channel: route the channel's `toAcceptor` domains to
2985
- * the acceptor over `transports`, and register local handlers for its `toConnector` pushes from
2986
- * `onPush`. The channel is the single source of truth for *what* is routed in each direction — the
2987
- * caller only supplies the transport(s) and the push handlers, never restated domain lists. Pass several
2988
- * transports to make the connector→acceptor path transport-agnostic (e.g. secure WS preferred, HTTP
2989
- * fallback).
2990
- *
2991
- * Sugar over {@link ActionRuntime.connectTo}. Returns the acceptor handler so the caller can later
2992
- * `clearTransportCache()` it.
2982
+ * Reusable transport definition. Devs construct these (`secureTransport({ carrier: wsCarrier(url) })`,
2983
+ * `plainTransport({ carrier: httpCarrier(...) })`, …) and pass them to a
2984
+ * `ConnectorHandler`. A single
2985
+ * definition can be shared across multiple handlers each handler builds its own live
2986
+ * {@link TransportConnection} via {@link TransportConnection._createConnection}.
2993
2987
  */
2994
- function connectChannel(runtime, acceptorCoordinate, options) {
2995
- const pushHandlers = options.onPush != null ? options.channel.toConnectorDomains.map((domain) => domain.wrapAsPartialLocalHandler(options.onPush)) : [];
2996
- return runtime.connectTo(acceptorCoordinate, {
2997
- transports: options.transports,
2998
- domains: [...options.channel.toAcceptorDomains],
2999
- localHandlers: pushHandlers,
3000
- defaultTimeout: options.defaultTimeout
3001
- });
2988
+ var Transport = class {};
2989
+ //#endregion
2990
+ //#region src/ActionRuntime/Transport/SecureSession/exchangeProtocol.ts
2991
+ function encodeExchange(envelope) {
2992
+ return JSON.stringify(envelope);
3002
2993
  }
3003
- /**
3004
- * Register an acceptor handler's execution for a channel straight from its definition: the channel's
3005
- * `toAcceptor` domains are served together with one merged, connection-aware case map (each case gets
3006
- * the primed request + the originating connection, as with
3007
- * {@link AcceptorHandler.forConnectionDomainCases}). The domain list is taken from the channel,
3008
- * never restated. Add the returned handler to the runtime alongside the acceptor handler:
3009
- * ```ts
3010
- * runtime.addHandlers([acceptChannelConnections(serverHandler, channel, { … }), serverHandler]);
3011
- * ```
3012
- */
3013
- function acceptChannelConnections(serverHandler, channel, cases) {
3014
- return serverHandler.forConnectionDomainCasesMulti(channel.toAcceptorDomains, cases);
2994
+ function decodeExchangeRequest(raw) {
2995
+ return parse(raw);
3015
2996
  }
3016
- /**
3017
- * Build the secure {@link AcceptorHandler} for a channel — the accept-in counterpart to
3018
- * {@link connectChannel}. It folds in the same boilerplate as {@link createSecureAcceptorHandler} (the
3019
- * `ClientCryptoKeyLink` + storage-backed TOFU resolver from one `storageAdapter`, the channel's codec +
3020
- * dictionary version, the `security` block from the runtime coordinate) but takes the `(runtime, channel,
3021
- * options)` shape of the channel family. Pair it with {@link acceptChannelConnections} for execution:
3022
- * ```ts
3023
- * const acceptor = acceptChannel(runtime, gameChannel, { clientEnv, storageAdapter, send });
3024
- * runtime.addHandlers([acceptChannelConnections(acceptor, gameChannel, { … }), acceptor]);
3025
- * ```
3026
- */
3027
- function acceptChannel(runtime, channel, options) {
3028
- return createSecureAcceptorHandler({
3029
- channel,
3030
- runtime,
3031
- clientEnv: options.clientEnv,
3032
- storageAdapter: options.storageAdapter,
3033
- link: options.link,
3034
- send: options.send,
3035
- securityLevel: options.securityLevel,
3036
- verifyKeyResolver: options.verifyKeyResolver,
3037
- defaultTimeout: options.defaultTimeout
3038
- });
2997
+ function decodeExchangeReply(raw) {
2998
+ return parse(raw);
3039
2999
  }
3040
- //#endregion
3041
- //#region src/ActionRuntime/Transport/codec/actionWireCodec.ts
3042
- /**
3043
- * Tiny integer codes for the payload type, so the verbose `"request"`/`"result"`/`"progress"`
3044
- * strings never hit the wire. The index in {@link ReversePayloadType} must line up with the value.
3045
- */
3046
- const PayloadTypeToInt = {
3047
- ["request"]: 0,
3048
- ["result"]: 1,
3049
- ["progress"]: 2
3050
- };
3051
- const ReversePayloadType = [
3052
- "request",
3053
- "result",
3054
- "progress"
3055
- ];
3056
- /**
3057
- * Build the positional `domain:id` ↔ integer dictionary. Both ends of a channel MUST build it from
3058
- * the same domains in the same order — the mapping is positional, so a mismatch routes to the wrong
3059
- * action. Add new transported domains to the end of the list.
3060
- */
3061
- function buildActionRouteDictionary(domains) {
3062
- const routeToInt = /* @__PURE__ */ new Map();
3063
- const intToRoute = [];
3064
- for (const dom of domains) for (const actionId of Object.keys(dom.actionSchema)) {
3065
- const routeKey = `${dom.domain}:${actionId}`;
3066
- if (routeToInt.has(routeKey)) continue;
3067
- routeToInt.set(routeKey, intToRoute.length);
3068
- intToRoute.push({
3069
- domain: dom.domain,
3070
- id: actionId,
3071
- allDomains: dom.allDomains
3072
- });
3000
+ function parse(raw) {
3001
+ try {
3002
+ return JSON.parse(raw);
3003
+ } catch {
3004
+ return;
3073
3005
  }
3074
- return {
3075
- routeToInt,
3076
- intToRoute
3077
- };
3078
3006
  }
3079
- /** Pull the type-specific payload (`input` / `result` / `progress`) out of a wire JSON object. */
3080
- function extractWirePayload(json) {
3081
- if (json.type === "request") return json.input;
3082
- if (json.type === "result") return json.result;
3083
- if (json.type === "progress") return json.progress;
3007
+ function bytesToBase64(bytes) {
3008
+ let binary = "";
3009
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
3010
+ return btoa(binary);
3084
3011
  }
3085
- /**
3086
- * Reassemble a full wire JSON object from its decoded parts. `inputHash`/`outputHash` are emitted
3087
- * empty the hydration constructors recompute them — and the result still satisfies
3088
- * `isActionPayload_Any_JsonObject` so it flows through validation like a JSON frame.
3089
- */
3090
- function assembleWireJson(routeMeta, payloadType, time, context, payloadData) {
3091
- const base = {
3092
- form: "data",
3093
- domain: routeMeta.domain,
3094
- id: routeMeta.id,
3095
- allDomains: routeMeta.allDomains,
3096
- time,
3097
- context
3098
- };
3099
- if (payloadType === "request") return {
3100
- ...base,
3101
- type: "request",
3102
- input: payloadData,
3103
- inputHash: ""
3104
- };
3105
- if (payloadType === "result") return {
3106
- ...base,
3107
- type: "result",
3108
- result: payloadData,
3109
- outputHash: ""
3012
+ function base64ToBytes(base64) {
3013
+ const binary = atob(base64);
3014
+ const bytes = new Uint8Array(binary.length);
3015
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
3016
+ return bytes;
3017
+ }
3018
+ //#endregion
3019
+ //#region src/ActionRuntime/Transport/SecureSession/establishExchangeSession.ts
3020
+ const textEncoder$1 = new TextEncoder();
3021
+ const textDecoder$1 = new TextDecoder();
3022
+ /** Plain path (no handshake/token): every action rides a bare `act` envelope, plaintext both ways. */
3023
+ function finalizePlainExchangeMethods(ctx) {
3024
+ return buildExchangeMethods(ctx, {});
3025
+ }
3026
+ /** Secure path: run the handshake (two exchanges) once at bring-up, then reuse the token + crypto. */
3027
+ async function finalizeSecureExchangeMethods(ctx) {
3028
+ return buildExchangeMethods(ctx, await runConnectorExchangeHandshake(ctx.carrier, ctx.secure));
3029
+ }
3030
+ function buildExchangeMethods(ctx, state) {
3031
+ const sendActionData = (inputs) => {
3032
+ runExchange(ctx.carrier, state, inputs).catch((err) => inputs.runningAction._abort(err));
3110
3033
  };
3111
3034
  return {
3112
- ...base,
3113
- type: "progress",
3114
- progress: payloadData
3035
+ sendActionData,
3036
+ updateRunConfig: ctx.updateRunConfig
3115
3037
  };
3116
3038
  }
3117
- //#endregion
3118
- //#region src/ActionRuntime/Transport/codec/createBinaryWireSessionFactory.ts
3119
- /**
3120
- * Positional layout of the *session* binary envelope — the leanest frame. Compared to the stateless
3121
- * adapter it replaces the 21-char `cuid` with a small per-connection integer and only carries
3122
- * `originClient` on the very first request of each direction (the peer remembers it afterwards).
3123
- *
3124
- * [ routeInt, typeInt, corrId, time, originClient?, payloadData ]
3125
- */
3126
- const ENVELOPE$1 = {
3127
- route: 0,
3128
- type: 1,
3129
- corr: 2,
3130
- time: 3,
3131
- originClient: 4,
3132
- payload: 5
3133
- };
3134
- const ENVELOPE_LENGTH$1 = 6;
3135
- /**
3136
- * How long a pending correlation entry is kept before it's swept. A correlation only matters until its
3137
- * action resolves or times out, so anything older than the longest realistic action timeout can be
3138
- * dropped — this bounds memory when requests time out or a connection dies mid-flight (their replies
3139
- * would never arrive, leaving the entry orphaned). Generous default so live correlations are never
3140
- * pruned (the default transport timeout is 10s).
3141
- */
3142
- const DEFAULT_CORRELATION_TTL_MS = 5 * 6e4;
3143
- function isKnownIdentity(coordinate) {
3144
- return coordinate != null && coordinate.envId !== "_unset_";
3145
- }
3146
- /**
3147
- * Drop entries older than `ttlMs`. Maps keep insertion order and entries are inserted in time order,
3148
- * so the oldest are first — stop sweeping at the first live entry.
3149
- */
3150
- function pruneExpired(map, now, ttlMs) {
3151
- for (const [key, entry] of map) {
3152
- if (now - entry.time <= ttlMs) break;
3153
- map.delete(key);
3039
+ async function runExchange(carrier, state, inputs) {
3040
+ const { action, runningAction, timeout } = inputs;
3041
+ const ac = new AbortController();
3042
+ let timedOut = false;
3043
+ const timeoutId = setTimeout(() => {
3044
+ timedOut = true;
3045
+ ac.abort();
3046
+ }, timeout);
3047
+ const unsubscribe = runningAction.addUpdateListeners([(update) => {
3048
+ if (update.type === "finished") {
3049
+ clearTimeout(timeoutId);
3050
+ ac.abort();
3051
+ }
3052
+ }]);
3053
+ try {
3054
+ const request = await buildRequestEnvelope(state, action);
3055
+ const replyRaw = await carrier.exchange(encodeExchange(request), { signal: ac.signal });
3056
+ if (action.type !== "request") return;
3057
+ const reply = decodeExchangeReply(asString(replyRaw));
3058
+ if (reply == null) throw err_nice_transport.fromId("invalid_action_response", { actionId: action.id });
3059
+ if (reply.k === "err") throw err_nice_transport.fromId("send_failed", {
3060
+ actionState: action.type,
3061
+ actionId: action.id,
3062
+ message: reply.message
3063
+ });
3064
+ const wire = await extractReplyWire(state, reply);
3065
+ if (wire == null || !isActionPayload_Result_JsonObject(wire)) throw err_nice_transport.fromId("invalid_action_response", { actionId: action.id });
3066
+ runningAction._completeWithResult(action._domain.hydrateResultPayload(wire));
3067
+ } catch (err) {
3068
+ if (timedOut) throw err_nice_transport.fromId("timeout", { timeout });
3069
+ throw err;
3070
+ } finally {
3071
+ clearTimeout(timeoutId);
3072
+ unsubscribe();
3154
3073
  }
3155
3074
  }
3156
- /**
3157
- * Builds a factory of *stateful, per-connection* codecs for {@link LinkTransport} /
3158
- * `AcceptorHandler` — the maximally compact binary wire. Call the returned factory once per live
3159
- * connection (each socket on the client, each accepted connection on the server) so every channel
3160
- * gets its own correlation + identity state.
3161
- *
3162
- * On top of everything {@link createBinaryWireAdapter} drops, a session also drops:
3163
- * - **`cuid`** — replaced by a per-connection integer correlation id. The initiator maps it to its
3164
- * real cuid; the responder echoes it; each side reconstructs the cuid from its own map. Correlation
3165
- * only needs to be unique per socket, so a counter suffices.
3166
- * - **`originClient` after the first request** — the first request each side sends carries its
3167
- * identity; the peer remembers it and injects it into later frames. Replies omit it entirely (a
3168
- * reply carries the initiator's own origin, which the initiator already knows).
3169
- *
3170
- * Both ends MUST build the factory from the same domains in the same order (positional dictionary).
3171
- * Text frames still return `undefined` from `incoming`, so JSON clients remain interoperable.
3172
- *
3173
- * Hibernation note: after a server connection is evicted its session resets, so a still-connected
3174
- * client (whose session persists) will keep omitting `originClient`. The server must therefore restore
3175
- * the connection→client binding from its own store (see `AcceptorHandler.rehydrateConnection`) and
3176
- * inject `originClient` from there — the session alone can't recover it.
3177
- */
3178
- function createBinaryWireSessionFactory(domains, options) {
3179
- const { routeToInt, intToRoute } = buildActionRouteDictionary(domains);
3180
- const unknownIdentity = RuntimeCoordinate.unknown.toJsonObject();
3181
- const ttlMs = options?.correlationTtlMs ?? DEFAULT_CORRELATION_TTL_MS;
3182
- return () => {
3183
- let outCounter = 0;
3184
- const corrToCuid = /* @__PURE__ */ new Map();
3185
- const cuidToCorr = /* @__PURE__ */ new Map();
3186
- let selfIdentity;
3187
- let peerIdentity;
3075
+ async function buildRequestEnvelope(state, action) {
3076
+ const wire = action.toJsonObject();
3077
+ if (state.crypto != null) {
3078
+ const ciphertext = await state.crypto.encryptFrame(textEncoder$1.encode(JSON.stringify(wire)));
3188
3079
  return {
3189
- outgoing: (input) => {
3190
- const json = input.action.toJsonObject();
3191
- const routeKey = `${json.domain}:${json.id}`;
3192
- const routeInt = routeToInt.get(routeKey);
3193
- if (routeInt == null) throw new Error(`[binary-wire] Cannot pack unregistered action route: ${routeKey}`);
3194
- const now = Date.now();
3195
- pruneExpired(corrToCuid, now, ttlMs);
3196
- pruneExpired(cuidToCorr, now, ttlMs);
3197
- let corr;
3198
- let wireIdentity;
3199
- if (json.type === "request") {
3200
- corr = outCounter++;
3201
- corrToCuid.set(corr, {
3202
- value: json.context.cuid,
3203
- time: now
3204
- });
3205
- if (selfIdentity == null && isKnownIdentity(json.context.originClient)) {
3206
- selfIdentity = json.context.originClient;
3207
- wireIdentity = json.context.originClient;
3208
- }
3209
- } else {
3210
- corr = cuidToCorr.get(json.context.cuid)?.value ?? -1;
3211
- if (json.type === "result") cuidToCorr.delete(json.context.cuid);
3212
- }
3213
- const envelope = new Array(ENVELOPE_LENGTH$1);
3214
- envelope[ENVELOPE$1.route] = routeInt;
3215
- envelope[ENVELOPE$1.type] = PayloadTypeToInt[json.type];
3216
- envelope[ENVELOPE$1.corr] = corr;
3217
- envelope[ENVELOPE$1.time] = json.time;
3218
- envelope[ENVELOPE$1.originClient] = wireIdentity;
3219
- envelope[ENVELOPE$1.payload] = extractWirePayload(json);
3220
- return pack(envelope);
3221
- },
3222
- incoming: (frame) => {
3223
- let buffer;
3224
- if (frame instanceof ArrayBuffer) buffer = new Uint8Array(frame);
3225
- else if (frame instanceof Uint8Array) buffer = frame;
3226
- else return;
3227
- try {
3228
- const envelope = unpack(buffer);
3229
- if (!Array.isArray(envelope) || envelope.length !== ENVELOPE_LENGTH$1) return void 0;
3230
- const routeMeta = intToRoute[envelope[ENVELOPE$1.route]];
3231
- const payloadType = ReversePayloadType[envelope[ENVELOPE$1.type]];
3232
- if (routeMeta == null || payloadType == null) return void 0;
3233
- const now = Date.now();
3234
- pruneExpired(corrToCuid, now, ttlMs);
3235
- pruneExpired(cuidToCorr, now, ttlMs);
3236
- const corr = envelope[ENVELOPE$1.corr];
3237
- const time = envelope[ENVELOPE$1.time];
3238
- const wireIdentity = envelope[ENVELOPE$1.originClient];
3239
- let cuid;
3240
- let originClient;
3241
- if (payloadType === "request") {
3242
- cuid = nanoid();
3243
- cuidToCorr.set(cuid, {
3244
- value: corr,
3245
- time: now
3246
- });
3247
- if (isKnownIdentity(wireIdentity)) peerIdentity = wireIdentity;
3248
- originClient = peerIdentity ?? unknownIdentity;
3249
- } else {
3250
- cuid = corrToCuid.get(corr)?.value ?? nanoid();
3251
- if (payloadType === "result") corrToCuid.delete(corr);
3252
- originClient = selfIdentity ?? unknownIdentity;
3253
- }
3254
- return assembleWireJson(routeMeta, payloadType, time, {
3255
- cuid,
3256
- timeCreated: time,
3257
- routing: [],
3258
- originClient
3259
- }, envelope[ENVELOPE$1.payload]);
3260
- } catch (e) {
3261
- console.error("[binary-wire] Failed to unpack binary action session frame", e);
3262
- return;
3263
- }
3264
- }
3080
+ k: "act",
3081
+ t: state.token,
3082
+ c: bytesToBase64(ciphertext)
3265
3083
  };
3084
+ }
3085
+ return {
3086
+ k: "act",
3087
+ t: state.token,
3088
+ w: wire
3266
3089
  };
3267
3090
  }
3268
- //#endregion
3269
- //#region src/ActionRuntime/Channel/secureChannel.ts
3270
- /**
3271
- * Derive a stable wire-dictionary version from the ordered route list (FNV-1a over `domain:id,…`), so
3272
- * the version moves automatically whenever the transported domains change — a stale peer is then
3273
- * rejected by the handshake instead of silently misrouting a positionally-packed frame.
3274
- */
3275
- function deriveDictionaryVersion(domains) {
3276
- const { intToRoute } = buildActionRouteDictionary(domains);
3277
- const signature = intToRoute.map((route) => `${route.domain}:${route.id}`).join(",");
3278
- let hash = 2166136261;
3279
- for (let i = 0; i < signature.length; i++) {
3280
- hash ^= signature.charCodeAt(i);
3281
- hash = Math.imul(hash, 16777619);
3091
+ async function extractReplyWire(state, reply) {
3092
+ if (reply.k !== "act") return void 0;
3093
+ if ("c" in reply) {
3094
+ if (state.crypto == null) return void 0;
3095
+ const plain = await state.crypto.decryptFrame(base64ToBytes(reply.c));
3096
+ return JSON.parse(textDecoder$1.decode(plain));
3282
3097
  }
3283
- return `auto:${(hash >>> 0).toString(16).padStart(8, "0")}`;
3098
+ return reply.w;
3284
3099
  }
3285
- /**
3286
- * Bundle a secure channel's shared identity from its transported domains. Both ends MUST call this
3287
- * with the same domains in the same order (the binary wire dictionary is positional). The
3288
- * `dictionaryVersion` is derived from those domains unless you pin an explicit one.
3289
- *
3290
- * Declare the domains *by role* — `toAcceptor` (connector→acceptor requests) and `toConnector`
3291
- * (acceptor→connector pushes) — so the routing for both ends is derived from the channel (see
3292
- * {@link connectChannel} and `acceptChannelConnections`) instead of being restated at each end. The
3293
- * wire dictionary spans `[...toAcceptor, ...toConnector]` in that order; add new domains to the end of
3294
- * their list to keep older peers compatible. (`domains` is still accepted as a legacy alias for
3295
- * `toAcceptor`.)
3296
- */
3297
- function defineSecureChannel(options) {
3298
- const base = defineChannel({
3299
- toAcceptor: options.toAcceptor,
3300
- toConnector: options.toConnector
3100
+ async function runConnectorExchangeHandshake(carrier, secure) {
3101
+ await secure.link.initialize();
3102
+ const handshake = createClientHandshake({
3103
+ link: secure.link,
3104
+ localCoordinate: secure.localCoordinate,
3105
+ dictionaryVersion: secure.dictionaryVersion,
3106
+ securityLevel: secure.securityLevel
3301
3107
  });
3302
- const allDomains = [...base.toAcceptorDomains, ...base.toConnectorDomains];
3108
+ const hsid = nanoid();
3109
+ const hello = await handshake.createHello();
3110
+ const welcomeReply = decodeExchangeReply(asString(await carrier.exchange(encodeExchange({
3111
+ k: "hs",
3112
+ hsid,
3113
+ m: encodeHandshakeMessage(hello)
3114
+ }))));
3115
+ if (welcomeReply?.k !== "hs") throw new Error("[exchange-handshake] expected a welcome reply");
3116
+ const welcome = decodeHandshakeMessage(welcomeReply.m);
3117
+ if (welcome == null) throw new Error("[exchange-handshake] malformed welcome");
3118
+ if (welcome.t === "reject") throw new Error(`[exchange-handshake] rejected by peer: ${welcome.reason}`);
3119
+ if (welcome.t !== "welcome") throw new Error(`[exchange-handshake] expected welcome, got ${welcome.t}`);
3120
+ const prove = await handshake.onWelcome(welcome);
3121
+ const acceptReply = decodeExchangeReply(asString(await carrier.exchange(encodeExchange({
3122
+ k: "hs",
3123
+ hsid,
3124
+ m: encodeHandshakeMessage(prove)
3125
+ }))));
3126
+ if (acceptReply?.k !== "hs") throw new Error("[exchange-handshake] expected an accept reply");
3127
+ const accept = decodeHandshakeMessage(acceptReply.m);
3128
+ if (accept == null) throw new Error("[exchange-handshake] malformed accept");
3129
+ if (accept.t === "reject") throw new Error(`[exchange-handshake] rejected by peer: ${accept.reason}`);
3130
+ if (accept.t !== "accept") throw new Error(`[exchange-handshake] expected accept, got ${accept.t}`);
3131
+ if (acceptReply.t == null) throw new Error("[exchange-handshake] accept missing session token");
3132
+ const result = await handshake.onAccept(accept);
3133
+ const crypto = result.securityLevel === "encrypted" ? createActionFrameCrypto({
3134
+ link: secure.link,
3135
+ linkedClientId: result.linkedClientId
3136
+ }) : void 0;
3303
3137
  return {
3304
- ...base,
3305
- dictionaryVersion: options.dictionaryVersion ?? deriveDictionaryVersion(allDomains),
3306
- createCodec: createBinaryWireSessionFactory(allDomains, options.sessionOptions)
3138
+ token: acceptReply.t,
3139
+ crypto
3307
3140
  };
3308
3141
  }
3309
- //#endregion
3310
- //#region src/ActionRuntime/Transport/SecureSession/exchangeProtocol.ts
3311
- function encodeExchange(envelope) {
3312
- return JSON.stringify(envelope);
3313
- }
3314
- function decodeExchangeRequest(raw) {
3315
- return parse(raw);
3316
- }
3317
- function decodeExchangeReply(raw) {
3318
- return parse(raw);
3319
- }
3320
- function parse(raw) {
3321
- try {
3322
- return JSON.parse(raw);
3323
- } catch {
3324
- return;
3325
- }
3326
- }
3327
- function bytesToBase64(bytes) {
3328
- let binary = "";
3329
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
3330
- return btoa(binary);
3142
+ function asString(frame) {
3143
+ if (typeof frame === "string") return frame;
3144
+ return textDecoder$1.decode(frame instanceof ArrayBuffer ? new Uint8Array(frame) : frame);
3331
3145
  }
3332
- function base64ToBytes(base64) {
3333
- const binary = atob(base64);
3334
- const bytes = new Uint8Array(binary.length);
3335
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
3336
- return bytes;
3146
+ //#endregion
3147
+ //#region src/ActionRuntime/Transport/helpers/addTransportStatusMetadata.ts
3148
+ function addTransportStatusMetadata(transportStatus) {
3149
+ if (transportStatus.status === "ready") return {
3150
+ status: "ready",
3151
+ readyData: transportStatus.readyData
3152
+ };
3153
+ if (transportStatus.status === "initializing") return {
3154
+ status: "initializing",
3155
+ initializationPromise: transportStatus.initializationPromise,
3156
+ timeStarted: Date.now()
3157
+ };
3158
+ if (transportStatus.status === "failed") return {
3159
+ status: "failed",
3160
+ error: transportStatus.error,
3161
+ timeFailed: Date.now()
3162
+ };
3163
+ if (transportStatus.status === "unsupported") return { status: "unsupported" };
3164
+ return { status: "uninitialized" };
3337
3165
  }
3338
3166
  //#endregion
3339
- //#region src/ActionRuntime/Transport/SecureSession/exchangeAcceptor.ts
3340
- const textEncoder$1 = new TextEncoder();
3341
- const textDecoder$1 = new TextDecoder();
3167
+ //#region src/ActionRuntime/Transport/TransportConnection.ts
3168
+ let transportOrd = 0;
3342
3169
  /**
3343
- * Acceptor (accept-in) side of the secure exchange protocol the HTTP counterpart to
3344
- * {@link AcceptorSecureSession}. Each POST body is one {@link decodeExchangeRequest} envelope; the
3345
- * acceptor drives the server handshake over the two `hs` POSTs (correlated by `hsid`, since stateless
3346
- * requests can't rely on channel ordering), mints a session **token** on accept, and on every later `act`
3347
- * POST resolves the session by token, decrypts the body (at `encrypted`), routes it through the runtime,
3348
- * and returns the (encrypted) result inline as the reply.
3349
- *
3350
- * Sessions and in-flight handshakes are held in memory — fine for a single-instance server. (Surviving a
3351
- * Durable-Object eviction would persist each token's `keyMaterial` and re-derive the key on a miss, the
3352
- * same primitive `AcceptorSecureSession.rehydrate` uses; left as a follow-up.)
3170
+ * Live, per-handler transport runtime built from a reusable {@link Transport} definition. Holds the
3171
+ * connection-scoped state (ordinal, initialized config, sockets / abort sets) that must not be shared
3172
+ * across handlers. Construct these via `definition._createConnection(...)`, never directly.
3353
3173
  */
3354
- var ExchangeAcceptor = class {
3355
- _security;
3356
- _runtime;
3357
- _allowedLevels;
3358
- _noneAllowed;
3359
- _pendingHandshakes = /* @__PURE__ */ new Map();
3360
- _sessions = /* @__PURE__ */ new Map();
3361
- constructor(config) {
3362
- this._security = config.security;
3363
- this._runtime = config.runtime;
3364
- this._allowedLevels = Array.isArray(config.security.securityLevel) ? config.security.securityLevel : [config.security.securityLevel];
3365
- this._noneAllowed = this._allowedLevels.includes("none");
3174
+ var TransportConnection = class {
3175
+ def;
3176
+ transOrd = transportOrd++;
3177
+ type;
3178
+ initialized;
3179
+ /** Backref to the public definition that created this connection (used for devtools route info). */
3180
+ definition;
3181
+ constructor(def) {
3182
+ this.def = def;
3183
+ this.type = def.type;
3184
+ this.initialized = def.initialize();
3366
3185
  }
3367
- /** Process one POST body (an exchange envelope), returning the reply body to send back. */
3368
- async handlePost(body) {
3369
- const request = decodeExchangeRequest(body);
3370
- if (request == null) return this._err("malformed exchange request");
3371
- if (request.k === "hs") return encodeExchange(await this._handleHandshake(request));
3372
- return encodeExchange(await this._handleAction(request));
3186
+ /**
3187
+ * Devtools route info for an action routed through this live connection. Defaults to the stateless
3188
+ * {@link definition}'s info; connections override to enrich it from live state (e.g. the actual
3189
+ * resolved socket URL) when the definition couldn't resolve it on its own.
3190
+ */
3191
+ getRouteInfo(input) {
3192
+ return this.definition?.getRouteInfo(input);
3373
3193
  }
3374
- async _handleHandshake(request) {
3375
- const message = decodeHandshakeMessage(request.m);
3376
- if (message == null) return {
3377
- k: "err",
3378
- message: "malformed handshake message"
3379
- };
3380
- const security = this._security;
3381
- await security.link.initialize();
3382
- let handshake = this._pendingHandshakes.get(request.hsid);
3383
- if (handshake == null) {
3384
- handshake = createServerHandshake({
3385
- link: security.link,
3386
- localCoordinate: security.localCoordinate,
3387
- dictionaryVersion: security.dictionaryVersion,
3388
- securityLevel: security.securityLevel,
3389
- verifyKeyResolver: security.verifyKeyResolver
3390
- });
3391
- this._pendingHandshakes.set(request.hsid, handshake);
3392
- }
3393
- if (message.t === "hello") return {
3394
- k: "hs",
3395
- m: encodeHandshakeMessage(await handshake.onHello(message))
3396
- };
3397
- if (message.t === "prove") {
3398
- const reply = await handshake.onProve(message);
3399
- this._pendingHandshakes.delete(request.hsid);
3400
- const result = handshake.getResult();
3401
- if (reply.t === "accept" && result != null) {
3402
- const token = nanoid();
3403
- this._sessions.set(token, {
3404
- client: new RuntimeCoordinate(result.remote),
3405
- securityLevel: result.securityLevel,
3406
- crypto: result.securityLevel === "encrypted" ? createActionFrameCrypto({
3407
- link: security.link,
3408
- linkedClientId: result.linkedClientId
3409
- }) : void 0
3410
- });
3411
- return {
3412
- k: "hs",
3413
- m: encodeHandshakeMessage(reply),
3414
- t: token
3415
- };
3416
- }
3194
+ /**
3195
+ * Whether a `ready`-status transport still needs asynchronous bring-up before its methods exist —
3196
+ * awaiting the carrier to open and/or running a handshake. Default `false`: a stateless transport
3197
+ * (HTTP) is usable the instant `getTransport` reports `ready`, so it stays a terminal *synchronous*
3198
+ * fallback in {@link ConnectionTransportManager}. Stream carriers (Link/WS) override to `true`.
3199
+ */
3200
+ _needsAsyncBringUp(_readyData) {
3201
+ return false;
3202
+ }
3203
+ /** Await the carrier becoming ready to send (e.g. a socket `open`). Default: nothing to await. */
3204
+ _awaitCarrierReady(_readyData) {
3205
+ return Promise.resolve();
3206
+ }
3207
+ /**
3208
+ * Finalize during async bring-up — may run a handshake, so it can be async. Defaults to the
3209
+ * synchronous {@link _finalizeTransportMethods}; secure stream carriers override to branch plain/secure.
3210
+ */
3211
+ _finalizeReady(readyData) {
3212
+ return this._finalizeTransportMethods(readyData);
3213
+ }
3214
+ _getCacheKey(input) {
3215
+ const parts = this.initialized.getTransportCacheKey?.(input);
3216
+ if (parts == null) return null;
3217
+ return parts.join("\0");
3218
+ }
3219
+ getCacheKey(input) {
3220
+ const inner = this._getCacheKey(input);
3221
+ if (inner == null) return null;
3222
+ return `${this.transOrd}:${inner}`;
3223
+ }
3224
+ /**
3225
+ * Whether this transport can serve the given action right now. Consulted by the manager before
3226
+ * cache-key resolution and `getTransport`; a `false` result skips this transport (treated as
3227
+ * `unsupported`) and the manager falls through to the next in preference order. Defaults to `true`
3228
+ * when the transport declares no gate.
3229
+ */
3230
+ isAvailable(input) {
3231
+ return this.initialized.isAvailable?.(input) ?? true;
3232
+ }
3233
+ getTransport(input) {
3234
+ return this._processTransportStatus(input);
3235
+ }
3236
+ _processTransportStatus(input) {
3237
+ const statusInfo = addTransportStatusMetadata(this.initialized.getTransport(input));
3238
+ if (statusInfo.status === "ready") {
3239
+ if (!this._needsAsyncBringUp(statusInfo.readyData)) return {
3240
+ status: "ready",
3241
+ readyData: this._finalizeTransportMethods(statusInfo.readyData)
3242
+ };
3417
3243
  return {
3418
- k: "hs",
3419
- m: encodeHandshakeMessage(reply)
3244
+ status: "initializing",
3245
+ timeStarted: Date.now(),
3246
+ initializationPromise: this._bringUp(statusInfo.readyData)
3420
3247
  };
3421
3248
  }
3422
- return {
3423
- k: "err",
3424
- message: `unexpected handshake message ${message.t}`
3425
- };
3426
- }
3427
- async _handleAction(request) {
3428
- let session;
3429
- let candidate;
3430
- if (request.t != null) {
3431
- session = this._sessions.get(request.t);
3432
- if (session == null) return {
3433
- k: "err",
3434
- message: "unknown or expired session token"
3435
- };
3436
- if ("c" in request) {
3437
- if (session.crypto == null) return {
3438
- k: "err",
3439
- message: "session is not encrypted"
3440
- };
3441
- const plain = await session.crypto.decryptFrame(base64ToBytes(request.c));
3442
- candidate = JSON.parse(textDecoder$1.decode(plain));
3443
- } else candidate = request.w;
3444
- } else {
3445
- if (!this._noneAllowed || "c" in request) return {
3446
- k: "err",
3447
- message: "missing session token"
3249
+ if (statusInfo.status === "initializing") {
3250
+ const initializationPromise = statusInfo.initializationPromise.then((result) => result.status === "ready" ? this._bringUp(result.readyData) : result);
3251
+ return {
3252
+ status: "initializing",
3253
+ timeStarted: statusInfo.timeStarted,
3254
+ initializationPromise
3448
3255
  };
3449
- candidate = request.w;
3450
3256
  }
3451
- if (!isActionPayload_Any_JsonObject(candidate)) return {
3452
- k: "err",
3453
- message: "malformed action wire"
3454
- };
3455
- const wire = candidate;
3456
- if (session != null && wire.type === "request") wire.context.originClient = session.client.toJsonObject();
3457
- const resultWire = (await (await this._runtime.handleActionPayloadWire(wire)).waitForResultPayload()).toJsonObject();
3458
- if (session?.crypto != null) return {
3459
- k: "act",
3460
- c: bytesToBase64(await session.crypto.encryptFrame(textEncoder$1.encode(JSON.stringify(resultWire))))
3461
- };
3257
+ return statusInfo;
3258
+ }
3259
+ /** Await carrier readiness, then finalize (possibly running a handshake) into the live methods. */
3260
+ async _bringUp(readyData) {
3261
+ await this._awaitCarrierReady(readyData);
3462
3262
  return {
3463
- k: "act",
3464
- w: resultWire
3263
+ status: "ready",
3264
+ readyData: await this._finalizeReady(readyData)
3465
3265
  };
3466
3266
  }
3467
- _err(message) {
3468
- return encodeExchange({
3469
- k: "err",
3470
- message
3267
+ };
3268
+ //#endregion
3269
+ //#region src/ActionRuntime/Transport/Exchange/ExchangeConnection.ts
3270
+ /**
3271
+ * Carrier-agnostic live connection for the exchange (request → single reply) shape — the HTTP
3272
+ * counterpart to {@link LinkConnection}. It owns only the bring-up (run the secure handshake on first
3273
+ * use); the request/reply lifecycle + crypto live in the shared `establishExchangeSession`.
3274
+ */
3275
+ var ExchangeConnection = class extends TransportConnection {
3276
+ constructor(def) {
3277
+ super({
3278
+ ...def,
3279
+ type: "exchange"
3280
+ });
3281
+ }
3282
+ _getCacheKey(input) {
3283
+ return this.initialized.getTransportCacheKey?.(input).join("\0") ?? "";
3284
+ }
3285
+ _needsAsyncBringUp(data) {
3286
+ return data.secureChannel != null && data.secureChannel.securityLevel !== "none";
3287
+ }
3288
+ _finalizeReady(data) {
3289
+ const secure = data.secureChannel;
3290
+ if (secure != null && secure.securityLevel !== "none") return finalizeSecureExchangeMethods({
3291
+ ...this._sessionContext(data),
3292
+ secure
3471
3293
  });
3294
+ return this._finalizeTransportMethods(data);
3295
+ }
3296
+ _finalizeTransportMethods(data) {
3297
+ return finalizePlainExchangeMethods(this._sessionContext(data));
3298
+ }
3299
+ _sessionContext(data) {
3300
+ return {
3301
+ carrier: data.carrier,
3302
+ updateRunConfig: data.updateRunConfig,
3303
+ secure: data.secureChannel
3304
+ };
3472
3305
  }
3473
3306
  };
3474
3307
  //#endregion
3475
- //#region src/ActionRuntime/Handler/PeerLink/Acceptor/createActionFetchHandler.ts
3476
- /** Permissive defaults — fine for a public action endpoint; override (or disable) via `cors`. */
3477
- const DEFAULT_CORS_HEADERS = {
3478
- "Access-Control-Allow-Origin": "*",
3479
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
3480
- "Access-Control-Allow-Headers": "Content-Type",
3481
- "Access-Control-Max-Age": "86400"
3482
- };
3308
+ //#region src/ActionRuntime/Transport/Exchange/ExchangeTransport.ts
3483
3309
  /**
3484
- * Build the `fetch` handler a server/Durable-Object exposes for action traffic, folding in the
3485
- * boilerplate every endpoint repeats: CORS (incl. the `OPTIONS` preflight), routing the `/action`
3486
- * `POST` body through the runtime (`handleActionPayloadWire` `waitForResultPayload`
3487
- * `toHttpResponse`), an optional WebSocket-upgrade hook, and a `404` fallback.
3488
- *
3489
- * It only touches web-standard `Request`/`Response`, so it stays transport-agnostic — the one
3490
- * environment-specific bit (the WS upgrade) is injected via {@link IActionFetchHandlerOptions.onWebSocketUpgrade}:
3491
- * ```ts
3492
- * this.fetchHandler = createActionFetchHandler(this.runtime, {
3493
- * onWebSocketUpgrade: () => {
3494
- * const pair = new WebSocketPair();
3495
- * this.ctx.acceptWebSocket(pair[1]);
3496
- * return new Response(null, { status: 101, webSocket: pair[0] });
3497
- * },
3498
- * });
3499
- * // async fetch(request) { return this.fetchHandler(request); }
3500
- * ```
3310
+ * A carrier-agnostic exchange (request single reply) transport: it drives nice-action's secure session
3311
+ * over any {@link IExchangeCarrier} (HTTP being the one built-in). The duplex counterpart is
3312
+ * {@link LinkTransport}; this is the no-push half its reply rides the response to its own request, so it
3313
+ * can't deliver an unsolicited frame (the runtime never picks it for the return path).
3501
3314
  */
3502
- function createActionFetchHandler(runtime, options = {}) {
3503
- const corsHeaders = options.cors === false ? {} : options.cors ?? DEFAULT_CORS_HEADERS;
3504
- const isActionPath = options.isActionPath ?? ((url) => url.pathname.endsWith("/action"));
3505
- const isWebSocketPath = options.isWebSocketPath ?? ((url) => url.pathname.endsWith("/ws"));
3506
- const exchangeAcceptor = options.security != null ? new ExchangeAcceptor({
3507
- runtime,
3508
- security: options.security
3509
- }) : void 0;
3510
- const withCors = (response) => {
3511
- if (options.cors === false) return response;
3512
- const headers = new Headers(response.headers);
3513
- for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
3514
- return new Response(response.body, {
3515
- status: response.status,
3516
- headers
3315
+ var ExchangeTransport = class ExchangeTransport extends Transport {
3316
+ options;
3317
+ type = "exchange";
3318
+ constructor(options) {
3319
+ super();
3320
+ this.options = options;
3321
+ }
3322
+ static create(options) {
3323
+ return new ExchangeTransport(options);
3324
+ }
3325
+ _createConnection(_ctx) {
3326
+ const options = this.options;
3327
+ return new ExchangeConnection({ initialize: () => ({
3328
+ getTransportCacheKey: options.getTransportCacheKey,
3329
+ isAvailable: options.available,
3330
+ getTransport: (input) => ({
3331
+ status: "ready",
3332
+ readyData: {
3333
+ carrier: options.openCarrier(input),
3334
+ secureChannel: options.security,
3335
+ updateRunConfig: options.updateRunConfig
3336
+ }
3337
+ })
3338
+ }) });
3339
+ }
3340
+ getRouteInfo(input) {
3341
+ if (this.options.getRouteInfo != null) return this.options.getRouteInfo(input);
3342
+ return {
3343
+ carrierLabel: this.options.label ?? "exchange",
3344
+ summary: this.options.label ?? "exchange"
3345
+ };
3346
+ }
3347
+ };
3348
+ //#endregion
3349
+ //#region src/ActionRuntime/Transport/helpers/createUnsetTransportResolvers.ts
3350
+ const createUnsetTransportResolvers = (transportLabel) => ({ onIncomingActionDataJson: (json) => {
3351
+ console.warn(`Received incoming action JSON [${json.domain}:${json.id}] on Transport [${transportLabel}] but no incoming data listener has been set.`);
3352
+ } });
3353
+ //#endregion
3354
+ //#region src/ActionRuntime/Transport/SecureSession/establishLinkSession.ts
3355
+ const HANDSHAKE_TIMEOUT_MS = 15e3;
3356
+ /** Plain path (no handshake): route every inbound frame to the runtime; send without crypto. */
3357
+ function finalizePlainLinkMethods(ctx) {
3358
+ const disconnectListeners = [];
3359
+ const abortSet = /* @__PURE__ */ new Set();
3360
+ const pipe = makePipe(ctx, void 0);
3361
+ ctx.channel.attach({
3362
+ onMessage: (frame) => void handleIncomingActionFrame(ctx, pipe, frame),
3363
+ onClose: () => onChannelClosed(ctx, disconnectListeners, abortSet),
3364
+ onError: () => onChannelClosed(ctx, disconnectListeners, abortSet)
3365
+ });
3366
+ return buildSendMethods(ctx, pipe, disconnectListeners, abortSet);
3367
+ }
3368
+ /**
3369
+ * Secure path: a single message handler feeds the handshake until it completes, then routes action
3370
+ * frames (decrypting at the `encrypted` level). Frames that race ahead of activation are buffered and
3371
+ * flushed once the handshake lands, so nothing is lost.
3372
+ */
3373
+ async function finalizeSecureLinkMethods(ctx) {
3374
+ const disconnectListeners = [];
3375
+ const abortSet = /* @__PURE__ */ new Set();
3376
+ const session = {};
3377
+ let active = false;
3378
+ const handshakeQueue = [];
3379
+ const handshakeWaiters = [];
3380
+ const pendingActionFrames = [];
3381
+ ctx.channel.attach({
3382
+ onMessage: (frame) => {
3383
+ if (active && session.pipe != null) {
3384
+ handleIncomingActionFrame(ctx, session.pipe, frame);
3385
+ return;
3386
+ }
3387
+ if (typeof frame === "string") {
3388
+ const message = decodeHandshakeMessage(frame);
3389
+ if (message != null) {
3390
+ const waiter = handshakeWaiters.shift();
3391
+ if (waiter != null) waiter(message);
3392
+ else handshakeQueue.push(message);
3393
+ return;
3394
+ }
3395
+ }
3396
+ pendingActionFrames.push(frame);
3397
+ },
3398
+ onClose: () => onChannelClosed(ctx, disconnectListeners, abortSet),
3399
+ onError: () => onChannelClosed(ctx, disconnectListeners, abortSet)
3400
+ });
3401
+ const nextHandshakeMessage = () => {
3402
+ const queued = handshakeQueue.shift();
3403
+ if (queued != null) return Promise.resolve(queued);
3404
+ return new Promise((resolve, reject) => {
3405
+ const timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("[link-handshake] timed out waiting for peer reply")), HANDSHAKE_TIMEOUT_MS);
3406
+ handshakeWaiters.push((message) => {
3407
+ clearTimeout(timeout);
3408
+ resolve(message);
3409
+ });
3517
3410
  });
3518
3411
  };
3519
- return async (request) => {
3520
- if (request.method === "OPTIONS") return withCors(new Response(null, { status: 204 }));
3521
- const url = new URL(request.url);
3522
- const isWebSocketUpgrade = options.isWebSocketUpgrade ?? ((req, u) => req.headers.get("Upgrade") === "websocket" && isWebSocketPath(u));
3523
- if (options.onWebSocketUpgrade != null && isWebSocketUpgrade(request, url)) return options.onWebSocketUpgrade(request, url);
3524
- if (request.method === "POST" && isActionPath(url)) {
3525
- if (exchangeAcceptor != null) {
3526
- const reply = await exchangeAcceptor.handlePost(await request.text());
3527
- return withCors(new Response(reply, {
3528
- status: 200,
3529
- headers: { "Content-Type": "application/json" }
3530
- }));
3531
- }
3532
- return withCors((await (await runtime.handleActionPayloadWire(await request.json())).waitForResultPayload()).toHttpResponse({ useErrorStatus: options.useErrorStatus }));
3412
+ const pipe = makePipe(ctx, await runClientHandshake(ctx.channel, ctx.secure, nextHandshakeMessage));
3413
+ session.pipe = pipe;
3414
+ active = true;
3415
+ for (const frame of pendingActionFrames) await handleIncomingActionFrame(ctx, pipe, frame);
3416
+ pendingActionFrames.length = 0;
3417
+ return buildSendMethods(ctx, pipe, disconnectListeners, abortSet);
3418
+ }
3419
+ function makePipe(ctx, crypto) {
3420
+ return createFrameCryptoPipe({
3421
+ write: (frame) => ctx.channel.send(frame),
3422
+ isOpen: () => ctx.channel.isOpen(),
3423
+ crypto
3424
+ });
3425
+ }
3426
+ async function runClientHandshake(channel, secure, nextHandshakeMessage) {
3427
+ await secure.link.initialize();
3428
+ const handshake = createClientHandshake({
3429
+ link: secure.link,
3430
+ localCoordinate: secure.localCoordinate,
3431
+ dictionaryVersion: secure.dictionaryVersion,
3432
+ securityLevel: secure.securityLevel
3433
+ });
3434
+ channel.send(encodeHandshakeMessage(await handshake.createHello()));
3435
+ const welcome = await nextHandshakeMessage();
3436
+ if (welcome.t === "reject") throw new Error(`[link-handshake] rejected by peer: ${welcome.reason}`);
3437
+ if (welcome.t !== "welcome") throw new Error(`[link-handshake] expected welcome, got ${welcome.t}`);
3438
+ channel.send(encodeHandshakeMessage(await handshake.onWelcome(welcome)));
3439
+ const accept = await nextHandshakeMessage();
3440
+ if (accept.t === "reject") throw new Error(`[link-handshake] rejected by peer: ${accept.reason}`);
3441
+ if (accept.t !== "accept") throw new Error(`[link-handshake] expected accept, got ${accept.t}`);
3442
+ const result = await handshake.onAccept(accept);
3443
+ return result.securityLevel === "encrypted" ? createActionFrameCrypto({
3444
+ link: secure.link,
3445
+ linkedClientId: result.linkedClientId
3446
+ }) : void 0;
3447
+ }
3448
+ function buildSendMethods(ctx, pipe, disconnectListeners, abortSet) {
3449
+ const channel = ctx.channel;
3450
+ const sendActionData = (inputs) => {
3451
+ const { action, runningAction, timeout } = inputs;
3452
+ if (!channel.isOpen()) {
3453
+ if (action.type === "request") runningAction._abort(ctx.makeDisconnectError(action.id));
3454
+ return;
3533
3455
  }
3534
- return withCors(new Response("Not found", { status: 404 }));
3456
+ if (action.type === "request") {
3457
+ abortSet.add(runningAction);
3458
+ const timeoutId = setTimeout(() => {
3459
+ runningAction._abort(err_nice_transport.fromId("timeout", { timeout }));
3460
+ }, timeout);
3461
+ runningAction.addUpdateListeners([(update) => {
3462
+ if (update.type === "finished") {
3463
+ clearTimeout(timeoutId);
3464
+ abortSet.delete(runningAction);
3465
+ }
3466
+ }]);
3467
+ }
3468
+ pipe.send(ctx.formatMessage?.outgoing(inputs) ?? JSON.stringify(inputs.action.toJsonObject()));
3535
3469
  };
3536
- }
3537
- //#endregion
3538
- //#region src/ActionRuntime/Handler/PeerLink/Acceptor/Hibernation/createHibernatableWsServerAdapter.ts
3539
- /**
3540
- * Wire the hibernation lifecycle for an acceptor handler on a transport whose connections outlive process
3541
- * eviction (e.g. a Durable Object's hibernatable WebSockets). It owns persistence end to end:
3542
- * registers `setAttachment` as the handler's connection-bound callback and immediately replays every
3543
- * live connection's stored binding via `getAttachment`, so results/pushes still route after a wake.
3544
- *
3545
- * Layered on top of the generic {@link AcceptorHandler} — it touches only the handler's neutral
3546
- * `setOnConnectionBound` / `rehydrateConnection` / `receive` / `dropConnection` surface, so no
3547
- * hibernation concern leaks into the handler itself.
3548
- *
3549
- * Construct it once when the handler is built, then forward connection events:
3550
- * ```ts
3551
- * const duplex = createHibernatableWsServerAdapter({ handler, getConnections, getAttachment, setAttachment });
3552
- * // webSocketMessage(ws, msg) => duplex.receive(ws, msg);
3553
- * // webSocketClose/Error(ws) => duplex.drop(ws);
3554
- * ```
3555
- */
3556
- function createHibernatableWsServerAdapter(options) {
3557
- const { handler, getConnections, getAttachment, setAttachment } = options;
3558
- handler.setOnConnectionBound(setAttachment);
3559
- for (const connection of getConnections()) {
3560
- const binding = getAttachment(connection);
3561
- if (binding != null) handler.rehydrateConnection(connection, binding);
3562
- }
3563
3470
  return {
3564
- receive: (connection, frame) => handler.receive(connection, frame),
3565
- drop: (connection) => handler.dropConnection(connection)
3471
+ sendActionData,
3472
+ updateRunConfig: ctx.updateRunConfig,
3473
+ addOnDisconnectListener: (cb) => {
3474
+ disconnectListeners.push(cb);
3475
+ },
3476
+ disconnect: () => {
3477
+ try {
3478
+ channel.close();
3479
+ } catch {}
3480
+ },
3481
+ sendReturnData: (payload, clients) => {
3482
+ const formatted = clients != null ? ctx.formatMessage?.outgoing({
3483
+ action: payload,
3484
+ ...clients
3485
+ }) : void 0;
3486
+ pipe.send(formatted ?? JSON.stringify(payload.toJsonObject()));
3487
+ }
3566
3488
  };
3567
3489
  }
3490
+ async function handleIncomingActionFrame(ctx, pipe, frame) {
3491
+ const decoded = await pipe.decryptIncoming(frame);
3492
+ if (decoded === void 0) return;
3493
+ const rawJson = decodeActionFrame(decoded, ctx.formatMessage);
3494
+ if (rawJson != null) ctx.resolvers.onIncomingActionDataJson(rawJson);
3495
+ }
3496
+ function onChannelClosed(ctx, disconnectListeners, abortSet) {
3497
+ for (const cb of disconnectListeners) cb();
3498
+ const error = ctx.makeDisconnectError("—");
3499
+ for (const ra of [...abortSet]) ra._abort(error);
3500
+ }
3568
3501
  //#endregion
3569
- //#region src/ActionRuntime/Channel/serveChannel.ts
3570
- /** Default accepted set, shared by every carrier: negotiate per connection to whatever the client picks. */
3571
- const DEFAULT_SERVER_SECURITY_LEVELS = [
3572
- "none",
3573
- "authenticated",
3574
- "encrypted"
3575
- ];
3502
+ //#region src/ActionRuntime/Transport/Link/LinkConnection.ts
3503
+ /** Abort error for a closed link channel (carrier-neutral the carrier itself isn't named). */
3504
+ function linkDisconnectError(actionId) {
3505
+ return err_nice_transport.fromId("send_failed", {
3506
+ actionId,
3507
+ actionState: "request",
3508
+ message: "link channel disconnected"
3509
+ });
3510
+ }
3576
3511
  /**
3577
- * Serve a secure channel over one or more carriers from a single call the accept-in dual of
3578
- * `connectChannel`. It builds the crypto identity (a {@link ClientCryptoKeyLink} + a storage-backed TOFU
3579
- * resolver) and the security block (coordinate, dictionary version, accepted levels) *once* from
3580
- * `(runtime, channel)` and fans them across every carrier, so the WebSocket and the secure-HTTP endpoint
3581
- * can never drift apart. It registers your handlers (plus the duplex acceptor it builds) on the runtime,
3582
- * wires hibernation when the duplex carrier declares it, and returns a single {@link IChannelServer} whose
3583
- * `fetch` / `duplex` / `pushToClient` you forward straight to the host:
3584
- * ```ts
3585
- * const server = serveChannel(runtime, channel, {
3586
- * clientEnv, storage,
3587
- * carriers: [wsAcceptorCarrier({ send, upgrade, hibernation }), httpAcceptorCarrier()],
3588
- * handlers: [localHandler],
3589
- * });
3590
- * // fetch(req) => server.fetch(req)
3591
- * // webSocketMessage(conn, m) => server.duplex?.receive(conn, m)
3592
- * // webSocketClose/Error(conn) => server.duplex?.drop(conn)
3593
- * ```
3594
- *
3595
- * `TConn` (the live-connection token a duplex carrier hands back through `send`/`receive`/`drop`) is
3596
- * inferred from the carriers — `WebSocket` for `wsAcceptorCarrier`, the data-channel type for a WebRTC
3597
- * carrier, and so on — so it stays carrier-agnostic.
3598
- */
3599
- function serveChannel(runtime, channel, options) {
3600
- const duplexCarriers = options.carriers.filter((carrier) => !isExchangeAcceptorCarrier(carrier));
3601
- const exchangeCarriers = options.carriers.filter(isExchangeAcceptorCarrier);
3602
- if (exchangeCarriers.length > 1) throw new Error("serveChannel: at most one exchange carrier is supported");
3603
- const exchangeCarrier = exchangeCarriers[0];
3604
- const exchangeSecure = exchangeCarrier != null && (exchangeCarrier.secure ?? true);
3605
- const anyDuplexSecure = duplexCarriers.some((carrier) => carrier.secure ?? true);
3606
- const securityLevel = options.securityLevel ?? DEFAULT_SERVER_SECURITY_LEVELS;
3607
- let secure;
3608
- if (anyDuplexSecure || exchangeSecure) {
3609
- const storage = options.storage;
3610
- 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.");
3611
- secure = {
3612
- storage,
3613
- link: options.link ?? new ClientCryptoKeyLink({ storageAdapter: storage }),
3614
- verifyKeyResolver: options.verifyKeyResolver ?? createStorageTofuVerifyKeyResolver(storage)
3615
- };
3512
+ * Carrier-agnostic live connection. It owns only the *bring-up* (open the carrier, then run the secure
3513
+ * session); the session itself — handshake, frame crypto, codec, send/receive lives in the shared
3514
+ * {@link finalizeSecureLinkMethods}/{@link finalizePlainLinkMethods}, so a WebSocket, a WebRTC data
3515
+ * channel, a Bluetooth characteristic, and an in-memory pipe all run the identical secure layer.
3516
+ */
3517
+ var LinkConnection = class extends TransportConnection {
3518
+ resolvers;
3519
+ constructor(def, resolvers) {
3520
+ super({
3521
+ ...def,
3522
+ type: "duplex"
3523
+ });
3524
+ this.resolvers = resolvers ?? createUnsetTransportResolvers("link");
3616
3525
  }
3617
- const handlers = [];
3618
- for (const carrier of duplexCarriers) {
3619
- const handler = (carrier.secure ?? true) && secure != null ? acceptChannel(runtime, channel, {
3620
- clientEnv: options.clientEnv,
3621
- storageAdapter: secure.storage,
3622
- link: secure.link,
3623
- verifyKeyResolver: secure.verifyKeyResolver,
3624
- securityLevel,
3625
- send: carrier.send,
3626
- defaultTimeout: options.defaultTimeout
3627
- }) : createAcceptorHandler({
3628
- clientEnv: options.clientEnv,
3629
- createFormatMessage: channel.createCodec,
3630
- send: carrier.send,
3631
- runtime,
3632
- defaultTimeout: options.defaultTimeout
3526
+ _getCacheKey(input) {
3527
+ return this.initialized.getTransportCacheKey?.(input).join("\0") ?? "";
3528
+ }
3529
+ _needsAsyncBringUp() {
3530
+ return true;
3531
+ }
3532
+ _awaitCarrierReady(data) {
3533
+ return data.channel.ready;
3534
+ }
3535
+ _finalizeReady(data) {
3536
+ const secure = data.secureChannel;
3537
+ if (secure != null && secure.securityLevel !== "none") return finalizeSecureLinkMethods({
3538
+ ...this._sessionContext(data),
3539
+ secure
3633
3540
  });
3634
- const router = carrier.hibernation != null ? createHibernatableWsServerAdapter({
3635
- handler,
3636
- ...carrier.hibernation
3637
- }) : {
3638
- receive: (connection, frame) => handler.receive(connection, frame),
3639
- drop: (connection) => handler.dropConnection(connection)
3541
+ return this._finalizeTransportMethods(data);
3542
+ }
3543
+ _sessionContext(data) {
3544
+ return {
3545
+ channel: data.channel,
3546
+ resolvers: this.resolvers,
3547
+ formatMessage: data.formatMessage,
3548
+ updateRunConfig: data.updateRunConfig,
3549
+ makeDisconnectError: linkDisconnectError
3640
3550
  };
3641
- carrier._activate(router);
3642
- handlers.push(handler);
3643
3551
  }
3644
- runtime.addHandlers([...options.handlers ?? [], ...handlers]);
3645
- const exchangeSecurity = exchangeSecure && secure != null ? {
3646
- link: secure.link,
3647
- verifyKeyResolver: secure.verifyKeyResolver,
3648
- localCoordinate: runtime.coordinate.toJsonObject(),
3649
- dictionaryVersion: channel.dictionaryVersion,
3650
- securityLevel
3651
- } : void 0;
3652
- const defaultIsUpgrade = (request) => request.headers.get("Upgrade") === "websocket";
3653
- const upgraders = [];
3654
- for (const carrier of duplexCarriers) {
3655
- if (carrier.upgrade == null) continue;
3656
- upgraders.push({
3657
- isUpgrade: carrier.isUpgrade ?? defaultIsUpgrade,
3658
- upgrade: carrier.upgrade
3659
- });
3552
+ _finalizeTransportMethods(data) {
3553
+ return finalizePlainLinkMethods(this._sessionContext(data));
3660
3554
  }
3661
- const fetch = createActionFetchHandler(runtime, {
3662
- cors: exchangeCarrier?.cors,
3663
- onWebSocketUpgrade: upgraders.length === 0 ? void 0 : (request, url) => (upgraders.find((u) => u.isUpgrade(request, url)) ?? upgraders[0]).upgrade(request, url),
3664
- isWebSocketUpgrade: upgraders.length === 0 ? void 0 : (request, url) => upgraders.some((u) => u.isUpgrade(request, url)),
3665
- isActionPath: exchangeCarrier != null ? exchangeCarrier.isActionPath ?? (() => true) : () => false,
3666
- security: exchangeSecurity,
3667
- useErrorStatus: exchangeCarrier?.useErrorStatus
3668
- });
3669
- const duplex = duplexCarriers.length === 1 ? duplexCarriers[0] : void 0;
3670
- const pushToClient = (target, request, pushOptions) => {
3671
- const owner = target instanceof RuntimeCoordinate ? handlers.find((handler) => handler.ownsLiveConnectionFor(target)) : handlers.find((handler) => handler.hasConnection(target));
3672
- if (owner == null) throw new Error("serveChannel: no duplex carrier holds a connection for the push target");
3673
- return owner.pushToClient(runtime, target, request, pushOptions);
3674
- };
3675
- return {
3676
- handlers,
3677
- fetch,
3678
- duplex,
3679
- pushToClient
3680
- };
3681
- }
3555
+ };
3682
3556
  //#endregion
3683
- //#region src/ActionRuntime/Handler/PeerLink/Acceptor/Hibernation/ConnectionStateStore.ts
3557
+ //#region src/ActionRuntime/Transport/Link/LinkTransport.ts
3684
3558
  /**
3685
- * A typed per-connection state store that co-owns the app state and the acceptor handler's routing
3686
- * binding in one attachment, so neither the consumer nor the handler has to hand-merge the two. Create
3687
- * it through {@link createConnectionStateStore} (which also wires binding persistence and replays
3688
- * surviving connections after a wake), then `get`/`set`/`clearApp` the app state directly.
3689
- *
3690
- * The mechanism is carrier-neutral — it only needs read/write/enumerate callbacks for the connection's
3691
- * attachment — but it pays off on transports whose connections outlive process eviction (e.g. a
3692
- * Durable Object's hibernatable WebSockets), which is why it lives beside the hibernation adapter.
3693
- *
3694
- * ```ts
3695
- * const players = createConnectionStateStore(serverHandler, {
3696
- * schema: vs_player,
3697
- * read: (ws) => ws.deserializeAttachment(),
3698
- * write: (ws, v) => ws.serializeAttachment(v),
3699
- * getConnections: () => ctx.getWebSockets(),
3700
- * });
3701
- * players.set(ws, player); // binding is preserved automatically
3702
- * const player = players.get(ws);
3703
- * ```
3559
+ * A carrier-agnostic transport: it drives nice-action's secure session + action routing over any
3560
+ * {@link IDuplexCarrier}. The WebSocket transport is the special case that opens a `WebSocket`;
3561
+ * this opens whatever `openChannel` returns, so the identical secure layer works over WebRTC, Bluetooth,
3562
+ * or an in-memory pipe. Reported with an overridable carrier label in the devtools (defaults to "link").
3704
3563
  */
3705
- var ConnectionStateStore = class {
3564
+ var LinkTransport = class LinkTransport extends Transport {
3706
3565
  options;
3566
+ type = "duplex";
3707
3567
  constructor(options) {
3568
+ super();
3708
3569
  this.options = options;
3709
3570
  }
3710
- /** The validated app state for a connection, or `null` if unset / invalid. */
3711
- get(connection) {
3712
- return this._readAttachment(connection).app ?? null;
3713
- }
3714
- /** Set the app state, preserving the runtime binding already pinned to the connection. */
3715
- set(connection, app) {
3716
- const existing = this._readAttachment(connection);
3717
- this.options.write(connection, {
3718
- app,
3719
- binding: existing.binding
3720
- });
3721
- }
3722
- /** Clear the app state but keep the binding (e.g. a spectator that stopped watching). */
3723
- clearApp(connection) {
3724
- const existing = this._readAttachment(connection);
3725
- this.options.write(connection, { binding: existing.binding });
3726
- }
3727
- /** Every live connection paired with its (validated) app state — for rebuilding in-memory state after a wake. */
3728
- entries() {
3729
- return this.options.getConnections().map((connection) => [connection, this._readAttachment(connection).app ?? null]);
3730
- }
3731
- /** @internal Persist a freshly-bound connection's binding, preserving any app state already stored. */
3732
- _persistBinding(connection, binding) {
3733
- const existing = this._readAttachment(connection);
3734
- this.options.write(connection, {
3735
- app: existing.app,
3736
- binding
3737
- });
3738
- }
3739
- /** @internal The persisted binding for a connection, if any (used to replay routing after a wake). */
3740
- _readBinding(connection) {
3741
- return this._readAttachment(connection).binding;
3571
+ static create(options) {
3572
+ return new LinkTransport(options);
3742
3573
  }
3743
- _readAttachment(connection) {
3744
- try {
3745
- const raw = this.options.read(connection);
3746
- if (typeof raw !== "object" || raw === null) return {};
3747
- const attachment = raw;
3748
- const result = {};
3749
- if (attachment.binding != null) result.binding = attachment.binding;
3750
- if (attachment.app !== void 0) {
3751
- const app = this._validateApp(attachment.app);
3752
- if (app !== void 0) result.app = app;
3753
- }
3754
- return result;
3755
- } catch {
3756
- return {};
3757
- }
3574
+ _createConnection(ctx) {
3575
+ const options = this.options;
3576
+ return new LinkConnection({ initialize: () => ({
3577
+ getTransportCacheKey: options.getTransportCacheKey,
3578
+ isAvailable: options.available,
3579
+ getTransport: (input) => ({
3580
+ status: "ready",
3581
+ readyData: {
3582
+ channel: options.openChannel(input),
3583
+ formatMessage: options.createFormatMessage?.() ?? options.formatMessage,
3584
+ updateRunConfig: options.updateRunConfig,
3585
+ secureChannel: options.security
3586
+ }
3587
+ })
3588
+ }) }, ctx.resolvers);
3758
3589
  }
3759
- _validateApp(value) {
3760
- const schema = this.options.schema;
3761
- if (schema == null) return value;
3762
- const result = schema["~standard"].validate(value);
3763
- if (result instanceof Promise) return void 0;
3764
- if (result.issues != null) return void 0;
3765
- return result.value;
3590
+ getRouteInfo(input) {
3591
+ if (this.options.getRouteInfo != null) return this.options.getRouteInfo(input);
3592
+ return {
3593
+ carrierLabel: this.options.label ?? "link",
3594
+ summary: this.options.label ?? "link"
3595
+ };
3766
3596
  }
3767
3597
  };
3768
- /**
3769
- * Build a per-connection {@link ConnectionStateStore} bound to an {@link AcceptorHandler}: it registers
3770
- * itself as the handler's connection-bound persistence callback (so bindings are written without
3771
- * overwriting app state) and immediately replays every live connection's stored binding via
3772
- * {@link AcceptorHandler.rehydrateConnection} — so on a transport that resumes after eviction (e.g. a
3773
- * Durable Object waking from hibernation) both the app identity and the action routing come back from a
3774
- * single attachment, with no storage reads and no hand-rolled merge.
3775
- *
3776
- * Lives outside the handler so the generic {@link AcceptorHandler} stays free of any attachment/
3777
- * hibernation concern — it exposes only the neutral `setOnConnectionBound` + `rehydrateConnection`
3778
- * hooks this builder drives.
3779
- */
3780
- function createConnectionStateStore(handler, options) {
3781
- const store = new ConnectionStateStore(options);
3782
- handler.setOnConnectionBound((connection, binding) => store._persistBinding(connection, binding));
3783
- for (const connection of options.getConnections()) {
3784
- const binding = store._readBinding(connection);
3785
- if (binding != null) handler.rehydrateConnection(connection, binding);
3786
- }
3787
- return store;
3788
- }
3789
3598
  //#endregion
3790
- //#region src/ActionRuntime/Transport/Carrier/duplex/inMemory/createInMemoryChannel.ts
3791
- /**
3792
- * Two cross-wired in-process byte channels — a loopback carrier with no socket. The client end is a
3793
- * {@link IDuplexCarrier} you hand to a {@link LinkTransport}; the server end plugs into an
3794
- * `AcceptorHandler` (`send: (_, f) => serverEndpoint.send(f)`, and `serverEndpoint.onMessage(f =>
3795
- * handler.receive(conn, f))`). Frames are delivered on a microtask, so each side observes the other
3796
- * asynchronously — exactly like a real transport — which makes this ideal for tests and for running
3797
- * two runtimes in one process (or proving a non-WS carrier end to end).
3798
- */
3799
- function createInMemoryChannelPair() {
3800
- let clientMessage;
3801
- let clientClose;
3802
- let serverMessage;
3803
- let serverClose;
3804
- let open = true;
3805
- const closeBoth = () => {
3806
- if (!open) return;
3807
- open = false;
3808
- queueMicrotask(() => {
3809
- clientClose?.();
3810
- serverClose?.();
3811
- });
3812
- };
3813
- return {
3814
- clientChannel: {
3815
- ready: Promise.resolve(),
3816
- isOpen: () => open,
3817
- send: (frame) => {
3818
- if (!open) return;
3819
- queueMicrotask(() => serverMessage?.(frame));
3820
- },
3821
- attach: ({ onMessage, onClose }) => {
3822
- clientMessage = onMessage;
3823
- clientClose = onClose;
3824
- },
3825
- close: closeBoth,
3826
- label: "in-memory"
3827
- },
3828
- serverEndpoint: {
3829
- send: (frame) => {
3830
- if (!open) return;
3831
- queueMicrotask(() => clientMessage?.(frame));
3832
- },
3833
- onMessage: (handler) => {
3834
- serverMessage = handler;
3835
- },
3836
- onClose: (handler) => {
3837
- serverClose = handler;
3838
- },
3839
- close: closeBoth
3840
- }
3599
+ //#region src/ActionRuntime/Transport/plainTransport.ts
3600
+ function plainTransport(options) {
3601
+ const carrier = options.carrier;
3602
+ if (isExchangeCarrierSource(carrier)) return ExchangeTransport.create({
3603
+ openCarrier: carrier.open,
3604
+ getTransportCacheKey: carrier.getCacheKey,
3605
+ available: options.available,
3606
+ getRouteInfo: carrier.getRouteInfo,
3607
+ label: options.label ?? carrier.carrierLabel,
3608
+ updateRunConfig: options.updateRunConfig
3609
+ });
3610
+ return LinkTransport.create({
3611
+ openChannel: carrier.open,
3612
+ formatMessage: options.formatMessage,
3613
+ createFormatMessage: options.createFormatMessage,
3614
+ getTransportCacheKey: carrier.getCacheKey,
3615
+ available: options.available,
3616
+ getRouteInfo: carrier.getRouteInfo,
3617
+ label: options.label ?? carrier.carrierLabel,
3618
+ updateRunConfig: options.updateRunConfig
3619
+ });
3620
+ }
3621
+ //#endregion
3622
+ //#region src/ActionRuntime/Transport/secureTransport.ts
3623
+ function secureTransport(options) {
3624
+ const link = options.link ?? (options.storageAdapter != null ? new ClientCryptoKeyLink({ storageAdapter: options.storageAdapter }) : void 0);
3625
+ if (link == null) throw new Error("secureTransport: provide `link` or `storageAdapter` for the crypto identity.");
3626
+ const security = {
3627
+ securityLevel: options.securityLevel,
3628
+ link,
3629
+ localCoordinate: options.runtime.coordinate.toJsonObject(),
3630
+ dictionaryVersion: options.channel.dictionaryVersion
3841
3631
  };
3632
+ const carrier = options.carrier;
3633
+ if (isExchangeCarrierSource(carrier)) return ExchangeTransport.create({
3634
+ openCarrier: carrier.open,
3635
+ getTransportCacheKey: carrier.getCacheKey,
3636
+ available: options.available,
3637
+ getRouteInfo: carrier.getRouteInfo,
3638
+ label: carrier.carrierLabel,
3639
+ security
3640
+ });
3641
+ return LinkTransport.create({
3642
+ openChannel: carrier.open,
3643
+ createFormatMessage: options.channel.createCodec,
3644
+ getTransportCacheKey: carrier.getCacheKey,
3645
+ available: options.available,
3646
+ getRouteInfo: carrier.getRouteInfo,
3647
+ label: carrier.carrierLabel,
3648
+ security
3649
+ });
3842
3650
  }
3843
3651
  //#endregion
3844
- //#region src/ActionRuntime/Transport/Carrier/duplex/inMemory/inMemoryCarrier.ts
3652
+ //#region src/ActionRuntime/Channel/ActionChannel.ts
3845
3653
  /**
3846
- * A loopback duplex carrier with no socket two cross-wired in-process ends. The connector end is an
3847
- * {@link IDuplexCarrierSource} for {@link secureTransport}; the acceptor end plugs into an
3848
- * `AcceptorHandler`. Ideal for tests and for running two runtimes in one process, or proving a
3849
- * non-WS carrier end to end.
3654
+ * Declare a transport-agnostic channel by role. Use it for HTTP, custom transports, or as the routing
3655
+ * half of a richer channel. The order of each list is part of the contract for wire formats that pack
3656
+ * positionally (see `defineSecureChannel`) add new domains to the end of their list. (`domains` is
3657
+ * accepted as a legacy alias for `toAcceptor`.)
3850
3658
  */
3851
- function inMemoryCarrier() {
3852
- const { clientChannel, serverEndpoint } = createInMemoryChannelPair();
3659
+ function defineChannel(options) {
3853
3660
  return {
3854
- carrier: {
3855
- carrierLabel: "memory",
3856
- open: () => clientChannel,
3857
- getCacheKey: () => ["memory"]
3858
- },
3859
- serverEndpoint
3661
+ toAcceptorDomains: options.toAcceptor,
3662
+ toConnectorDomains: options.toConnector
3860
3663
  };
3861
3664
  }
3862
- //#endregion
3863
- //#region src/ActionRuntime/Transport/Carrier/duplex/rtc/rtcDataChannelByteChannel.ts
3864
3665
  /**
3865
- * Adapt a WebRTC `RTCDataChannel` to the carrier-agnostic {@link IDuplexCarrier}, so two browsers
3866
- * (or two mobile apps) linked peer-to-peer no server in the middle run the *same* secure session as
3867
- * a WebSocket. Hand it to `createSecureLinkTransport({ openChannel: () => rtcDataChannelByteChannel(dc) })`.
3868
- *
3869
- * The data channel must already be created (its negotiation/signaling is the app's concern); this only
3870
- * drives bytes over it. Binary frames are requested as `ArrayBuffer` so the binary session codec unpacks
3871
- * them synchronously; a `Blob` (if the channel hands one back) is normalized to a buffer.
3666
+ * Open a connection to a peer from a single call — the dial-out dual of `serveChannel`. The channel is
3667
+ * the single source of truth for *what* is routed (`toAcceptor` domains forwarded to the peer,
3668
+ * `toConnector` pushes handled locally from `onPush`); the call binds the shared facts — the channel's
3669
+ * codec/dictionary version, the runtime, and one crypto identity (a {@link ClientCryptoKeyLink} over
3670
+ * `storage`) into every transport in `transports`, so none of them restate the channel or runtime.
3671
+ * List several transports to make the path transport-agnostic (secure WS preferred, HTTP fallback):
3672
+ * ```ts
3673
+ * const handler = connectChannel(runtime, lobbyChannel, {
3674
+ * peer: runtime_coordinate_lobby_do,
3675
+ * storage,
3676
+ * transports: [{ carrier: wsCarrier(url) }, { carrier: httpCarrier(...), secure: false }],
3677
+ * onPush: { player_joined: (p) => { … } },
3678
+ * });
3679
+ * ```
3680
+ * Returns the {@link ConnectorHandler} so the caller can later `clearTransportCache()` it.
3872
3681
  */
3873
- function rtcDataChannelByteChannel(dc) {
3874
- dc.binaryType = "arraybuffer";
3875
- let intentional = false;
3876
- let onMessage;
3877
- let onClose;
3878
- const preAttach = [];
3879
- const deliver = (frame) => {
3880
- if (onMessage != null) onMessage(frame);
3881
- else preAttach.push(frame);
3882
- };
3883
- dc.addEventListener("message", async (event) => {
3884
- const frame = await normalizeFrame$1(event.data);
3885
- if (frame !== void 0) deliver(frame);
3886
- });
3887
- dc.addEventListener("close", () => {
3888
- if (!intentional) console.error("RTCDataChannel closed");
3889
- onClose?.();
3682
+ function connectChannel(runtime, channel, options) {
3683
+ const securityLevel = options.securityLevel ?? "authenticated";
3684
+ const anySecure = options.transports.some((transport) => transport.secure ?? true);
3685
+ let link = options.link;
3686
+ if (anySecure && link == null) {
3687
+ if (options.storage == null) throw new Error("connectChannel: a secure transport requires `storage` (or `link`). Pass it, or set `secure: false` on the transport for a plain connection.");
3688
+ link = new ClientCryptoKeyLink({ storageAdapter: options.storage });
3689
+ }
3690
+ const transports = options.transports.map((transport) => {
3691
+ const carrier = transport.carrier;
3692
+ const secure = transport.secure ?? true;
3693
+ if (isExchangeCarrierSource(carrier)) return secure ? secureTransport({
3694
+ channel,
3695
+ runtime,
3696
+ link,
3697
+ securityLevel: transport.securityLevel ?? securityLevel,
3698
+ available: transport.available,
3699
+ carrier
3700
+ }) : plainTransport({
3701
+ carrier,
3702
+ available: transport.available,
3703
+ label: transport.label
3704
+ });
3705
+ return secure ? secureTransport({
3706
+ channel,
3707
+ runtime,
3708
+ link,
3709
+ securityLevel: transport.securityLevel ?? securityLevel,
3710
+ available: transport.available,
3711
+ carrier
3712
+ }) : plainTransport({
3713
+ carrier,
3714
+ createFormatMessage: channel.createCodec,
3715
+ available: transport.available,
3716
+ label: transport.label
3717
+ });
3890
3718
  });
3891
- dc.addEventListener("error", (event) => {
3892
- console.error("RTCDataChannel error:", event);
3893
- onClose?.();
3719
+ const pushHandlers = options.onPush != null ? channel.toConnectorDomains.map((domain) => domain.wrapAsPartialLocalHandler(options.onPush)) : [];
3720
+ return runtime.connectTo(options.peer, {
3721
+ transports,
3722
+ domains: [...channel.toAcceptorDomains],
3723
+ localHandlers: pushHandlers,
3724
+ defaultTimeout: options.defaultTimeout
3894
3725
  });
3895
- return {
3896
- ready: new Promise((resolve, reject) => {
3897
- if (dc.readyState === "open") {
3898
- resolve();
3899
- return;
3900
- }
3901
- dc.addEventListener("open", () => resolve(), { once: true });
3902
- dc.addEventListener("error", (event) => reject(event), { once: true });
3903
- dc.addEventListener("close", () => reject(/* @__PURE__ */ new Error("RTCDataChannel closed before open")), { once: true });
3904
- }),
3905
- isOpen: () => dc.readyState === "open",
3906
- send: (frame) => {
3907
- if (typeof frame === "string" || frame instanceof ArrayBuffer) dc.send(frame);
3908
- else dc.send(new Uint8Array(frame));
3909
- },
3910
- attach: (handlers) => {
3911
- onMessage = handlers.onMessage;
3912
- onClose = handlers.onClose;
3913
- for (const frame of preAttach) handlers.onMessage(frame);
3914
- preAttach.length = 0;
3915
- },
3916
- close: () => {
3917
- intentional = true;
3918
- try {
3919
- dc.close();
3920
- } catch {}
3921
- },
3922
- get label() {
3923
- return dc.label != null && dc.label !== "" ? dc.label : void 0;
3924
- }
3925
- };
3926
3726
  }
3927
- async function normalizeFrame$1(data) {
3928
- if (typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array) return data;
3929
- if (typeof Blob !== "undefined" && data instanceof Blob) return await data.arrayBuffer();
3727
+ /**
3728
+ * Register an acceptor handler's execution for a channel straight from its definition: the channel's
3729
+ * `toAcceptor` domains are served together with one merged, connection-aware case map (each case gets
3730
+ * the primed request + the originating connection, as with
3731
+ * {@link AcceptorHandler.forConnectionDomainCases}). The domain list is taken from the channel,
3732
+ * never restated. Add the returned handler to the runtime alongside the acceptor handler:
3733
+ * ```ts
3734
+ * runtime.addHandlers([acceptChannelConnections(serverHandler, channel, { … }), serverHandler]);
3735
+ * ```
3736
+ */
3737
+ function acceptChannelConnections(serverHandler, channel, cases) {
3738
+ return serverHandler.forConnectionDomainCasesMulti(channel.toAcceptorDomains, cases);
3930
3739
  }
3931
- //#endregion
3932
- //#region src/ActionRuntime/Transport/Carrier/duplex/rtc/rtcCarrier.ts
3933
3740
  /**
3934
- * A WebRTC {@link IDuplexCarrierSource} over an already-negotiated `RTCDataChannel` (signaling is the
3935
- * app's concern). Hand it to {@link secureTransport} so two browsers/apps linked peer-to-peer run the
3936
- * identical secure session as a WebSocket.
3741
+ * Build the secure {@link AcceptorHandler} for a channel the accept-in counterpart to
3742
+ * {@link connectChannel}. It folds in the same boilerplate as {@link createSecureAcceptorHandler} (the
3743
+ * `ClientCryptoKeyLink` + storage-backed TOFU resolver from one `storageAdapter`, the channel's codec +
3744
+ * dictionary version, the `security` block from the runtime coordinate) but takes the `(runtime, channel,
3745
+ * options)` shape of the channel family. Pair it with {@link acceptChannelConnections} for execution:
3746
+ * ```ts
3747
+ * const acceptor = acceptChannel(runtime, gameChannel, { clientEnv, storageAdapter, send });
3748
+ * runtime.addHandlers([acceptChannelConnections(acceptor, gameChannel, { … }), acceptor]);
3749
+ * ```
3937
3750
  */
3938
- function rtcCarrier(dataChannel, options = {}) {
3939
- return {
3940
- carrierLabel: "webrtc",
3941
- open: () => rtcDataChannelByteChannel(dataChannel),
3942
- getCacheKey: options.getTransportCacheKey ?? (() => ["webrtc"]),
3943
- getRouteInfo: options.getRouteInfo
3944
- };
3751
+ function acceptChannel(runtime, channel, options) {
3752
+ return createSecureAcceptorHandler({
3753
+ channel,
3754
+ runtime,
3755
+ clientEnv: options.clientEnv,
3756
+ storageAdapter: options.storageAdapter,
3757
+ link: options.link,
3758
+ send: options.send,
3759
+ securityLevel: options.securityLevel,
3760
+ verifyKeyResolver: options.verifyKeyResolver,
3761
+ defaultTimeout: options.defaultTimeout
3762
+ });
3945
3763
  }
3946
3764
  //#endregion
3947
- //#region src/ActionRuntime/Transport/Carrier/duplex/ws/err_nice_transport_ws.ts
3948
- let EErrId_NiceTransport_WebSocket = /* @__PURE__ */ function(EErrId_NiceTransport_WebSocket) {
3949
- EErrId_NiceTransport_WebSocket["ws_disconnected"] = "ws_disconnected";
3950
- EErrId_NiceTransport_WebSocket["ws_create_failed"] = "ws_create_failed";
3951
- EErrId_NiceTransport_WebSocket["ws_error"] = "ws_error";
3952
- return EErrId_NiceTransport_WebSocket;
3953
- }({});
3954
- const err_nice_transport_ws = err_nice_transport.createChildDomain({
3955
- domain: "ws_transport",
3956
- schema: {
3957
- ["ws_disconnected"]: err({ message: () => `WebSocket transport disconnected.` }),
3958
- ["ws_create_failed"]: err({ message: ({ originalError }) => `Failed to create WebSocket transport.${originalError ? ` Original error: ${originalError.message}` : ""}` }),
3959
- ["ws_error"]: err({ message: ({ originalError }) => `WebSocket transport error.${originalError ? ` Original error: ${originalError.message}` : ""}` })
3960
- }
3961
- });
3962
- //#endregion
3963
- //#region src/ActionRuntime/Transport/Carrier/duplex/ws/ws_util.ts
3765
+ //#region src/ActionRuntime/Transport/codec/actionWireCodec.ts
3964
3766
  /**
3965
- * Send a text or binary frame over a socket. A binary formatter may hand back a `Uint8Array` whose
3966
- * backing buffer is typed as `ArrayBufferLike` (msgpackr pools buffers / may be `SharedArrayBuffer`),
3967
- * which `WebSocket.send`'s `BufferSource` parameter rejects — copy it into a fresh `ArrayBuffer`-backed
3968
- * view so the type (and the bytes) are safe to send.
3767
+ * Tiny integer codes for the payload type, so the verbose `"request"`/`"result"`/`"progress"`
3768
+ * strings never hit the wire. The index in {@link ReversePayloadType} must line up with the value.
3969
3769
  */
3970
- function sendFrame(ws, data) {
3971
- if (typeof data === "string" || data instanceof ArrayBuffer) {
3972
- ws.send(data);
3973
- return;
3974
- }
3975
- ws.send(new Uint8Array(data));
3976
- }
3977
- /** Compact a WebSocket URL to `host/pathname` for devtools display, falling back to the raw url. */
3978
- function shortWs(url) {
3979
- try {
3980
- const u = new URL(url);
3981
- return `${u.host}${u.pathname}`;
3982
- } catch {
3983
- return url;
3984
- }
3985
- }
3986
- //#endregion
3987
- //#region src/ActionRuntime/Transport/Carrier/duplex/ws/webSocketByteChannel.ts
3770
+ const PayloadTypeToInt = {
3771
+ ["request"]: 0,
3772
+ ["result"]: 1,
3773
+ ["progress"]: 2
3774
+ };
3775
+ const ReversePayloadType = [
3776
+ "request",
3777
+ "result",
3778
+ "progress"
3779
+ ];
3988
3780
  /**
3989
- * Adapt a `WebSocket` to the carrier-agnostic {@link IDuplexCarrier}, so the WebSocket becomes
3990
- * "just another carrier" under the shared secure session. It owns every WebSocket-specific concern
3991
- * awaiting `open`, normalizing `Blob` frames to bytes, suppressing the log on a deliberate `close`, and
3992
- * buffering any frame that arrives before the session attaches its handler — leaving the session itself
3993
- * carrier-neutral.
3781
+ * Build the positional `domain:id` integer dictionary. Both ends of a channel MUST build it from
3782
+ * the same domains in the same order the mapping is positional, so a mismatch routes to the wrong
3783
+ * action. Add new transported domains to the end of the list.
3994
3784
  */
3995
- function webSocketByteChannel(ws) {
3996
- let intentional = false;
3997
- let onMessage;
3998
- let onClose;
3999
- const preAttach = [];
4000
- const deliver = (frame) => {
4001
- if (onMessage != null) onMessage(frame);
4002
- else preAttach.push(frame);
4003
- };
4004
- ws.addEventListener("message", async (event) => {
4005
- const frame = await normalizeFrame(event.data);
4006
- if (frame !== void 0) deliver(frame);
4007
- });
4008
- ws.addEventListener("close", (event) => {
4009
- if (!intentional) console.error("WebSocket closed:", event);
4010
- onClose?.();
4011
- });
4012
- ws.addEventListener("error", (event) => {
4013
- console.error("WebSocket error:", event);
4014
- onClose?.();
4015
- });
3785
+ function buildActionRouteDictionary(domains) {
3786
+ const routeToInt = /* @__PURE__ */ new Map();
3787
+ const intToRoute = [];
3788
+ for (const dom of domains) for (const actionId of Object.keys(dom.actionSchema)) {
3789
+ const routeKey = `${dom.domain}:${actionId}`;
3790
+ if (routeToInt.has(routeKey)) continue;
3791
+ routeToInt.set(routeKey, intToRoute.length);
3792
+ intToRoute.push({
3793
+ domain: dom.domain,
3794
+ id: actionId,
3795
+ allDomains: dom.allDomains
3796
+ });
3797
+ }
4016
3798
  return {
4017
- ready: new Promise((resolve, reject) => {
4018
- if (ws.readyState === WebSocket.OPEN) {
4019
- resolve();
4020
- return;
4021
- }
4022
- ws.addEventListener("open", () => resolve(), { once: true });
4023
- ws.addEventListener("error", (event) => reject(event), { once: true });
4024
- ws.addEventListener("close", (event) => reject(/* @__PURE__ */ new Error(`WebSocket closed before open: code=${event.code}`)), { once: true });
4025
- }),
4026
- isOpen: () => ws.readyState === WebSocket.OPEN,
4027
- send: (frame) => sendFrame(ws, frame),
4028
- attach: (handlers) => {
4029
- onMessage = handlers.onMessage;
4030
- onClose = handlers.onClose;
4031
- for (const frame of preAttach) handlers.onMessage(frame);
4032
- preAttach.length = 0;
4033
- },
4034
- close: () => {
4035
- intentional = true;
4036
- try {
4037
- ws.close();
4038
- } catch {}
4039
- },
4040
- get label() {
4041
- return ws.url != null && ws.url !== "" ? ws.url : void 0;
4042
- }
3799
+ routeToInt,
3800
+ intToRoute
4043
3801
  };
4044
3802
  }
4045
- /** Accept text + binary frames (ArrayBuffer / Uint8Array / Blob); Blobs are converted to a buffer. */
4046
- async function normalizeFrame(data) {
4047
- if (typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array) return data;
4048
- if (typeof Blob !== "undefined" && data instanceof Blob) return await data.arrayBuffer();
3803
+ /** Pull the type-specific payload (`input` / `result` / `progress`) out of a wire JSON object. */
3804
+ function extractWirePayload(json) {
3805
+ if (json.type === "request") return json.input;
3806
+ if (json.type === "result") return json.result;
3807
+ if (json.type === "progress") return json.progress;
4049
3808
  }
4050
- //#endregion
4051
- //#region src/ActionRuntime/Transport/Carrier/duplex/ws/wsCarrier.ts
4052
3809
  /**
4053
- * A WebSocket {@link IDuplexCarrierSource}: opens an `arraybuffer` socket to `url` (cached per endpoint)
4054
- * and adapts it to a carrier. Hand it to {@link secureTransport} (or `LinkTransport`) — the WebSocket is
4055
- * now "just another carrier" under the shared secure session, with no WS-specific transport class.
3810
+ * Reassemble a full wire JSON object from its decoded parts. `inputHash`/`outputHash` are emitted
3811
+ * empty the hydration constructors recompute them and the result still satisfies
3812
+ * `isActionPayload_Any_JsonObject` so it flows through validation like a JSON frame.
4056
3813
  */
4057
- function wsCarrier(url, options = {}) {
4058
- return {
4059
- carrierLabel: "ws",
4060
- open: (input) => webSocketByteChannel(options.createWebSocket?.(input) ?? defaultWebSocket(url)),
4061
- getCacheKey: options.getTransportCacheKey ?? (() => [url]),
4062
- getRouteInfo: options.getRouteInfo ?? (() => ({
4063
- carrierLabel: "ws",
4064
- url,
4065
- summary: `ws ${shortWs(url)}`
4066
- }))
3814
+ function assembleWireJson(routeMeta, payloadType, time, context, payloadData) {
3815
+ const base = {
3816
+ form: "data",
3817
+ domain: routeMeta.domain,
3818
+ id: routeMeta.id,
3819
+ allDomains: routeMeta.allDomains,
3820
+ time,
3821
+ context
4067
3822
  };
4068
- }
4069
- function defaultWebSocket(url) {
4070
- const ws = new WebSocket(url);
4071
- ws.binaryType = "arraybuffer";
4072
- return ws;
4073
- }
4074
- //#endregion
4075
- //#region src/ActionRuntime/Transport/Carrier/exchange/http/httpAcceptorCarrier.ts
4076
- /**
4077
- * An HTTP {@link IExchangeAcceptorCarrier}: the accept-in dual of {@link httpCarrier}. It serves the
4078
- * secure exchange protocol (handshake → token session → encrypted frames) over web-standard
4079
- * `Request`/`Response`. The crypto identity, runtime coordinate, dictionary version, and accepted security
4080
- * levels are all supplied centrally by `serveChannel`, so this only needs to say which requests carry an
4081
- * action envelope and how to answer CORS.
4082
- */
4083
- function httpAcceptorCarrier(options = {}) {
4084
- return {
4085
- shape: "exchange",
4086
- carrierLabel: options.carrierLabel ?? "http",
4087
- secure: options.secure,
4088
- isActionPath: options.isActionPath,
4089
- cors: options.cors,
4090
- useErrorStatus: options.useErrorStatus
3823
+ if (payloadType === "request") return {
3824
+ ...base,
3825
+ type: "request",
3826
+ input: payloadData,
3827
+ inputHash: ""
3828
+ };
3829
+ if (payloadType === "result") return {
3830
+ ...base,
3831
+ type: "result",
3832
+ result: payloadData,
3833
+ outputHash: ""
4091
3834
  };
4092
- }
4093
- //#endregion
4094
- //#region src/ActionRuntime/Transport/Carrier/exchange/http/httpCarrier.ts
4095
- function shortPath(url) {
4096
- try {
4097
- return new URL(url).pathname || url;
4098
- } catch {
4099
- return url;
4100
- }
4101
- }
4102
- /**
4103
- * An HTTP {@link IExchangeCarrierSource}: each `exchange` POSTs one frame body to the action endpoint and
4104
- * resolves with the response body as the single correlated reply. Hand it to {@link secureTransport} —
4105
- * HTTP then runs the *same* secure session as a duplex carrier (handshake → token → encrypted frames),
4106
- * the request/reply correlation provided for free by the HTTP transaction.
4107
- *
4108
- * `createRequest` derives the URL/headers per action (keep it simple with `() => ({ url })`). The body is
4109
- * the session's responsibility, so it is never built here.
4110
- */
4111
- function httpCarrier(createRequest, options = {}) {
4112
- const doFetch = options.fetch ?? fetch;
4113
3835
  return {
4114
- shape: "exchange",
4115
- carrierLabel: "http",
4116
- open: (input) => {
4117
- const request = createRequest(input);
4118
- return {
4119
- label: request.url,
4120
- exchange: async (frame, opts) => {
4121
- return await (await doFetch(request.url, {
4122
- method: "POST",
4123
- headers: {
4124
- "Content-Type": "application/json",
4125
- ...request.headers
4126
- },
4127
- body: typeof frame === "string" ? frame : new Uint8Array(frame),
4128
- signal: opts?.signal
4129
- })).text();
4130
- }
4131
- };
4132
- },
4133
- getCacheKey: options.getTransportCacheKey ?? ((input) => [createRequest(input).url]),
4134
- getRouteInfo: options.getRouteInfo ?? ((input) => {
4135
- const { url } = createRequest(input);
4136
- return {
4137
- carrierLabel: "http",
4138
- method: "POST",
4139
- url,
4140
- summary: `POST ${shortPath(url)}`
4141
- };
4142
- })
3836
+ ...base,
3837
+ type: "progress",
3838
+ progress: payloadData
4143
3839
  };
4144
3840
  }
4145
3841
  //#endregion
4146
- //#region src/ActionRuntime/Transport/codec/createBinaryWireAdapter.ts
3842
+ //#region src/ActionRuntime/Transport/codec/createBinaryWireSessionFactory.ts
4147
3843
  /**
4148
- * Positional layout of the stateless binary envelope. A flat tuple (rather than an object) strips the
4149
- * repeated `domain`/`id`/`form`/`type` and context key names from every frame, and we carry only the
4150
- * context fields the receiver can't recompute: `cuid` (correlation) and `originClient` (return
4151
- * routing).
4152
- *
4153
- * [ routeInt, typeInt, time, cuid, originClient, payloadData ]
3844
+ * Positional layout of the *session* binary envelope the leanest frame. Compared to the stateless
3845
+ * adapter it replaces the 21-char `cuid` with a small per-connection integer and only carries
3846
+ * `originClient` on the very first request of each direction (the peer remembers it afterwards).
4154
3847
  *
4155
- * Dropped vs the JSON wire: `form`/`type` strings, `inputHash`/`outputHash` (recomputed on hydrate),
4156
- * `context.timeCreated` (reconstructed from `time`) and `context.routing` (rebuilt empty — the
4157
- * receiver re-stamps its own route items as it handles the action). For the leanest possible frames
4158
- * (integer correlation, identity dropped after a handshake), use `createBinaryWireSessionFactory`.
3848
+ * [ routeInt, typeInt, corrId, time, originClient?, payloadData ]
4159
3849
  */
4160
- const ENVELOPE = {
3850
+ const ENVELOPE$1 = {
4161
3851
  route: 0,
4162
3852
  type: 1,
4163
- time: 2,
4164
- cuid: 3,
3853
+ corr: 2,
3854
+ time: 3,
4165
3855
  originClient: 4,
4166
3856
  payload: 5
4167
3857
  };
4168
- const ENVELOPE_LENGTH = 6;
3858
+ const ENVELOPE_LENGTH$1 = 6;
4169
3859
  /**
4170
- * Builds a *stateless* `formatMessage` pipeline for {@link LinkTransport}, packing action
4171
- * payloads into a compact msgpackr binary frame instead of JSON. The `domain`/`id` route collapses to
4172
- * a single integer drawn from a shared dictionary; `form`/`type`, the recomputable
4173
- * `inputHash`/`outputHash`, and the per-frame `context.routing`/`context.timeCreated` are all dropped
4174
- * (see {@link ENVELOPE}).
4175
- *
4176
- * No validation runs here: `incoming` blindly reconstructs the wire JSON shape and hands it back to
4177
- * the connection, which flows into `ActionRuntime` → `domain.hydrateAnyAction()` where the Valibot
4178
- * schemas validate it exactly as they would for a JSON frame.
4179
- *
4180
- * Both ends of the socket MUST construct the adapter with the same domains in the same order — the
4181
- * integer dictionary is positional. Mismatched dictionaries will route to the wrong action.
4182
- *
4183
- * Because `incoming` returns `undefined` for text frames, a binary server can still serve plain-JSON
4184
- * clients on the same runtime (the connection falls back to its built-in JSON parser).
3860
+ * How long a pending correlation entry is kept before it's swept. A correlation only matters until its
3861
+ * action resolves or times out, so anything older than the longest realistic action timeout can be
3862
+ * dropped this bounds memory when requests time out or a connection dies mid-flight (their replies
3863
+ * would never arrive, leaving the entry orphaned). Generous default so live correlations are never
3864
+ * pruned (the default transport timeout is 10s).
4185
3865
  */
4186
- function createBinaryWireAdapter(domains) {
4187
- const { routeToInt, intToRoute } = buildActionRouteDictionary(domains);
4188
- return {
4189
- outgoing: (input) => {
4190
- const json = input.action.toJsonObject();
4191
- const routeKey = `${json.domain}:${json.id}`;
4192
- const routeInt = routeToInt.get(routeKey);
4193
- if (routeInt == null) throw new Error(`[binary-wire] Cannot pack unregistered action route: ${routeKey}`);
4194
- const envelope = new Array(ENVELOPE_LENGTH);
4195
- envelope[ENVELOPE.route] = routeInt;
4196
- envelope[ENVELOPE.type] = PayloadTypeToInt[json.type];
4197
- envelope[ENVELOPE.time] = json.time;
4198
- envelope[ENVELOPE.cuid] = json.context.cuid;
4199
- envelope[ENVELOPE.originClient] = json.context.originClient;
4200
- envelope[ENVELOPE.payload] = extractWirePayload(json);
4201
- return pack(envelope);
4202
- },
4203
- incoming: (frame) => {
4204
- let buffer;
4205
- if (frame instanceof ArrayBuffer) buffer = new Uint8Array(frame);
4206
- else if (frame instanceof Uint8Array) buffer = frame;
4207
- else return;
4208
- try {
4209
- const envelope = unpack(buffer);
4210
- if (!Array.isArray(envelope) || envelope.length !== ENVELOPE_LENGTH) return void 0;
4211
- const routeMeta = intToRoute[envelope[ENVELOPE.route]];
4212
- const payloadType = ReversePayloadType[envelope[ENVELOPE.type]];
4213
- if (routeMeta == null || payloadType == null) return void 0;
4214
- const time = envelope[ENVELOPE.time];
4215
- return assembleWireJson(routeMeta, payloadType, time, {
4216
- cuid: envelope[ENVELOPE.cuid],
4217
- timeCreated: time,
4218
- routing: [],
4219
- originClient: envelope[ENVELOPE.originClient]
4220
- }, envelope[ENVELOPE.payload]);
4221
- } catch (e) {
4222
- console.error("[binary-wire] Failed to unpack binary action frame", e);
4223
- return;
4224
- }
4225
- }
4226
- };
3866
+ const DEFAULT_CORRELATION_TTL_MS = 5 * 6e4;
3867
+ function isKnownIdentity(coordinate) {
3868
+ return coordinate != null && coordinate.envId !== "_unset_";
4227
3869
  }
4228
- //#endregion
4229
- //#region src/ActionRuntime/Transport/Transport.ts
4230
3870
  /**
4231
- * Reusable transport definition. Devs construct these (`secureTransport({ carrier: wsCarrier(url) })`,
4232
- * `plainTransport({ carrier: httpCarrier(...) })`, …) and pass them to a
4233
- * `ConnectorHandler`. A single
4234
- * definition can be shared across multiple handlers — each handler builds its own live
4235
- * {@link TransportConnection} via {@link TransportConnection._createConnection}.
3871
+ * Drop entries older than `ttlMs`. Maps keep insertion order and entries are inserted in time order,
3872
+ * so the oldest are first stop sweeping at the first live entry.
4236
3873
  */
4237
- var Transport = class {};
4238
- //#endregion
4239
- //#region src/ActionRuntime/Transport/SecureSession/establishExchangeSession.ts
4240
- const textEncoder = new TextEncoder();
4241
- const textDecoder = new TextDecoder();
4242
- /** Plain path (no handshake/token): every action rides a bare `act` envelope, plaintext both ways. */
4243
- function finalizePlainExchangeMethods(ctx) {
4244
- return buildExchangeMethods(ctx, {});
4245
- }
4246
- /** Secure path: run the handshake (two exchanges) once at bring-up, then reuse the token + crypto. */
4247
- async function finalizeSecureExchangeMethods(ctx) {
4248
- return buildExchangeMethods(ctx, await runConnectorExchangeHandshake(ctx.carrier, ctx.secure));
4249
- }
4250
- function buildExchangeMethods(ctx, state) {
4251
- const sendActionData = (inputs) => {
4252
- runExchange(ctx.carrier, state, inputs).catch((err) => inputs.runningAction._abort(err));
4253
- };
4254
- return {
4255
- sendActionData,
4256
- updateRunConfig: ctx.updateRunConfig
4257
- };
4258
- }
4259
- async function runExchange(carrier, state, inputs) {
4260
- const { action, runningAction, timeout } = inputs;
4261
- const ac = new AbortController();
4262
- let timedOut = false;
4263
- const timeoutId = setTimeout(() => {
4264
- timedOut = true;
4265
- ac.abort();
4266
- }, timeout);
4267
- const unsubscribe = runningAction.addUpdateListeners([(update) => {
4268
- if (update.type === "finished") {
4269
- clearTimeout(timeoutId);
4270
- ac.abort();
4271
- }
4272
- }]);
4273
- try {
4274
- const request = await buildRequestEnvelope(state, action);
4275
- const replyRaw = await carrier.exchange(encodeExchange(request), { signal: ac.signal });
4276
- if (action.type !== "request") return;
4277
- const reply = decodeExchangeReply(asString(replyRaw));
4278
- if (reply == null) throw err_nice_transport.fromId("invalid_action_response", { actionId: action.id });
4279
- if (reply.k === "err") throw err_nice_transport.fromId("send_failed", {
4280
- actionState: action.type,
4281
- actionId: action.id,
4282
- message: reply.message
4283
- });
4284
- const wire = await extractReplyWire(state, reply);
4285
- if (wire == null || !isActionPayload_Result_JsonObject(wire)) throw err_nice_transport.fromId("invalid_action_response", { actionId: action.id });
4286
- runningAction._completeWithResult(action._domain.hydrateResultPayload(wire));
4287
- } catch (err) {
4288
- if (timedOut) throw err_nice_transport.fromId("timeout", { timeout });
4289
- throw err;
4290
- } finally {
4291
- clearTimeout(timeoutId);
4292
- unsubscribe();
3874
+ function pruneExpired(map, now, ttlMs) {
3875
+ for (const [key, entry] of map) {
3876
+ if (now - entry.time <= ttlMs) break;
3877
+ map.delete(key);
4293
3878
  }
4294
3879
  }
4295
- async function buildRequestEnvelope(state, action) {
4296
- const wire = action.toJsonObject();
4297
- if (state.crypto != null) {
4298
- const ciphertext = await state.crypto.encryptFrame(textEncoder.encode(JSON.stringify(wire)));
3880
+ /**
3881
+ * Builds a factory of *stateful, per-connection* codecs for {@link LinkTransport} /
3882
+ * `AcceptorHandler` — the maximally compact binary wire. Call the returned factory once per live
3883
+ * connection (each socket on the client, each accepted connection on the server) so every channel
3884
+ * gets its own correlation + identity state.
3885
+ *
3886
+ * On top of everything {@link createBinaryWireAdapter} drops, a session also drops:
3887
+ * - **`cuid`** — replaced by a per-connection integer correlation id. The initiator maps it to its
3888
+ * real cuid; the responder echoes it; each side reconstructs the cuid from its own map. Correlation
3889
+ * only needs to be unique per socket, so a counter suffices.
3890
+ * - **`originClient` after the first request** — the first request each side sends carries its
3891
+ * identity; the peer remembers it and injects it into later frames. Replies omit it entirely (a
3892
+ * reply carries the initiator's own origin, which the initiator already knows).
3893
+ *
3894
+ * Both ends MUST build the factory from the same domains in the same order (positional dictionary).
3895
+ * Text frames still return `undefined` from `incoming`, so JSON clients remain interoperable.
3896
+ *
3897
+ * Hibernation note: after a server connection is evicted its session resets, so a still-connected
3898
+ * client (whose session persists) will keep omitting `originClient`. The server must therefore restore
3899
+ * the connection→client binding from its own store (see `AcceptorHandler.rehydrateConnection`) and
3900
+ * inject `originClient` from there — the session alone can't recover it.
3901
+ */
3902
+ function createBinaryWireSessionFactory(domains, options) {
3903
+ const { routeToInt, intToRoute } = buildActionRouteDictionary(domains);
3904
+ const unknownIdentity = RuntimeCoordinate.unknown.toJsonObject();
3905
+ const ttlMs = options?.correlationTtlMs ?? DEFAULT_CORRELATION_TTL_MS;
3906
+ return () => {
3907
+ let outCounter = 0;
3908
+ const corrToCuid = /* @__PURE__ */ new Map();
3909
+ const cuidToCorr = /* @__PURE__ */ new Map();
3910
+ let selfIdentity;
3911
+ let peerIdentity;
4299
3912
  return {
4300
- k: "act",
4301
- t: state.token,
4302
- c: bytesToBase64(ciphertext)
3913
+ outgoing: (input) => {
3914
+ const json = input.action.toJsonObject();
3915
+ const routeKey = `${json.domain}:${json.id}`;
3916
+ const routeInt = routeToInt.get(routeKey);
3917
+ if (routeInt == null) throw new Error(`[binary-wire] Cannot pack unregistered action route: ${routeKey}`);
3918
+ const now = Date.now();
3919
+ pruneExpired(corrToCuid, now, ttlMs);
3920
+ pruneExpired(cuidToCorr, now, ttlMs);
3921
+ let corr;
3922
+ let wireIdentity;
3923
+ if (json.type === "request") {
3924
+ corr = outCounter++;
3925
+ corrToCuid.set(corr, {
3926
+ value: json.context.cuid,
3927
+ time: now
3928
+ });
3929
+ if (selfIdentity == null && isKnownIdentity(json.context.originClient)) {
3930
+ selfIdentity = json.context.originClient;
3931
+ wireIdentity = json.context.originClient;
3932
+ }
3933
+ } else {
3934
+ corr = cuidToCorr.get(json.context.cuid)?.value ?? -1;
3935
+ if (json.type === "result") cuidToCorr.delete(json.context.cuid);
3936
+ }
3937
+ const envelope = new Array(ENVELOPE_LENGTH$1);
3938
+ envelope[ENVELOPE$1.route] = routeInt;
3939
+ envelope[ENVELOPE$1.type] = PayloadTypeToInt[json.type];
3940
+ envelope[ENVELOPE$1.corr] = corr;
3941
+ envelope[ENVELOPE$1.time] = json.time;
3942
+ envelope[ENVELOPE$1.originClient] = wireIdentity;
3943
+ envelope[ENVELOPE$1.payload] = extractWirePayload(json);
3944
+ return pack(envelope);
3945
+ },
3946
+ incoming: (frame) => {
3947
+ let buffer;
3948
+ if (frame instanceof ArrayBuffer) buffer = new Uint8Array(frame);
3949
+ else if (frame instanceof Uint8Array) buffer = frame;
3950
+ else return;
3951
+ try {
3952
+ const envelope = unpack(buffer);
3953
+ if (!Array.isArray(envelope) || envelope.length !== ENVELOPE_LENGTH$1) return void 0;
3954
+ const routeMeta = intToRoute[envelope[ENVELOPE$1.route]];
3955
+ const payloadType = ReversePayloadType[envelope[ENVELOPE$1.type]];
3956
+ if (routeMeta == null || payloadType == null) return void 0;
3957
+ const now = Date.now();
3958
+ pruneExpired(corrToCuid, now, ttlMs);
3959
+ pruneExpired(cuidToCorr, now, ttlMs);
3960
+ const corr = envelope[ENVELOPE$1.corr];
3961
+ const time = envelope[ENVELOPE$1.time];
3962
+ const wireIdentity = envelope[ENVELOPE$1.originClient];
3963
+ let cuid;
3964
+ let originClient;
3965
+ if (payloadType === "request") {
3966
+ cuid = nanoid();
3967
+ cuidToCorr.set(cuid, {
3968
+ value: corr,
3969
+ time: now
3970
+ });
3971
+ if (isKnownIdentity(wireIdentity)) peerIdentity = wireIdentity;
3972
+ originClient = peerIdentity ?? unknownIdentity;
3973
+ } else {
3974
+ cuid = corrToCuid.get(corr)?.value ?? nanoid();
3975
+ if (payloadType === "result") corrToCuid.delete(corr);
3976
+ originClient = selfIdentity ?? unknownIdentity;
3977
+ }
3978
+ return assembleWireJson(routeMeta, payloadType, time, {
3979
+ cuid,
3980
+ timeCreated: time,
3981
+ routing: [],
3982
+ originClient
3983
+ }, envelope[ENVELOPE$1.payload]);
3984
+ } catch (e) {
3985
+ console.error("[binary-wire] Failed to unpack binary action session frame", e);
3986
+ return;
3987
+ }
3988
+ }
4303
3989
  };
4304
- }
4305
- return {
4306
- k: "act",
4307
- t: state.token,
4308
- w: wire
4309
- };
4310
- }
4311
- async function extractReplyWire(state, reply) {
4312
- if (reply.k !== "act") return void 0;
4313
- if ("c" in reply) {
4314
- if (state.crypto == null) return void 0;
4315
- const plain = await state.crypto.decryptFrame(base64ToBytes(reply.c));
4316
- return JSON.parse(textDecoder.decode(plain));
4317
- }
4318
- return reply.w;
4319
- }
4320
- async function runConnectorExchangeHandshake(carrier, secure) {
4321
- await secure.link.initialize();
4322
- const handshake = createClientHandshake({
4323
- link: secure.link,
4324
- localCoordinate: secure.localCoordinate,
4325
- dictionaryVersion: secure.dictionaryVersion,
4326
- securityLevel: secure.securityLevel
4327
- });
4328
- const hsid = nanoid();
4329
- const hello = await handshake.createHello();
4330
- const welcomeReply = decodeExchangeReply(asString(await carrier.exchange(encodeExchange({
4331
- k: "hs",
4332
- hsid,
4333
- m: encodeHandshakeMessage(hello)
4334
- }))));
4335
- if (welcomeReply?.k !== "hs") throw new Error("[exchange-handshake] expected a welcome reply");
4336
- const welcome = decodeHandshakeMessage(welcomeReply.m);
4337
- if (welcome == null) throw new Error("[exchange-handshake] malformed welcome");
4338
- if (welcome.t === "reject") throw new Error(`[exchange-handshake] rejected by peer: ${welcome.reason}`);
4339
- if (welcome.t !== "welcome") throw new Error(`[exchange-handshake] expected welcome, got ${welcome.t}`);
4340
- const prove = await handshake.onWelcome(welcome);
4341
- const acceptReply = decodeExchangeReply(asString(await carrier.exchange(encodeExchange({
4342
- k: "hs",
4343
- hsid,
4344
- m: encodeHandshakeMessage(prove)
4345
- }))));
4346
- if (acceptReply?.k !== "hs") throw new Error("[exchange-handshake] expected an accept reply");
4347
- const accept = decodeHandshakeMessage(acceptReply.m);
4348
- if (accept == null) throw new Error("[exchange-handshake] malformed accept");
4349
- if (accept.t === "reject") throw new Error(`[exchange-handshake] rejected by peer: ${accept.reason}`);
4350
- if (accept.t !== "accept") throw new Error(`[exchange-handshake] expected accept, got ${accept.t}`);
4351
- if (acceptReply.t == null) throw new Error("[exchange-handshake] accept missing session token");
4352
- const result = await handshake.onAccept(accept);
4353
- const crypto = result.securityLevel === "encrypted" ? createActionFrameCrypto({
4354
- link: secure.link,
4355
- linkedClientId: result.linkedClientId
4356
- }) : void 0;
4357
- return {
4358
- token: acceptReply.t,
4359
- crypto
4360
3990
  };
4361
3991
  }
4362
- function asString(frame) {
4363
- if (typeof frame === "string") return frame;
4364
- return textDecoder.decode(frame instanceof ArrayBuffer ? new Uint8Array(frame) : frame);
4365
- }
4366
3992
  //#endregion
4367
- //#region src/ActionRuntime/Transport/helpers/addTransportStatusMetadata.ts
4368
- function addTransportStatusMetadata(transportStatus) {
4369
- if (transportStatus.status === "ready") return {
4370
- status: "ready",
4371
- readyData: transportStatus.readyData
4372
- };
4373
- if (transportStatus.status === "initializing") return {
4374
- status: "initializing",
4375
- initializationPromise: transportStatus.initializationPromise,
4376
- timeStarted: Date.now()
4377
- };
4378
- if (transportStatus.status === "failed") return {
4379
- status: "failed",
4380
- error: transportStatus.error,
4381
- timeFailed: Date.now()
3993
+ //#region src/ActionRuntime/Channel/secureChannel.ts
3994
+ /**
3995
+ * Derive a stable wire-dictionary version from the ordered route list (FNV-1a over `domain:id,…`), so
3996
+ * the version moves automatically whenever the transported domains change — a stale peer is then
3997
+ * rejected by the handshake instead of silently misrouting a positionally-packed frame.
3998
+ */
3999
+ function deriveDictionaryVersion(domains) {
4000
+ const { intToRoute } = buildActionRouteDictionary(domains);
4001
+ const signature = intToRoute.map((route) => `${route.domain}:${route.id}`).join(",");
4002
+ let hash = 2166136261;
4003
+ for (let i = 0; i < signature.length; i++) {
4004
+ hash ^= signature.charCodeAt(i);
4005
+ hash = Math.imul(hash, 16777619);
4006
+ }
4007
+ return `auto:${(hash >>> 0).toString(16).padStart(8, "0")}`;
4008
+ }
4009
+ /**
4010
+ * Bundle a secure channel's shared identity from its transported domains. Both ends MUST call this
4011
+ * with the same domains in the same order (the binary wire dictionary is positional). The
4012
+ * `dictionaryVersion` is derived from those domains unless you pin an explicit one.
4013
+ *
4014
+ * Declare the domains *by role* — `toAcceptor` (connector→acceptor requests) and `toConnector`
4015
+ * (acceptor→connector pushes) — so the routing for both ends is derived from the channel (see
4016
+ * {@link connectChannel} and `acceptChannelConnections`) instead of being restated at each end. The
4017
+ * wire dictionary spans `[...toAcceptor, ...toConnector]` in that order; add new domains to the end of
4018
+ * their list to keep older peers compatible. (`domains` is still accepted as a legacy alias for
4019
+ * `toAcceptor`.)
4020
+ */
4021
+ function defineSecureChannel(options) {
4022
+ const base = defineChannel({
4023
+ toAcceptor: options.toAcceptor,
4024
+ toConnector: options.toConnector
4025
+ });
4026
+ const allDomains = [...base.toAcceptorDomains, ...base.toConnectorDomains];
4027
+ return {
4028
+ ...base,
4029
+ dictionaryVersion: options.dictionaryVersion ?? deriveDictionaryVersion(allDomains),
4030
+ createCodec: createBinaryWireSessionFactory(allDomains, options.sessionOptions)
4382
4031
  };
4383
- if (transportStatus.status === "unsupported") return { status: "unsupported" };
4384
- return { status: "uninitialized" };
4385
4032
  }
4386
4033
  //#endregion
4387
- //#region src/ActionRuntime/Transport/TransportConnection.ts
4388
- let transportOrd = 0;
4034
+ //#region src/ActionRuntime/Transport/SecureSession/exchangeAcceptor.ts
4035
+ const textEncoder = new TextEncoder();
4036
+ const textDecoder = new TextDecoder();
4389
4037
  /**
4390
- * Live, per-handler transport runtime built from a reusable {@link Transport} definition. Holds the
4391
- * connection-scoped state (ordinal, initialized config, sockets / abort sets) that must not be shared
4392
- * across handlers. Construct these via `definition._createConnection(...)`, never directly.
4038
+ * Acceptor (accept-in) side of the secure exchange protocol the HTTP counterpart to
4039
+ * {@link AcceptorSecureSession}. Each POST body is one {@link decodeExchangeRequest} envelope; the
4040
+ * acceptor drives the server handshake over the two `hs` POSTs (correlated by `hsid`, since stateless
4041
+ * requests can't rely on channel ordering), mints a session **token** on accept, and on every later `act`
4042
+ * POST resolves the session by token, decrypts the body (at `encrypted`), routes it through the runtime,
4043
+ * and returns the (encrypted) result inline as the reply.
4044
+ *
4045
+ * Sessions and in-flight handshakes are held in memory — fine for a single-instance server. (Surviving a
4046
+ * Durable-Object eviction would persist each token's `keyMaterial` and re-derive the key on a miss, the
4047
+ * same primitive `AcceptorSecureSession.rehydrate` uses; left as a follow-up.)
4393
4048
  */
4394
- var TransportConnection = class {
4395
- def;
4396
- transOrd = transportOrd++;
4397
- type;
4398
- initialized;
4399
- /** Backref to the public definition that created this connection (used for devtools route info). */
4400
- definition;
4401
- constructor(def) {
4402
- this.def = def;
4403
- this.type = def.type;
4404
- this.initialized = def.initialize();
4405
- }
4406
- /**
4407
- * Devtools route info for an action routed through this live connection. Defaults to the stateless
4408
- * {@link definition}'s info; connections override to enrich it from live state (e.g. the actual
4409
- * resolved socket URL) when the definition couldn't resolve it on its own.
4410
- */
4411
- getRouteInfo(input) {
4412
- return this.definition?.getRouteInfo(input);
4413
- }
4414
- /**
4415
- * Whether a `ready`-status transport still needs asynchronous bring-up before its methods exist —
4416
- * awaiting the carrier to open and/or running a handshake. Default `false`: a stateless transport
4417
- * (HTTP) is usable the instant `getTransport` reports `ready`, so it stays a terminal *synchronous*
4418
- * fallback in {@link ConnectionTransportManager}. Stream carriers (Link/WS) override to `true`.
4419
- */
4420
- _needsAsyncBringUp(_readyData) {
4421
- return false;
4422
- }
4423
- /** Await the carrier becoming ready to send (e.g. a socket `open`). Default: nothing to await. */
4424
- _awaitCarrierReady(_readyData) {
4425
- return Promise.resolve();
4426
- }
4427
- /**
4428
- * Finalize during async bring-up — may run a handshake, so it can be async. Defaults to the
4429
- * synchronous {@link _finalizeTransportMethods}; secure stream carriers override to branch plain/secure.
4430
- */
4431
- _finalizeReady(readyData) {
4432
- return this._finalizeTransportMethods(readyData);
4433
- }
4434
- _getCacheKey(input) {
4435
- const parts = this.initialized.getTransportCacheKey?.(input);
4436
- if (parts == null) return null;
4437
- return parts.join("\0");
4438
- }
4439
- getCacheKey(input) {
4440
- const inner = this._getCacheKey(input);
4441
- if (inner == null) return null;
4442
- return `${this.transOrd}:${inner}`;
4443
- }
4444
- /**
4445
- * Whether this transport can serve the given action right now. Consulted by the manager before
4446
- * cache-key resolution and `getTransport`; a `false` result skips this transport (treated as
4447
- * `unsupported`) and the manager falls through to the next in preference order. Defaults to `true`
4448
- * when the transport declares no gate.
4449
- */
4450
- isAvailable(input) {
4451
- return this.initialized.isAvailable?.(input) ?? true;
4049
+ var ExchangeAcceptor = class {
4050
+ _security;
4051
+ _runtime;
4052
+ _allowedLevels;
4053
+ _noneAllowed;
4054
+ _pendingHandshakes = /* @__PURE__ */ new Map();
4055
+ _sessions = /* @__PURE__ */ new Map();
4056
+ constructor(config) {
4057
+ this._security = config.security;
4058
+ this._runtime = config.runtime;
4059
+ this._allowedLevels = Array.isArray(config.security.securityLevel) ? config.security.securityLevel : [config.security.securityLevel];
4060
+ this._noneAllowed = this._allowedLevels.includes("none");
4452
4061
  }
4453
- getTransport(input) {
4454
- return this._processTransportStatus(input);
4062
+ /** Process one POST body (an exchange envelope), returning the reply body to send back. */
4063
+ async handlePost(body) {
4064
+ const request = decodeExchangeRequest(body);
4065
+ if (request == null) return this._err("malformed exchange request");
4066
+ if (request.k === "hs") return encodeExchange(await this._handleHandshake(request));
4067
+ return encodeExchange(await this._handleAction(request));
4455
4068
  }
4456
- _processTransportStatus(input) {
4457
- const statusInfo = addTransportStatusMetadata(this.initialized.getTransport(input));
4458
- if (statusInfo.status === "ready") {
4459
- if (!this._needsAsyncBringUp(statusInfo.readyData)) return {
4460
- status: "ready",
4461
- readyData: this._finalizeTransportMethods(statusInfo.readyData)
4462
- };
4463
- return {
4464
- status: "initializing",
4465
- timeStarted: Date.now(),
4466
- initializationPromise: this._bringUp(statusInfo.readyData)
4467
- };
4069
+ async _handleHandshake(request) {
4070
+ const message = decodeHandshakeMessage(request.m);
4071
+ if (message == null) return {
4072
+ k: "err",
4073
+ message: "malformed handshake message"
4074
+ };
4075
+ const security = this._security;
4076
+ await security.link.initialize();
4077
+ let handshake = this._pendingHandshakes.get(request.hsid);
4078
+ if (handshake == null) {
4079
+ handshake = createServerHandshake({
4080
+ link: security.link,
4081
+ localCoordinate: security.localCoordinate,
4082
+ dictionaryVersion: security.dictionaryVersion,
4083
+ securityLevel: security.securityLevel,
4084
+ verifyKeyResolver: security.verifyKeyResolver
4085
+ });
4086
+ this._pendingHandshakes.set(request.hsid, handshake);
4468
4087
  }
4469
- if (statusInfo.status === "initializing") {
4470
- const initializationPromise = statusInfo.initializationPromise.then((result) => result.status === "ready" ? this._bringUp(result.readyData) : result);
4088
+ if (message.t === "hello") return {
4089
+ k: "hs",
4090
+ m: encodeHandshakeMessage(await handshake.onHello(message))
4091
+ };
4092
+ if (message.t === "prove") {
4093
+ const reply = await handshake.onProve(message);
4094
+ this._pendingHandshakes.delete(request.hsid);
4095
+ const result = handshake.getResult();
4096
+ if (reply.t === "accept" && result != null) {
4097
+ const token = nanoid();
4098
+ this._sessions.set(token, {
4099
+ client: new RuntimeCoordinate(result.remote),
4100
+ securityLevel: result.securityLevel,
4101
+ crypto: result.securityLevel === "encrypted" ? createActionFrameCrypto({
4102
+ link: security.link,
4103
+ linkedClientId: result.linkedClientId
4104
+ }) : void 0
4105
+ });
4106
+ return {
4107
+ k: "hs",
4108
+ m: encodeHandshakeMessage(reply),
4109
+ t: token
4110
+ };
4111
+ }
4471
4112
  return {
4472
- status: "initializing",
4473
- timeStarted: statusInfo.timeStarted,
4474
- initializationPromise
4113
+ k: "hs",
4114
+ m: encodeHandshakeMessage(reply)
4475
4115
  };
4476
4116
  }
4477
- return statusInfo;
4117
+ return {
4118
+ k: "err",
4119
+ message: `unexpected handshake message ${message.t}`
4120
+ };
4478
4121
  }
4479
- /** Await carrier readiness, then finalize (possibly running a handshake) into the live methods. */
4480
- async _bringUp(readyData) {
4481
- await this._awaitCarrierReady(readyData);
4122
+ async _handleAction(request) {
4123
+ let session;
4124
+ let candidate;
4125
+ if (request.t != null) {
4126
+ session = this._sessions.get(request.t);
4127
+ if (session == null) return {
4128
+ k: "err",
4129
+ message: "unknown or expired session token"
4130
+ };
4131
+ if ("c" in request) {
4132
+ if (session.crypto == null) return {
4133
+ k: "err",
4134
+ message: "session is not encrypted"
4135
+ };
4136
+ const plain = await session.crypto.decryptFrame(base64ToBytes(request.c));
4137
+ candidate = JSON.parse(textDecoder.decode(plain));
4138
+ } else candidate = request.w;
4139
+ } else {
4140
+ if (!this._noneAllowed || "c" in request) return {
4141
+ k: "err",
4142
+ message: "missing session token"
4143
+ };
4144
+ candidate = request.w;
4145
+ }
4146
+ if (!isActionPayload_Any_JsonObject(candidate)) return {
4147
+ k: "err",
4148
+ message: "malformed action wire"
4149
+ };
4150
+ const wire = candidate;
4151
+ if (session != null && wire.type === "request") wire.context.originClient = session.client.toJsonObject();
4152
+ const resultWire = (await (await this._runtime.handleActionPayloadWire(wire)).waitForResultPayload()).toJsonObject();
4153
+ if (session?.crypto != null) return {
4154
+ k: "act",
4155
+ c: bytesToBase64(await session.crypto.encryptFrame(textEncoder.encode(JSON.stringify(resultWire))))
4156
+ };
4482
4157
  return {
4483
- status: "ready",
4484
- readyData: await this._finalizeReady(readyData)
4158
+ k: "act",
4159
+ w: resultWire
4485
4160
  };
4486
4161
  }
4162
+ _err(message) {
4163
+ return encodeExchange({
4164
+ k: "err",
4165
+ message
4166
+ });
4167
+ }
4487
4168
  };
4488
4169
  //#endregion
4489
- //#region src/ActionRuntime/Transport/Exchange/ExchangeConnection.ts
4170
+ //#region src/ActionRuntime/Handler/PeerLink/Acceptor/createActionFetchHandler.ts
4171
+ /** Permissive defaults — fine for a public action endpoint; override (or disable) via `cors`. */
4172
+ const DEFAULT_CORS_HEADERS = {
4173
+ "Access-Control-Allow-Origin": "*",
4174
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
4175
+ "Access-Control-Allow-Headers": "Content-Type",
4176
+ "Access-Control-Max-Age": "86400"
4177
+ };
4490
4178
  /**
4491
- * Carrier-agnostic live connection for the exchange (request single reply) shape the HTTP
4492
- * counterpart to {@link LinkConnection}. It owns only the bring-up (run the secure handshake on first
4493
- * use); the request/reply lifecycle + crypto live in the shared `establishExchangeSession`.
4179
+ * Build the `fetch` handler a server/Durable-Object exposes for action traffic, folding in the
4180
+ * boilerplate every endpoint repeats: CORS (incl. the `OPTIONS` preflight), routing the `/action`
4181
+ * `POST` body through the runtime (`handleActionPayloadWire` `waitForResultPayload`
4182
+ * `toHttpResponse`), an optional WebSocket-upgrade hook, and a `404` fallback.
4183
+ *
4184
+ * It only touches web-standard `Request`/`Response`, so it stays transport-agnostic — the one
4185
+ * environment-specific bit (the WS upgrade) is injected via {@link IActionFetchHandlerOptions.onWebSocketUpgrade}:
4186
+ * ```ts
4187
+ * this.fetchHandler = createActionFetchHandler(this.runtime, {
4188
+ * onWebSocketUpgrade: () => {
4189
+ * const pair = new WebSocketPair();
4190
+ * this.ctx.acceptWebSocket(pair[1]);
4191
+ * return new Response(null, { status: 101, webSocket: pair[0] });
4192
+ * },
4193
+ * });
4194
+ * // async fetch(request) { return this.fetchHandler(request); }
4195
+ * ```
4494
4196
  */
4495
- var ExchangeConnection = class extends TransportConnection {
4496
- constructor(def) {
4497
- super({
4498
- ...def,
4499
- type: "exchange"
4197
+ function createActionFetchHandler(runtime, options = {}) {
4198
+ const corsHeaders = options.cors === false ? {} : options.cors ?? DEFAULT_CORS_HEADERS;
4199
+ const isActionPath = options.isActionPath ?? ((url) => url.pathname.endsWith("/action"));
4200
+ const isWebSocketPath = options.isWebSocketPath ?? ((url) => url.pathname.endsWith("/ws"));
4201
+ const exchangeAcceptor = options.security != null ? new ExchangeAcceptor({
4202
+ runtime,
4203
+ security: options.security
4204
+ }) : void 0;
4205
+ const withCors = (response) => {
4206
+ if (options.cors === false) return response;
4207
+ const headers = new Headers(response.headers);
4208
+ for (const [key, value] of Object.entries(corsHeaders)) headers.set(key, value);
4209
+ return new Response(response.body, {
4210
+ status: response.status,
4211
+ headers
4500
4212
  });
4213
+ };
4214
+ return async (request) => {
4215
+ if (request.method === "OPTIONS") return withCors(new Response(null, { status: 204 }));
4216
+ const url = new URL(request.url);
4217
+ const isWebSocketUpgrade = options.isWebSocketUpgrade ?? ((req, u) => req.headers.get("Upgrade") === "websocket" && isWebSocketPath(u));
4218
+ if (options.onWebSocketUpgrade != null && isWebSocketUpgrade(request, url)) return options.onWebSocketUpgrade(request, url);
4219
+ if (request.method === "POST" && isActionPath(url)) {
4220
+ if (exchangeAcceptor != null) {
4221
+ const reply = await exchangeAcceptor.handlePost(await request.text());
4222
+ return withCors(new Response(reply, {
4223
+ status: 200,
4224
+ headers: { "Content-Type": "application/json" }
4225
+ }));
4226
+ }
4227
+ return withCors((await (await runtime.handleActionPayloadWire(await request.json())).waitForResultPayload()).toHttpResponse({ useErrorStatus: options.useErrorStatus }));
4228
+ }
4229
+ return withCors(new Response("Not found", { status: 404 }));
4230
+ };
4231
+ }
4232
+ //#endregion
4233
+ //#region src/ActionRuntime/Handler/PeerLink/Acceptor/Hibernation/ConnectionStateStore.ts
4234
+ /**
4235
+ * A typed per-connection state store that co-owns the app state and the acceptor handler's routing
4236
+ * binding in one attachment, so neither the consumer nor the handler has to hand-merge the two. Create
4237
+ * it through {@link createConnectionStateStore} (which also wires binding persistence and replays
4238
+ * surviving connections after a wake), then `get`/`set`/`clearApp` the app state directly.
4239
+ *
4240
+ * The mechanism is carrier-neutral — it only needs read/write/enumerate callbacks for the connection's
4241
+ * attachment — but it pays off on transports whose connections outlive process eviction (e.g. a
4242
+ * Durable Object's hibernatable WebSockets), which is why it lives beside the hibernation adapter.
4243
+ *
4244
+ * ```ts
4245
+ * const players = createConnectionStateStore(serverHandler, {
4246
+ * schema: vs_player,
4247
+ * read: (ws) => ws.deserializeAttachment(),
4248
+ * write: (ws, v) => ws.serializeAttachment(v),
4249
+ * getConnections: () => ctx.getWebSockets(),
4250
+ * });
4251
+ * players.set(ws, player); // binding is preserved automatically
4252
+ * const player = players.get(ws);
4253
+ * ```
4254
+ */
4255
+ var ConnectionStateStore = class {
4256
+ options;
4257
+ constructor(options) {
4258
+ this.options = options;
4501
4259
  }
4502
- _getCacheKey(input) {
4503
- return this.initialized.getTransportCacheKey?.(input).join("\0") ?? "";
4260
+ /** The validated app state for a connection, or `null` if unset / invalid. */
4261
+ get(connection) {
4262
+ return this._readAttachment(connection).app ?? null;
4504
4263
  }
4505
- _needsAsyncBringUp(data) {
4506
- return data.secureChannel != null && data.secureChannel.securityLevel !== "none";
4264
+ /** Set the app state, preserving the runtime binding already pinned to the connection. */
4265
+ set(connection, app) {
4266
+ const existing = this._readAttachment(connection);
4267
+ this.options.write(connection, {
4268
+ app,
4269
+ binding: existing.binding
4270
+ });
4507
4271
  }
4508
- _finalizeReady(data) {
4509
- const secure = data.secureChannel;
4510
- if (secure != null && secure.securityLevel !== "none") return finalizeSecureExchangeMethods({
4511
- ...this._sessionContext(data),
4512
- secure
4272
+ /** Clear the app state but keep the binding (e.g. a spectator that stopped watching). */
4273
+ clearApp(connection) {
4274
+ const existing = this._readAttachment(connection);
4275
+ this.options.write(connection, { binding: existing.binding });
4276
+ }
4277
+ /** Every live connection paired with its (validated) app state — for rebuilding in-memory state after a wake. */
4278
+ entries() {
4279
+ return this.options.getConnections().map((connection) => [connection, this._readAttachment(connection).app ?? null]);
4280
+ }
4281
+ /** @internal Persist a freshly-bound connection's binding, preserving any app state already stored. */
4282
+ _persistBinding(connection, binding) {
4283
+ const existing = this._readAttachment(connection);
4284
+ this.options.write(connection, {
4285
+ app: existing.app,
4286
+ binding
4513
4287
  });
4514
- return this._finalizeTransportMethods(data);
4515
4288
  }
4516
- _finalizeTransportMethods(data) {
4517
- return finalizePlainExchangeMethods(this._sessionContext(data));
4289
+ /** @internal The persisted binding for a connection, if any (used to replay routing after a wake). */
4290
+ _readBinding(connection) {
4291
+ return this._readAttachment(connection).binding;
4518
4292
  }
4519
- _sessionContext(data) {
4520
- return {
4521
- carrier: data.carrier,
4522
- updateRunConfig: data.updateRunConfig,
4523
- secure: data.secureChannel
4524
- };
4293
+ _readAttachment(connection) {
4294
+ try {
4295
+ const raw = this.options.read(connection);
4296
+ if (typeof raw !== "object" || raw === null) return {};
4297
+ const attachment = raw;
4298
+ const result = {};
4299
+ if (attachment.binding != null) result.binding = attachment.binding;
4300
+ if (attachment.app !== void 0) {
4301
+ const app = this._validateApp(attachment.app);
4302
+ if (app !== void 0) result.app = app;
4303
+ }
4304
+ return result;
4305
+ } catch {
4306
+ return {};
4307
+ }
4308
+ }
4309
+ _validateApp(value) {
4310
+ const schema = this.options.schema;
4311
+ if (schema == null) return value;
4312
+ const result = schema["~standard"].validate(value);
4313
+ if (result instanceof Promise) return void 0;
4314
+ if (result.issues != null) return void 0;
4315
+ return result.value;
4525
4316
  }
4526
4317
  };
4318
+ /**
4319
+ * Build a per-connection {@link ConnectionStateStore} bound to an {@link AcceptorHandler}: it registers
4320
+ * itself as the handler's connection-bound persistence callback (so bindings are written without
4321
+ * overwriting app state) and immediately replays every live connection's stored binding via
4322
+ * {@link AcceptorHandler.rehydrateConnection} — so on a transport that resumes after eviction (e.g. a
4323
+ * Durable Object waking from hibernation) both the app identity and the action routing come back from a
4324
+ * single attachment, with no storage reads and no hand-rolled merge.
4325
+ *
4326
+ * Lives outside the handler so the generic {@link AcceptorHandler} stays free of any attachment/
4327
+ * hibernation concern — it exposes only the neutral `setOnConnectionBound` + `rehydrateConnection`
4328
+ * hooks this builder drives.
4329
+ */
4330
+ function createConnectionStateStore(handler, options) {
4331
+ const store = new ConnectionStateStore(options);
4332
+ handler.setOnConnectionBound((connection, binding) => store._persistBinding(connection, binding));
4333
+ for (const connection of options.getConnections()) {
4334
+ const binding = store._readBinding(connection);
4335
+ if (binding != null) handler.rehydrateConnection(connection, binding);
4336
+ }
4337
+ return store;
4338
+ }
4527
4339
  //#endregion
4528
- //#region src/ActionRuntime/Transport/Exchange/ExchangeTransport.ts
4340
+ //#region src/ActionRuntime/Handler/PeerLink/Acceptor/Hibernation/createHibernatableWsServerAdapter.ts
4529
4341
  /**
4530
- * A carrier-agnostic exchange (request single reply) transport: it drives nice-action's secure session
4531
- * over any {@link IExchangeCarrier} (HTTP being the one built-in). The duplex counterpart is
4532
- * {@link LinkTransport}; this is the no-push half its reply rides the response to its own request, so it
4533
- * can't deliver an unsolicited frame (the runtime never picks it for the return path).
4342
+ * Wire the hibernation lifecycle for an acceptor handler on a transport whose connections outlive process
4343
+ * eviction (e.g. a Durable Object's hibernatable WebSockets). It owns persistence end to end:
4344
+ * registers `setAttachment` as the handler's connection-bound callback and immediately replays every
4345
+ * live connection's stored binding via `getAttachment`, so results/pushes still route after a wake.
4346
+ *
4347
+ * Layered on top of the generic {@link AcceptorHandler} — it touches only the handler's neutral
4348
+ * `setOnConnectionBound` / `rehydrateConnection` / `receive` / `dropConnection` surface, so no
4349
+ * hibernation concern leaks into the handler itself.
4350
+ *
4351
+ * Construct it once when the handler is built, then forward connection events:
4352
+ * ```ts
4353
+ * const duplex = createHibernatableWsServerAdapter({ handler, getConnections, getAttachment, setAttachment });
4354
+ * // webSocketMessage(ws, msg) => duplex.receive(ws, msg);
4355
+ * // webSocketClose/Error(ws) => duplex.drop(ws);
4356
+ * ```
4534
4357
  */
4535
- var ExchangeTransport = class ExchangeTransport extends Transport {
4536
- options;
4537
- type = "exchange";
4538
- constructor(options) {
4539
- super();
4540
- this.options = options;
4358
+ function createHibernatableWsServerAdapter(options) {
4359
+ const { handler, getConnections, getAttachment, setAttachment } = options;
4360
+ handler.setOnConnectionBound(setAttachment);
4361
+ for (const connection of getConnections()) {
4362
+ const binding = getAttachment(connection);
4363
+ if (binding != null) handler.rehydrateConnection(connection, binding);
4541
4364
  }
4542
- static create(options) {
4543
- return new ExchangeTransport(options);
4365
+ return {
4366
+ receive: (connection, frame) => handler.receive(connection, frame),
4367
+ drop: (connection) => handler.dropConnection(connection)
4368
+ };
4369
+ }
4370
+ //#endregion
4371
+ //#region src/ActionRuntime/Channel/serveChannel.ts
4372
+ /** Default accepted set, shared by every carrier: negotiate per connection to whatever the client picks. */
4373
+ const DEFAULT_SERVER_SECURITY_LEVELS = [
4374
+ "none",
4375
+ "authenticated",
4376
+ "encrypted"
4377
+ ];
4378
+ function serveChannel(runtime, channel, options) {
4379
+ const duplexCarriers = options.carriers.filter((carrier) => !isExchangeAcceptorCarrier(carrier));
4380
+ const exchangeCarriers = options.carriers.filter(isExchangeAcceptorCarrier);
4381
+ if (exchangeCarriers.length > 1) throw new Error("serveChannel: at most one exchange carrier is supported");
4382
+ const exchangeCarrier = exchangeCarriers[0];
4383
+ const singleDuplex = duplexCarriers.length === 1;
4384
+ if (options.connectionState != null && !singleDuplex) throw new Error("serveChannel: `connectionState` requires exactly one duplex carrier");
4385
+ if (options.channelCases != null && !singleDuplex) throw new Error("serveChannel: `channelCases` requires exactly one duplex carrier");
4386
+ const exchangeSecure = exchangeCarrier != null && (exchangeCarrier.secure ?? true);
4387
+ const anyDuplexSecure = duplexCarriers.some((carrier) => carrier.secure ?? true);
4388
+ const securityLevel = options.securityLevel ?? DEFAULT_SERVER_SECURITY_LEVELS;
4389
+ let secure;
4390
+ if (anyDuplexSecure || exchangeSecure) {
4391
+ const storage = options.storage;
4392
+ 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.");
4393
+ secure = {
4394
+ storage,
4395
+ link: options.link ?? new ClientCryptoKeyLink({ storageAdapter: storage }),
4396
+ verifyKeyResolver: options.verifyKeyResolver ?? createStorageTofuVerifyKeyResolver(storage)
4397
+ };
4544
4398
  }
4545
- _createConnection(_ctx) {
4546
- const options = this.options;
4547
- return new ExchangeConnection({ initialize: () => ({
4548
- getTransportCacheKey: options.getTransportCacheKey,
4549
- isAvailable: options.available,
4550
- getTransport: (input) => ({
4551
- status: "ready",
4552
- readyData: {
4553
- carrier: options.openCarrier(input),
4554
- secureChannel: options.security,
4555
- updateRunConfig: options.updateRunConfig
4556
- }
4399
+ const plainRouter = (handler) => ({
4400
+ receive: (connection, frame) => handler.receive(connection, frame),
4401
+ drop: (connection) => handler.dropConnection(connection)
4402
+ });
4403
+ const asObject = (value) => typeof value === "object" && value != null ? value : {};
4404
+ const handlers = [];
4405
+ let connections;
4406
+ for (const carrier of duplexCarriers) {
4407
+ const handler = (carrier.secure ?? true) && secure != null ? acceptChannel(runtime, channel, {
4408
+ clientEnv: options.clientEnv,
4409
+ storageAdapter: secure.storage,
4410
+ link: secure.link,
4411
+ verifyKeyResolver: secure.verifyKeyResolver,
4412
+ securityLevel,
4413
+ send: carrier.send,
4414
+ defaultTimeout: options.defaultTimeout
4415
+ }) : createAcceptorHandler({
4416
+ clientEnv: options.clientEnv,
4417
+ createFormatMessage: channel.createCodec,
4418
+ send: carrier.send,
4419
+ runtime,
4420
+ defaultTimeout: options.defaultTimeout
4421
+ });
4422
+ const attach = carrier.attachmentStore;
4423
+ let router;
4424
+ if (attach == null) router = plainRouter(handler);
4425
+ else if (options.connectionState != null) {
4426
+ connections = createConnectionStateStore(handler, {
4427
+ schema: options.connectionState.schema,
4428
+ getConnections: attach.getConnections,
4429
+ read: attach.read,
4430
+ write: attach.write
4431
+ });
4432
+ router = plainRouter(handler);
4433
+ } else router = createHibernatableWsServerAdapter({
4434
+ handler,
4435
+ getConnections: attach.getConnections,
4436
+ getAttachment: (connection) => attach.read(connection)?.binding,
4437
+ setAttachment: (connection, binding) => attach.write(connection, {
4438
+ ...asObject(attach.read(connection)),
4439
+ binding
4557
4440
  })
4558
- }) });
4441
+ });
4442
+ carrier._activate(router);
4443
+ handlers.push(handler);
4559
4444
  }
4560
- getRouteInfo(input) {
4561
- if (this.options.getRouteInfo != null) return this.options.getRouteInfo(input);
4562
- return {
4563
- carrierLabel: this.options.label ?? "exchange",
4564
- summary: this.options.label ?? "exchange"
4565
- };
4445
+ runtime.addHandlers([...options.handlers ?? [], ...handlers]);
4446
+ if (options.channelCases != null) runtime.addHandlers([acceptChannelConnections(handlers[0], channel, options.channelCases)]);
4447
+ const exchangeSecurity = exchangeSecure && secure != null ? {
4448
+ link: secure.link,
4449
+ verifyKeyResolver: secure.verifyKeyResolver,
4450
+ localCoordinate: runtime.coordinate.toJsonObject(),
4451
+ dictionaryVersion: channel.dictionaryVersion,
4452
+ securityLevel
4453
+ } : void 0;
4454
+ const defaultIsUpgrade = (request) => request.headers.get("Upgrade") === "websocket";
4455
+ const upgraders = [];
4456
+ for (const carrier of duplexCarriers) {
4457
+ if (carrier.upgrade == null) continue;
4458
+ upgraders.push({
4459
+ isUpgrade: carrier.isUpgrade ?? defaultIsUpgrade,
4460
+ upgrade: carrier.upgrade
4461
+ });
4566
4462
  }
4567
- };
4568
- //#endregion
4569
- //#region src/ActionRuntime/Transport/helpers/createUnsetTransportResolvers.ts
4570
- const createUnsetTransportResolvers = (transportLabel) => ({ onIncomingActionDataJson: (json) => {
4571
- console.warn(`Received incoming action JSON [${json.domain}:${json.id}] on Transport [${transportLabel}] but no incoming data listener has been set.`);
4572
- } });
4573
- //#endregion
4574
- //#region src/ActionRuntime/Transport/SecureSession/establishLinkSession.ts
4575
- const HANDSHAKE_TIMEOUT_MS = 15e3;
4576
- /** Plain path (no handshake): route every inbound frame to the runtime; send without crypto. */
4577
- function finalizePlainLinkMethods(ctx) {
4578
- const disconnectListeners = [];
4579
- const abortSet = /* @__PURE__ */ new Set();
4580
- const pipe = makePipe(ctx, void 0);
4581
- ctx.channel.attach({
4582
- onMessage: (frame) => void handleIncomingActionFrame(ctx, pipe, frame),
4583
- onClose: () => onChannelClosed(ctx, disconnectListeners, abortSet),
4584
- onError: () => onChannelClosed(ctx, disconnectListeners, abortSet)
4463
+ const fetch = createActionFetchHandler(runtime, {
4464
+ cors: exchangeCarrier?.cors,
4465
+ onWebSocketUpgrade: upgraders.length === 0 ? void 0 : (request, url) => (upgraders.find((u) => u.isUpgrade(request, url)) ?? upgraders[0]).upgrade(request, url),
4466
+ isWebSocketUpgrade: upgraders.length === 0 ? void 0 : (request, url) => upgraders.some((u) => u.isUpgrade(request, url)),
4467
+ isActionPath: exchangeCarrier != null ? exchangeCarrier.isActionPath ?? (() => true) : () => false,
4468
+ security: exchangeSecurity,
4469
+ useErrorStatus: exchangeCarrier?.useErrorStatus
4585
4470
  });
4586
- return buildSendMethods(ctx, pipe, disconnectListeners, abortSet);
4471
+ const duplex = duplexCarriers.length === 1 ? duplexCarriers[0] : void 0;
4472
+ const pushToClient = (target, request, pushOptions) => {
4473
+ const owner = target instanceof RuntimeCoordinate ? handlers.find((handler) => handler.ownsLiveConnectionFor(target)) : handlers.find((handler) => handler.hasConnection(target));
4474
+ if (owner == null) throw new Error("serveChannel: no duplex carrier holds a connection for the push target");
4475
+ return owner.pushToClient(runtime, target, request, pushOptions);
4476
+ };
4477
+ const broadcast = (makeRequest, broadcastOptions) => {
4478
+ if (!singleDuplex) throw new Error("serveChannel: broadcast requires exactly one duplex carrier — broadcast over a specific handlers[i] instead");
4479
+ handlers[0].broadcast(makeRequest, {
4480
+ runtime,
4481
+ ...broadcastOptions
4482
+ });
4483
+ };
4484
+ return {
4485
+ handlers,
4486
+ fetch,
4487
+ duplex,
4488
+ pushToClient,
4489
+ broadcast,
4490
+ connections
4491
+ };
4587
4492
  }
4493
+ //#endregion
4494
+ //#region src/ActionRuntime/Transport/Carrier/duplex/inMemory/createInMemoryChannel.ts
4588
4495
  /**
4589
- * Secure path: a single message handler feeds the handshake until it completes, then routes action
4590
- * frames (decrypting at the `encrypted` level). Frames that race ahead of activation are buffered and
4591
- * flushed once the handshake lands, so nothing is lost.
4496
+ * Two cross-wired in-process byte channels a loopback carrier with no socket. The client end is a
4497
+ * {@link IDuplexCarrier} you hand to a {@link LinkTransport}; the server end plugs into an
4498
+ * `AcceptorHandler` (`send: (_, f) => serverEndpoint.send(f)`, and `serverEndpoint.onMessage(f =>
4499
+ * handler.receive(conn, f))`). Frames are delivered on a microtask, so each side observes the other
4500
+ * asynchronously — exactly like a real transport — which makes this ideal for tests and for running
4501
+ * two runtimes in one process (or proving a non-WS carrier end to end).
4592
4502
  */
4593
- async function finalizeSecureLinkMethods(ctx) {
4594
- const disconnectListeners = [];
4595
- const abortSet = /* @__PURE__ */ new Set();
4596
- const session = {};
4597
- let active = false;
4598
- const handshakeQueue = [];
4599
- const handshakeWaiters = [];
4600
- const pendingActionFrames = [];
4601
- ctx.channel.attach({
4602
- onMessage: (frame) => {
4603
- if (active && session.pipe != null) {
4604
- handleIncomingActionFrame(ctx, session.pipe, frame);
4605
- return;
4606
- }
4607
- if (typeof frame === "string") {
4608
- const message = decodeHandshakeMessage(frame);
4609
- if (message != null) {
4610
- const waiter = handshakeWaiters.shift();
4611
- if (waiter != null) waiter(message);
4612
- else handshakeQueue.push(message);
4613
- return;
4614
- }
4615
- }
4616
- pendingActionFrames.push(frame);
4617
- },
4618
- onClose: () => onChannelClosed(ctx, disconnectListeners, abortSet),
4619
- onError: () => onChannelClosed(ctx, disconnectListeners, abortSet)
4620
- });
4621
- const nextHandshakeMessage = () => {
4622
- const queued = handshakeQueue.shift();
4623
- if (queued != null) return Promise.resolve(queued);
4624
- return new Promise((resolve, reject) => {
4625
- const timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("[link-handshake] timed out waiting for peer reply")), HANDSHAKE_TIMEOUT_MS);
4626
- handshakeWaiters.push((message) => {
4627
- clearTimeout(timeout);
4628
- resolve(message);
4629
- });
4503
+ function createInMemoryChannelPair() {
4504
+ let clientMessage;
4505
+ let clientClose;
4506
+ let serverMessage;
4507
+ let serverClose;
4508
+ let open = true;
4509
+ const closeBoth = () => {
4510
+ if (!open) return;
4511
+ open = false;
4512
+ queueMicrotask(() => {
4513
+ clientClose?.();
4514
+ serverClose?.();
4630
4515
  });
4631
4516
  };
4632
- const pipe = makePipe(ctx, await runClientHandshake(ctx.channel, ctx.secure, nextHandshakeMessage));
4633
- session.pipe = pipe;
4634
- active = true;
4635
- for (const frame of pendingActionFrames) await handleIncomingActionFrame(ctx, pipe, frame);
4636
- pendingActionFrames.length = 0;
4637
- return buildSendMethods(ctx, pipe, disconnectListeners, abortSet);
4638
- }
4639
- function makePipe(ctx, crypto) {
4640
- return createFrameCryptoPipe({
4641
- write: (frame) => ctx.channel.send(frame),
4642
- isOpen: () => ctx.channel.isOpen(),
4643
- crypto
4644
- });
4645
- }
4646
- async function runClientHandshake(channel, secure, nextHandshakeMessage) {
4647
- await secure.link.initialize();
4648
- const handshake = createClientHandshake({
4649
- link: secure.link,
4650
- localCoordinate: secure.localCoordinate,
4651
- dictionaryVersion: secure.dictionaryVersion,
4652
- securityLevel: secure.securityLevel
4653
- });
4654
- channel.send(encodeHandshakeMessage(await handshake.createHello()));
4655
- const welcome = await nextHandshakeMessage();
4656
- if (welcome.t === "reject") throw new Error(`[link-handshake] rejected by peer: ${welcome.reason}`);
4657
- if (welcome.t !== "welcome") throw new Error(`[link-handshake] expected welcome, got ${welcome.t}`);
4658
- channel.send(encodeHandshakeMessage(await handshake.onWelcome(welcome)));
4659
- const accept = await nextHandshakeMessage();
4660
- if (accept.t === "reject") throw new Error(`[link-handshake] rejected by peer: ${accept.reason}`);
4661
- if (accept.t !== "accept") throw new Error(`[link-handshake] expected accept, got ${accept.t}`);
4662
- const result = await handshake.onAccept(accept);
4663
- return result.securityLevel === "encrypted" ? createActionFrameCrypto({
4664
- link: secure.link,
4665
- linkedClientId: result.linkedClientId
4666
- }) : void 0;
4667
- }
4668
- function buildSendMethods(ctx, pipe, disconnectListeners, abortSet) {
4669
- const channel = ctx.channel;
4670
- const sendActionData = (inputs) => {
4671
- const { action, runningAction, timeout } = inputs;
4672
- if (!channel.isOpen()) {
4673
- if (action.type === "request") runningAction._abort(ctx.makeDisconnectError(action.id));
4674
- return;
4675
- }
4676
- if (action.type === "request") {
4677
- abortSet.add(runningAction);
4678
- const timeoutId = setTimeout(() => {
4679
- runningAction._abort(err_nice_transport.fromId("timeout", { timeout }));
4680
- }, timeout);
4681
- runningAction.addUpdateListeners([(update) => {
4682
- if (update.type === "finished") {
4683
- clearTimeout(timeoutId);
4684
- abortSet.delete(runningAction);
4685
- }
4686
- }]);
4517
+ return {
4518
+ clientChannel: {
4519
+ ready: Promise.resolve(),
4520
+ isOpen: () => open,
4521
+ send: (frame) => {
4522
+ if (!open) return;
4523
+ queueMicrotask(() => serverMessage?.(frame));
4524
+ },
4525
+ attach: ({ onMessage, onClose }) => {
4526
+ clientMessage = onMessage;
4527
+ clientClose = onClose;
4528
+ },
4529
+ close: closeBoth,
4530
+ label: "in-memory"
4531
+ },
4532
+ serverEndpoint: {
4533
+ send: (frame) => {
4534
+ if (!open) return;
4535
+ queueMicrotask(() => clientMessage?.(frame));
4536
+ },
4537
+ onMessage: (handler) => {
4538
+ serverMessage = handler;
4539
+ },
4540
+ onClose: (handler) => {
4541
+ serverClose = handler;
4542
+ },
4543
+ close: closeBoth
4687
4544
  }
4688
- pipe.send(ctx.formatMessage?.outgoing(inputs) ?? JSON.stringify(inputs.action.toJsonObject()));
4689
4545
  };
4546
+ }
4547
+ //#endregion
4548
+ //#region src/ActionRuntime/Transport/Carrier/duplex/inMemory/inMemoryCarrier.ts
4549
+ /**
4550
+ * A loopback duplex carrier with no socket — two cross-wired in-process ends. The connector end is an
4551
+ * {@link IDuplexCarrierSource} for {@link secureTransport}; the acceptor end plugs into an
4552
+ * `AcceptorHandler`. Ideal for tests and for running two runtimes in one process, or proving a
4553
+ * non-WS carrier end to end.
4554
+ */
4555
+ function inMemoryCarrier() {
4556
+ const { clientChannel, serverEndpoint } = createInMemoryChannelPair();
4557
+ return {
4558
+ carrier: {
4559
+ carrierLabel: "memory",
4560
+ open: () => clientChannel,
4561
+ getCacheKey: () => ["memory"]
4562
+ },
4563
+ serverEndpoint
4564
+ };
4565
+ }
4566
+ //#endregion
4567
+ //#region src/ActionRuntime/Transport/Carrier/duplex/rtc/rtcDataChannelByteChannel.ts
4568
+ /**
4569
+ * Adapt a WebRTC `RTCDataChannel` to the carrier-agnostic {@link IDuplexCarrier}, so two browsers
4570
+ * (or two mobile apps) linked peer-to-peer — no server in the middle — run the *same* secure session as
4571
+ * a WebSocket. Hand it to `createSecureLinkTransport({ openChannel: () => rtcDataChannelByteChannel(dc) })`.
4572
+ *
4573
+ * The data channel must already be created (its negotiation/signaling is the app's concern); this only
4574
+ * drives bytes over it. Binary frames are requested as `ArrayBuffer` so the binary session codec unpacks
4575
+ * them synchronously; a `Blob` (if the channel hands one back) is normalized to a buffer.
4576
+ */
4577
+ function rtcDataChannelByteChannel(dc) {
4578
+ dc.binaryType = "arraybuffer";
4579
+ let intentional = false;
4580
+ let onMessage;
4581
+ let onClose;
4582
+ const preAttach = [];
4583
+ const deliver = (frame) => {
4584
+ if (onMessage != null) onMessage(frame);
4585
+ else preAttach.push(frame);
4586
+ };
4587
+ dc.addEventListener("message", async (event) => {
4588
+ const frame = await normalizeFrame$1(event.data);
4589
+ if (frame !== void 0) deliver(frame);
4590
+ });
4591
+ dc.addEventListener("close", () => {
4592
+ if (!intentional) console.error("RTCDataChannel closed");
4593
+ onClose?.();
4594
+ });
4595
+ dc.addEventListener("error", (event) => {
4596
+ console.error("RTCDataChannel error:", event);
4597
+ onClose?.();
4598
+ });
4690
4599
  return {
4691
- sendActionData,
4692
- updateRunConfig: ctx.updateRunConfig,
4693
- addOnDisconnectListener: (cb) => {
4694
- disconnectListeners.push(cb);
4600
+ ready: new Promise((resolve, reject) => {
4601
+ if (dc.readyState === "open") {
4602
+ resolve();
4603
+ return;
4604
+ }
4605
+ dc.addEventListener("open", () => resolve(), { once: true });
4606
+ dc.addEventListener("error", (event) => reject(event), { once: true });
4607
+ dc.addEventListener("close", () => reject(/* @__PURE__ */ new Error("RTCDataChannel closed before open")), { once: true });
4608
+ }),
4609
+ isOpen: () => dc.readyState === "open",
4610
+ send: (frame) => {
4611
+ if (typeof frame === "string" || frame instanceof ArrayBuffer) dc.send(frame);
4612
+ else dc.send(new Uint8Array(frame));
4695
4613
  },
4696
- disconnect: () => {
4614
+ attach: (handlers) => {
4615
+ onMessage = handlers.onMessage;
4616
+ onClose = handlers.onClose;
4617
+ for (const frame of preAttach) handlers.onMessage(frame);
4618
+ preAttach.length = 0;
4619
+ },
4620
+ close: () => {
4621
+ intentional = true;
4697
4622
  try {
4698
- channel.close();
4623
+ dc.close();
4699
4624
  } catch {}
4700
4625
  },
4701
- sendReturnData: (payload, clients) => {
4702
- const formatted = clients != null ? ctx.formatMessage?.outgoing({
4703
- action: payload,
4704
- ...clients
4705
- }) : void 0;
4706
- pipe.send(formatted ?? JSON.stringify(payload.toJsonObject()));
4626
+ get label() {
4627
+ return dc.label != null && dc.label !== "" ? dc.label : void 0;
4707
4628
  }
4708
4629
  };
4709
4630
  }
4710
- async function handleIncomingActionFrame(ctx, pipe, frame) {
4711
- const decoded = await pipe.decryptIncoming(frame);
4712
- if (decoded === void 0) return;
4713
- const rawJson = decodeActionFrame(decoded, ctx.formatMessage);
4714
- if (rawJson != null) ctx.resolvers.onIncomingActionDataJson(rawJson);
4715
- }
4716
- function onChannelClosed(ctx, disconnectListeners, abortSet) {
4717
- for (const cb of disconnectListeners) cb();
4718
- const error = ctx.makeDisconnectError("—");
4719
- for (const ra of [...abortSet]) ra._abort(error);
4631
+ async function normalizeFrame$1(data) {
4632
+ if (typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array) return data;
4633
+ if (typeof Blob !== "undefined" && data instanceof Blob) return await data.arrayBuffer();
4720
4634
  }
4721
4635
  //#endregion
4722
- //#region src/ActionRuntime/Transport/Link/LinkConnection.ts
4723
- /** Abort error for a closed link channel (carrier-neutral — the carrier itself isn't named). */
4724
- function linkDisconnectError(actionId) {
4725
- return err_nice_transport.fromId("send_failed", {
4726
- actionId,
4727
- actionState: "request",
4728
- message: "link channel disconnected"
4729
- });
4730
- }
4636
+ //#region src/ActionRuntime/Transport/Carrier/duplex/rtc/rtcCarrier.ts
4731
4637
  /**
4732
- * Carrier-agnostic live connection. It owns only the *bring-up* (open the carrier, then run the secure
4733
- * session); the session itself handshake, frame crypto, codec, send/receive lives in the shared
4734
- * {@link finalizeSecureLinkMethods}/{@link finalizePlainLinkMethods}, so a WebSocket, a WebRTC data
4735
- * channel, a Bluetooth characteristic, and an in-memory pipe all run the identical secure layer.
4638
+ * A WebRTC {@link IDuplexCarrierSource} over an already-negotiated `RTCDataChannel` (signaling is the
4639
+ * app's concern). Hand it to {@link secureTransport} so two browsers/apps linked peer-to-peer run the
4640
+ * identical secure session as a WebSocket.
4736
4641
  */
4737
- var LinkConnection = class extends TransportConnection {
4738
- resolvers;
4739
- constructor(def, resolvers) {
4740
- super({
4741
- ...def,
4742
- type: "duplex"
4743
- });
4744
- this.resolvers = resolvers ?? createUnsetTransportResolvers("link");
4745
- }
4746
- _getCacheKey(input) {
4747
- return this.initialized.getTransportCacheKey?.(input).join("\0") ?? "";
4748
- }
4749
- _needsAsyncBringUp() {
4750
- return true;
4751
- }
4752
- _awaitCarrierReady(data) {
4753
- return data.channel.ready;
4754
- }
4755
- _finalizeReady(data) {
4756
- const secure = data.secureChannel;
4757
- if (secure != null && secure.securityLevel !== "none") return finalizeSecureLinkMethods({
4758
- ...this._sessionContext(data),
4759
- secure
4760
- });
4761
- return this._finalizeTransportMethods(data);
4642
+ function rtcCarrier(dataChannel, options = {}) {
4643
+ return {
4644
+ carrierLabel: "webrtc",
4645
+ open: () => rtcDataChannelByteChannel(dataChannel),
4646
+ getCacheKey: options.getTransportCacheKey ?? (() => ["webrtc"]),
4647
+ getRouteInfo: options.getRouteInfo
4648
+ };
4649
+ }
4650
+ //#endregion
4651
+ //#region src/ActionRuntime/Transport/Carrier/duplex/ws/err_nice_transport_ws.ts
4652
+ let EErrId_NiceTransport_WebSocket = /* @__PURE__ */ function(EErrId_NiceTransport_WebSocket) {
4653
+ EErrId_NiceTransport_WebSocket["ws_disconnected"] = "ws_disconnected";
4654
+ EErrId_NiceTransport_WebSocket["ws_create_failed"] = "ws_create_failed";
4655
+ EErrId_NiceTransport_WebSocket["ws_error"] = "ws_error";
4656
+ return EErrId_NiceTransport_WebSocket;
4657
+ }({});
4658
+ const err_nice_transport_ws = err_nice_transport.createChildDomain({
4659
+ domain: "ws_transport",
4660
+ schema: {
4661
+ ["ws_disconnected"]: err({ message: () => `WebSocket transport disconnected.` }),
4662
+ ["ws_create_failed"]: err({ message: ({ originalError }) => `Failed to create WebSocket transport.${originalError ? ` Original error: ${originalError.message}` : ""}` }),
4663
+ ["ws_error"]: err({ message: ({ originalError }) => `WebSocket transport error.${originalError ? ` Original error: ${originalError.message}` : ""}` })
4762
4664
  }
4763
- _sessionContext(data) {
4764
- return {
4765
- channel: data.channel,
4766
- resolvers: this.resolvers,
4767
- formatMessage: data.formatMessage,
4768
- updateRunConfig: data.updateRunConfig,
4769
- makeDisconnectError: linkDisconnectError
4770
- };
4665
+ });
4666
+ //#endregion
4667
+ //#region src/ActionRuntime/Transport/Carrier/duplex/ws/ws_util.ts
4668
+ /**
4669
+ * Send a text or binary frame over a socket. A binary formatter may hand back a `Uint8Array` whose
4670
+ * backing buffer is typed as `ArrayBufferLike` (msgpackr pools buffers / may be `SharedArrayBuffer`),
4671
+ * which `WebSocket.send`'s `BufferSource` parameter rejects — copy it into a fresh `ArrayBuffer`-backed
4672
+ * view so the type (and the bytes) are safe to send.
4673
+ */
4674
+ function sendFrame(ws, data) {
4675
+ if (typeof data === "string" || data instanceof ArrayBuffer) {
4676
+ ws.send(data);
4677
+ return;
4771
4678
  }
4772
- _finalizeTransportMethods(data) {
4773
- return finalizePlainLinkMethods(this._sessionContext(data));
4679
+ ws.send(new Uint8Array(data));
4680
+ }
4681
+ /** Compact a WebSocket URL to `host/pathname` for devtools display, falling back to the raw url. */
4682
+ function shortWs(url) {
4683
+ try {
4684
+ const u = new URL(url);
4685
+ return `${u.host}${u.pathname}`;
4686
+ } catch {
4687
+ return url;
4774
4688
  }
4775
- };
4689
+ }
4776
4690
  //#endregion
4777
- //#region src/ActionRuntime/Transport/Link/LinkTransport.ts
4691
+ //#region src/ActionRuntime/Transport/Carrier/duplex/ws/webSocketByteChannel.ts
4692
+ /**
4693
+ * Adapt a `WebSocket` to the carrier-agnostic {@link IDuplexCarrier}, so the WebSocket becomes
4694
+ * "just another carrier" under the shared secure session. It owns every WebSocket-specific concern —
4695
+ * awaiting `open`, normalizing `Blob` frames to bytes, suppressing the log on a deliberate `close`, and
4696
+ * buffering any frame that arrives before the session attaches its handler — leaving the session itself
4697
+ * carrier-neutral.
4698
+ */
4699
+ function webSocketByteChannel(ws) {
4700
+ let intentional = false;
4701
+ let onMessage;
4702
+ let onClose;
4703
+ const preAttach = [];
4704
+ const deliver = (frame) => {
4705
+ if (onMessage != null) onMessage(frame);
4706
+ else preAttach.push(frame);
4707
+ };
4708
+ ws.addEventListener("message", async (event) => {
4709
+ const frame = await normalizeFrame(event.data);
4710
+ if (frame !== void 0) deliver(frame);
4711
+ });
4712
+ ws.addEventListener("close", (event) => {
4713
+ if (!intentional) console.error("WebSocket closed:", event);
4714
+ onClose?.();
4715
+ });
4716
+ ws.addEventListener("error", (event) => {
4717
+ console.error("WebSocket error:", event);
4718
+ onClose?.();
4719
+ });
4720
+ return {
4721
+ ready: new Promise((resolve, reject) => {
4722
+ if (ws.readyState === WebSocket.OPEN) {
4723
+ resolve();
4724
+ return;
4725
+ }
4726
+ ws.addEventListener("open", () => resolve(), { once: true });
4727
+ ws.addEventListener("error", (event) => reject(event), { once: true });
4728
+ ws.addEventListener("close", (event) => reject(/* @__PURE__ */ new Error(`WebSocket closed before open: code=${event.code}`)), { once: true });
4729
+ }),
4730
+ isOpen: () => ws.readyState === WebSocket.OPEN,
4731
+ send: (frame) => sendFrame(ws, frame),
4732
+ attach: (handlers) => {
4733
+ onMessage = handlers.onMessage;
4734
+ onClose = handlers.onClose;
4735
+ for (const frame of preAttach) handlers.onMessage(frame);
4736
+ preAttach.length = 0;
4737
+ },
4738
+ close: () => {
4739
+ intentional = true;
4740
+ try {
4741
+ ws.close();
4742
+ } catch {}
4743
+ },
4744
+ get label() {
4745
+ return ws.url != null && ws.url !== "" ? ws.url : void 0;
4746
+ }
4747
+ };
4748
+ }
4749
+ /** Accept text + binary frames (ArrayBuffer / Uint8Array / Blob); Blobs are converted to a buffer. */
4750
+ async function normalizeFrame(data) {
4751
+ if (typeof data === "string" || data instanceof ArrayBuffer || data instanceof Uint8Array) return data;
4752
+ if (typeof Blob !== "undefined" && data instanceof Blob) return await data.arrayBuffer();
4753
+ }
4754
+ //#endregion
4755
+ //#region src/ActionRuntime/Transport/Carrier/duplex/ws/wsCarrier.ts
4778
4756
  /**
4779
- * A carrier-agnostic transport: it drives nice-action's secure session + action routing over any
4780
- * {@link IDuplexCarrier}. The WebSocket transport is the special case that opens a `WebSocket`;
4781
- * this opens whatever `openChannel` returns, so the identical secure layer works over WebRTC, Bluetooth,
4782
- * or an in-memory pipe. Reported with an overridable carrier label in the devtools (defaults to "link").
4757
+ * A WebSocket {@link IDuplexCarrierSource}: opens an `arraybuffer` socket to `url` (cached per endpoint)
4758
+ * and adapts it to a carrier. Hand it to {@link secureTransport} (or `LinkTransport`) — the WebSocket is
4759
+ * now "just another carrier" under the shared secure session, with no WS-specific transport class.
4783
4760
  */
4784
- var LinkTransport = class LinkTransport extends Transport {
4785
- options;
4786
- type = "duplex";
4787
- constructor(options) {
4788
- super();
4789
- this.options = options;
4790
- }
4791
- static create(options) {
4792
- return new LinkTransport(options);
4793
- }
4794
- _createConnection(ctx) {
4795
- const options = this.options;
4796
- return new LinkConnection({ initialize: () => ({
4797
- getTransportCacheKey: options.getTransportCacheKey,
4798
- isAvailable: options.available,
4799
- getTransport: (input) => ({
4800
- status: "ready",
4801
- readyData: {
4802
- channel: options.openChannel(input),
4803
- formatMessage: options.createFormatMessage?.() ?? options.formatMessage,
4804
- updateRunConfig: options.updateRunConfig,
4805
- secureChannel: options.security
4806
- }
4807
- })
4808
- }) }, ctx.resolvers);
4809
- }
4810
- getRouteInfo(input) {
4811
- if (this.options.getRouteInfo != null) return this.options.getRouteInfo(input);
4812
- return {
4813
- carrierLabel: this.options.label ?? "link",
4814
- summary: this.options.label ?? "link"
4815
- };
4816
- }
4817
- };
4761
+ function wsCarrier(url, options = {}) {
4762
+ return {
4763
+ carrierLabel: "ws",
4764
+ open: (input) => webSocketByteChannel(options.createWebSocket?.(input) ?? defaultWebSocket(url)),
4765
+ getCacheKey: options.getTransportCacheKey ?? (() => [url]),
4766
+ getRouteInfo: options.getRouteInfo ?? (() => ({
4767
+ carrierLabel: "ws",
4768
+ url,
4769
+ summary: `ws ${shortWs(url)}`
4770
+ }))
4771
+ };
4772
+ }
4773
+ function defaultWebSocket(url) {
4774
+ const ws = new WebSocket(url);
4775
+ ws.binaryType = "arraybuffer";
4776
+ return ws;
4777
+ }
4818
4778
  //#endregion
4819
- //#region src/ActionRuntime/Transport/Carrier/Carrier.types.ts
4779
+ //#region src/ActionRuntime/Transport/Carrier/exchange/http/httpAcceptorCarrier.ts
4820
4780
  /**
4821
- * Narrow a carrier source to the exchange shape via its `shape` discriminant the one branch the
4822
- * transport factories ({@link secureTransport}, {@link plainTransport}) use to pick the duplex vs
4823
- * exchange transport. A duplex source carries no `shape`, so the `else` branch is the duplex one.
4781
+ * An HTTP {@link IExchangeAcceptorCarrier}: the accept-in dual of {@link httpCarrier}. It serves the
4782
+ * secure exchange protocol (handshake token session encrypted frames) over web-standard
4783
+ * `Request`/`Response`. The crypto identity, runtime coordinate, dictionary version, and accepted security
4784
+ * levels are all supplied centrally by `serveChannel`, so this only needs to say which requests carry an
4785
+ * action envelope and how to answer CORS.
4824
4786
  */
4825
- function isExchangeCarrierSource(carrier) {
4826
- return "shape" in carrier && carrier.shape === "exchange";
4787
+ function httpAcceptorCarrier(options = {}) {
4788
+ return {
4789
+ shape: "exchange",
4790
+ carrierLabel: options.carrierLabel ?? "http",
4791
+ secure: options.secure,
4792
+ isActionPath: options.isActionPath,
4793
+ cors: options.cors,
4794
+ useErrorStatus: options.useErrorStatus
4795
+ };
4827
4796
  }
4828
4797
  //#endregion
4829
- //#region src/ActionRuntime/Transport/plainTransport.ts
4830
- function plainTransport(options) {
4831
- const carrier = options.carrier;
4832
- if (isExchangeCarrierSource(carrier)) return ExchangeTransport.create({
4833
- openCarrier: carrier.open,
4834
- getTransportCacheKey: carrier.getCacheKey,
4835
- available: options.available,
4836
- getRouteInfo: carrier.getRouteInfo,
4837
- label: options.label ?? carrier.carrierLabel,
4838
- updateRunConfig: options.updateRunConfig
4839
- });
4840
- return LinkTransport.create({
4841
- openChannel: carrier.open,
4842
- formatMessage: options.formatMessage,
4843
- createFormatMessage: options.createFormatMessage,
4844
- getTransportCacheKey: carrier.getCacheKey,
4845
- available: options.available,
4846
- getRouteInfo: carrier.getRouteInfo,
4847
- label: options.label ?? carrier.carrierLabel,
4848
- updateRunConfig: options.updateRunConfig
4849
- });
4798
+ //#region src/ActionRuntime/Transport/Carrier/exchange/http/httpCarrier.ts
4799
+ function shortPath(url) {
4800
+ try {
4801
+ return new URL(url).pathname || url;
4802
+ } catch {
4803
+ return url;
4804
+ }
4805
+ }
4806
+ /**
4807
+ * An HTTP {@link IExchangeCarrierSource}: each `exchange` POSTs one frame body to the action endpoint and
4808
+ * resolves with the response body as the single correlated reply. Hand it to {@link secureTransport}
4809
+ * HTTP then runs the *same* secure session as a duplex carrier (handshake → token → encrypted frames),
4810
+ * the request/reply correlation provided for free by the HTTP transaction.
4811
+ *
4812
+ * `createRequest` derives the URL/headers per action (keep it simple with `() => ({ url })`). The body is
4813
+ * the session's responsibility, so it is never built here.
4814
+ */
4815
+ function httpCarrier(createRequest, options = {}) {
4816
+ const doFetch = options.fetch ?? fetch;
4817
+ return {
4818
+ shape: "exchange",
4819
+ carrierLabel: "http",
4820
+ open: (input) => {
4821
+ const request = createRequest(input);
4822
+ return {
4823
+ label: request.url,
4824
+ exchange: async (frame, opts) => {
4825
+ return await (await doFetch(request.url, {
4826
+ method: "POST",
4827
+ headers: {
4828
+ "Content-Type": "application/json",
4829
+ ...request.headers
4830
+ },
4831
+ body: typeof frame === "string" ? frame : new Uint8Array(frame),
4832
+ signal: opts?.signal
4833
+ })).text();
4834
+ }
4835
+ };
4836
+ },
4837
+ getCacheKey: options.getTransportCacheKey ?? ((input) => [createRequest(input).url]),
4838
+ getRouteInfo: options.getRouteInfo ?? ((input) => {
4839
+ const { url } = createRequest(input);
4840
+ return {
4841
+ carrierLabel: "http",
4842
+ method: "POST",
4843
+ url,
4844
+ summary: `POST ${shortPath(url)}`
4845
+ };
4846
+ })
4847
+ };
4850
4848
  }
4851
4849
  //#endregion
4852
- //#region src/ActionRuntime/Transport/secureTransport.ts
4853
- function secureTransport(options) {
4854
- const link = new ClientCryptoKeyLink({ storageAdapter: options.storageAdapter });
4855
- const security = {
4856
- securityLevel: options.securityLevel,
4857
- link,
4858
- localCoordinate: options.runtime.coordinate.toJsonObject(),
4859
- dictionaryVersion: options.channel.dictionaryVersion
4850
+ //#region src/ActionRuntime/Transport/codec/createBinaryWireAdapter.ts
4851
+ /**
4852
+ * Positional layout of the stateless binary envelope. A flat tuple (rather than an object) strips the
4853
+ * repeated `domain`/`id`/`form`/`type` and context key names from every frame, and we carry only the
4854
+ * context fields the receiver can't recompute: `cuid` (correlation) and `originClient` (return
4855
+ * routing).
4856
+ *
4857
+ * [ routeInt, typeInt, time, cuid, originClient, payloadData ]
4858
+ *
4859
+ * Dropped vs the JSON wire: `form`/`type` strings, `inputHash`/`outputHash` (recomputed on hydrate),
4860
+ * `context.timeCreated` (reconstructed from `time`) and `context.routing` (rebuilt empty — the
4861
+ * receiver re-stamps its own route items as it handles the action). For the leanest possible frames
4862
+ * (integer correlation, identity dropped after a handshake), use `createBinaryWireSessionFactory`.
4863
+ */
4864
+ const ENVELOPE = {
4865
+ route: 0,
4866
+ type: 1,
4867
+ time: 2,
4868
+ cuid: 3,
4869
+ originClient: 4,
4870
+ payload: 5
4871
+ };
4872
+ const ENVELOPE_LENGTH = 6;
4873
+ /**
4874
+ * Builds a *stateless* `formatMessage` pipeline for {@link LinkTransport}, packing action
4875
+ * payloads into a compact msgpackr binary frame instead of JSON. The `domain`/`id` route collapses to
4876
+ * a single integer drawn from a shared dictionary; `form`/`type`, the recomputable
4877
+ * `inputHash`/`outputHash`, and the per-frame `context.routing`/`context.timeCreated` are all dropped
4878
+ * (see {@link ENVELOPE}).
4879
+ *
4880
+ * No validation runs here: `incoming` blindly reconstructs the wire JSON shape and hands it back to
4881
+ * the connection, which flows into `ActionRuntime` → `domain.hydrateAnyAction()` where the Valibot
4882
+ * schemas validate it exactly as they would for a JSON frame.
4883
+ *
4884
+ * Both ends of the socket MUST construct the adapter with the same domains in the same order — the
4885
+ * integer dictionary is positional. Mismatched dictionaries will route to the wrong action.
4886
+ *
4887
+ * Because `incoming` returns `undefined` for text frames, a binary server can still serve plain-JSON
4888
+ * clients on the same runtime (the connection falls back to its built-in JSON parser).
4889
+ */
4890
+ function createBinaryWireAdapter(domains) {
4891
+ const { routeToInt, intToRoute } = buildActionRouteDictionary(domains);
4892
+ return {
4893
+ outgoing: (input) => {
4894
+ const json = input.action.toJsonObject();
4895
+ const routeKey = `${json.domain}:${json.id}`;
4896
+ const routeInt = routeToInt.get(routeKey);
4897
+ if (routeInt == null) throw new Error(`[binary-wire] Cannot pack unregistered action route: ${routeKey}`);
4898
+ const envelope = new Array(ENVELOPE_LENGTH);
4899
+ envelope[ENVELOPE.route] = routeInt;
4900
+ envelope[ENVELOPE.type] = PayloadTypeToInt[json.type];
4901
+ envelope[ENVELOPE.time] = json.time;
4902
+ envelope[ENVELOPE.cuid] = json.context.cuid;
4903
+ envelope[ENVELOPE.originClient] = json.context.originClient;
4904
+ envelope[ENVELOPE.payload] = extractWirePayload(json);
4905
+ return pack(envelope);
4906
+ },
4907
+ incoming: (frame) => {
4908
+ let buffer;
4909
+ if (frame instanceof ArrayBuffer) buffer = new Uint8Array(frame);
4910
+ else if (frame instanceof Uint8Array) buffer = frame;
4911
+ else return;
4912
+ try {
4913
+ const envelope = unpack(buffer);
4914
+ if (!Array.isArray(envelope) || envelope.length !== ENVELOPE_LENGTH) return void 0;
4915
+ const routeMeta = intToRoute[envelope[ENVELOPE.route]];
4916
+ const payloadType = ReversePayloadType[envelope[ENVELOPE.type]];
4917
+ if (routeMeta == null || payloadType == null) return void 0;
4918
+ const time = envelope[ENVELOPE.time];
4919
+ return assembleWireJson(routeMeta, payloadType, time, {
4920
+ cuid: envelope[ENVELOPE.cuid],
4921
+ timeCreated: time,
4922
+ routing: [],
4923
+ originClient: envelope[ENVELOPE.originClient]
4924
+ }, envelope[ENVELOPE.payload]);
4925
+ } catch (e) {
4926
+ console.error("[binary-wire] Failed to unpack binary action frame", e);
4927
+ return;
4928
+ }
4929
+ }
4860
4930
  };
4861
- const carrier = options.carrier;
4862
- if (isExchangeCarrierSource(carrier)) return ExchangeTransport.create({
4863
- openCarrier: carrier.open,
4864
- getTransportCacheKey: carrier.getCacheKey,
4865
- available: options.available,
4866
- getRouteInfo: carrier.getRouteInfo,
4867
- label: carrier.carrierLabel,
4868
- security
4869
- });
4870
- return LinkTransport.create({
4871
- openChannel: carrier.open,
4872
- createFormatMessage: options.channel.createCodec,
4873
- getTransportCacheKey: carrier.getCacheKey,
4874
- available: options.available,
4875
- getRouteInfo: carrier.getRouteInfo,
4876
- label: carrier.carrierLabel,
4877
- security
4878
- });
4879
4931
  }
4880
4932
  //#endregion
4881
- 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, decodeExchangeReply, decodeExchangeRequest, decodeHandshakeMessage, defineChannel, defineSecureChannel, encodeExchange, 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 };
4933
+ 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, decodeExchangeReply, decodeExchangeRequest, decodeHandshakeMessage, defineChannel, defineSecureChannel, encodeExchange, 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, rtcCarrier, rtcDataChannelByteChannel, runtimeLinkId, serveChannel, wsAcceptorCarrier, wsCarrier };
4882
4934
 
4883
4935
  //# sourceMappingURL=index.mjs.map