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