@oatscenter/trellis 0.100.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/auth/authorization/client_context.d.ts +61 -0
- package/auth/authorization/client_context.js +266 -0
- package/auth/authorization/nats_registry.d.ts +41 -0
- package/auth/authorization/nats_registry.js +283 -0
- package/auth/authorization/provider_cache.d.ts +90 -0
- package/auth/authorization/provider_cache.js +783 -0
- package/auth/authorization/refresh.d.ts +46 -0
- package/auth/authorization/refresh.js +246 -0
- package/auth/authorization/types.d.ts +207 -0
- package/auth/authorization/types.js +60 -0
- package/auth/authorization_context.d.ts +11 -0
- package/auth/authorization_context.js +10 -0
- package/auth/browser/flow_types.d.ts +84 -0
- package/auth/browser/flow_types.js +70 -0
- package/auth/browser/portal.d.ts +21 -0
- package/auth/browser/portal.js +307 -0
- package/auth/browser/storage.d.ts +26 -0
- package/auth/browser/storage.js +224 -0
- package/auth/browser.d.ts +11 -0
- package/auth/browser.js +10 -0
- package/auth/browser_recovery.d.ts +13 -0
- package/auth/browser_recovery.js +38 -0
- package/auth/device_activation.d.ts +134 -0
- package/auth/device_activation.js +376 -0
- package/auth/http_error.d.ts +12 -0
- package/auth/http_error.js +32 -0
- package/auth/keys.d.ts +5 -0
- package/auth/keys.js +63 -0
- package/auth/mod.d.ts +16 -0
- package/auth/mod.js +16 -0
- package/auth/proof.d.ts +31 -0
- package/auth/proof.js +95 -0
- package/auth/protocol_wasm/trellis_protocol_wasm.d.ts +138 -0
- package/auth/protocol_wasm/trellis_protocol_wasm.js +605 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bg.wasm +0 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bytes.d.ts +1 -0
- package/auth/protocol_wasm/trellis_protocol_wasm_bytes.js +2 -0
- package/auth/protocol_wasm.d.ts +179 -0
- package/auth/protocol_wasm.js +133 -0
- package/auth/session_auth.d.ts +30 -0
- package/auth/session_auth.js +77 -0
- package/auth/session_proof.d.ts +53 -0
- package/auth/session_proof.js +43 -0
- package/auth/time.d.ts +6 -0
- package/auth/time.js +7 -0
- package/auth/trellis_id.d.ts +1 -0
- package/auth/trellis_id.js +5 -0
- package/auth/utils.d.ts +10 -0
- package/auth/utils.js +69 -0
- package/auth.d.ts +1 -0
- package/auth.js +1 -0
- package/caller.d.ts +111 -0
- package/caller.js +85 -0
- package/client.d.ts +14 -0
- package/client.js +1 -0
- package/client_connect.d.ts +143 -0
- package/client_connect.js +1097 -0
- package/codec.d.ts +13 -0
- package/codec.js +192 -0
- package/connection.d.ts +107 -0
- package/connection.js +396 -0
- package/device/activation.d.ts +42 -0
- package/device/activation.js +319 -0
- package/device.d.ts +80 -0
- package/device.js +687 -0
- package/env.d.ts +2 -0
- package/env.js +10 -0
- package/errors/AuthError.d.ts +30 -0
- package/errors/AuthError.js +34 -0
- package/errors/KVError.d.ts +30 -0
- package/errors/KVError.js +36 -0
- package/errors/OperationLifecycleError.d.ts +110 -0
- package/errors/OperationLifecycleError.js +142 -0
- package/errors/RemoteError.d.ts +61 -0
- package/errors/RemoteError.js +72 -0
- package/errors/SchemaValidationError.d.ts +48 -0
- package/errors/SchemaValidationError.js +49 -0
- package/errors/StoreError.d.ts +21 -0
- package/errors/StoreError.js +31 -0
- package/errors/TransferError.d.ts +21 -0
- package/errors/TransferError.js +31 -0
- package/errors/TransportError.d.ts +29 -0
- package/errors/TransportError.js +33 -0
- package/errors/TrellisError.d.ts +15 -0
- package/errors/TrellisError.js +15 -0
- package/errors/ValidationError.d.ts +50 -0
- package/errors/ValidationError.js +59 -0
- package/errors/index.d.ts +254 -0
- package/errors/index.js +238 -0
- package/generated.d.ts +4 -0
- package/generated.js +3 -0
- package/generated_support.d.ts +120 -0
- package/generated_support.js +359 -0
- package/globals.d.ts +9 -0
- package/globals.js +16 -0
- package/health_transport.d.ts +18 -0
- package/health_transport.js +32 -0
- package/helpers.d.ts +11 -0
- package/helpers.js +53 -0
- package/index.d.ts +2 -0
- package/index.js +2 -0
- package/internal_sdk/generated/apis/auth/mod.d.ts +805 -0
- package/internal_sdk/generated/apis/auth/mod.js +958 -0
- package/internal_sdk/generated/apis/core/mod.d.ts +79 -0
- package/internal_sdk/generated/apis/core/mod.js +93 -0
- package/internal_sdk/generated/apis/events/mod.d.ts +224 -0
- package/internal_sdk/generated/apis/events/mod.js +313 -0
- package/internal_sdk/generated/apis/health/mod.d.ts +106 -0
- package/internal_sdk/generated/apis/health/mod.js +132 -0
- package/internal_sdk/generated/apis/index.d.ts +7 -0
- package/internal_sdk/generated/apis/index.js +8 -0
- package/internal_sdk/generated/apis/jobs/mod.d.ts +176 -0
- package/internal_sdk/generated/apis/jobs/mod.js +209 -0
- package/internal_sdk/generated/apis/state/mod.d.ts +104 -0
- package/internal_sdk/generated/apis/state/mod.js +139 -0
- package/internal_sdk/generated/index.d.ts +4 -0
- package/internal_sdk/generated/index.js +5 -0
- package/internal_sdk/generated/package.json +18 -0
- package/internal_sdk/generated/participants/cli/mod.d.ts +402 -0
- package/internal_sdk/generated/participants/cli/mod.js +460 -0
- package/internal_sdk/generated/participants/cli/types.d.ts +2 -0
- package/internal_sdk/generated/participants/cli/types.js +3 -0
- package/internal_sdk/generated/participants/console/mod.d.ts +489 -0
- package/internal_sdk/generated/participants/console/mod.js +579 -0
- package/internal_sdk/generated/participants/console/types.d.ts +2 -0
- package/internal_sdk/generated/participants/console/types.js +3 -0
- package/internal_sdk/generated/participants/eventsRuntime/mod.d.ts +64 -0
- package/internal_sdk/generated/participants/eventsRuntime/mod.js +42 -0
- package/internal_sdk/generated/participants/eventsRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/eventsRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/healthRuntime/mod.d.ts +58 -0
- package/internal_sdk/generated/participants/healthRuntime/mod.js +36 -0
- package/internal_sdk/generated/participants/healthRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/healthRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/index.d.ts +8 -0
- package/internal_sdk/generated/participants/index.js +9 -0
- package/internal_sdk/generated/participants/jobsRuntime/mod.d.ts +64 -0
- package/internal_sdk/generated/participants/jobsRuntime/mod.js +42 -0
- package/internal_sdk/generated/participants/jobsRuntime/types.d.ts +2 -0
- package/internal_sdk/generated/participants/jobsRuntime/types.js +3 -0
- package/internal_sdk/generated/participants/platform/mod.d.ts +164 -0
- package/internal_sdk/generated/participants/platform/mod.js +149 -0
- package/internal_sdk/generated/participants/platform/types.d.ts +7 -0
- package/internal_sdk/generated/participants/platform/types.js +5 -0
- package/internal_sdk/generated/participants/portal/mod.d.ts +131 -0
- package/internal_sdk/generated/participants/portal/mod.js +109 -0
- package/internal_sdk/generated/participants/portal/types.d.ts +2 -0
- package/internal_sdk/generated/participants/portal/types.js +3 -0
- package/internal_sdk/generated/types/_internal/p0.d.ts +13511 -0
- package/internal_sdk/generated/types/_internal/p0.js +6105 -0
- package/internal_sdk/generated/types/index.d.ts +2951 -0
- package/internal_sdk/generated/types/index.js +1477 -0
- package/jobs.d.ts +309 -0
- package/jobs.js +389 -0
- package/kv.d.ts +85 -0
- package/kv.js +330 -0
- package/operations.d.ts +189 -0
- package/operations.js +847 -0
- package/package.json +73 -0
- package/pagination.d.ts +25 -0
- package/pagination.js +117 -0
- package/participant.d.ts +6 -0
- package/participant.js +6 -0
- package/participant_runtime/api.d.ts +131 -0
- package/participant_runtime/api.js +42 -0
- package/participant_runtime/json.d.ts +16 -0
- package/participant_runtime/json.js +193 -0
- package/participant_runtime/metadata.d.ts +34 -0
- package/participant_runtime/metadata.js +4 -0
- package/participant_runtime/participant.d.ts +200 -0
- package/participant_runtime/participant.js +330 -0
- package/participant_runtime/schemas.d.ts +347 -0
- package/participant_runtime/schemas.js +211 -0
- package/participant_runtime/surface_names.d.ts +16 -0
- package/participant_runtime/surface_names.js +34 -0
- package/portable.d.ts +30 -0
- package/portable.js +14 -0
- package/provider.d.ts +69 -0
- package/provider.js +68 -0
- package/runtime_transport.d.ts +25 -0
- package/runtime_transport.js +48 -0
- package/service/mod.d.ts +14 -0
- package/service/mod.js +13 -0
- package/service/outbox_inbox.d.ts +259 -0
- package/service/outbox_inbox.js +908 -0
- package/service/runtime/bootstrap.d.ts +56 -0
- package/service/runtime/bootstrap.js +226 -0
- package/service/runtime/core.d.ts +46 -0
- package/service/runtime/core.js +1747 -0
- package/service/runtime/health.d.ts +97 -0
- package/service/runtime/health.js +153 -0
- package/service/runtime/health_schemas.d.ts +23 -0
- package/service/runtime/health_schemas.js +20 -0
- package/service/runtime/internal_jobs/active-job.d.ts +40 -0
- package/service/runtime/internal_jobs/active-job.js +91 -0
- package/service/runtime/internal_jobs/bindings.d.ts +40 -0
- package/service/runtime/internal_jobs/bindings.js +1 -0
- package/service/runtime/internal_jobs/cancellation-registry.d.ts +13 -0
- package/service/runtime/internal_jobs/cancellation-registry.js +55 -0
- package/service/runtime/internal_jobs/heartbeat.d.ts +33 -0
- package/service/runtime/internal_jobs/heartbeat.js +65 -0
- package/service/runtime/internal_jobs/job-manager.d.ts +126 -0
- package/service/runtime/internal_jobs/job-manager.js +821 -0
- package/service/runtime/internal_jobs/key-coordinator.d.ts +280 -0
- package/service/runtime/internal_jobs/key-coordinator.js +747 -0
- package/service/runtime/internal_jobs/projection.d.ts +3 -0
- package/service/runtime/internal_jobs/projection.js +42 -0
- package/service/runtime/internal_jobs/runtime-worker.d.ts +126 -0
- package/service/runtime/internal_jobs/runtime-worker.js +454 -0
- package/service/runtime/internal_jobs/types.d.ts +246 -0
- package/service/runtime/internal_jobs/types.js +163 -0
- package/service/runtime/logger.d.ts +2 -0
- package/service/runtime/logger.js +7 -0
- package/service/runtime/runtime.d.ts +16 -0
- package/service/runtime/runtime.js +1 -0
- package/service/runtime/service.d.ts +372 -0
- package/service/runtime/service.js +1896 -0
- package/service/runtime/transfer/download.d.ts +27 -0
- package/service/runtime/transfer/download.js +1 -0
- package/service/runtime/transfer/protocol.d.ts +15 -0
- package/service/runtime/transfer/protocol.js +46 -0
- package/service/runtime/transfer/queue.d.ts +18 -0
- package/service/runtime/transfer/queue.js +136 -0
- package/service/runtime/transfer/upload.d.ts +40 -0
- package/service/runtime/transfer/upload.js +27 -0
- package/service/runtime/transfer.d.ts +62 -0
- package/service/runtime/transfer.js +763 -0
- package/session.d.ts +862 -0
- package/session.js +3407 -0
- package/store.d.ts +71 -0
- package/store.js +412 -0
- package/store_entry.d.ts +20 -0
- package/store_entry.js +75 -0
- package/tasks.d.ts +10 -0
- package/tasks.js +28 -0
- package/telemetry/carrier.d.ts +8 -0
- package/telemetry/carrier.js +20 -0
- package/telemetry/core.d.ts +7 -0
- package/telemetry/core.js +14 -0
- package/telemetry/env.d.ts +2 -0
- package/telemetry/env.js +10 -0
- package/telemetry/init.d.ts +2 -0
- package/telemetry/init.js +7 -0
- package/telemetry/metrics.d.ts +81 -0
- package/telemetry/metrics.js +267 -0
- package/telemetry/mod.d.ts +13 -0
- package/telemetry/mod.js +8 -0
- package/telemetry/nats.d.ts +6 -0
- package/telemetry/nats.js +6 -0
- package/telemetry/result.d.ts +1 -0
- package/telemetry/result.js +18 -0
- package/telemetry/runtime.d.ts +1 -0
- package/telemetry/runtime.js +141 -0
- package/telemetry/trace.d.ts +4 -0
- package/telemetry/trace.js +26 -0
- package/telemetry/trellis.d.ts +1 -0
- package/telemetry/trellis.js +1 -0
- package/telemetry.d.ts +1 -0
- package/telemetry.js +1 -0
- package/transfer.d.ts +112 -0
- package/transfer.js +525 -0
- package/transfer_protocol.d.ts +1 -0
- package/transfer_protocol.js +13 -0
package/session.js
ADDED
|
@@ -0,0 +1,3407 @@
|
|
|
1
|
+
import { jetstream, jetstreamManager, } from "@nats-io/jetstream";
|
|
2
|
+
import { createInbox, headers as natsHeaders, } from "@nats-io/nats-core";
|
|
3
|
+
import { boundApiSubject, feedControlSubject, routeQueueGroup, } from "./participant_runtime/api.js";
|
|
4
|
+
import { encodeEventSubjectParameterToken } from "./helpers.js";
|
|
5
|
+
import { buildEventProofInput } from "./auth/proof.js";
|
|
6
|
+
import { AuthorizationProviderCache, } from "./auth/authorization_context.js";
|
|
7
|
+
import { AuthorizationProviderUnavailableError } from "./auth/authorization/provider_cache.js";
|
|
8
|
+
import { AsyncResult, BaseError, err, isErr, ok, Result, } from "@oatscenter/result";
|
|
9
|
+
import { context, createNatsHeaderCarrier, extractTraceContext, injectTraceContext, recordTrellisError, SpanStatusCode, startClientSpan, startServerSpan, trace, withSpanAsync, } from "./telemetry/mod.js";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
import { AssertError, Pointer } from "typebox/value";
|
|
12
|
+
import { ulid } from "ulid";
|
|
13
|
+
import { encodeSchema, parse, parseSchema, parseUnknownSchema, } from "./codec.js";
|
|
14
|
+
import { AuthError, BUILTIN_RPC_ERRORS, getBuiltinRpcError, machineErrorCode, SchemaValidationError, TransferError, TransportError, UnexpectedError, ValidationError, } from "./errors/index.js";
|
|
15
|
+
import { RemoteError } from "./errors/RemoteError.js";
|
|
16
|
+
import { logger } from "./globals.js";
|
|
17
|
+
import { TypedKV, } from "./kv.js";
|
|
18
|
+
import { TrellisErrorDataSchema } from "./errors/RemoteError.js";
|
|
19
|
+
import { OperationInvoker, } from "./operations.js";
|
|
20
|
+
import { API as STATE_API, Conflict as StateRpcConflict, } from "./internal_sdk/generated/apis/state/mod.js";
|
|
21
|
+
import { API as EVENTS_API } from "./internal_sdk/generated/apis/events/mod.js";
|
|
22
|
+
import { createTransferHandle, } from "./transfer.js";
|
|
23
|
+
import { TrellisTasks } from "./tasks.js";
|
|
24
|
+
import { TrellisConnection } from "./connection.js";
|
|
25
|
+
class EventVerificationAuthError extends AuthError {
|
|
26
|
+
retryable;
|
|
27
|
+
constructor(retryable) {
|
|
28
|
+
super({
|
|
29
|
+
reason: retryable ? "authorization_unavailable" : "invalid_signature",
|
|
30
|
+
});
|
|
31
|
+
this.retryable = retryable;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Verifies one received request or event through the provider-local WASM cache. */
|
|
35
|
+
export async function verifyLocalAuthorization(args) {
|
|
36
|
+
if (!args.permission && !(args.kind === "request" && args.identityOnly)) {
|
|
37
|
+
return err(new AuthError({ reason: "insufficient_permissions" }));
|
|
38
|
+
}
|
|
39
|
+
if (!args.cache) {
|
|
40
|
+
return err(new AuthError({ reason: "invalid_signature" }));
|
|
41
|
+
}
|
|
42
|
+
const proof = args.message.headers?.get("proof");
|
|
43
|
+
const contextDigest = args.message.headers?.get("authorization-context");
|
|
44
|
+
const sessionKey = args.message.headers?.get("session-key");
|
|
45
|
+
if (!proof || !contextDigest || !sessionKey) {
|
|
46
|
+
return err(new AuthError({ reason: "missing_proof" }));
|
|
47
|
+
}
|
|
48
|
+
let result;
|
|
49
|
+
try {
|
|
50
|
+
if (args.kind === "request") {
|
|
51
|
+
const iatHeader = args.message.headers?.get("iat");
|
|
52
|
+
const requestId = args.message.headers?.get("request-id");
|
|
53
|
+
const reply = args.message.reply;
|
|
54
|
+
const iat = Number(iatHeader);
|
|
55
|
+
if (!Number.isSafeInteger(iat) || !requestId || !reply) {
|
|
56
|
+
return err(new AuthError({ reason: "invalid_signature" }));
|
|
57
|
+
}
|
|
58
|
+
const request = {
|
|
59
|
+
contextDigest,
|
|
60
|
+
sessionKey,
|
|
61
|
+
subject: args.message.subject,
|
|
62
|
+
reply,
|
|
63
|
+
payload: args.proofPayload ??
|
|
64
|
+
new Uint8Array(args.message.data ?? new Uint8Array()),
|
|
65
|
+
iat,
|
|
66
|
+
requestId,
|
|
67
|
+
proof,
|
|
68
|
+
requiredPermissions: args.permission
|
|
69
|
+
? [toVerifierPermission(args.permission)]
|
|
70
|
+
: [],
|
|
71
|
+
requiredCapabilities: [...args.requiredCapabilities],
|
|
72
|
+
};
|
|
73
|
+
result = await args.cache.verifyRequest(request);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const eventId = args.message.headers?.get("Nats-Msg-Id");
|
|
77
|
+
const eventTime = args.message.headers?.get("Trellis-Event-Time");
|
|
78
|
+
const descriptorIdentity = args.message.headers?.get("Trellis-Event-Descriptor");
|
|
79
|
+
if (!eventId || !eventTime || descriptorIdentity !== args.descriptorIdentity) {
|
|
80
|
+
return err(new AuthError({ reason: "invalid_signature" }));
|
|
81
|
+
}
|
|
82
|
+
const event = {
|
|
83
|
+
contextDigest,
|
|
84
|
+
sessionKey,
|
|
85
|
+
descriptorIdentity,
|
|
86
|
+
subject: args.message.subject,
|
|
87
|
+
payload: new Uint8Array(args.message.data ?? new Uint8Array()),
|
|
88
|
+
eventId,
|
|
89
|
+
eventTime,
|
|
90
|
+
proof,
|
|
91
|
+
requiredCapabilities: [...args.requiredCapabilities],
|
|
92
|
+
};
|
|
93
|
+
result = await args.cache.verifyEvent(event);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error instanceof AuthorizationProviderUnavailableError) {
|
|
98
|
+
return err(args.kind === "event"
|
|
99
|
+
? new EventVerificationAuthError(true)
|
|
100
|
+
: new AuthError({ reason: "authorization_unavailable" }));
|
|
101
|
+
}
|
|
102
|
+
return err(new AuthError({ reason: "invalid_signature" }));
|
|
103
|
+
}
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
return err(new AuthError({
|
|
106
|
+
reason: localAuthorizationErrorReason(result.error.code),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
return ok(toVerifiedCaller(result.contextDigest, result.context));
|
|
110
|
+
}
|
|
111
|
+
function toVerifierPermission(permission) {
|
|
112
|
+
if (permission.surfaceKind === "operation" && permission.action === "control") {
|
|
113
|
+
const separator = permission.surfaceName.lastIndexOf(".");
|
|
114
|
+
if (separator <= 0 || separator === permission.surfaceName.length - 1) {
|
|
115
|
+
throw new Error("operation signal permission is invalid");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
target: {
|
|
119
|
+
kind: "operationSignal",
|
|
120
|
+
api: `${permission.apiId}@${permission.apiVersion}`,
|
|
121
|
+
operation: permission.surfaceName.slice(0, separator),
|
|
122
|
+
signal: permission.surfaceName.slice(separator + 1),
|
|
123
|
+
},
|
|
124
|
+
action: permission.action,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
target: {
|
|
129
|
+
kind: "apiSurface",
|
|
130
|
+
api: `${permission.apiId}@${permission.apiVersion}`,
|
|
131
|
+
surface: permission.surfaceKind,
|
|
132
|
+
name: permission.surfaceName,
|
|
133
|
+
},
|
|
134
|
+
action: permission.action,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function toVerifiedCaller(contextDigest, projection) {
|
|
138
|
+
return {
|
|
139
|
+
type: "verified",
|
|
140
|
+
sessionKey: projection.sessionKey,
|
|
141
|
+
principalId: projection.principalId,
|
|
142
|
+
principalKind: projection.principalKind,
|
|
143
|
+
participantId: projection.participantId,
|
|
144
|
+
contextDigest,
|
|
145
|
+
connectionId: projection.connectionId,
|
|
146
|
+
loginSessionId: projection.loginSessionId,
|
|
147
|
+
identityKeyId: projection.identityKeyId,
|
|
148
|
+
deploymentId: projection.deploymentId,
|
|
149
|
+
instanceId: projection.instanceId,
|
|
150
|
+
grantRevision: projection.grantRevision,
|
|
151
|
+
platformPrivileges: [...projection.platformPrivileges],
|
|
152
|
+
inboxPrefix: projection.inboxPrefix,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function localAuthorizationErrorReason(code) {
|
|
156
|
+
switch (code) {
|
|
157
|
+
case "PermissionDenied":
|
|
158
|
+
return "insufficient_permissions";
|
|
159
|
+
case "ReplySubjectMismatch":
|
|
160
|
+
return "reply_subject_mismatch";
|
|
161
|
+
case "ProofIatOutOfRange":
|
|
162
|
+
return "iat_out_of_range";
|
|
163
|
+
case "ContextExpired":
|
|
164
|
+
case "EventRevoked":
|
|
165
|
+
return "session_expired";
|
|
166
|
+
default:
|
|
167
|
+
return "invalid_signature";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Safely extract JSON from a NATS message.
|
|
172
|
+
* The .json() method can throw if the message data is not valid JSON.
|
|
173
|
+
*/
|
|
174
|
+
export function safeJson(msg) {
|
|
175
|
+
return Result.try(() => msg.json());
|
|
176
|
+
}
|
|
177
|
+
function transportCauseContext(cause) {
|
|
178
|
+
if (cause instanceof Error) {
|
|
179
|
+
return {
|
|
180
|
+
causeName: cause.name,
|
|
181
|
+
causeMessage: cause.message,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return { cause: String(cause) };
|
|
185
|
+
}
|
|
186
|
+
function createTransportError(args) {
|
|
187
|
+
return new TransportError({
|
|
188
|
+
code: args.code,
|
|
189
|
+
message: args.message,
|
|
190
|
+
hint: args.hint,
|
|
191
|
+
cause: args.cause,
|
|
192
|
+
context: {
|
|
193
|
+
...(args.context ?? {}),
|
|
194
|
+
...(args.cause === undefined ? {} : transportCauseContext(args.cause)),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function requestFailedTransportError(args) {
|
|
199
|
+
return createTransportError({
|
|
200
|
+
code: args.code,
|
|
201
|
+
message: args.message,
|
|
202
|
+
hint: args.hint,
|
|
203
|
+
cause: args.cause,
|
|
204
|
+
context: {
|
|
205
|
+
subject: args.subject,
|
|
206
|
+
...(args.method === undefined ? {} : { method: args.method }),
|
|
207
|
+
...(args.context ?? {}),
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function classifyRequestTransportFailure(args) {
|
|
212
|
+
const message = args.cause instanceof Error
|
|
213
|
+
? args.cause.message
|
|
214
|
+
: String(args.cause);
|
|
215
|
+
const isNoResponders = message.includes("no responders");
|
|
216
|
+
const isNatsPermission = args.cause instanceof Error &&
|
|
217
|
+
[
|
|
218
|
+
"PermissionViolationError",
|
|
219
|
+
"AuthorizationError",
|
|
220
|
+
"UserAuthenticationExpiredError",
|
|
221
|
+
]
|
|
222
|
+
.includes(args.cause.name);
|
|
223
|
+
return requestFailedTransportError({
|
|
224
|
+
code: isNoResponders
|
|
225
|
+
? "trellis.request.unavailable"
|
|
226
|
+
: isNatsPermission
|
|
227
|
+
? "trellis.request.denied"
|
|
228
|
+
: "trellis.request.failed",
|
|
229
|
+
message: isNoResponders
|
|
230
|
+
? "Trellis could not reach the requested capability."
|
|
231
|
+
: isNatsPermission
|
|
232
|
+
? "Trellis denied this request."
|
|
233
|
+
: "Trellis could not complete the request.",
|
|
234
|
+
hint: isNoResponders
|
|
235
|
+
? "Check that the target service is installed and reachable, then try again."
|
|
236
|
+
: isNatsPermission
|
|
237
|
+
? "Sign in with a profile that has the required capability, then try again."
|
|
238
|
+
: "Retry the request. If it keeps failing, check Trellis runtime health.",
|
|
239
|
+
cause: args.cause,
|
|
240
|
+
method: args.method,
|
|
241
|
+
subject: args.subject,
|
|
242
|
+
context: {
|
|
243
|
+
...(args.callerCapabilities === undefined
|
|
244
|
+
? {}
|
|
245
|
+
: { requiredCapabilities: args.callerCapabilities }),
|
|
246
|
+
noResponders: isNoResponders,
|
|
247
|
+
lowLevelMessage: message,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** Creates the existing typed transport error for an unavailable optional action. */
|
|
252
|
+
export function createActionUnavailableError(action, capabilities) {
|
|
253
|
+
return requestFailedTransportError({
|
|
254
|
+
code: "trellis.request.unavailable",
|
|
255
|
+
message: "Trellis could not reach the requested capability.",
|
|
256
|
+
hint: "Wait for the optional capability to become available, then try again.",
|
|
257
|
+
subject: action,
|
|
258
|
+
context: { action, capabilities },
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function encodeRuntimeSchema(schema, data) {
|
|
262
|
+
if (schema && typeof schema === "object" &&
|
|
263
|
+
typeof Reflect.get(schema, "encode") === "function") {
|
|
264
|
+
try {
|
|
265
|
+
const encoded = schema.encode(data);
|
|
266
|
+
const json = JSON.stringify(encoded);
|
|
267
|
+
if (json === undefined) {
|
|
268
|
+
throw new TypeError("Codec encoded no JSON value");
|
|
269
|
+
}
|
|
270
|
+
return ok(json);
|
|
271
|
+
}
|
|
272
|
+
catch (cause) {
|
|
273
|
+
return err(new UnexpectedError({ cause }));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return encodeSchema(schema, data);
|
|
277
|
+
}
|
|
278
|
+
function parseRuntimeSchema(schema, data) {
|
|
279
|
+
if (schema && typeof schema === "object" &&
|
|
280
|
+
typeof Reflect.get(schema, "decode") === "function") {
|
|
281
|
+
try {
|
|
282
|
+
return ok(schema.decode(data));
|
|
283
|
+
}
|
|
284
|
+
catch (cause) {
|
|
285
|
+
return err(new UnexpectedError({ cause }));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return parseUnknownSchema(schema, data);
|
|
289
|
+
}
|
|
290
|
+
export function base64urlEncode(data) {
|
|
291
|
+
const b64 = btoa(String.fromCharCode(...data));
|
|
292
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
293
|
+
}
|
|
294
|
+
export function base64urlDecode(s) {
|
|
295
|
+
const normalized = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
296
|
+
const padLen = (4 - (normalized.length % 4)) % 4;
|
|
297
|
+
const padded = normalized + "=".repeat(padLen);
|
|
298
|
+
const bin = atob(padded);
|
|
299
|
+
const out = new Uint8Array(bin.length);
|
|
300
|
+
for (let i = 0; i < bin.length; i++)
|
|
301
|
+
out[i] = bin.charCodeAt(i);
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
export function toArrayBuffer(data) {
|
|
305
|
+
const buf = data.buffer;
|
|
306
|
+
if (buf instanceof ArrayBuffer) {
|
|
307
|
+
return buf.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
308
|
+
}
|
|
309
|
+
const copy = new Uint8Array(data.byteLength);
|
|
310
|
+
copy.set(data);
|
|
311
|
+
return copy.buffer;
|
|
312
|
+
}
|
|
313
|
+
export async function sha256(data) {
|
|
314
|
+
const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(data));
|
|
315
|
+
return new Uint8Array(digest);
|
|
316
|
+
}
|
|
317
|
+
export function buildProofInput(contextDigest, subject, reply, payloadHash, iat, requestId) {
|
|
318
|
+
const enc = new TextEncoder();
|
|
319
|
+
const contextDigestBytes = base64urlDecode(contextDigest);
|
|
320
|
+
if (contextDigestBytes.length !== 32) {
|
|
321
|
+
throw new Error("authorization context digest must encode 32 bytes");
|
|
322
|
+
}
|
|
323
|
+
if (reply.length === 0) {
|
|
324
|
+
throw new Error("request reply subject must not be empty");
|
|
325
|
+
}
|
|
326
|
+
const domainBytes = enc.encode("trellis.authorization-request-proof.v1");
|
|
327
|
+
const subjectBytes = enc.encode(subject);
|
|
328
|
+
const replyBytes = enc.encode(reply);
|
|
329
|
+
const iatBytes = enc.encode(String(iat));
|
|
330
|
+
const requestIdBytes = enc.encode(requestId);
|
|
331
|
+
const components = [
|
|
332
|
+
domainBytes,
|
|
333
|
+
contextDigestBytes,
|
|
334
|
+
subjectBytes,
|
|
335
|
+
replyBytes,
|
|
336
|
+
payloadHash,
|
|
337
|
+
iatBytes,
|
|
338
|
+
requestIdBytes,
|
|
339
|
+
];
|
|
340
|
+
const total = components.reduce((sum, value) => sum + 4 + value.length, 0);
|
|
341
|
+
const buf = new Uint8Array(total);
|
|
342
|
+
const view = new DataView(buf.buffer);
|
|
343
|
+
let offset = 0;
|
|
344
|
+
for (const component of components) {
|
|
345
|
+
view.setUint32(offset, component.length);
|
|
346
|
+
offset += 4;
|
|
347
|
+
buf.set(component, offset);
|
|
348
|
+
offset += component.length;
|
|
349
|
+
}
|
|
350
|
+
return buf;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Returns true when a handler result explicitly leaves operation completion to
|
|
354
|
+
* an external control path.
|
|
355
|
+
*/
|
|
356
|
+
export function isOperationDeferred(value) {
|
|
357
|
+
return !!value && typeof value === "object" &&
|
|
358
|
+
"kind" in value && value.kind === "deferred";
|
|
359
|
+
}
|
|
360
|
+
/** State CAS failure with the authoritative current value, when present. */
|
|
361
|
+
export class StateConflictError extends BaseError {
|
|
362
|
+
name = "StateConflictError";
|
|
363
|
+
current;
|
|
364
|
+
constructor(current) {
|
|
365
|
+
super("State revision conflict");
|
|
366
|
+
this.current = current;
|
|
367
|
+
}
|
|
368
|
+
toSerializable() {
|
|
369
|
+
return this.baseSerializable();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Raised when an optional participant resource is not installed. */
|
|
373
|
+
export class ResourceUnavailableError extends BaseError {
|
|
374
|
+
kind;
|
|
375
|
+
resourceName;
|
|
376
|
+
name = "ResourceUnavailableError";
|
|
377
|
+
/** Construct an unavailable-resource failure. */
|
|
378
|
+
constructor(kind, resourceName) {
|
|
379
|
+
super(`${kind} resource '${resourceName}' is unavailable`);
|
|
380
|
+
this.kind = kind;
|
|
381
|
+
this.resourceName = resourceName;
|
|
382
|
+
}
|
|
383
|
+
toSerializable() {
|
|
384
|
+
return this.baseSerializable();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
export function isResultLike(value) {
|
|
388
|
+
return value instanceof Result;
|
|
389
|
+
}
|
|
390
|
+
function compactHandlerErrorContext(context) {
|
|
391
|
+
return Object.fromEntries(Object.entries(context).filter(([key, value]) => key !== "traceId" && value !== undefined));
|
|
392
|
+
}
|
|
393
|
+
function sanitizeHandlerErrorContext(error) {
|
|
394
|
+
delete error.getContext().subject;
|
|
395
|
+
}
|
|
396
|
+
export function annotateHandlerBoundaryError(cause, context) {
|
|
397
|
+
const error = cause instanceof BaseError && !(cause instanceof RemoteError)
|
|
398
|
+
? cause
|
|
399
|
+
: new UnexpectedError({ cause });
|
|
400
|
+
sanitizeHandlerErrorContext(error);
|
|
401
|
+
error.withContext(compactHandlerErrorContext(context));
|
|
402
|
+
error.withTraceId(context.traceId);
|
|
403
|
+
return error;
|
|
404
|
+
}
|
|
405
|
+
function recordRuntimeError(error, attributes) {
|
|
406
|
+
recordTrellisError(error, {
|
|
407
|
+
messagingSystem: "nats",
|
|
408
|
+
...attributes,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
const DurableOperationSignalSchema = Type.Object({
|
|
412
|
+
operationId: Type.String(),
|
|
413
|
+
sequence: Type.Number(),
|
|
414
|
+
requestId: Type.String(),
|
|
415
|
+
signal: Type.String(),
|
|
416
|
+
input: Type.Optional(Type.Any()),
|
|
417
|
+
acceptedAt: Type.String(),
|
|
418
|
+
acknowledged: Type.Boolean(),
|
|
419
|
+
});
|
|
420
|
+
const DurableOperationSnapshotSchema = Type.Object({
|
|
421
|
+
id: Type.String(),
|
|
422
|
+
service: Type.String(),
|
|
423
|
+
operation: Type.String(),
|
|
424
|
+
revision: Type.Number(),
|
|
425
|
+
state: Type.Union([
|
|
426
|
+
Type.Literal("pending"),
|
|
427
|
+
Type.Literal("running"),
|
|
428
|
+
Type.Literal("completed"),
|
|
429
|
+
Type.Literal("failed"),
|
|
430
|
+
Type.Literal("cancelled"),
|
|
431
|
+
]),
|
|
432
|
+
createdAt: Type.String(),
|
|
433
|
+
updatedAt: Type.String(),
|
|
434
|
+
completedAt: Type.Optional(Type.String()),
|
|
435
|
+
progress: Type.Optional(Type.Any()),
|
|
436
|
+
transfer: Type.Optional(Type.Object({
|
|
437
|
+
chunkIndex: Type.Number(),
|
|
438
|
+
chunkBytes: Type.Number(),
|
|
439
|
+
transferredBytes: Type.Number(),
|
|
440
|
+
})),
|
|
441
|
+
output: Type.Optional(Type.Any()),
|
|
442
|
+
error: Type.Optional(Type.Object({
|
|
443
|
+
id: Type.Optional(Type.String()),
|
|
444
|
+
type: Type.String(),
|
|
445
|
+
message: Type.String(),
|
|
446
|
+
context: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
447
|
+
traceId: Type.Optional(Type.String()),
|
|
448
|
+
})),
|
|
449
|
+
});
|
|
450
|
+
export const DurableOperationRecordSchema = Type.Object({
|
|
451
|
+
invocationId: Type.String(),
|
|
452
|
+
callerSessionKey: Type.String(),
|
|
453
|
+
apiId: Type.String(),
|
|
454
|
+
operation: Type.String(),
|
|
455
|
+
input: Type.Any(),
|
|
456
|
+
ownerInstanceId: Type.String(),
|
|
457
|
+
ownerConnectionId: Type.String(),
|
|
458
|
+
ownerEpoch: Type.Number(),
|
|
459
|
+
leaseExpiresAt: Type.String(),
|
|
460
|
+
cancelRequestedAt: Type.Optional(Type.String()),
|
|
461
|
+
transferGrant: Type.Optional(Type.Any()),
|
|
462
|
+
sequence: Type.Number(),
|
|
463
|
+
signalSequence: Type.Number(),
|
|
464
|
+
signals: Type.Array(DurableOperationSignalSchema),
|
|
465
|
+
snapshot: DurableOperationSnapshotSchema,
|
|
466
|
+
});
|
|
467
|
+
export function buildRuntimeOperationSnapshot(runtime, state, patch) {
|
|
468
|
+
const updatedAt = new Date().toISOString();
|
|
469
|
+
const completedAt = state === "completed" || state === "failed" || state === "cancelled"
|
|
470
|
+
? (patch?.completedAt ?? updatedAt)
|
|
471
|
+
: patch?.completedAt;
|
|
472
|
+
return {
|
|
473
|
+
id: runtime.id,
|
|
474
|
+
service: runtime.service,
|
|
475
|
+
operation: runtime.operation,
|
|
476
|
+
revision: patch?.revision ?? runtime.snapshot.revision + 1,
|
|
477
|
+
state,
|
|
478
|
+
createdAt: patch?.createdAt ?? runtime.snapshot.createdAt,
|
|
479
|
+
updatedAt,
|
|
480
|
+
...(completedAt ? { completedAt } : {}),
|
|
481
|
+
...(patch?.progress !== undefined
|
|
482
|
+
? { progress: patch.progress }
|
|
483
|
+
: runtime.snapshot.progress !== undefined
|
|
484
|
+
? { progress: runtime.snapshot.progress }
|
|
485
|
+
: {}),
|
|
486
|
+
...(patch?.transfer !== undefined
|
|
487
|
+
? { transfer: patch.transfer }
|
|
488
|
+
: runtime.snapshot.transfer !== undefined
|
|
489
|
+
? { transfer: runtime.snapshot.transfer }
|
|
490
|
+
: {}),
|
|
491
|
+
...(patch?.output !== undefined
|
|
492
|
+
? { output: patch.output }
|
|
493
|
+
: runtime.snapshot.output !== undefined
|
|
494
|
+
? { output: runtime.snapshot.output }
|
|
495
|
+
: {}),
|
|
496
|
+
...(patch?.error
|
|
497
|
+
? { error: patch.error }
|
|
498
|
+
: runtime.snapshot.error
|
|
499
|
+
? { error: runtime.snapshot.error }
|
|
500
|
+
: {}),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function isRuntimeOperationSnapshot(value) {
|
|
504
|
+
return !!value && typeof value === "object" &&
|
|
505
|
+
typeof value.id === "string" &&
|
|
506
|
+
typeof value.service === "string" &&
|
|
507
|
+
typeof value.operation === "string" &&
|
|
508
|
+
typeof value.revision === "number" &&
|
|
509
|
+
typeof value.state === "string" &&
|
|
510
|
+
typeof value.createdAt === "string" &&
|
|
511
|
+
typeof value.updatedAt === "string";
|
|
512
|
+
}
|
|
513
|
+
export function isTerminalRuntimeOperationSnapshot(value) {
|
|
514
|
+
return isRuntimeOperationSnapshot(value) && (value.state === "completed" || value.state === "failed" ||
|
|
515
|
+
value.state === "cancelled");
|
|
516
|
+
}
|
|
517
|
+
const FEED_CANCEL_TOMBSTONE_MS = 30_000;
|
|
518
|
+
const MAX_FEED_CANCEL_TOMBSTONES = 1_024;
|
|
519
|
+
const MAX_OPERATION_INPUT_BYTES = 256 * 1024;
|
|
520
|
+
const MAX_OPERATION_PROGRESS_BYTES = 64 * 1024;
|
|
521
|
+
const MAX_OPERATION_OUTPUT_BYTES = 512 * 1024;
|
|
522
|
+
const MAX_OPERATION_ERROR_BYTES = 32 * 1024;
|
|
523
|
+
const MAX_OPERATION_SIGNALS = 100;
|
|
524
|
+
const MAX_OPERATION_SIGNAL_BYTES = 64 * 1024;
|
|
525
|
+
const MAX_OPERATION_RECORD_BYTES = 1024 * 1024;
|
|
526
|
+
function createEventListenerContext(args) {
|
|
527
|
+
const messageId = readMessageHeader(args.message, "Nats-Msg-Id");
|
|
528
|
+
const messageTime = readMessageHeader(args.message, "Trellis-Event-Time");
|
|
529
|
+
const sequence = Reflect.get(args.message, "seq");
|
|
530
|
+
return {
|
|
531
|
+
id: messageId ?? "",
|
|
532
|
+
time: new Date(messageTime ?? 0),
|
|
533
|
+
subject: args.subject,
|
|
534
|
+
mode: args.mode,
|
|
535
|
+
...(args.group ? { group: args.group } : {}),
|
|
536
|
+
...(typeof sequence === "number" ? { sequence } : {}),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function readMessageHeader(message, key) {
|
|
540
|
+
const headers = Reflect.get(message, "headers");
|
|
541
|
+
if (typeof headers !== "object" || headers === null)
|
|
542
|
+
return undefined;
|
|
543
|
+
const get = Reflect.get(headers, "get");
|
|
544
|
+
if (typeof get !== "function")
|
|
545
|
+
return undefined;
|
|
546
|
+
const value = get.call(headers, key);
|
|
547
|
+
return typeof value === "string" ? value : undefined;
|
|
548
|
+
}
|
|
549
|
+
function eventConsumerGroupEvents(group) {
|
|
550
|
+
const events = new Set();
|
|
551
|
+
for (const groupEvents of Object.values(group.uses ?? {})) {
|
|
552
|
+
for (const event of groupEvents)
|
|
553
|
+
events.add(event);
|
|
554
|
+
}
|
|
555
|
+
for (const event of group.self ?? [])
|
|
556
|
+
events.add(event);
|
|
557
|
+
return [...events].sort();
|
|
558
|
+
}
|
|
559
|
+
function jetStreamDeliveryProof(message) {
|
|
560
|
+
const raw = Reflect.get(message, "msg");
|
|
561
|
+
const reply = raw && typeof raw === "object"
|
|
562
|
+
? Reflect.get(raw, "reply")
|
|
563
|
+
: undefined;
|
|
564
|
+
if (typeof reply !== "string" || !reply.startsWith("$JS.ACK.")) {
|
|
565
|
+
throw new Error("JetStream delivery has no acknowledgement subject");
|
|
566
|
+
}
|
|
567
|
+
return reply;
|
|
568
|
+
}
|
|
569
|
+
function isConsumerNotFoundError(error) {
|
|
570
|
+
return error instanceof Error && (error.name === "ConsumerNotFoundError" ||
|
|
571
|
+
error.message.includes("consumer not found"));
|
|
572
|
+
}
|
|
573
|
+
const internalEventConsumers = Symbol("trellis.internal.eventConsumers");
|
|
574
|
+
const internalApiBindings = Symbol("trellis.internal.apiBindings");
|
|
575
|
+
/**
|
|
576
|
+
* Creates a Trellis runtime with bootstrap-resolved bindings.
|
|
577
|
+
*
|
|
578
|
+
* @internal
|
|
579
|
+
*/
|
|
580
|
+
export function createTrellisInternal(name, nats, auth, opts) {
|
|
581
|
+
const { eventConsumers, apiBindings, inboxPrefix, ...publicOpts } = opts ?? {};
|
|
582
|
+
const internalOpts = {
|
|
583
|
+
...publicOpts,
|
|
584
|
+
[internalEventConsumers]: eventConsumers,
|
|
585
|
+
[internalApiBindings]: apiBindings,
|
|
586
|
+
};
|
|
587
|
+
return new Trellis(name, nats, auth, internalOpts, inboxPrefix);
|
|
588
|
+
}
|
|
589
|
+
function surfaceGroupName(key) {
|
|
590
|
+
return lowerCamelIdent(key.split(".")[0] ?? key);
|
|
591
|
+
}
|
|
592
|
+
function surfaceLeafName(key) {
|
|
593
|
+
const parts = key.split(".");
|
|
594
|
+
parts.shift();
|
|
595
|
+
return lowerCamelIdent(parts.length === 0 ? key : parts.join("."));
|
|
596
|
+
}
|
|
597
|
+
function lowerCamelIdent(value) {
|
|
598
|
+
const pascal = value
|
|
599
|
+
.split(/[^A-Za-z0-9]+/)
|
|
600
|
+
.filter((part) => part.length > 0)
|
|
601
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
602
|
+
.join("");
|
|
603
|
+
return pascal.length === 0 ? "_" : pascal[0].toLowerCase() + pascal.slice(1);
|
|
604
|
+
}
|
|
605
|
+
function addSurfaceLeaf(surface, key, leaf) {
|
|
606
|
+
const group = surfaceGroupName(key);
|
|
607
|
+
surface[group] ??= {};
|
|
608
|
+
surface[group][surfaceLeafName(key)] = leaf;
|
|
609
|
+
}
|
|
610
|
+
function natsSubjectMatches(pattern, subject) {
|
|
611
|
+
const patternParts = pattern.split(".");
|
|
612
|
+
const subjectParts = subject.split(".");
|
|
613
|
+
for (let index = 0; index < patternParts.length; index += 1) {
|
|
614
|
+
const part = patternParts[index];
|
|
615
|
+
if (part === ">")
|
|
616
|
+
return true;
|
|
617
|
+
const subjectPart = subjectParts[index];
|
|
618
|
+
if (subjectPart === undefined)
|
|
619
|
+
return false;
|
|
620
|
+
if (part !== "*" && part !== subjectPart)
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
return patternParts.length === subjectParts.length;
|
|
624
|
+
}
|
|
625
|
+
const STATE_RUNTIME_RPC = {
|
|
626
|
+
Get: {
|
|
627
|
+
subject: "rpc.v1.state.Get",
|
|
628
|
+
input: STATE_API.actions["rpc:Get"].input,
|
|
629
|
+
output: STATE_API.actions["rpc:Get"].output,
|
|
630
|
+
callerCapabilities: [],
|
|
631
|
+
errors: STATE_API.actions["rpc:Get"].errors.map((error) => error.type),
|
|
632
|
+
declaredErrorTypes: STATE_API.actions["rpc:Get"].errors.map((error) => error.type),
|
|
633
|
+
runtimeErrors: STATE_API.actions["rpc:Get"].errors,
|
|
634
|
+
},
|
|
635
|
+
Put: {
|
|
636
|
+
subject: "rpc.v1.state.Put",
|
|
637
|
+
input: STATE_API.actions["rpc:Put"].input,
|
|
638
|
+
output: STATE_API.actions["rpc:Put"].output,
|
|
639
|
+
callerCapabilities: [],
|
|
640
|
+
errors: STATE_API.actions["rpc:Put"].errors.map((error) => error.type),
|
|
641
|
+
declaredErrorTypes: STATE_API.actions["rpc:Put"].errors.map((error) => error.type),
|
|
642
|
+
runtimeErrors: STATE_API.actions["rpc:Put"].errors,
|
|
643
|
+
},
|
|
644
|
+
Delete: {
|
|
645
|
+
subject: "rpc.v1.state.Delete",
|
|
646
|
+
input: STATE_API.actions["rpc:Delete"].input,
|
|
647
|
+
output: STATE_API.actions["rpc:Delete"].output,
|
|
648
|
+
callerCapabilities: [],
|
|
649
|
+
errors: STATE_API.actions["rpc:Delete"].errors.map((error) => error.type),
|
|
650
|
+
declaredErrorTypes: STATE_API.actions["rpc:Delete"].errors.map((error) => error.type),
|
|
651
|
+
runtimeErrors: STATE_API.actions["rpc:Delete"].errors,
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
/** Typed handle for one generated single-value State resource. */
|
|
655
|
+
export class StateHandle {
|
|
656
|
+
#resourceName;
|
|
657
|
+
#definition;
|
|
658
|
+
#migrations;
|
|
659
|
+
#isCurrent;
|
|
660
|
+
#isAvailable;
|
|
661
|
+
#calls;
|
|
662
|
+
/** Construct a handle from generated metadata and settled State transport calls. */
|
|
663
|
+
constructor(args) {
|
|
664
|
+
this.#resourceName = args.resourceName;
|
|
665
|
+
this.#definition = args.definition;
|
|
666
|
+
this.#migrations = args.migrations ?? {};
|
|
667
|
+
this.#isCurrent = args.isCurrent ?? (() => true);
|
|
668
|
+
this.#isAvailable = args.isAvailable ?? (() => true);
|
|
669
|
+
this.#calls = args.calls;
|
|
670
|
+
}
|
|
671
|
+
async #project(entry) {
|
|
672
|
+
const version = Number(entry.representationVersion);
|
|
673
|
+
const encoded = JSON.parse(new TextDecoder().decode(entry.value));
|
|
674
|
+
let value;
|
|
675
|
+
if (version === this.#definition.version) {
|
|
676
|
+
value = this.#definition.codec.decode(encoded);
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
const historicCodec = this.#definition.migrations[version];
|
|
680
|
+
const migrate = this.#migrations[version];
|
|
681
|
+
if (!historicCodec || !migrate) {
|
|
682
|
+
throw new Error(`Unsupported State representation version ${version}`);
|
|
683
|
+
}
|
|
684
|
+
const migrated = await migrate(historicCodec.decode(encoded));
|
|
685
|
+
value = this.#definition.codec.decode(this.#definition.codec.encode(migrated instanceof Result ? migrated.orThrow() : migrated));
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
value,
|
|
689
|
+
revision: entry.revision,
|
|
690
|
+
createdAt: entry.createdAt,
|
|
691
|
+
updatedAt: entry.updatedAt,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
#assertCurrent() {
|
|
695
|
+
if (!this.#isAvailable()) {
|
|
696
|
+
throw new ResourceUnavailableError("State", this.#resourceName);
|
|
697
|
+
}
|
|
698
|
+
if (!this.#isCurrent()) {
|
|
699
|
+
throw new Error(`State resource '${this.#resourceName}' has a stale generation`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async #conflict(error) {
|
|
703
|
+
return Result.err(new StateConflictError(error.data.current
|
|
704
|
+
? await this.#project(error.data.current)
|
|
705
|
+
: undefined));
|
|
706
|
+
}
|
|
707
|
+
#write(value, mode, expectedRevision) {
|
|
708
|
+
return AsyncResult.from((async () => {
|
|
709
|
+
try {
|
|
710
|
+
this.#assertCurrent();
|
|
711
|
+
const result = await this.#calls.set({
|
|
712
|
+
resourceName: this.#resourceName,
|
|
713
|
+
representationVersion: BigInt(this.#definition.version),
|
|
714
|
+
value: new TextEncoder().encode(JSON.stringify(this.#definition.codec.encode(value))),
|
|
715
|
+
mode,
|
|
716
|
+
...(expectedRevision === undefined
|
|
717
|
+
? {}
|
|
718
|
+
: { revision: expectedRevision }),
|
|
719
|
+
});
|
|
720
|
+
if (result.isErr()) {
|
|
721
|
+
return result.error instanceof StateRpcConflict
|
|
722
|
+
? await this.#conflict(result.error)
|
|
723
|
+
: result;
|
|
724
|
+
}
|
|
725
|
+
return Result.ok(await this.#project(result.unwrapOrElse(() => {
|
|
726
|
+
throw new Error("State set unexpectedly failed");
|
|
727
|
+
}).entry));
|
|
728
|
+
}
|
|
729
|
+
catch (cause) {
|
|
730
|
+
return Result.err(cause instanceof BaseError ? cause : new UnexpectedError({ cause }));
|
|
731
|
+
}
|
|
732
|
+
})());
|
|
733
|
+
}
|
|
734
|
+
/** Read the current value, migrating only in memory when required. */
|
|
735
|
+
get() {
|
|
736
|
+
return AsyncResult.from((async () => {
|
|
737
|
+
try {
|
|
738
|
+
this.#assertCurrent();
|
|
739
|
+
const result = await this.#calls.get({
|
|
740
|
+
resourceName: this.#resourceName,
|
|
741
|
+
});
|
|
742
|
+
if (result.isErr())
|
|
743
|
+
return result;
|
|
744
|
+
const response = result.unwrapOrElse(() => {
|
|
745
|
+
throw new Error("State get unexpectedly failed");
|
|
746
|
+
});
|
|
747
|
+
return Result.ok(response.entry ? await this.#project(response.entry) : undefined);
|
|
748
|
+
}
|
|
749
|
+
catch (cause) {
|
|
750
|
+
return Result.err(cause instanceof BaseError ? cause : new UnexpectedError({ cause }));
|
|
751
|
+
}
|
|
752
|
+
})());
|
|
753
|
+
}
|
|
754
|
+
/** Create the value only when no live value exists. */
|
|
755
|
+
create(value) {
|
|
756
|
+
return this.#write(value, "create");
|
|
757
|
+
}
|
|
758
|
+
/** Unconditionally replace the current value. */
|
|
759
|
+
set(value) {
|
|
760
|
+
return this.#write(value, "set");
|
|
761
|
+
}
|
|
762
|
+
/** Replace the value only while `revision` remains current. */
|
|
763
|
+
replace(revision, value) {
|
|
764
|
+
return this.#write(value, "replace", revision);
|
|
765
|
+
}
|
|
766
|
+
/** Delete unconditionally or only while `revision` remains current. */
|
|
767
|
+
delete(revision) {
|
|
768
|
+
return AsyncResult.from((async () => {
|
|
769
|
+
try {
|
|
770
|
+
this.#assertCurrent();
|
|
771
|
+
const result = await this.#calls.delete({
|
|
772
|
+
resourceName: this.#resourceName,
|
|
773
|
+
...(revision === undefined ? {} : { revision }),
|
|
774
|
+
});
|
|
775
|
+
if (result.isErr()) {
|
|
776
|
+
return result.error instanceof StateRpcConflict
|
|
777
|
+
? await this.#conflict(result.error)
|
|
778
|
+
: result;
|
|
779
|
+
}
|
|
780
|
+
return Result.ok(result.unwrapOrElse(() => {
|
|
781
|
+
throw new Error("State delete unexpectedly failed");
|
|
782
|
+
}).deleted);
|
|
783
|
+
}
|
|
784
|
+
catch (cause) {
|
|
785
|
+
return Result.err(cause instanceof BaseError ? cause : new UnexpectedError({ cause }));
|
|
786
|
+
}
|
|
787
|
+
})());
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const DEFAULT_NO_RESPONDER_MAX_RETRIES = 2;
|
|
791
|
+
const DEFAULT_NO_RESPONDER_RETRY_MS = 200;
|
|
792
|
+
function activeTraceId(span) {
|
|
793
|
+
const traceId = span.spanContext().traceId;
|
|
794
|
+
return traceId === "00000000000000000000000000000000" ? undefined : traceId;
|
|
795
|
+
}
|
|
796
|
+
function traceIdFromTraceparent(traceparent) {
|
|
797
|
+
const [version, traceId, parentId, flags, extra] = traceparent?.split("-") ??
|
|
798
|
+
[];
|
|
799
|
+
if (extra !== undefined ||
|
|
800
|
+
!/^[0-9a-f]{2}$/u.test(version ?? "") ||
|
|
801
|
+
version === "ff" ||
|
|
802
|
+
!/^[0-9a-f]{32}$/u.test(traceId ?? "") ||
|
|
803
|
+
traceId === "00000000000000000000000000000000" ||
|
|
804
|
+
!/^[0-9a-f]{16}$/u.test(parentId ?? "") ||
|
|
805
|
+
parentId === "0000000000000000" ||
|
|
806
|
+
!/^[0-9a-f]{2}$/u.test(flags ?? "")) {
|
|
807
|
+
return undefined;
|
|
808
|
+
}
|
|
809
|
+
return traceId;
|
|
810
|
+
}
|
|
811
|
+
const EMPTY_TRELLIS_API = {
|
|
812
|
+
rpc: {},
|
|
813
|
+
operations: {},
|
|
814
|
+
events: {},
|
|
815
|
+
feeds: {},
|
|
816
|
+
subjects: {},
|
|
817
|
+
};
|
|
818
|
+
function isBrowserAuthRequiredError(error) {
|
|
819
|
+
return machineErrorCode(error) === "session_not_found";
|
|
820
|
+
}
|
|
821
|
+
function isDeclaredRpcError(errorNames, type) {
|
|
822
|
+
return !!errorNames?.includes(type);
|
|
823
|
+
}
|
|
824
|
+
function isRuntimeRpcErrorDesc(value) {
|
|
825
|
+
return !!value &&
|
|
826
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
827
|
+
typeof Reflect.get(value, "type") === "string" &&
|
|
828
|
+
typeof Reflect.get(value, "fromSerializable") === "function";
|
|
829
|
+
}
|
|
830
|
+
const payloadSizeEncoder = new TextEncoder();
|
|
831
|
+
function payloadByteLength(payload) {
|
|
832
|
+
return typeof payload === "string"
|
|
833
|
+
? payloadSizeEncoder.encode(payload).byteLength
|
|
834
|
+
: payload.byteLength;
|
|
835
|
+
}
|
|
836
|
+
function causeMessage(cause) {
|
|
837
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
838
|
+
}
|
|
839
|
+
function causeLogData(cause) {
|
|
840
|
+
return cause instanceof Error
|
|
841
|
+
? { message: cause.message, stack: cause.stack, name: cause.name }
|
|
842
|
+
: cause;
|
|
843
|
+
}
|
|
844
|
+
function reconstructDeclaredRpcError(errorNames, runtimeErrors, data, json) {
|
|
845
|
+
if (!isDeclaredRpcError(errorNames, data.type)) {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
const runtimeError = getBuiltinRpcError(data.type) ??
|
|
849
|
+
runtimeErrors?.find((candidate) => candidate.type === data.type);
|
|
850
|
+
if (!runtimeError) {
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
const parsed = runtimeError.schema
|
|
854
|
+
? parseRuntimeSchema(runtimeError.schema, json).take()
|
|
855
|
+
: data;
|
|
856
|
+
if (isErr(parsed)) {
|
|
857
|
+
return parsed.error instanceof ValidationError ||
|
|
858
|
+
parsed.error instanceof UnexpectedError
|
|
859
|
+
? parsed.error
|
|
860
|
+
: new UnexpectedError({ cause: parsed.error });
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
const reconstructed = runtimeError.fromSerializable(parsed);
|
|
864
|
+
if (reconstructed instanceof BaseError) {
|
|
865
|
+
return reconstructed;
|
|
866
|
+
}
|
|
867
|
+
return new UnexpectedError({
|
|
868
|
+
cause: new Error(`RPC error '${data.type}' reconstructed to a non-Trellis error instance`),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
catch (cause) {
|
|
872
|
+
return new UnexpectedError({ cause });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
async function sleep(ms) {
|
|
876
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
877
|
+
}
|
|
878
|
+
export class Trellis {
|
|
879
|
+
name;
|
|
880
|
+
timeout;
|
|
881
|
+
stream;
|
|
882
|
+
state;
|
|
883
|
+
rpc;
|
|
884
|
+
event;
|
|
885
|
+
feed;
|
|
886
|
+
operation;
|
|
887
|
+
handle;
|
|
888
|
+
/** Framework-neutral lifecycle handle for this Trellis runtime connection. */
|
|
889
|
+
connection;
|
|
890
|
+
contractId;
|
|
891
|
+
contractDigest;
|
|
892
|
+
#nats;
|
|
893
|
+
#js;
|
|
894
|
+
#auth;
|
|
895
|
+
#inboxPrefix;
|
|
896
|
+
api;
|
|
897
|
+
#log;
|
|
898
|
+
#tasks;
|
|
899
|
+
#hasExplicitApi;
|
|
900
|
+
#noResponderMaxRetries;
|
|
901
|
+
#noResponderRetryMs;
|
|
902
|
+
#onSessionNotFound;
|
|
903
|
+
#operationStore;
|
|
904
|
+
#operationDeploymentId;
|
|
905
|
+
#feedOwnerId;
|
|
906
|
+
#eventConsumers;
|
|
907
|
+
#apiBindings;
|
|
908
|
+
#durableEventLoops = new Map();
|
|
909
|
+
#durableEventListenersStopped = false;
|
|
910
|
+
#activeFeeds = new Map();
|
|
911
|
+
#pendingFeedCancels = new Map();
|
|
912
|
+
#resourceGeneration;
|
|
913
|
+
#resourceAvailability;
|
|
914
|
+
#stateMigrations;
|
|
915
|
+
constructor(name, // Must be unique for a service
|
|
916
|
+
nats, auth, opts, inboxPrefix = "_INBOX") {
|
|
917
|
+
const internalOpts = opts;
|
|
918
|
+
const api = opts?.api;
|
|
919
|
+
this.name = name;
|
|
920
|
+
this.#nats = nats;
|
|
921
|
+
this.#js = jetstream(this.#nats);
|
|
922
|
+
this.#auth = auth;
|
|
923
|
+
this.#feedOwnerId = auth.sessionKey;
|
|
924
|
+
this.#inboxPrefix = inboxPrefix;
|
|
925
|
+
this.api = (api ?? EMPTY_TRELLIS_API);
|
|
926
|
+
this.#log = (opts?.log ?? logger).child({ lib: "trellis" });
|
|
927
|
+
this.timeout = opts?.timeout ?? 3000;
|
|
928
|
+
this.stream = opts?.stream ?? "trellis";
|
|
929
|
+
this.contractId = opts?.contractId;
|
|
930
|
+
this.contractDigest = opts?.contractDigest;
|
|
931
|
+
this.#hasExplicitApi = api !== undefined;
|
|
932
|
+
this.#noResponderMaxRetries = opts?.noResponderRetry?.maxAttempts ??
|
|
933
|
+
DEFAULT_NO_RESPONDER_MAX_RETRIES;
|
|
934
|
+
this.#noResponderRetryMs = opts?.noResponderRetry?.baseDelayMs ??
|
|
935
|
+
DEFAULT_NO_RESPONDER_RETRY_MS;
|
|
936
|
+
this.#onSessionNotFound = opts?.onSessionNotFound;
|
|
937
|
+
this.#resourceGeneration = opts?.resourceGeneration ?? (() => 0);
|
|
938
|
+
this.#resourceAvailability = opts?.resourceAvailability ?? (() => true);
|
|
939
|
+
this.#stateMigrations = opts?.stateMigrations ?? {};
|
|
940
|
+
this.#eventConsumers = internalOpts?.[internalEventConsumers] ?? {};
|
|
941
|
+
this.#apiBindings = internalOpts?.[internalApiBindings] ?? {};
|
|
942
|
+
this.connection = opts?.connection ??
|
|
943
|
+
new TrellisConnection({ kind: "client" });
|
|
944
|
+
this.#tasks = new TrellisTasks({ log: this.#log });
|
|
945
|
+
this.state = this.#createStateFacade(opts?.state);
|
|
946
|
+
this.rpc = this.#createRpcFacade();
|
|
947
|
+
this.handle = { rpc: this.#createRpcHandleFacade() };
|
|
948
|
+
this.event = this.#createEventFacade();
|
|
949
|
+
this.feed = this.#createFeedFacade();
|
|
950
|
+
this.operation = this.#createOperationFacade();
|
|
951
|
+
}
|
|
952
|
+
get nats() {
|
|
953
|
+
return this.#nats;
|
|
954
|
+
}
|
|
955
|
+
get js() {
|
|
956
|
+
return this.#js;
|
|
957
|
+
}
|
|
958
|
+
get auth() {
|
|
959
|
+
return this.#auth;
|
|
960
|
+
}
|
|
961
|
+
#createStateFacade(state) {
|
|
962
|
+
const stores = (state ?? {});
|
|
963
|
+
const facade = {};
|
|
964
|
+
const stateRpc = (name) => {
|
|
965
|
+
const descriptor = Object.values(this.api.rpc).find((candidate) => candidate.permission.apiId === "trellis.state" &&
|
|
966
|
+
candidate.permission.apiVersion === "v1" &&
|
|
967
|
+
candidate.permission.surfaceName === name);
|
|
968
|
+
if (!descriptor) {
|
|
969
|
+
throw new Error(`Generated participant is missing State.${name}`);
|
|
970
|
+
}
|
|
971
|
+
return descriptor;
|
|
972
|
+
};
|
|
973
|
+
for (const [resourceName, descriptor] of Object.entries(stores)) {
|
|
974
|
+
Object.defineProperty(facade, resourceName, {
|
|
975
|
+
enumerable: true,
|
|
976
|
+
get: () => {
|
|
977
|
+
const generation = this.#resourceGeneration();
|
|
978
|
+
return new StateHandle({
|
|
979
|
+
resourceName,
|
|
980
|
+
definition: descriptor,
|
|
981
|
+
migrations: this.#stateMigrations[resourceName],
|
|
982
|
+
isCurrent: () => generation === this.#resourceGeneration(),
|
|
983
|
+
isAvailable: () => this.#resourceAvailability(resourceName),
|
|
984
|
+
calls: {
|
|
985
|
+
get: (input) => this.#requestBuiltRpc("State.Get", input, {
|
|
986
|
+
...STATE_RUNTIME_RPC.Get,
|
|
987
|
+
subject: stateRpc("Get").subject,
|
|
988
|
+
}),
|
|
989
|
+
set: (input) => this.#requestBuiltRpc("State.Put", {
|
|
990
|
+
resourceName: input.resourceName,
|
|
991
|
+
representationVersion: Number(input.representationVersion),
|
|
992
|
+
value: input.value,
|
|
993
|
+
mode: input.mode,
|
|
994
|
+
...(input.revision === undefined
|
|
995
|
+
? {}
|
|
996
|
+
: { revision: input.revision }),
|
|
997
|
+
}, {
|
|
998
|
+
...STATE_RUNTIME_RPC.Put,
|
|
999
|
+
subject: stateRpc("Put").subject,
|
|
1000
|
+
}),
|
|
1001
|
+
delete: (input) => this.#requestBuiltRpc("State.Delete", {
|
|
1002
|
+
resourceName: input.resourceName,
|
|
1003
|
+
...(input.revision === undefined
|
|
1004
|
+
? {}
|
|
1005
|
+
: { revision: input.revision }),
|
|
1006
|
+
}, {
|
|
1007
|
+
...STATE_RUNTIME_RPC.Delete,
|
|
1008
|
+
subject: stateRpc("Delete").subject,
|
|
1009
|
+
}),
|
|
1010
|
+
},
|
|
1011
|
+
});
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
return facade;
|
|
1016
|
+
}
|
|
1017
|
+
#createRpcFacade() {
|
|
1018
|
+
const surface = {};
|
|
1019
|
+
for (const method of Object.keys(this.api.rpc ?? {})) {
|
|
1020
|
+
const leaf = (input, opts) => this.request(method, input, opts);
|
|
1021
|
+
addSurfaceLeaf(surface, method, leaf);
|
|
1022
|
+
}
|
|
1023
|
+
return surface;
|
|
1024
|
+
}
|
|
1025
|
+
#createRpcHandleFacade() {
|
|
1026
|
+
const surface = {};
|
|
1027
|
+
for (const method of Object.keys(this.api.rpc ?? {})) {
|
|
1028
|
+
addSurfaceLeaf(surface, method, (handler) => this.mount(method, handler));
|
|
1029
|
+
}
|
|
1030
|
+
return surface;
|
|
1031
|
+
}
|
|
1032
|
+
#createEventFacade() {
|
|
1033
|
+
const surface = {};
|
|
1034
|
+
for (const event of Object.keys(this.api.events ?? {})) {
|
|
1035
|
+
addSurfaceLeaf(surface, event, {
|
|
1036
|
+
prepare: (payload) => this.prepare(event, payload),
|
|
1037
|
+
publish: (payload) => this.publish(event, payload),
|
|
1038
|
+
listen: (handler, subjectData = {}, opts = {}) => this.listenEvent(event, subjectData, handler, opts),
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
return surface;
|
|
1042
|
+
}
|
|
1043
|
+
#createEventPublishFacade() {
|
|
1044
|
+
const surface = {};
|
|
1045
|
+
for (const event of Object.keys(this.api.events ?? {})) {
|
|
1046
|
+
addSurfaceLeaf(surface, event, {
|
|
1047
|
+
prepare: (payload) => this.prepare(event, payload),
|
|
1048
|
+
publish: (payload) => this.publish(event, payload),
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
return surface;
|
|
1052
|
+
}
|
|
1053
|
+
#createFeedFacade() {
|
|
1054
|
+
const surface = {};
|
|
1055
|
+
for (const feed of Object.keys(this.api.feeds ?? {})) {
|
|
1056
|
+
const leaf = (input, opts) => this.feedHandle(feed).input(input).subscribe(opts);
|
|
1057
|
+
addSurfaceLeaf(surface, feed, leaf);
|
|
1058
|
+
}
|
|
1059
|
+
return surface;
|
|
1060
|
+
}
|
|
1061
|
+
#createHandlerTrellis() {
|
|
1062
|
+
return {
|
|
1063
|
+
rpc: this.rpc,
|
|
1064
|
+
event: this.#createEventPublishFacade(),
|
|
1065
|
+
feed: this.feed,
|
|
1066
|
+
operation: this.operation,
|
|
1067
|
+
request: this.request.bind(this),
|
|
1068
|
+
prepare: (event, data) => this.prepare(event, data),
|
|
1069
|
+
publish: (event, data) => this.publish(event, data),
|
|
1070
|
+
publishPrepared: (event) => this.publishPrepared(event),
|
|
1071
|
+
stopEventListeners: () => this.stopEventListeners(),
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
#createOperationFacade() {
|
|
1075
|
+
const surface = {};
|
|
1076
|
+
for (const operation of Object.keys(this.api.operations ?? {})) {
|
|
1077
|
+
addSurfaceLeaf(surface, operation, this.operationHandle(operation));
|
|
1078
|
+
}
|
|
1079
|
+
return surface;
|
|
1080
|
+
}
|
|
1081
|
+
#unknownApiError(kind, name) {
|
|
1082
|
+
const base = `Unknown ${kind} '${name}'.`;
|
|
1083
|
+
if (this.#hasExplicitApi) {
|
|
1084
|
+
return new Error(`${base} Did you forget to include its API module?`);
|
|
1085
|
+
}
|
|
1086
|
+
return new Error(`${base} No API surface was provided to the private transport session.`);
|
|
1087
|
+
}
|
|
1088
|
+
async operationStoreHandle() {
|
|
1089
|
+
if (!this.#operationStore) {
|
|
1090
|
+
if (!this.#operationDeploymentId) {
|
|
1091
|
+
throw new Error("Service operation runtime requires a deployment operation store id");
|
|
1092
|
+
}
|
|
1093
|
+
const bucket = `trellis_operations_${this.#operationDeploymentId}`;
|
|
1094
|
+
const operationStore = (async () => {
|
|
1095
|
+
const result = await TypedKV.open(this.#nats, bucket, {
|
|
1096
|
+
version: 1,
|
|
1097
|
+
migrations: {},
|
|
1098
|
+
codec: {
|
|
1099
|
+
encode: (value) => value,
|
|
1100
|
+
decode: (value) => value,
|
|
1101
|
+
},
|
|
1102
|
+
}, {
|
|
1103
|
+
bindOnly: true,
|
|
1104
|
+
ttl: 30 * 24 * 60 * 60 * 1_000,
|
|
1105
|
+
maxValueBytes: 1024 * 1024,
|
|
1106
|
+
});
|
|
1107
|
+
const value = result.take();
|
|
1108
|
+
if (isErr(value)) {
|
|
1109
|
+
throw value.error;
|
|
1110
|
+
}
|
|
1111
|
+
return value;
|
|
1112
|
+
})();
|
|
1113
|
+
this.#operationStore = operationStore;
|
|
1114
|
+
}
|
|
1115
|
+
return this.#operationStore;
|
|
1116
|
+
}
|
|
1117
|
+
setOperationDeploymentId(id) {
|
|
1118
|
+
this.#operationDeploymentId = id;
|
|
1119
|
+
}
|
|
1120
|
+
setFeedOwnerId(id) {
|
|
1121
|
+
this.#feedOwnerId = id;
|
|
1122
|
+
}
|
|
1123
|
+
async loadOperationRecord(operationId) {
|
|
1124
|
+
const store = await this.operationStoreHandle();
|
|
1125
|
+
const entry = await store.getEntry(operationId);
|
|
1126
|
+
const value = entry.take();
|
|
1127
|
+
if (isErr(value)) {
|
|
1128
|
+
throw value.error;
|
|
1129
|
+
}
|
|
1130
|
+
if (!value)
|
|
1131
|
+
return null;
|
|
1132
|
+
return value.value ?? null;
|
|
1133
|
+
}
|
|
1134
|
+
async listNonterminalOperationRecords() {
|
|
1135
|
+
const store = await this.operationStoreHandle();
|
|
1136
|
+
const keys = await store.keys();
|
|
1137
|
+
const keyValue = keys.take();
|
|
1138
|
+
if (isErr(keyValue))
|
|
1139
|
+
throw keyValue.error;
|
|
1140
|
+
const records = [];
|
|
1141
|
+
for await (const key of keyValue) {
|
|
1142
|
+
const record = await this.loadOperationRecord(key);
|
|
1143
|
+
if (record && record.snapshot.state !== "completed" &&
|
|
1144
|
+
record.snapshot.state !== "failed" &&
|
|
1145
|
+
record.snapshot.state !== "cancelled")
|
|
1146
|
+
records.push(record);
|
|
1147
|
+
}
|
|
1148
|
+
return records;
|
|
1149
|
+
}
|
|
1150
|
+
async saveOperationRecord(runtime) {
|
|
1151
|
+
const store = await this.operationStoreHandle();
|
|
1152
|
+
const loaded = await store.getEntry(runtime.id);
|
|
1153
|
+
const loadedValue = loaded.take();
|
|
1154
|
+
if (isErr(loadedValue))
|
|
1155
|
+
throw loadedValue.error;
|
|
1156
|
+
const existing = loadedValue?.value;
|
|
1157
|
+
if (existing && existing.revision !== runtime.revision) {
|
|
1158
|
+
throw new Error("operation revision conflict");
|
|
1159
|
+
}
|
|
1160
|
+
const revision = existing ? runtime.revision + 1 : runtime.revision;
|
|
1161
|
+
const record = {
|
|
1162
|
+
invocationId: runtime.id,
|
|
1163
|
+
callerSessionKey: runtime.callerSessionKey,
|
|
1164
|
+
invocationDigest: runtime.invocationDigest,
|
|
1165
|
+
caller: runtime.caller,
|
|
1166
|
+
creatorPrincipalId: runtime.creatorPrincipalId,
|
|
1167
|
+
creatorParticipantId: runtime.creatorParticipantId,
|
|
1168
|
+
apiId: runtime.apiId,
|
|
1169
|
+
operation: runtime.operation,
|
|
1170
|
+
input: runtime.input,
|
|
1171
|
+
revision,
|
|
1172
|
+
ownerInstanceId: runtime.ownerInstanceId,
|
|
1173
|
+
ownerConnectionId: runtime.ownerConnectionId,
|
|
1174
|
+
ownerEpoch: runtime.ownerEpoch,
|
|
1175
|
+
leaseExpiresAt: runtime.leaseExpiresAt,
|
|
1176
|
+
...(runtime.cancelRequestedAt
|
|
1177
|
+
? { cancelRequestedAt: runtime.cancelRequestedAt }
|
|
1178
|
+
: {}),
|
|
1179
|
+
...(runtime.transferGrant
|
|
1180
|
+
? { transferGrant: runtime.transferGrant }
|
|
1181
|
+
: {}),
|
|
1182
|
+
sequence: runtime.sequence,
|
|
1183
|
+
signalSequence: runtime.signalSequence,
|
|
1184
|
+
signals: runtime.signals,
|
|
1185
|
+
snapshot: runtime.snapshot,
|
|
1186
|
+
};
|
|
1187
|
+
const byteLength = (value) => new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
1188
|
+
if (byteLength(record.input) > MAX_OPERATION_INPUT_BYTES) {
|
|
1189
|
+
throw new Error("operation input exceeds 256 KiB");
|
|
1190
|
+
}
|
|
1191
|
+
if (record.snapshot.progress !== undefined &&
|
|
1192
|
+
byteLength(record.snapshot.progress) > MAX_OPERATION_PROGRESS_BYTES)
|
|
1193
|
+
throw new Error("operation progress exceeds 64 KiB");
|
|
1194
|
+
if (record.snapshot.output !== undefined &&
|
|
1195
|
+
byteLength(record.snapshot.output) > MAX_OPERATION_OUTPUT_BYTES)
|
|
1196
|
+
throw new Error("operation output exceeds 512 KiB");
|
|
1197
|
+
if (record.snapshot.error !== undefined &&
|
|
1198
|
+
byteLength(record.snapshot.error) > MAX_OPERATION_ERROR_BYTES)
|
|
1199
|
+
throw new Error("operation error exceeds 32 KiB");
|
|
1200
|
+
if ((record.signals?.length ?? 0) > MAX_OPERATION_SIGNALS) {
|
|
1201
|
+
throw new Error("operation signal limit exceeded");
|
|
1202
|
+
}
|
|
1203
|
+
for (const signal of record.signals) {
|
|
1204
|
+
if (byteLength(signal.input ?? null) > MAX_OPERATION_SIGNAL_BYTES) {
|
|
1205
|
+
throw new Error("operation signal payload exceeds 64 KiB");
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (byteLength(record) > MAX_OPERATION_RECORD_BYTES) {
|
|
1209
|
+
throw new Error("operation record exceeds 1 MiB");
|
|
1210
|
+
}
|
|
1211
|
+
const saved = loadedValue === undefined
|
|
1212
|
+
? await store.create(runtime.id, record)
|
|
1213
|
+
: await store.replace(runtime.id, loadedValue.revision, record);
|
|
1214
|
+
const value = saved.take();
|
|
1215
|
+
if (isErr(value))
|
|
1216
|
+
throw value.error;
|
|
1217
|
+
runtime.revision = revision;
|
|
1218
|
+
}
|
|
1219
|
+
request(method, input, opts) {
|
|
1220
|
+
const rpcApi = this.api["rpc"];
|
|
1221
|
+
const ctx = rpcApi[method];
|
|
1222
|
+
if (!ctx) {
|
|
1223
|
+
return AsyncResult.from(Promise.resolve(err(new UnexpectedError({
|
|
1224
|
+
cause: this.#unknownApiError("RPC method", method.toString()),
|
|
1225
|
+
context: { method: method.toString() },
|
|
1226
|
+
}))));
|
|
1227
|
+
}
|
|
1228
|
+
return this.#requestBuiltRpcUnknown(method, input, ctx, opts);
|
|
1229
|
+
}
|
|
1230
|
+
#requestBuiltRpcUnknown(method, input, ctx, opts) {
|
|
1231
|
+
return this.#requestBuiltRpc(method, input, ctx, opts);
|
|
1232
|
+
}
|
|
1233
|
+
#requestBuiltRpc(method, input, ctx, opts) {
|
|
1234
|
+
return AsyncResult.from((async () => {
|
|
1235
|
+
this.#log.trace({ method: String(method) }, `Calling ${method.toString()}.`);
|
|
1236
|
+
const msg = encodeRuntimeSchema(ctx.input, input).take();
|
|
1237
|
+
if (isErr(msg)) {
|
|
1238
|
+
recordRuntimeError(msg.error, {
|
|
1239
|
+
surface: "rpc",
|
|
1240
|
+
direction: "client",
|
|
1241
|
+
operation: method,
|
|
1242
|
+
phase: "request_encoding",
|
|
1243
|
+
});
|
|
1244
|
+
return msg;
|
|
1245
|
+
}
|
|
1246
|
+
const subject = this.template(ctx.subject, input).take();
|
|
1247
|
+
if (isErr(subject)) {
|
|
1248
|
+
recordRuntimeError(subject.error, {
|
|
1249
|
+
surface: "rpc",
|
|
1250
|
+
direction: "client",
|
|
1251
|
+
operation: method,
|
|
1252
|
+
phase: "request_encoding",
|
|
1253
|
+
});
|
|
1254
|
+
return subject;
|
|
1255
|
+
}
|
|
1256
|
+
const span = startClientSpan(method, subject);
|
|
1257
|
+
const attempt = async () => {
|
|
1258
|
+
const msgResult = await this.#requestMessageWithRetry({
|
|
1259
|
+
method,
|
|
1260
|
+
subject,
|
|
1261
|
+
payload: msg,
|
|
1262
|
+
timeout: opts?.timeout ?? this.timeout,
|
|
1263
|
+
signal: opts?.signal,
|
|
1264
|
+
callerCapabilities: ctx.callerCapabilities,
|
|
1265
|
+
span,
|
|
1266
|
+
});
|
|
1267
|
+
const response = msgResult.take();
|
|
1268
|
+
if (isErr(response)) {
|
|
1269
|
+
recordRuntimeError(response.error, {
|
|
1270
|
+
surface: "rpc",
|
|
1271
|
+
direction: "client",
|
|
1272
|
+
operation: method,
|
|
1273
|
+
phase: "request_send",
|
|
1274
|
+
});
|
|
1275
|
+
return response;
|
|
1276
|
+
}
|
|
1277
|
+
if (response.headers?.get("status") === "error") {
|
|
1278
|
+
const json = safeJson(response).take();
|
|
1279
|
+
if (isErr(json)) {
|
|
1280
|
+
const error = requestFailedTransportError({
|
|
1281
|
+
code: "trellis.request.invalid_response",
|
|
1282
|
+
message: "Trellis returned an invalid response.",
|
|
1283
|
+
hint: "Retry the request. If it keeps happening, check the Trellis capability handling this request.",
|
|
1284
|
+
method,
|
|
1285
|
+
subject,
|
|
1286
|
+
cause: json.error.cause,
|
|
1287
|
+
});
|
|
1288
|
+
recordRuntimeError(error, {
|
|
1289
|
+
surface: "rpc",
|
|
1290
|
+
direction: "client",
|
|
1291
|
+
operation: method,
|
|
1292
|
+
phase: "response_decoding",
|
|
1293
|
+
});
|
|
1294
|
+
return err(error);
|
|
1295
|
+
}
|
|
1296
|
+
const errorData = parse(TrellisErrorDataSchema, json).take();
|
|
1297
|
+
if (isErr(errorData)) {
|
|
1298
|
+
const error = requestFailedTransportError({
|
|
1299
|
+
code: "trellis.request.invalid_response",
|
|
1300
|
+
message: "Trellis returned an invalid response.",
|
|
1301
|
+
hint: "Retry the request. If it keeps happening, check the Trellis capability handling this request.",
|
|
1302
|
+
method,
|
|
1303
|
+
subject,
|
|
1304
|
+
cause: errorData.error,
|
|
1305
|
+
});
|
|
1306
|
+
recordRuntimeError(error, {
|
|
1307
|
+
surface: "rpc",
|
|
1308
|
+
direction: "client",
|
|
1309
|
+
operation: method,
|
|
1310
|
+
phase: "response_decoding",
|
|
1311
|
+
});
|
|
1312
|
+
return err(error);
|
|
1313
|
+
}
|
|
1314
|
+
const declaredErrorTypes = Array.isArray(ctx.declaredErrorTypes)
|
|
1315
|
+
? ctx.declaredErrorTypes.filter((value) => typeof value === "string")
|
|
1316
|
+
: ctx.errors;
|
|
1317
|
+
const runtimeErrors = Array.isArray(ctx.runtimeErrors)
|
|
1318
|
+
? ctx.runtimeErrors.filter(isRuntimeRpcErrorDesc)
|
|
1319
|
+
: undefined;
|
|
1320
|
+
const reconstructed = reconstructDeclaredRpcError(declaredErrorTypes, runtimeErrors, errorData, json);
|
|
1321
|
+
if (reconstructed) {
|
|
1322
|
+
await this.#handleBrowserAuthRequired(reconstructed);
|
|
1323
|
+
recordRuntimeError(new RemoteError({ error: errorData }), {
|
|
1324
|
+
surface: "rpc",
|
|
1325
|
+
direction: "client",
|
|
1326
|
+
operation: method,
|
|
1327
|
+
phase: "remote_error",
|
|
1328
|
+
});
|
|
1329
|
+
return err(reconstructed);
|
|
1330
|
+
}
|
|
1331
|
+
const remoteError = new RemoteError({ error: errorData });
|
|
1332
|
+
await this.#handleBrowserAuthRequired(remoteError);
|
|
1333
|
+
recordRuntimeError(remoteError, {
|
|
1334
|
+
surface: "rpc",
|
|
1335
|
+
direction: "client",
|
|
1336
|
+
operation: method,
|
|
1337
|
+
phase: "remote_error",
|
|
1338
|
+
});
|
|
1339
|
+
return err(remoteError);
|
|
1340
|
+
}
|
|
1341
|
+
const json = safeJson(response).take();
|
|
1342
|
+
if (isErr(json)) {
|
|
1343
|
+
const error = requestFailedTransportError({
|
|
1344
|
+
code: "trellis.request.invalid_response",
|
|
1345
|
+
message: "Trellis returned an invalid response.",
|
|
1346
|
+
hint: "Retry the request. If it keeps happening, check the Trellis capability handling this request.",
|
|
1347
|
+
method,
|
|
1348
|
+
subject,
|
|
1349
|
+
cause: json.error.cause,
|
|
1350
|
+
});
|
|
1351
|
+
recordRuntimeError(error, {
|
|
1352
|
+
surface: "rpc",
|
|
1353
|
+
direction: "client",
|
|
1354
|
+
operation: method,
|
|
1355
|
+
phase: "response_decoding",
|
|
1356
|
+
});
|
|
1357
|
+
return err(error);
|
|
1358
|
+
}
|
|
1359
|
+
const outputResult = parseRuntimeSchema(ctx.output, json).take();
|
|
1360
|
+
if (isErr(outputResult)) {
|
|
1361
|
+
recordRuntimeError(outputResult.error, {
|
|
1362
|
+
surface: "rpc",
|
|
1363
|
+
direction: "client",
|
|
1364
|
+
operation: method,
|
|
1365
|
+
phase: "response_decoding",
|
|
1366
|
+
});
|
|
1367
|
+
return err(outputResult.error);
|
|
1368
|
+
}
|
|
1369
|
+
return ok(outputResult);
|
|
1370
|
+
};
|
|
1371
|
+
return await withSpanAsync(span, async () => {
|
|
1372
|
+
try {
|
|
1373
|
+
const result = await attempt();
|
|
1374
|
+
const value = result.take();
|
|
1375
|
+
if (isErr(value)) {
|
|
1376
|
+
span.setStatus({
|
|
1377
|
+
code: SpanStatusCode.ERROR,
|
|
1378
|
+
message: value.error.message,
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
1383
|
+
}
|
|
1384
|
+
return result;
|
|
1385
|
+
}
|
|
1386
|
+
catch (cause) {
|
|
1387
|
+
const unexpected = cause instanceof TransportError
|
|
1388
|
+
? cause
|
|
1389
|
+
: new UnexpectedError({ cause });
|
|
1390
|
+
span.setStatus({
|
|
1391
|
+
code: SpanStatusCode.ERROR,
|
|
1392
|
+
message: unexpected.message,
|
|
1393
|
+
});
|
|
1394
|
+
span.recordException(unexpected);
|
|
1395
|
+
recordRuntimeError(unexpected, {
|
|
1396
|
+
surface: "rpc",
|
|
1397
|
+
direction: "client",
|
|
1398
|
+
operation: method,
|
|
1399
|
+
phase: "unexpected",
|
|
1400
|
+
});
|
|
1401
|
+
return err(unexpected);
|
|
1402
|
+
}
|
|
1403
|
+
finally {
|
|
1404
|
+
span.end();
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
})());
|
|
1408
|
+
}
|
|
1409
|
+
async #handleBrowserAuthRequired(error) {
|
|
1410
|
+
if (!this.#onSessionNotFound || !isBrowserAuthRequiredError(error)) {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
await this.#onSessionNotFound();
|
|
1414
|
+
}
|
|
1415
|
+
async #authenticateFeedRequest(args) {
|
|
1416
|
+
return await verifyLocalAuthorization({
|
|
1417
|
+
kind: "request",
|
|
1418
|
+
cache: this.#auth.authorizationProviderCache,
|
|
1419
|
+
message: args.msg,
|
|
1420
|
+
permission: args.permission,
|
|
1421
|
+
requiredCapabilities: args.requiredCapabilities,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
feedHandle(feed) {
|
|
1425
|
+
const descriptor = this.api.feeds?.[feed];
|
|
1426
|
+
if (!descriptor) {
|
|
1427
|
+
throw this.#unknownApiError("feed", feed.toString());
|
|
1428
|
+
}
|
|
1429
|
+
return {
|
|
1430
|
+
input: (input) => ({
|
|
1431
|
+
subscribe: (opts) => this.#subscribeFeed(feed.toString(), descriptor, input, opts),
|
|
1432
|
+
}),
|
|
1433
|
+
handle: (handler) => this.#handleFeed(feed.toString(), descriptor, handler),
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
#subscribeFeed(feed, descriptor, input, opts) {
|
|
1437
|
+
return AsyncResult.from((async () => {
|
|
1438
|
+
const payload = encodeRuntimeSchema(descriptor.input, input).take();
|
|
1439
|
+
if (isErr(payload)) {
|
|
1440
|
+
recordRuntimeError(payload.error, {
|
|
1441
|
+
surface: "feed",
|
|
1442
|
+
direction: "client",
|
|
1443
|
+
operation: feed,
|
|
1444
|
+
phase: "request_encoding",
|
|
1445
|
+
});
|
|
1446
|
+
return payload;
|
|
1447
|
+
}
|
|
1448
|
+
const subject = this.template(descriptor.subject, input).take();
|
|
1449
|
+
if (isErr(subject)) {
|
|
1450
|
+
recordRuntimeError(subject.error, {
|
|
1451
|
+
surface: "feed",
|
|
1452
|
+
direction: "client",
|
|
1453
|
+
operation: feed,
|
|
1454
|
+
phase: "request_template",
|
|
1455
|
+
});
|
|
1456
|
+
return subject;
|
|
1457
|
+
}
|
|
1458
|
+
const inbox = createInbox(this.#inboxPrefix);
|
|
1459
|
+
const authHeaders = await this.createRequestProof(subject, payload, inbox);
|
|
1460
|
+
if (opts?.signal?.aborted) {
|
|
1461
|
+
const error = createTransportError({
|
|
1462
|
+
code: "trellis.feed.subscribe_aborted",
|
|
1463
|
+
message: "The feed subscription was aborted before Trellis acknowledged it.",
|
|
1464
|
+
hint: "Retry the subscription if the feed is still needed.",
|
|
1465
|
+
context: { feed, subject },
|
|
1466
|
+
});
|
|
1467
|
+
recordRuntimeError(error, {
|
|
1468
|
+
surface: "feed",
|
|
1469
|
+
direction: "client",
|
|
1470
|
+
operation: feed,
|
|
1471
|
+
phase: "handshake",
|
|
1472
|
+
});
|
|
1473
|
+
return err(error);
|
|
1474
|
+
}
|
|
1475
|
+
const headers = natsHeaders();
|
|
1476
|
+
headers.set("proof", authHeaders.proof);
|
|
1477
|
+
headers.set("iat", String(authHeaders.iat));
|
|
1478
|
+
headers.set("request-id", authHeaders.requestId);
|
|
1479
|
+
headers.set("authorization-context", authHeaders.contextDigest);
|
|
1480
|
+
headers.set("session-key", this.#auth.sessionKey);
|
|
1481
|
+
injectTraceContext(createNatsHeaderCarrier(headers));
|
|
1482
|
+
const sub = this.#nats.subscribe(inbox);
|
|
1483
|
+
const iterator = sub[Symbol.asyncIterator]();
|
|
1484
|
+
let feedId;
|
|
1485
|
+
let cancelRequested = false;
|
|
1486
|
+
let cancelSent = false;
|
|
1487
|
+
let controlSubject;
|
|
1488
|
+
const cancel = async () => {
|
|
1489
|
+
cancelRequested = true;
|
|
1490
|
+
if (cancelSent || controlSubject === undefined || feedId === undefined)
|
|
1491
|
+
return;
|
|
1492
|
+
cancelSent = true;
|
|
1493
|
+
try {
|
|
1494
|
+
const cancelPayload = JSON.stringify({
|
|
1495
|
+
_trellisFeedCancel: inbox,
|
|
1496
|
+
feedId,
|
|
1497
|
+
});
|
|
1498
|
+
const auth = await this.createRequestProof(controlSubject, cancelPayload, inbox);
|
|
1499
|
+
const cancelHeaders = natsHeaders();
|
|
1500
|
+
cancelHeaders.set("proof", auth.proof);
|
|
1501
|
+
cancelHeaders.set("iat", String(auth.iat));
|
|
1502
|
+
cancelHeaders.set("request-id", auth.requestId);
|
|
1503
|
+
cancelHeaders.set("authorization-context", auth.contextDigest);
|
|
1504
|
+
cancelHeaders.set("session-key", this.#auth.sessionKey);
|
|
1505
|
+
injectTraceContext(createNatsHeaderCarrier(cancelHeaders));
|
|
1506
|
+
this.#nats.publish(controlSubject, cancelPayload, {
|
|
1507
|
+
headers: cancelHeaders,
|
|
1508
|
+
reply: inbox,
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
catch {
|
|
1512
|
+
// Best effort: the transport may already be closing with the feed.
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
const abort = () => {
|
|
1516
|
+
sub.unsubscribe();
|
|
1517
|
+
void cancel();
|
|
1518
|
+
};
|
|
1519
|
+
opts?.signal?.addEventListener("abort", abort, { once: true });
|
|
1520
|
+
try {
|
|
1521
|
+
this.#nats.publish(subject, payload, { headers, reply: inbox });
|
|
1522
|
+
}
|
|
1523
|
+
catch (cause) {
|
|
1524
|
+
opts?.signal?.removeEventListener("abort", abort);
|
|
1525
|
+
sub.unsubscribe();
|
|
1526
|
+
await cancel();
|
|
1527
|
+
const error = createTransportError({
|
|
1528
|
+
code: "trellis.feed.subscribe_failed",
|
|
1529
|
+
message: "Trellis could not subscribe to the feed.",
|
|
1530
|
+
hint: "Retry the subscription. If it keeps failing, check Trellis runtime health.",
|
|
1531
|
+
cause,
|
|
1532
|
+
context: { feed, subject },
|
|
1533
|
+
});
|
|
1534
|
+
recordRuntimeError(error, {
|
|
1535
|
+
surface: "feed",
|
|
1536
|
+
direction: "client",
|
|
1537
|
+
operation: feed,
|
|
1538
|
+
phase: "request_send",
|
|
1539
|
+
});
|
|
1540
|
+
return err(error);
|
|
1541
|
+
}
|
|
1542
|
+
let timeoutId;
|
|
1543
|
+
let abortHandler;
|
|
1544
|
+
const handshakePromises = [
|
|
1545
|
+
iterator.next(),
|
|
1546
|
+
new Promise((resolve) => {
|
|
1547
|
+
timeoutId = setTimeout(() => resolve("timeout"), this.timeout);
|
|
1548
|
+
}),
|
|
1549
|
+
];
|
|
1550
|
+
const signal = opts?.signal;
|
|
1551
|
+
if (signal) {
|
|
1552
|
+
handshakePromises.push(new Promise((resolve) => {
|
|
1553
|
+
abortHandler = () => resolve("aborted");
|
|
1554
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
1555
|
+
}));
|
|
1556
|
+
}
|
|
1557
|
+
const firstFrame = await Promise.race(handshakePromises);
|
|
1558
|
+
if (timeoutId !== undefined)
|
|
1559
|
+
clearTimeout(timeoutId);
|
|
1560
|
+
if (signal && abortHandler) {
|
|
1561
|
+
signal.removeEventListener("abort", abortHandler);
|
|
1562
|
+
}
|
|
1563
|
+
if (firstFrame === "timeout" || firstFrame === "aborted") {
|
|
1564
|
+
opts?.signal?.removeEventListener("abort", abort);
|
|
1565
|
+
sub.unsubscribe();
|
|
1566
|
+
await cancel();
|
|
1567
|
+
const error = createTransportError({
|
|
1568
|
+
code: firstFrame === "timeout"
|
|
1569
|
+
? "trellis.feed.subscribe_timeout"
|
|
1570
|
+
: "trellis.feed.subscribe_aborted",
|
|
1571
|
+
message: firstFrame === "timeout"
|
|
1572
|
+
? "Trellis did not receive a feed acknowledgement."
|
|
1573
|
+
: "The feed subscription was aborted before Trellis acknowledged it.",
|
|
1574
|
+
hint: firstFrame === "timeout"
|
|
1575
|
+
? "Check that the target service is running and has the current deployment digest, then retry."
|
|
1576
|
+
: "Retry the subscription if the feed is still needed.",
|
|
1577
|
+
context: { feed, subject },
|
|
1578
|
+
});
|
|
1579
|
+
recordRuntimeError(error, {
|
|
1580
|
+
surface: "feed",
|
|
1581
|
+
direction: "client",
|
|
1582
|
+
operation: feed,
|
|
1583
|
+
phase: "handshake",
|
|
1584
|
+
});
|
|
1585
|
+
return err(error);
|
|
1586
|
+
}
|
|
1587
|
+
if (firstFrame.done) {
|
|
1588
|
+
opts?.signal?.removeEventListener("abort", abort);
|
|
1589
|
+
sub.unsubscribe();
|
|
1590
|
+
await cancel();
|
|
1591
|
+
const error = createTransportError({
|
|
1592
|
+
code: "trellis.feed.subscribe_closed",
|
|
1593
|
+
message: "Trellis closed the feed before acknowledging it.",
|
|
1594
|
+
hint: "Retry the subscription. If it keeps failing, check Trellis runtime health.",
|
|
1595
|
+
context: { feed, subject },
|
|
1596
|
+
});
|
|
1597
|
+
recordRuntimeError(error, {
|
|
1598
|
+
surface: "feed",
|
|
1599
|
+
direction: "client",
|
|
1600
|
+
operation: feed,
|
|
1601
|
+
phase: "handshake",
|
|
1602
|
+
});
|
|
1603
|
+
return err(error);
|
|
1604
|
+
}
|
|
1605
|
+
const firstMessage = firstFrame.value;
|
|
1606
|
+
controlSubject = firstMessage.headers?.get("feed-control-subject");
|
|
1607
|
+
feedId = firstMessage.headers?.get("feed-id");
|
|
1608
|
+
if (cancelRequested)
|
|
1609
|
+
await cancel();
|
|
1610
|
+
if (firstMessage.headers?.get("status") === "error") {
|
|
1611
|
+
opts?.signal?.removeEventListener("abort", abort);
|
|
1612
|
+
sub.unsubscribe();
|
|
1613
|
+
await cancel();
|
|
1614
|
+
const error = createTransportError({
|
|
1615
|
+
code: "trellis.feed.failed",
|
|
1616
|
+
message: "Trellis rejected the feed subscription.",
|
|
1617
|
+
hint: "Retry the subscription. If it keeps failing, check Trellis runtime health and permissions.",
|
|
1618
|
+
context: { feed, subject, frame: firstMessage.string() },
|
|
1619
|
+
});
|
|
1620
|
+
recordRuntimeError(error, {
|
|
1621
|
+
surface: "feed",
|
|
1622
|
+
direction: "client",
|
|
1623
|
+
operation: feed,
|
|
1624
|
+
phase: "remote_error",
|
|
1625
|
+
});
|
|
1626
|
+
return err(error);
|
|
1627
|
+
}
|
|
1628
|
+
const firstEvent = firstMessage.headers?.get("feed-status") === "ready"
|
|
1629
|
+
? undefined
|
|
1630
|
+
: firstMessage;
|
|
1631
|
+
const eventSchema = descriptor.event;
|
|
1632
|
+
return ok((async function* () {
|
|
1633
|
+
try {
|
|
1634
|
+
const parseFeedFrame = (msg) => {
|
|
1635
|
+
if (msg.headers?.get("status") === "error") {
|
|
1636
|
+
const error = createTransportError({
|
|
1637
|
+
code: "trellis.feed.failed",
|
|
1638
|
+
message: "Trellis stopped the feed.",
|
|
1639
|
+
hint: "Retry the subscription. If it keeps failing, check Trellis runtime health.",
|
|
1640
|
+
context: { feed, subject, frame: msg.string() },
|
|
1641
|
+
});
|
|
1642
|
+
recordRuntimeError(error, {
|
|
1643
|
+
surface: "feed",
|
|
1644
|
+
direction: "client",
|
|
1645
|
+
operation: feed,
|
|
1646
|
+
phase: "remote_error",
|
|
1647
|
+
});
|
|
1648
|
+
throw error;
|
|
1649
|
+
}
|
|
1650
|
+
const json = safeJson(msg).take();
|
|
1651
|
+
if (isErr(json)) {
|
|
1652
|
+
recordRuntimeError(json.error, {
|
|
1653
|
+
surface: "feed",
|
|
1654
|
+
direction: "client",
|
|
1655
|
+
operation: feed,
|
|
1656
|
+
phase: "event_decoding",
|
|
1657
|
+
});
|
|
1658
|
+
throw json.error;
|
|
1659
|
+
}
|
|
1660
|
+
const parsed = parseRuntimeSchema(eventSchema, json).take();
|
|
1661
|
+
if (isErr(parsed)) {
|
|
1662
|
+
recordRuntimeError(parsed.error, {
|
|
1663
|
+
surface: "feed",
|
|
1664
|
+
direction: "client",
|
|
1665
|
+
operation: feed,
|
|
1666
|
+
phase: "event_validation",
|
|
1667
|
+
});
|
|
1668
|
+
throw parsed.error;
|
|
1669
|
+
}
|
|
1670
|
+
return parsed;
|
|
1671
|
+
};
|
|
1672
|
+
if (firstEvent)
|
|
1673
|
+
yield parseFeedFrame(firstEvent);
|
|
1674
|
+
while (true) {
|
|
1675
|
+
const next = await iterator.next();
|
|
1676
|
+
if (next.done)
|
|
1677
|
+
break;
|
|
1678
|
+
yield parseFeedFrame(next.value);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
finally {
|
|
1682
|
+
opts?.signal?.removeEventListener("abort", abort);
|
|
1683
|
+
sub.unsubscribe();
|
|
1684
|
+
await cancel();
|
|
1685
|
+
}
|
|
1686
|
+
})());
|
|
1687
|
+
})());
|
|
1688
|
+
}
|
|
1689
|
+
async #handleFeed(feed, descriptor, handler) {
|
|
1690
|
+
const subject = this.template(descriptor.subject, {}, true).take();
|
|
1691
|
+
if (isErr(subject))
|
|
1692
|
+
throw subject.error;
|
|
1693
|
+
let sub;
|
|
1694
|
+
let controlSub;
|
|
1695
|
+
const controlSubject = feedControlSubject(subject, this.#feedOwnerId);
|
|
1696
|
+
try {
|
|
1697
|
+
sub = this.#nats.subscribe(subject, { queue: routeQueueGroup(subject) });
|
|
1698
|
+
controlSub = this.#nats.subscribe(controlSubject);
|
|
1699
|
+
}
|
|
1700
|
+
catch (cause) {
|
|
1701
|
+
const error = createTransportError({
|
|
1702
|
+
code: "trellis.feed.listen_failed",
|
|
1703
|
+
message: "Trellis could not listen for feed requests.",
|
|
1704
|
+
hint: "Check the service deployment digest and runtime permissions, then restart the service.",
|
|
1705
|
+
cause,
|
|
1706
|
+
context: { feed, subject },
|
|
1707
|
+
});
|
|
1708
|
+
recordRuntimeError(error, {
|
|
1709
|
+
surface: "feed",
|
|
1710
|
+
direction: "server",
|
|
1711
|
+
operation: feed,
|
|
1712
|
+
phase: "listen",
|
|
1713
|
+
});
|
|
1714
|
+
throw error;
|
|
1715
|
+
}
|
|
1716
|
+
const task = AsyncResult.try(async () => {
|
|
1717
|
+
for await (const msg of sub) {
|
|
1718
|
+
void (async () => {
|
|
1719
|
+
try {
|
|
1720
|
+
const result = await this.#processFeedMessage(feed, descriptor, msg, handler);
|
|
1721
|
+
const value = result.take();
|
|
1722
|
+
if (isErr(value)) {
|
|
1723
|
+
this.#respondWithError(msg, value.error);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
catch (cause) {
|
|
1727
|
+
const error = annotateHandlerBoundaryError(cause, {
|
|
1728
|
+
feed,
|
|
1729
|
+
requestId: msg.headers?.get("request-id"),
|
|
1730
|
+
service: this.name,
|
|
1731
|
+
contractId: this.contractId,
|
|
1732
|
+
contractDigest: this.contractDigest,
|
|
1733
|
+
traceId: traceIdFromTraceparent(msg.headers?.get("traceparent")),
|
|
1734
|
+
});
|
|
1735
|
+
recordRuntimeError(error, {
|
|
1736
|
+
surface: "feed",
|
|
1737
|
+
direction: "server",
|
|
1738
|
+
operation: feed,
|
|
1739
|
+
phase: "handler_throw",
|
|
1740
|
+
});
|
|
1741
|
+
this.#respondWithError(msg, error);
|
|
1742
|
+
}
|
|
1743
|
+
})();
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
this.#tasks.add(`feed:${feed}`, task);
|
|
1747
|
+
this.#tasks.add(`feed:${feed}:control`, AsyncResult.try(async () => {
|
|
1748
|
+
for await (const msg of controlSub) {
|
|
1749
|
+
const result = await this.#processFeedMessage(feed, descriptor, msg, handler);
|
|
1750
|
+
const value = result.take();
|
|
1751
|
+
if (isErr(value))
|
|
1752
|
+
this.#respondWithError(msg, value.error);
|
|
1753
|
+
}
|
|
1754
|
+
}));
|
|
1755
|
+
}
|
|
1756
|
+
async #processFeedMessage(feed, descriptor, msg, handler) {
|
|
1757
|
+
const json = safeJson(msg).take();
|
|
1758
|
+
if (isErr(json)) {
|
|
1759
|
+
recordRuntimeError(json.error, {
|
|
1760
|
+
surface: "feed",
|
|
1761
|
+
direction: "server",
|
|
1762
|
+
operation: feed,
|
|
1763
|
+
phase: "request_decoding",
|
|
1764
|
+
});
|
|
1765
|
+
return json;
|
|
1766
|
+
}
|
|
1767
|
+
const caller = await this.#authenticateFeedRequest({
|
|
1768
|
+
msg,
|
|
1769
|
+
permission: descriptor.permission,
|
|
1770
|
+
requiredCapabilities: descriptor.subscribeCapabilities,
|
|
1771
|
+
});
|
|
1772
|
+
const callerValue = caller.take();
|
|
1773
|
+
if (isErr(callerValue)) {
|
|
1774
|
+
recordRuntimeError(callerValue.error, {
|
|
1775
|
+
surface: "feed",
|
|
1776
|
+
direction: "server",
|
|
1777
|
+
operation: feed,
|
|
1778
|
+
phase: "auth",
|
|
1779
|
+
});
|
|
1780
|
+
return callerValue;
|
|
1781
|
+
}
|
|
1782
|
+
const cancelReply = json && typeof json === "object" &&
|
|
1783
|
+
!Array.isArray(json) && Object.keys(json).length === 2 &&
|
|
1784
|
+
typeof json._trellisFeedCancel ===
|
|
1785
|
+
"string" &&
|
|
1786
|
+
typeof json.feedId === "string"
|
|
1787
|
+
? json._trellisFeedCancel
|
|
1788
|
+
: undefined;
|
|
1789
|
+
if (cancelReply !== undefined) {
|
|
1790
|
+
if (cancelReply !== msg.reply) {
|
|
1791
|
+
return err(new AuthError({
|
|
1792
|
+
reason: "reply_subject_mismatch",
|
|
1793
|
+
context: { expected: msg.reply, actual: cancelReply },
|
|
1794
|
+
}));
|
|
1795
|
+
}
|
|
1796
|
+
const active = this.#activeFeeds.get(cancelReply);
|
|
1797
|
+
const controlFeedId = json.feedId;
|
|
1798
|
+
if (!active || active.feedId !== controlFeedId ||
|
|
1799
|
+
msg.subject !==
|
|
1800
|
+
feedControlSubject(descriptor.subject, this.#feedOwnerId, controlFeedId) ||
|
|
1801
|
+
callerValue.type !== "verified" ||
|
|
1802
|
+
active.principalId !== callerValue.principalId ||
|
|
1803
|
+
active.participantId !== callerValue.participantId) {
|
|
1804
|
+
return err(new AuthError({
|
|
1805
|
+
reason: "feed_control_denied",
|
|
1806
|
+
context: { feed, feedId: controlFeedId },
|
|
1807
|
+
}));
|
|
1808
|
+
}
|
|
1809
|
+
active.controller.abort();
|
|
1810
|
+
if (this.#activeFeeds.delete(cancelReply))
|
|
1811
|
+
return ok(undefined);
|
|
1812
|
+
const previous = this.#pendingFeedCancels.get(cancelReply);
|
|
1813
|
+
if (previous !== undefined)
|
|
1814
|
+
clearTimeout(previous);
|
|
1815
|
+
if (previous !== undefined ||
|
|
1816
|
+
this.#pendingFeedCancels.size < MAX_FEED_CANCEL_TOMBSTONES) {
|
|
1817
|
+
const timeout = setTimeout(() => {
|
|
1818
|
+
this.#pendingFeedCancels.delete(cancelReply);
|
|
1819
|
+
}, FEED_CANCEL_TOMBSTONE_MS);
|
|
1820
|
+
this.#pendingFeedCancels.set(cancelReply, timeout);
|
|
1821
|
+
}
|
|
1822
|
+
return ok(undefined);
|
|
1823
|
+
}
|
|
1824
|
+
const parsed = parseRuntimeSchema(descriptor.input, json).take();
|
|
1825
|
+
if (isErr(parsed)) {
|
|
1826
|
+
recordRuntimeError(parsed.error, {
|
|
1827
|
+
surface: "feed",
|
|
1828
|
+
direction: "server",
|
|
1829
|
+
operation: feed,
|
|
1830
|
+
phase: "input_validation",
|
|
1831
|
+
});
|
|
1832
|
+
return parsed;
|
|
1833
|
+
}
|
|
1834
|
+
if (!msg.reply) {
|
|
1835
|
+
const error = new UnexpectedError({
|
|
1836
|
+
context: { feed, reason: "missing_reply" },
|
|
1837
|
+
});
|
|
1838
|
+
recordRuntimeError(error, {
|
|
1839
|
+
surface: "feed",
|
|
1840
|
+
direction: "server",
|
|
1841
|
+
operation: feed,
|
|
1842
|
+
phase: "handshake",
|
|
1843
|
+
});
|
|
1844
|
+
return err(error);
|
|
1845
|
+
}
|
|
1846
|
+
const pendingCancel = this.#pendingFeedCancels.get(msg.reply);
|
|
1847
|
+
if (pendingCancel !== undefined) {
|
|
1848
|
+
clearTimeout(pendingCancel);
|
|
1849
|
+
this.#pendingFeedCancels.delete(msg.reply);
|
|
1850
|
+
return ok(undefined);
|
|
1851
|
+
}
|
|
1852
|
+
const controller = new AbortController();
|
|
1853
|
+
if (callerValue.type !== "verified") {
|
|
1854
|
+
return err(new AuthError({ reason: "feed_creator_identity_required" }));
|
|
1855
|
+
}
|
|
1856
|
+
const feedId = crypto.randomUUID();
|
|
1857
|
+
this.#activeFeeds.get(msg.reply)?.controller.abort();
|
|
1858
|
+
this.#activeFeeds.set(msg.reply, {
|
|
1859
|
+
controller,
|
|
1860
|
+
feedId,
|
|
1861
|
+
principalId: callerValue.principalId,
|
|
1862
|
+
participantId: callerValue.participantId,
|
|
1863
|
+
});
|
|
1864
|
+
try {
|
|
1865
|
+
const readyHeaders = natsHeaders();
|
|
1866
|
+
readyHeaders.set("feed-status", "ready");
|
|
1867
|
+
readyHeaders.set("feed-id", feedId);
|
|
1868
|
+
readyHeaders.set("feed-control-subject", feedControlSubject(descriptor.subject, this.#feedOwnerId, feedId));
|
|
1869
|
+
this.#nats.publish(msg.reply, new Uint8Array(), {
|
|
1870
|
+
headers: readyHeaders,
|
|
1871
|
+
});
|
|
1872
|
+
if (controller.signal.aborted)
|
|
1873
|
+
return ok(undefined);
|
|
1874
|
+
const handlerResult = await handler({
|
|
1875
|
+
input: parsed,
|
|
1876
|
+
caller: callerValue,
|
|
1877
|
+
signal: controller.signal,
|
|
1878
|
+
emit: (event) => AsyncResult.from((async () => {
|
|
1879
|
+
const payload = encodeRuntimeSchema(descriptor.event, event).take();
|
|
1880
|
+
if (isErr(payload)) {
|
|
1881
|
+
recordRuntimeError(payload.error, {
|
|
1882
|
+
surface: "feed",
|
|
1883
|
+
direction: "server",
|
|
1884
|
+
operation: feed,
|
|
1885
|
+
phase: "event_encoding",
|
|
1886
|
+
});
|
|
1887
|
+
return payload;
|
|
1888
|
+
}
|
|
1889
|
+
if (!msg.reply) {
|
|
1890
|
+
const error = new UnexpectedError({
|
|
1891
|
+
context: { feed, reason: "missing_reply" },
|
|
1892
|
+
});
|
|
1893
|
+
recordRuntimeError(error, {
|
|
1894
|
+
surface: "feed",
|
|
1895
|
+
direction: "server",
|
|
1896
|
+
operation: feed,
|
|
1897
|
+
phase: "event_publish",
|
|
1898
|
+
});
|
|
1899
|
+
return err(error);
|
|
1900
|
+
}
|
|
1901
|
+
try {
|
|
1902
|
+
this.#nats.publish(msg.reply, payload);
|
|
1903
|
+
}
|
|
1904
|
+
catch (cause) {
|
|
1905
|
+
const error = new UnexpectedError({
|
|
1906
|
+
cause,
|
|
1907
|
+
context: { feed },
|
|
1908
|
+
});
|
|
1909
|
+
recordRuntimeError(error, {
|
|
1910
|
+
surface: "feed",
|
|
1911
|
+
direction: "server",
|
|
1912
|
+
operation: feed,
|
|
1913
|
+
phase: "event_publish",
|
|
1914
|
+
});
|
|
1915
|
+
return err(error);
|
|
1916
|
+
}
|
|
1917
|
+
return ok(undefined);
|
|
1918
|
+
})()),
|
|
1919
|
+
});
|
|
1920
|
+
const handlerOutcome = isResultLike(handlerResult)
|
|
1921
|
+
? handlerResult.take()
|
|
1922
|
+
: handlerResult;
|
|
1923
|
+
if (isErr(handlerOutcome)) {
|
|
1924
|
+
const error = annotateHandlerBoundaryError(handlerOutcome.error, {
|
|
1925
|
+
feed,
|
|
1926
|
+
requestId: msg.headers?.get("request-id"),
|
|
1927
|
+
service: this.name,
|
|
1928
|
+
contractId: this.contractId,
|
|
1929
|
+
contractDigest: this.contractDigest,
|
|
1930
|
+
traceId: traceIdFromTraceparent(msg.headers?.get("traceparent")),
|
|
1931
|
+
});
|
|
1932
|
+
recordRuntimeError(error, {
|
|
1933
|
+
surface: "feed",
|
|
1934
|
+
direction: "server",
|
|
1935
|
+
operation: feed,
|
|
1936
|
+
phase: "handler_result",
|
|
1937
|
+
});
|
|
1938
|
+
return err(error);
|
|
1939
|
+
}
|
|
1940
|
+
return ok(undefined);
|
|
1941
|
+
}
|
|
1942
|
+
finally {
|
|
1943
|
+
if (this.#activeFeeds.get(msg.reply)?.controller === controller) {
|
|
1944
|
+
this.#activeFeeds.delete(msg.reply);
|
|
1945
|
+
}
|
|
1946
|
+
controller.abort();
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
operationHandle(operation, unavailable) {
|
|
1950
|
+
const descriptor = this.api["operations"]?.[operation];
|
|
1951
|
+
if (!descriptor) {
|
|
1952
|
+
throw this.#unknownApiError("operation", operation.toString());
|
|
1953
|
+
}
|
|
1954
|
+
const transport = {
|
|
1955
|
+
requestJson: (subject, body) => {
|
|
1956
|
+
const error = unavailable?.();
|
|
1957
|
+
return error
|
|
1958
|
+
? AsyncResult.from(Promise.resolve(err(error)))
|
|
1959
|
+
: this.#requestJson(subject, body);
|
|
1960
|
+
},
|
|
1961
|
+
watchJson: (subject, body) => {
|
|
1962
|
+
const error = unavailable?.();
|
|
1963
|
+
return error
|
|
1964
|
+
? AsyncResult.from(Promise.resolve(err(error)))
|
|
1965
|
+
: this.#watchJson(subject, body);
|
|
1966
|
+
},
|
|
1967
|
+
putTransfer: (grant, body) => AsyncResult.from((async () => {
|
|
1968
|
+
const handle = createTransferHandle(this.#nats, this.#auth, this.timeout, grant, this.#inboxPrefix);
|
|
1969
|
+
if (!(handle instanceof Object) || !("send" in handle)) {
|
|
1970
|
+
return err(new TransferError({
|
|
1971
|
+
operation: "transfer",
|
|
1972
|
+
context: { reason: "invalid_operation_transfer_grant" },
|
|
1973
|
+
}));
|
|
1974
|
+
}
|
|
1975
|
+
return await handle.send(body);
|
|
1976
|
+
})()),
|
|
1977
|
+
};
|
|
1978
|
+
return new OperationInvoker(transport, descriptor);
|
|
1979
|
+
}
|
|
1980
|
+
transfer(grant) {
|
|
1981
|
+
return createTransferHandle(this.#nats, this.#auth, this.timeout, grant, this.#inboxPrefix);
|
|
1982
|
+
}
|
|
1983
|
+
/*
|
|
1984
|
+
* Mount a handler to process requests made to a specific Trellis API
|
|
1985
|
+
*/
|
|
1986
|
+
async mount(method, fn) {
|
|
1987
|
+
const methodName = method;
|
|
1988
|
+
const ctx = this.api["rpc"][methodName];
|
|
1989
|
+
if (!ctx) {
|
|
1990
|
+
throw this.#unknownApiError("RPC method", method.toString());
|
|
1991
|
+
}
|
|
1992
|
+
const task = this.#handleRPC(methodName, fn);
|
|
1993
|
+
this.#tasks.add(methodName, task);
|
|
1994
|
+
}
|
|
1995
|
+
#handleRPC(method, fn, subjectData = {}) {
|
|
1996
|
+
// Get API details
|
|
1997
|
+
const ctx = this.api["rpc"][method];
|
|
1998
|
+
const subject = this.template(ctx.subject, subjectData, true).take();
|
|
1999
|
+
if (isErr(subject)) {
|
|
2000
|
+
return AsyncResult.lift(subject);
|
|
2001
|
+
}
|
|
2002
|
+
const handlerTrellis = this.#createHandlerTrellis();
|
|
2003
|
+
this.#log.info({ method: String(method) }, `Mounting ${method.toString()} RPC handler`);
|
|
2004
|
+
const sub = this.#nats.subscribe(subject, {
|
|
2005
|
+
queue: routeQueueGroup(subject),
|
|
2006
|
+
});
|
|
2007
|
+
return AsyncResult.try(async () => {
|
|
2008
|
+
for await (const msg of sub) {
|
|
2009
|
+
const resultPromise = await this.#processRPCMessage(method, ctx, msg, fn, handlerTrellis);
|
|
2010
|
+
const result = resultPromise.take();
|
|
2011
|
+
if (isErr(result)) {
|
|
2012
|
+
this.#respondWithError(msg, result.error, { method: String(method) });
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
const sent = this.#respondWithPayload(msg, result.payload, undefined, {
|
|
2016
|
+
method: String(method),
|
|
2017
|
+
responseKind: "success",
|
|
2018
|
+
});
|
|
2019
|
+
if (sent.isErr()) {
|
|
2020
|
+
const responseBytes = payloadByteLength(result.payload);
|
|
2021
|
+
const message = causeMessage(sent.error.cause);
|
|
2022
|
+
this.#respondWithError(msg, new TransportError({
|
|
2023
|
+
code: "trellis.rpc.response_send_failed",
|
|
2024
|
+
message: message.includes("max_payload")
|
|
2025
|
+
? "Trellis RPC response exceeded NATS max_payload."
|
|
2026
|
+
: "Trellis could not send the RPC response.",
|
|
2027
|
+
hint: "Reduce the requested page size or use a narrower RPC that does not include large detail payloads.",
|
|
2028
|
+
cause: sent.error.cause,
|
|
2029
|
+
context: {
|
|
2030
|
+
method: String(method),
|
|
2031
|
+
subject: msg.subject,
|
|
2032
|
+
responseBytes,
|
|
2033
|
+
causeMessage: message,
|
|
2034
|
+
},
|
|
2035
|
+
}), { method: String(method), responseBytes });
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
if (result.afterReply.length > 0) {
|
|
2039
|
+
for (const task of result.afterReply) {
|
|
2040
|
+
try {
|
|
2041
|
+
await task();
|
|
2042
|
+
}
|
|
2043
|
+
catch (error) {
|
|
2044
|
+
this.#log.error({ method: String(method), error }, "RPC after-reply task failed");
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
async #processRPCMessage(method, ctx, msg, fn, handlerTrellis) {
|
|
2052
|
+
this.#log.debug({ method: String(method), subject: msg.subject }, "Processing RPC message");
|
|
2053
|
+
// Extract trace context from incoming NATS headers
|
|
2054
|
+
const parentContext = extractTraceContext(createNatsHeaderCarrier({
|
|
2055
|
+
get: (k) => msg.headers?.get(k) ?? undefined,
|
|
2056
|
+
set: () => { }, // Server doesn't need to set headers on incoming messages
|
|
2057
|
+
}));
|
|
2058
|
+
// Start a server span for this RPC handler
|
|
2059
|
+
const span = startServerSpan(method, msg.subject, parentContext);
|
|
2060
|
+
const incomingTraceId = traceIdFromTraceparent(msg.headers?.get("traceparent"));
|
|
2061
|
+
// Execute the handler within the span's context
|
|
2062
|
+
return withSpanAsync(span, async () => {
|
|
2063
|
+
const afterReply = [];
|
|
2064
|
+
const execute = async () => {
|
|
2065
|
+
const jsonData = safeJson(msg).take();
|
|
2066
|
+
if (isErr(jsonData)) {
|
|
2067
|
+
this.#log.warn({ method, error: jsonData.error.message }, "Failed to parse JSON");
|
|
2068
|
+
span.setStatus({
|
|
2069
|
+
code: SpanStatusCode.ERROR,
|
|
2070
|
+
message: "Failed to parse JSON",
|
|
2071
|
+
});
|
|
2072
|
+
recordRuntimeError(jsonData.error, {
|
|
2073
|
+
surface: "rpc",
|
|
2074
|
+
direction: "server",
|
|
2075
|
+
operation: String(method),
|
|
2076
|
+
phase: "parse",
|
|
2077
|
+
});
|
|
2078
|
+
return jsonData;
|
|
2079
|
+
}
|
|
2080
|
+
const parsedInput = parseRuntimeSchema(ctx.input, jsonData).take();
|
|
2081
|
+
if (isErr(parsedInput)) {
|
|
2082
|
+
span.setStatus({
|
|
2083
|
+
code: SpanStatusCode.ERROR,
|
|
2084
|
+
message: "Input validation failed",
|
|
2085
|
+
});
|
|
2086
|
+
recordRuntimeError(parsedInput.error, {
|
|
2087
|
+
surface: "rpc",
|
|
2088
|
+
direction: "server",
|
|
2089
|
+
operation: String(method),
|
|
2090
|
+
phase: "input_validation",
|
|
2091
|
+
});
|
|
2092
|
+
return parsedInput;
|
|
2093
|
+
}
|
|
2094
|
+
let caller;
|
|
2095
|
+
let callerInboxPrefix = "_INBOX";
|
|
2096
|
+
const handlerRequestIdFromHeader = msg.headers?.get("request-id") ?? "";
|
|
2097
|
+
const handlerTraceIdFromHeader = traceIdFromTraceparent(msg.headers?.get("traceparent"));
|
|
2098
|
+
const authRequired = ctx.authRequired ?? true;
|
|
2099
|
+
if (!authRequired) {
|
|
2100
|
+
caller = {
|
|
2101
|
+
type: "internal",
|
|
2102
|
+
capabilities: [],
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
else {
|
|
2106
|
+
const auth = await verifyLocalAuthorization({
|
|
2107
|
+
kind: "request",
|
|
2108
|
+
cache: this.#auth.authorizationProviderCache,
|
|
2109
|
+
message: msg,
|
|
2110
|
+
permission: ctx.permission,
|
|
2111
|
+
requiredCapabilities: ctx.callerCapabilities,
|
|
2112
|
+
});
|
|
2113
|
+
const authValue = auth.take();
|
|
2114
|
+
if (isErr(authValue)) {
|
|
2115
|
+
span.setStatus({
|
|
2116
|
+
code: SpanStatusCode.ERROR,
|
|
2117
|
+
message: authValue.error.message,
|
|
2118
|
+
});
|
|
2119
|
+
recordRuntimeError(authValue.error, {
|
|
2120
|
+
surface: "rpc",
|
|
2121
|
+
direction: "server",
|
|
2122
|
+
operation: String(method),
|
|
2123
|
+
phase: "auth",
|
|
2124
|
+
});
|
|
2125
|
+
return err(authValue.error);
|
|
2126
|
+
}
|
|
2127
|
+
caller = authValue;
|
|
2128
|
+
callerInboxPrefix = authValue.inboxPrefix;
|
|
2129
|
+
}
|
|
2130
|
+
span.setAttribute("auth.caller.type", caller.type);
|
|
2131
|
+
if (caller.type === "verified") {
|
|
2132
|
+
span.setAttribute("auth.principal.kind", caller.principalKind);
|
|
2133
|
+
span.setAttribute("auth.principal.id", caller.principalId);
|
|
2134
|
+
span.setAttribute("auth.participant.id", caller.participantId);
|
|
2135
|
+
span.setAttribute("auth.connection.id", caller.connectionId);
|
|
2136
|
+
span.setAttribute("auth.grant.revision", caller.grantRevision);
|
|
2137
|
+
if (caller.loginSessionId) {
|
|
2138
|
+
span.setAttribute("auth.login_session.id", caller.loginSessionId);
|
|
2139
|
+
}
|
|
2140
|
+
if (caller.deploymentId) {
|
|
2141
|
+
span.setAttribute("auth.deployment.id", caller.deploymentId);
|
|
2142
|
+
}
|
|
2143
|
+
if (caller.instanceId) {
|
|
2144
|
+
span.setAttribute("auth.instance.id", caller.instanceId);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
const invokeHandler = fn;
|
|
2148
|
+
const handlerResultWrapped = await AsyncResult.try(async () => await Promise.resolve(invokeHandler({
|
|
2149
|
+
input: parsedInput,
|
|
2150
|
+
context: {
|
|
2151
|
+
caller,
|
|
2152
|
+
sessionKey: caller.type === "verified" ? caller.sessionKey : "",
|
|
2153
|
+
inboxPrefix: callerInboxPrefix,
|
|
2154
|
+
permission: ctx.permission,
|
|
2155
|
+
requiredCapabilities: ctx.callerCapabilities,
|
|
2156
|
+
requestId: handlerRequestIdFromHeader || undefined,
|
|
2157
|
+
traceId: handlerTraceIdFromHeader || undefined,
|
|
2158
|
+
afterReply: (task) => afterReply.push(task),
|
|
2159
|
+
},
|
|
2160
|
+
client: handlerTrellis,
|
|
2161
|
+
})));
|
|
2162
|
+
if (handlerResultWrapped.isErr()) {
|
|
2163
|
+
const error = annotateHandlerBoundaryError(handlerResultWrapped.error, {
|
|
2164
|
+
method: String(method),
|
|
2165
|
+
requestId: msg.headers?.get("request-id"),
|
|
2166
|
+
service: this.name,
|
|
2167
|
+
contractId: this.contractId,
|
|
2168
|
+
contractDigest: this.contractDigest,
|
|
2169
|
+
traceId: activeTraceId(span) ?? incomingTraceId,
|
|
2170
|
+
});
|
|
2171
|
+
this.#log.error({
|
|
2172
|
+
method,
|
|
2173
|
+
error: error.message,
|
|
2174
|
+
cause: error.cause instanceof Error
|
|
2175
|
+
? { message: error.cause.message, stack: error.cause.stack }
|
|
2176
|
+
: error.cause,
|
|
2177
|
+
}, "Handler threw unexpectedly.");
|
|
2178
|
+
span.setStatus({
|
|
2179
|
+
code: SpanStatusCode.ERROR,
|
|
2180
|
+
message: error.message,
|
|
2181
|
+
});
|
|
2182
|
+
span.recordException(error);
|
|
2183
|
+
recordRuntimeError(error, {
|
|
2184
|
+
surface: "rpc",
|
|
2185
|
+
direction: "server",
|
|
2186
|
+
operation: String(method),
|
|
2187
|
+
phase: "handler_throw",
|
|
2188
|
+
});
|
|
2189
|
+
return err(error);
|
|
2190
|
+
}
|
|
2191
|
+
const handlerResult = handlerResultWrapped.take();
|
|
2192
|
+
const handlerOutcome = handlerResult.take();
|
|
2193
|
+
if (isErr(handlerOutcome)) {
|
|
2194
|
+
const error = annotateHandlerBoundaryError(handlerOutcome.error, {
|
|
2195
|
+
method: String(method),
|
|
2196
|
+
requestId: msg.headers?.get("request-id"),
|
|
2197
|
+
service: this.name,
|
|
2198
|
+
contractId: this.contractId,
|
|
2199
|
+
contractDigest: this.contractDigest,
|
|
2200
|
+
traceId: activeTraceId(span) ?? incomingTraceId,
|
|
2201
|
+
});
|
|
2202
|
+
this.#log.error({
|
|
2203
|
+
method,
|
|
2204
|
+
error: error.message,
|
|
2205
|
+
errorType: error.name,
|
|
2206
|
+
cause: error.cause instanceof Error
|
|
2207
|
+
? { message: error.cause.message, stack: error.cause.stack }
|
|
2208
|
+
: error.cause,
|
|
2209
|
+
}, "Handler returned error.");
|
|
2210
|
+
span.setStatus({
|
|
2211
|
+
code: SpanStatusCode.ERROR,
|
|
2212
|
+
message: error.message,
|
|
2213
|
+
});
|
|
2214
|
+
recordRuntimeError(error, {
|
|
2215
|
+
surface: "rpc",
|
|
2216
|
+
direction: "server",
|
|
2217
|
+
operation: String(method),
|
|
2218
|
+
phase: "handler_result",
|
|
2219
|
+
});
|
|
2220
|
+
return err(error);
|
|
2221
|
+
}
|
|
2222
|
+
const encoded = encodeSchema(ctx.output, handlerOutcome).take();
|
|
2223
|
+
if (isErr(encoded)) {
|
|
2224
|
+
span.setStatus({
|
|
2225
|
+
code: SpanStatusCode.ERROR,
|
|
2226
|
+
message: "Output encoding failed",
|
|
2227
|
+
});
|
|
2228
|
+
recordRuntimeError(encoded.error, {
|
|
2229
|
+
surface: "rpc",
|
|
2230
|
+
direction: "server",
|
|
2231
|
+
operation: String(method),
|
|
2232
|
+
phase: "output_encoding",
|
|
2233
|
+
});
|
|
2234
|
+
return encoded;
|
|
2235
|
+
}
|
|
2236
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
2237
|
+
return ok({ payload: encoded, afterReply });
|
|
2238
|
+
};
|
|
2239
|
+
const result = await execute();
|
|
2240
|
+
if (isErr(result)) {
|
|
2241
|
+
result.error.withTraceId(activeTraceId(span) ?? incomingTraceId);
|
|
2242
|
+
}
|
|
2243
|
+
span.end();
|
|
2244
|
+
return result;
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
#respondWithPayload(msg, payload, options, context) {
|
|
2248
|
+
const responseBytes = payloadByteLength(payload);
|
|
2249
|
+
try {
|
|
2250
|
+
msg.respond(payload, options);
|
|
2251
|
+
return ok(undefined);
|
|
2252
|
+
}
|
|
2253
|
+
catch (cause) {
|
|
2254
|
+
const error = new UnexpectedError({
|
|
2255
|
+
cause,
|
|
2256
|
+
context: {
|
|
2257
|
+
method: context.method,
|
|
2258
|
+
responseKind: context.responseKind,
|
|
2259
|
+
subject: msg.subject,
|
|
2260
|
+
reply: msg.reply,
|
|
2261
|
+
responseBytes,
|
|
2262
|
+
causeMessage: causeMessage(cause),
|
|
2263
|
+
},
|
|
2264
|
+
});
|
|
2265
|
+
this.#log.error({
|
|
2266
|
+
method: context.method,
|
|
2267
|
+
responseKind: context.responseKind,
|
|
2268
|
+
subject: msg.subject,
|
|
2269
|
+
reply: msg.reply,
|
|
2270
|
+
responseBytes,
|
|
2271
|
+
cause: causeLogData(cause),
|
|
2272
|
+
}, "Failed to send RPC response");
|
|
2273
|
+
return err(error);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
#respondWithError(msg, error, context = {}) {
|
|
2277
|
+
const trellisError = error instanceof BaseError &&
|
|
2278
|
+
!(error instanceof RemoteError)
|
|
2279
|
+
? error
|
|
2280
|
+
: new UnexpectedError({ cause: error });
|
|
2281
|
+
this.#log.error({
|
|
2282
|
+
method: context.method,
|
|
2283
|
+
subject: msg.subject,
|
|
2284
|
+
responseBytes: context.responseBytes,
|
|
2285
|
+
error: trellisError.toSerializable(),
|
|
2286
|
+
}, "RPC error");
|
|
2287
|
+
const errorData = trellisError.toSerializable();
|
|
2288
|
+
const hdrs = natsHeaders();
|
|
2289
|
+
hdrs.set("status", "error");
|
|
2290
|
+
const serialized = Result.try(() => JSON.stringify(errorData));
|
|
2291
|
+
if (serialized.isErr()) {
|
|
2292
|
+
this.#log.error({ error: serialized.error }, "Failed to serialize error response");
|
|
2293
|
+
this.#respondWithPayload(msg, '{"type":"UnexpectedError","message":"Failed to serialize error"}', { headers: hdrs }, { method: context.method, responseKind: "error" });
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
this.#respondWithPayload(msg, serialized.take(), { headers: hdrs }, { method: context.method, responseKind: "error" });
|
|
2297
|
+
}
|
|
2298
|
+
respondWithError(msg, error) {
|
|
2299
|
+
this.#respondWithError(msg, error);
|
|
2300
|
+
}
|
|
2301
|
+
/**
|
|
2302
|
+
* Builds a stable event subject, encoded payload, and publish headers.
|
|
2303
|
+
*
|
|
2304
|
+
* The prepared event intentionally carries no contract id or digest so callers
|
|
2305
|
+
* can persist it in a service-owned outbox without coupling storage to the
|
|
2306
|
+
* publisher's current deployment metadata.
|
|
2307
|
+
*/
|
|
2308
|
+
prepare(event, data) {
|
|
2309
|
+
try {
|
|
2310
|
+
const eventName = event;
|
|
2311
|
+
const ctx = this.api["events"][eventName];
|
|
2312
|
+
if (!ctx) {
|
|
2313
|
+
const error = new UnexpectedError({
|
|
2314
|
+
cause: this.#unknownApiError("event", event.toString()),
|
|
2315
|
+
context: { event: event.toString() },
|
|
2316
|
+
});
|
|
2317
|
+
recordRuntimeError(error, {
|
|
2318
|
+
surface: "event",
|
|
2319
|
+
direction: "publisher",
|
|
2320
|
+
operation: event,
|
|
2321
|
+
phase: "prepare",
|
|
2322
|
+
});
|
|
2323
|
+
return err(error);
|
|
2324
|
+
}
|
|
2325
|
+
const subject = this.template(ctx.subject, data).take();
|
|
2326
|
+
if (isErr(subject)) {
|
|
2327
|
+
logger.error({ err: subject.error }, "Failed to template event.");
|
|
2328
|
+
recordRuntimeError(subject.error, {
|
|
2329
|
+
surface: "event",
|
|
2330
|
+
direction: "publisher",
|
|
2331
|
+
operation: event,
|
|
2332
|
+
phase: "request_encoding",
|
|
2333
|
+
});
|
|
2334
|
+
return subject;
|
|
2335
|
+
}
|
|
2336
|
+
const header = {
|
|
2337
|
+
id: ulid(),
|
|
2338
|
+
time: new Date().toISOString(),
|
|
2339
|
+
};
|
|
2340
|
+
const payload = Object.freeze({ ...data });
|
|
2341
|
+
const msg = encodeSchema(ctx.event, payload).take();
|
|
2342
|
+
if (isErr(msg)) {
|
|
2343
|
+
logger.error({ err: msg.error }, "Failed to encode event.");
|
|
2344
|
+
const error = new UnexpectedError({ cause: msg.error });
|
|
2345
|
+
recordRuntimeError(error, {
|
|
2346
|
+
surface: "event",
|
|
2347
|
+
direction: "publisher",
|
|
2348
|
+
operation: event,
|
|
2349
|
+
phase: "request_encoding",
|
|
2350
|
+
});
|
|
2351
|
+
return err(error);
|
|
2352
|
+
}
|
|
2353
|
+
const headers = natsHeaders();
|
|
2354
|
+
injectTraceContext(createNatsHeaderCarrier(headers));
|
|
2355
|
+
const headerRecord = {};
|
|
2356
|
+
for (const [key, value] of headers) {
|
|
2357
|
+
headerRecord[key] = value.join(",");
|
|
2358
|
+
}
|
|
2359
|
+
headerRecord["Trellis-Event-Descriptor"] = ctx.descriptorIdentity;
|
|
2360
|
+
return ok(Object.freeze({
|
|
2361
|
+
event: event.toString(),
|
|
2362
|
+
descriptorIdentity: ctx.descriptorIdentity,
|
|
2363
|
+
subject,
|
|
2364
|
+
header: Object.freeze(header),
|
|
2365
|
+
payload,
|
|
2366
|
+
encodedPayload: msg,
|
|
2367
|
+
headers: Object.freeze(headerRecord),
|
|
2368
|
+
}));
|
|
2369
|
+
}
|
|
2370
|
+
catch (cause) {
|
|
2371
|
+
const error = new UnexpectedError({
|
|
2372
|
+
cause,
|
|
2373
|
+
context: { event: event.toString() },
|
|
2374
|
+
});
|
|
2375
|
+
recordRuntimeError(error, {
|
|
2376
|
+
surface: "event",
|
|
2377
|
+
direction: "publisher",
|
|
2378
|
+
operation: event,
|
|
2379
|
+
phase: "prepare",
|
|
2380
|
+
});
|
|
2381
|
+
return err(error);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Publishes a previously prepared event without regenerating its id, time,
|
|
2386
|
+
* subject, payload, or headers.
|
|
2387
|
+
*/
|
|
2388
|
+
publishPrepared(event) {
|
|
2389
|
+
return AsyncResult.from((async () => {
|
|
2390
|
+
try {
|
|
2391
|
+
const headers = natsHeaders();
|
|
2392
|
+
for (const [key, value] of Object.entries(event.headers)) {
|
|
2393
|
+
headers.set(key, value);
|
|
2394
|
+
}
|
|
2395
|
+
headers.set("Nats-Msg-Id", event.header.id);
|
|
2396
|
+
headers.set("Trellis-Event-Time", event.header.time);
|
|
2397
|
+
headers.set("Trellis-Event-Descriptor", event.descriptorIdentity);
|
|
2398
|
+
const proof = await this.#createEventProof(event);
|
|
2399
|
+
headers.set("proof", proof.proof);
|
|
2400
|
+
headers.set("authorization-context", proof.contextDigest);
|
|
2401
|
+
headers.set("session-key", this.#auth.sessionKey);
|
|
2402
|
+
logger.trace({ subject: event.subject }, `Publishing ${event.event} event.`);
|
|
2403
|
+
await this.#js.publish(event.subject, event.encodedPayload, {
|
|
2404
|
+
headers,
|
|
2405
|
+
});
|
|
2406
|
+
return ok(undefined);
|
|
2407
|
+
}
|
|
2408
|
+
catch (cause) {
|
|
2409
|
+
const error = new UnexpectedError({
|
|
2410
|
+
cause,
|
|
2411
|
+
context: { event: event.event },
|
|
2412
|
+
});
|
|
2413
|
+
recordRuntimeError(error, {
|
|
2414
|
+
surface: "event",
|
|
2415
|
+
direction: "publisher",
|
|
2416
|
+
operation: event.event,
|
|
2417
|
+
phase: "publish",
|
|
2418
|
+
});
|
|
2419
|
+
return err(error);
|
|
2420
|
+
}
|
|
2421
|
+
})());
|
|
2422
|
+
}
|
|
2423
|
+
publish(event, data) {
|
|
2424
|
+
return AsyncResult.from((async () => {
|
|
2425
|
+
const prepared = this.prepare(event, data).take();
|
|
2426
|
+
if (isErr(prepared))
|
|
2427
|
+
return prepared;
|
|
2428
|
+
return await this.publishPrepared(prepared);
|
|
2429
|
+
})());
|
|
2430
|
+
}
|
|
2431
|
+
listenEvent(event, subjectData, fn, opts) {
|
|
2432
|
+
return AsyncResult.from((async () => {
|
|
2433
|
+
try {
|
|
2434
|
+
const eventName = event;
|
|
2435
|
+
const ctx = this.api["events"][eventName];
|
|
2436
|
+
if (!ctx) {
|
|
2437
|
+
return err(new UnexpectedError({
|
|
2438
|
+
cause: this.#unknownApiError("event", event.toString()),
|
|
2439
|
+
context: { event: event.toString() },
|
|
2440
|
+
}));
|
|
2441
|
+
}
|
|
2442
|
+
const subject = this.template(ctx.subject, subjectData, true).take();
|
|
2443
|
+
if (isErr(subject))
|
|
2444
|
+
return subject;
|
|
2445
|
+
if (opts?.mode === "ephemeral") {
|
|
2446
|
+
return await this.#startEphemeralEvent(eventName, ctx, subject, fn, opts.signal);
|
|
2447
|
+
}
|
|
2448
|
+
const groupResult = this.#resolveEventConsumerGroup(eventName, opts);
|
|
2449
|
+
const group = groupResult.take();
|
|
2450
|
+
if (isErr(group))
|
|
2451
|
+
return group;
|
|
2452
|
+
this.#registerDurableEventHandler({
|
|
2453
|
+
group,
|
|
2454
|
+
event: eventName,
|
|
2455
|
+
ctx,
|
|
2456
|
+
subject,
|
|
2457
|
+
fn,
|
|
2458
|
+
signal: opts?.signal,
|
|
2459
|
+
});
|
|
2460
|
+
return ok(undefined);
|
|
2461
|
+
}
|
|
2462
|
+
catch (cause) {
|
|
2463
|
+
return err(new UnexpectedError({ cause, context: { event: event.toString() } }));
|
|
2464
|
+
}
|
|
2465
|
+
})());
|
|
2466
|
+
}
|
|
2467
|
+
async #startEphemeralEvent(event, ctx, subject, fn, signal) {
|
|
2468
|
+
let sub;
|
|
2469
|
+
try {
|
|
2470
|
+
sub = this.#nats.subscribe(subject);
|
|
2471
|
+
if (signal) {
|
|
2472
|
+
if (signal.aborted) {
|
|
2473
|
+
sub.unsubscribe();
|
|
2474
|
+
return ok(undefined);
|
|
2475
|
+
}
|
|
2476
|
+
signal.addEventListener("abort", () => sub?.unsubscribe(), {
|
|
2477
|
+
once: true,
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
catch (cause) {
|
|
2482
|
+
if (sub) {
|
|
2483
|
+
sub.unsubscribe();
|
|
2484
|
+
}
|
|
2485
|
+
return err(new UnexpectedError({
|
|
2486
|
+
cause,
|
|
2487
|
+
context: { event: String(event), subject },
|
|
2488
|
+
}));
|
|
2489
|
+
}
|
|
2490
|
+
const task = AsyncResult.try(async () => {
|
|
2491
|
+
for await (const msg of sub) {
|
|
2492
|
+
const proofResult = await this.#validateEventProof(event, ctx, msg);
|
|
2493
|
+
const proofValue = proofResult.take();
|
|
2494
|
+
if (isErr(proofValue)) {
|
|
2495
|
+
this.#log.warn({ error: proofValue.error, event, subject: msg.subject }, "Event auth validation failed");
|
|
2496
|
+
continue;
|
|
2497
|
+
}
|
|
2498
|
+
const parsedEvent = this.#parseEventMessage(event, ctx, msg);
|
|
2499
|
+
const m = parsedEvent.take();
|
|
2500
|
+
if (isErr(m)) {
|
|
2501
|
+
this.#log.error({ error: m.error }, "Event validation failed");
|
|
2502
|
+
recordRuntimeError(m.error, {
|
|
2503
|
+
surface: "event",
|
|
2504
|
+
direction: "consumer",
|
|
2505
|
+
operation: String(event),
|
|
2506
|
+
phase: "input_validation",
|
|
2507
|
+
});
|
|
2508
|
+
continue;
|
|
2509
|
+
}
|
|
2510
|
+
const handlerResult = await this.#invokeEventHandler({
|
|
2511
|
+
event,
|
|
2512
|
+
payload: m,
|
|
2513
|
+
mode: "ephemeral",
|
|
2514
|
+
message: msg,
|
|
2515
|
+
fn,
|
|
2516
|
+
});
|
|
2517
|
+
const handlerValue = handlerResult.take();
|
|
2518
|
+
if (isErr(handlerValue)) {
|
|
2519
|
+
recordRuntimeError(handlerValue.error, {
|
|
2520
|
+
surface: "event",
|
|
2521
|
+
direction: "consumer",
|
|
2522
|
+
operation: String(event),
|
|
2523
|
+
phase: "handler_result",
|
|
2524
|
+
});
|
|
2525
|
+
this.#log.error({
|
|
2526
|
+
error: handlerValue.error.toSerializable(),
|
|
2527
|
+
event,
|
|
2528
|
+
subject: msg.subject,
|
|
2529
|
+
}, "Event handler failed");
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
});
|
|
2533
|
+
this.#tasks.add(`event:${event}:${ulid()}`, task);
|
|
2534
|
+
return ok(undefined);
|
|
2535
|
+
}
|
|
2536
|
+
async #invokeEventHandler(args) {
|
|
2537
|
+
const annotation = {
|
|
2538
|
+
event: String(args.event),
|
|
2539
|
+
service: this.name,
|
|
2540
|
+
contractId: this.contractId,
|
|
2541
|
+
contractDigest: this.contractDigest,
|
|
2542
|
+
traceId: traceIdFromTraceparent(args.message.headers?.get("traceparent")),
|
|
2543
|
+
};
|
|
2544
|
+
try {
|
|
2545
|
+
const result = await Promise.resolve(args.fn(args.payload, createEventListenerContext({
|
|
2546
|
+
subject: args.message.subject,
|
|
2547
|
+
mode: args.mode,
|
|
2548
|
+
...(args.group ? { group: args.group } : {}),
|
|
2549
|
+
message: args.message,
|
|
2550
|
+
})));
|
|
2551
|
+
const outcome = isResultLike(result) ? result.take() : result;
|
|
2552
|
+
if (isErr(outcome)) {
|
|
2553
|
+
return err(annotateHandlerBoundaryError(outcome.error, annotation));
|
|
2554
|
+
}
|
|
2555
|
+
return ok(undefined);
|
|
2556
|
+
}
|
|
2557
|
+
catch (cause) {
|
|
2558
|
+
return err(annotateHandlerBoundaryError(cause, annotation));
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
#resolveEventConsumerGroup(event, opts) {
|
|
2562
|
+
const metadata = this.#eventConsumers.metadata;
|
|
2563
|
+
const bindings = this.#eventConsumers.bindings ?? {};
|
|
2564
|
+
const groups = Object.entries(metadata ?? {})
|
|
2565
|
+
.filter(([, group]) => eventConsumerGroupEvents(group).includes(String(event)))
|
|
2566
|
+
.map(([group]) => group);
|
|
2567
|
+
if (opts?.group) {
|
|
2568
|
+
if (!groups.includes(opts.group)) {
|
|
2569
|
+
return err(new UnexpectedError({
|
|
2570
|
+
cause: new Error(`Event '${String(event)}' is not declared in event consumer group '${opts.group}'.`),
|
|
2571
|
+
context: { event: String(event), group: opts.group },
|
|
2572
|
+
}));
|
|
2573
|
+
}
|
|
2574
|
+
if (!bindings[opts.group]) {
|
|
2575
|
+
return err(new UnexpectedError({
|
|
2576
|
+
cause: new Error(`Event consumer group '${opts.group}' has no Trellis-provisioned binding.`),
|
|
2577
|
+
context: { event: String(event), group: opts.group },
|
|
2578
|
+
}));
|
|
2579
|
+
}
|
|
2580
|
+
return ok(opts.group);
|
|
2581
|
+
}
|
|
2582
|
+
if (groups.length === 0) {
|
|
2583
|
+
return err(new UnexpectedError({
|
|
2584
|
+
cause: new Error(`Event '${String(event)}' is not declared in any event consumer group.`),
|
|
2585
|
+
context: { event: String(event) },
|
|
2586
|
+
}));
|
|
2587
|
+
}
|
|
2588
|
+
if (groups.length > 1) {
|
|
2589
|
+
return err(new UnexpectedError({
|
|
2590
|
+
cause: new Error(`Event '${String(event)}' is declared in multiple event consumer groups; pass opts.group.`),
|
|
2591
|
+
context: { event: String(event), groups },
|
|
2592
|
+
}));
|
|
2593
|
+
}
|
|
2594
|
+
const group = groups[0];
|
|
2595
|
+
if (!bindings[group]) {
|
|
2596
|
+
return err(new UnexpectedError({
|
|
2597
|
+
cause: new Error(`Event consumer group '${group}' has no Trellis-provisioned binding.`),
|
|
2598
|
+
context: { event: String(event), group },
|
|
2599
|
+
}));
|
|
2600
|
+
}
|
|
2601
|
+
return ok(group);
|
|
2602
|
+
}
|
|
2603
|
+
#registerDurableEventHandler(args) {
|
|
2604
|
+
if (args.signal?.aborted || this.#durableEventListenersStopped)
|
|
2605
|
+
return;
|
|
2606
|
+
const binding = this.#eventConsumers.bindings?.[args.group];
|
|
2607
|
+
if (!binding) {
|
|
2608
|
+
throw new Error(`Event consumer group '${args.group}' has no Trellis-provisioned binding.`);
|
|
2609
|
+
}
|
|
2610
|
+
if (!Number.isInteger(binding.concurrency) || binding.concurrency < 1) {
|
|
2611
|
+
throw new Error(`Event consumer group '${args.group}' has invalid concurrency ${binding.concurrency}; expected a positive integer`);
|
|
2612
|
+
}
|
|
2613
|
+
const loop = this.#durableEventLoops.get(args.group) ?? {
|
|
2614
|
+
registrations: [],
|
|
2615
|
+
concurrency: binding.concurrency,
|
|
2616
|
+
startedWorkers: new Set(),
|
|
2617
|
+
messages: new Set(),
|
|
2618
|
+
};
|
|
2619
|
+
if (loop.concurrency !== binding.concurrency) {
|
|
2620
|
+
throw new Error(`Event consumer group '${args.group}' is already registered with concurrency ${loop.concurrency}; binding now requires ${binding.concurrency}`);
|
|
2621
|
+
}
|
|
2622
|
+
const registration = {
|
|
2623
|
+
event: args.event,
|
|
2624
|
+
ctx: args.ctx,
|
|
2625
|
+
subject: args.subject,
|
|
2626
|
+
fn: args.fn,
|
|
2627
|
+
};
|
|
2628
|
+
loop.registrations.push(registration);
|
|
2629
|
+
this.#durableEventLoops.set(args.group, loop);
|
|
2630
|
+
args.signal?.addEventListener("abort", () => {
|
|
2631
|
+
const index = loop.registrations.indexOf(registration);
|
|
2632
|
+
if (index >= 0)
|
|
2633
|
+
loop.registrations.splice(index, 1);
|
|
2634
|
+
if (!this.#durableEventConsumerGroupReady(args.group, loop)) {
|
|
2635
|
+
for (const messages of loop.messages)
|
|
2636
|
+
messages.stop();
|
|
2637
|
+
}
|
|
2638
|
+
}, { once: true });
|
|
2639
|
+
this.#startDurableEventConsumer(args.group, loop);
|
|
2640
|
+
}
|
|
2641
|
+
#startDurableEventConsumer(group, loop) {
|
|
2642
|
+
if (this.#durableEventListenersStopped ||
|
|
2643
|
+
!this.#durableEventConsumerGroupReady(group, loop)) {
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
for (let workerIndex = 0; workerIndex < loop.concurrency; workerIndex += 1) {
|
|
2647
|
+
if (loop.startedWorkers.has(workerIndex))
|
|
2648
|
+
continue;
|
|
2649
|
+
loop.startedWorkers.add(workerIndex);
|
|
2650
|
+
this.#tasks.add(`event-consumer:${group}:${workerIndex}:${ulid()}`, this.#runDurableEventConsumer(group, loop, workerIndex));
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
#durableEventConsumerGroupReady(group, loop) {
|
|
2654
|
+
const metadata = this.#eventConsumers.metadata?.[group];
|
|
2655
|
+
if (!metadata)
|
|
2656
|
+
return false;
|
|
2657
|
+
return eventConsumerGroupEvents(metadata).every((event) => loop.registrations.some((registration) => String(registration.event) === event));
|
|
2658
|
+
}
|
|
2659
|
+
#runDurableEventConsumer(group, loop, workerIndex) {
|
|
2660
|
+
return AsyncResult.from((async () => {
|
|
2661
|
+
const binding = this.#eventConsumers.bindings?.[group];
|
|
2662
|
+
if (!binding) {
|
|
2663
|
+
return err(new UnexpectedError({
|
|
2664
|
+
cause: new Error(`Event consumer group '${group}' has no Trellis-provisioned binding.`),
|
|
2665
|
+
context: { group },
|
|
2666
|
+
}));
|
|
2667
|
+
}
|
|
2668
|
+
let originalOpened = false;
|
|
2669
|
+
let fetchingReplay = true;
|
|
2670
|
+
try {
|
|
2671
|
+
const infoResult = await AsyncResult.try(async () => {
|
|
2672
|
+
const jsm = await jetstreamManager(this.#nats);
|
|
2673
|
+
return await jsm.consumers.info(binding.stream, binding.consumerName);
|
|
2674
|
+
});
|
|
2675
|
+
const info = infoResult.take();
|
|
2676
|
+
if (isErr(info)) {
|
|
2677
|
+
if (this.#durableEventListenersStopped ||
|
|
2678
|
+
!this.#durableEventConsumerGroupReady(group, loop)) {
|
|
2679
|
+
return ok(undefined);
|
|
2680
|
+
}
|
|
2681
|
+
if (isConsumerNotFoundError(info.error.cause)) {
|
|
2682
|
+
this.#log.debug({ group, stream: binding.stream, consumer: binding.consumerName }, "Durable event consumer is not available yet; retrying");
|
|
2683
|
+
await sleep(25);
|
|
2684
|
+
return ok(undefined);
|
|
2685
|
+
}
|
|
2686
|
+
return info;
|
|
2687
|
+
}
|
|
2688
|
+
originalOpened = true;
|
|
2689
|
+
const replayInfo = await (await jetstreamManager(this.#nats)).consumers
|
|
2690
|
+
.info(binding.replayBinding.stream, binding.replayBinding.consumerName);
|
|
2691
|
+
const consumers = [
|
|
2692
|
+
this.#js.consumers.getConsumerFromInfo(info),
|
|
2693
|
+
this.#js.consumers.getConsumerFromInfo(replayInfo),
|
|
2694
|
+
];
|
|
2695
|
+
let replay = false;
|
|
2696
|
+
while (!this.#durableEventListenersStopped &&
|
|
2697
|
+
this.#durableEventConsumerGroupReady(group, loop)) {
|
|
2698
|
+
fetchingReplay = replay;
|
|
2699
|
+
const messages = await consumers[replay ? 1 : 0].fetch({
|
|
2700
|
+
max_messages: 1,
|
|
2701
|
+
expires: 1_000,
|
|
2702
|
+
});
|
|
2703
|
+
const isReplay = replay;
|
|
2704
|
+
replay = !replay;
|
|
2705
|
+
loop.messages.add(messages);
|
|
2706
|
+
if (!this.#durableEventConsumerGroupReady(group, loop)) {
|
|
2707
|
+
messages.stop();
|
|
2708
|
+
loop.messages.delete(messages);
|
|
2709
|
+
break;
|
|
2710
|
+
}
|
|
2711
|
+
try {
|
|
2712
|
+
await this.#handleDurableEventConsumer(group, loop, messages, isReplay)
|
|
2713
|
+
.orThrow();
|
|
2714
|
+
}
|
|
2715
|
+
finally {
|
|
2716
|
+
loop.messages.delete(messages);
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
catch (cause) {
|
|
2721
|
+
if (this.#durableEventListenersStopped ||
|
|
2722
|
+
!this.#durableEventConsumerGroupReady(group, loop)) {
|
|
2723
|
+
return ok(undefined);
|
|
2724
|
+
}
|
|
2725
|
+
if (isConsumerNotFoundError(cause) &&
|
|
2726
|
+
(fetchingReplay || !originalOpened)) {
|
|
2727
|
+
this.#log.debug({ group, stream: binding.stream, consumer: binding.consumerName }, "Durable event consumer is not available yet; retrying");
|
|
2728
|
+
await sleep(25);
|
|
2729
|
+
return ok(undefined);
|
|
2730
|
+
}
|
|
2731
|
+
await sleep(25);
|
|
2732
|
+
return err(new UnexpectedError({ cause, context: { group } }));
|
|
2733
|
+
}
|
|
2734
|
+
finally {
|
|
2735
|
+
loop.startedWorkers.delete(workerIndex);
|
|
2736
|
+
if (!this.#durableEventListenersStopped) {
|
|
2737
|
+
this.#startDurableEventConsumer(group, loop);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
return ok(undefined);
|
|
2741
|
+
})());
|
|
2742
|
+
}
|
|
2743
|
+
#handleDurableEventConsumer(group, loop, messages, isReplay = false) {
|
|
2744
|
+
return AsyncResult.try(async () => {
|
|
2745
|
+
const binding = this.#eventConsumers.bindings?.[group];
|
|
2746
|
+
if (!binding) {
|
|
2747
|
+
throw new Error(`Event consumer group '${group}' is unavailable`);
|
|
2748
|
+
}
|
|
2749
|
+
for await (const msg of messages) {
|
|
2750
|
+
let replayEnvelope;
|
|
2751
|
+
let deliveredMessage = msg;
|
|
2752
|
+
if (isReplay) {
|
|
2753
|
+
const parsedReplayEnvelope = JSON.parse(new TextDecoder().decode(msg.data));
|
|
2754
|
+
replayEnvelope = parsedReplayEnvelope;
|
|
2755
|
+
if (parsedReplayEnvelope.resourceId !== binding.resourceId ||
|
|
2756
|
+
parsedReplayEnvelope.originalRecordSequence <= 0) {
|
|
2757
|
+
msg.ack();
|
|
2758
|
+
continue;
|
|
2759
|
+
}
|
|
2760
|
+
let inspected;
|
|
2761
|
+
try {
|
|
2762
|
+
inspected = await this.#requestConsumerManagement("DeadLetters.Inspect", {
|
|
2763
|
+
resourceId: binding.resourceId,
|
|
2764
|
+
deadLetterId: parsedReplayEnvelope.deadLetterId,
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
catch (error) {
|
|
2768
|
+
this.#log.warn({ error, group }, "Replay state inspection unavailable");
|
|
2769
|
+
const delivery = msg.info.deliveryCount;
|
|
2770
|
+
msg.nak(binding.backoffMs[Math.min(Math.max(0, delivery - 1), binding.backoffMs.length - 1)] ?? binding.ackWaitMs);
|
|
2771
|
+
continue;
|
|
2772
|
+
}
|
|
2773
|
+
const detail = Reflect.get(inspected, "deadLetter");
|
|
2774
|
+
const deadLetter = detail && typeof detail === "object"
|
|
2775
|
+
? Reflect.get(detail, "deadLetter")
|
|
2776
|
+
: undefined;
|
|
2777
|
+
const projectedGeneration = deadLetter && typeof deadLetter === "object"
|
|
2778
|
+
? Reflect.get(deadLetter, "generation")
|
|
2779
|
+
: undefined;
|
|
2780
|
+
if (typeof projectedGeneration !== "number" ||
|
|
2781
|
+
projectedGeneration < parsedReplayEnvelope.generation) {
|
|
2782
|
+
msg.nak(25);
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
if (projectedGeneration !== parsedReplayEnvelope.generation ||
|
|
2786
|
+
!["replayPending", "replaying"].includes(Reflect.get(deadLetter, "state"))) {
|
|
2787
|
+
msg.ack();
|
|
2788
|
+
continue;
|
|
2789
|
+
}
|
|
2790
|
+
const originalHeaders = natsHeaders();
|
|
2791
|
+
for (const [name, values] of Object.entries(parsedReplayEnvelope.originalHeaders)) {
|
|
2792
|
+
for (const value of values)
|
|
2793
|
+
originalHeaders.append(name, value);
|
|
2794
|
+
}
|
|
2795
|
+
const data = new Uint8Array(parsedReplayEnvelope.originalPayloadBytes);
|
|
2796
|
+
deliveredMessage = {
|
|
2797
|
+
data,
|
|
2798
|
+
headers: originalHeaders,
|
|
2799
|
+
subject: parsedReplayEnvelope.originalSubject,
|
|
2800
|
+
json: () => JSON.parse(new TextDecoder().decode(data)),
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
if (!this.#durableEventConsumerGroupReady(group, loop)) {
|
|
2804
|
+
messages.stop();
|
|
2805
|
+
break;
|
|
2806
|
+
}
|
|
2807
|
+
const matching = loop.registrations.filter((registration) => natsSubjectMatches(registration.subject, deliveredMessage.subject));
|
|
2808
|
+
if (matching.length === 0) {
|
|
2809
|
+
this.#log.warn({ group, subject: msg.subject }, "Durable event consumer received message without registered handler");
|
|
2810
|
+
msg.nak();
|
|
2811
|
+
continue;
|
|
2812
|
+
}
|
|
2813
|
+
let failed = false;
|
|
2814
|
+
for (const registration of matching) {
|
|
2815
|
+
const proofResult = await this.#validateEventProof(registration.event, registration.ctx, deliveredMessage);
|
|
2816
|
+
const proofValue = proofResult.take();
|
|
2817
|
+
if (isErr(proofValue)) {
|
|
2818
|
+
recordRuntimeError(proofValue.error, {
|
|
2819
|
+
surface: "event",
|
|
2820
|
+
direction: "consumer",
|
|
2821
|
+
operation: String(registration.event),
|
|
2822
|
+
phase: "auth",
|
|
2823
|
+
});
|
|
2824
|
+
this.#log.warn({
|
|
2825
|
+
error: proofValue.error,
|
|
2826
|
+
event: registration.event,
|
|
2827
|
+
subject: msg.subject,
|
|
2828
|
+
}, "Event auth validation failed");
|
|
2829
|
+
if (proofValue.error instanceof EventVerificationAuthError &&
|
|
2830
|
+
proofValue.error.retryable) {
|
|
2831
|
+
const delivery = msg.info.deliveryCount;
|
|
2832
|
+
msg.nak(binding.backoffMs[Math.min(Math.max(0, delivery - 1), binding.backoffMs.length - 1)] ?? binding.ackWaitMs);
|
|
2833
|
+
}
|
|
2834
|
+
else {
|
|
2835
|
+
if (replayEnvelope) {
|
|
2836
|
+
try {
|
|
2837
|
+
msg.working();
|
|
2838
|
+
await this.#reportConsumerDelivery({
|
|
2839
|
+
resourceId: binding.resourceId,
|
|
2840
|
+
sourceStream: binding.replayBinding.stream,
|
|
2841
|
+
sourceSequence: BigInt(msg.info.streamSequence),
|
|
2842
|
+
deliveryCount: msg.info.deliveryCount,
|
|
2843
|
+
deliveryProof: jetStreamDeliveryProof(msg),
|
|
2844
|
+
replayGeneration: replayEnvelope.generation,
|
|
2845
|
+
outcome: "unreplayable",
|
|
2846
|
+
error: proofValue.error.message,
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
catch (error) {
|
|
2850
|
+
this.#log.warn({ error, group }, "Replay delivery report unavailable");
|
|
2851
|
+
failed = true;
|
|
2852
|
+
break;
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
msg.term();
|
|
2856
|
+
}
|
|
2857
|
+
failed = true;
|
|
2858
|
+
break;
|
|
2859
|
+
}
|
|
2860
|
+
const parsedEvent = this.#parseEventMessage(registration.event, registration.ctx, deliveredMessage);
|
|
2861
|
+
const eventPayload = parsedEvent.take();
|
|
2862
|
+
if (isErr(eventPayload)) {
|
|
2863
|
+
this.#log.error({ error: eventPayload.error }, "Event validation failed");
|
|
2864
|
+
recordRuntimeError(eventPayload.error, {
|
|
2865
|
+
surface: "event",
|
|
2866
|
+
direction: "consumer",
|
|
2867
|
+
operation: String(registration.event),
|
|
2868
|
+
phase: "input_validation",
|
|
2869
|
+
});
|
|
2870
|
+
msg.term();
|
|
2871
|
+
failed = true;
|
|
2872
|
+
break;
|
|
2873
|
+
}
|
|
2874
|
+
const delivery = msg.info.deliveryCount;
|
|
2875
|
+
const wait = binding.backoffMs[Math.min(Math.max(0, delivery - 1), binding.backoffMs.length - 1)] ?? binding.ackWaitMs;
|
|
2876
|
+
const progress = setInterval(() => msg.working(), Math.max(1, Math.min(wait - 1, Math.floor(wait / 3))));
|
|
2877
|
+
const handlerResult = await this.#invokeEventHandler({
|
|
2878
|
+
event: registration.event,
|
|
2879
|
+
payload: eventPayload,
|
|
2880
|
+
mode: "durable",
|
|
2881
|
+
group,
|
|
2882
|
+
message: deliveredMessage,
|
|
2883
|
+
fn: registration.fn,
|
|
2884
|
+
}).finally(() => clearInterval(progress));
|
|
2885
|
+
const handlerValue = handlerResult.take();
|
|
2886
|
+
if (isErr(handlerValue)) {
|
|
2887
|
+
recordRuntimeError(handlerValue.error, {
|
|
2888
|
+
surface: "event",
|
|
2889
|
+
direction: "consumer",
|
|
2890
|
+
operation: String(registration.event),
|
|
2891
|
+
phase: "handler_result",
|
|
2892
|
+
});
|
|
2893
|
+
this.#log.error({
|
|
2894
|
+
error: handlerValue.error.toSerializable(),
|
|
2895
|
+
event: registration.event,
|
|
2896
|
+
subject: msg.subject,
|
|
2897
|
+
}, "Event handler failed");
|
|
2898
|
+
if (delivery >= binding.maxDeliver) {
|
|
2899
|
+
try {
|
|
2900
|
+
msg.working();
|
|
2901
|
+
await this.#reportConsumerDelivery({
|
|
2902
|
+
resourceId: binding.resourceId,
|
|
2903
|
+
sourceStream: isReplay
|
|
2904
|
+
? binding.replayBinding.stream
|
|
2905
|
+
: binding.stream,
|
|
2906
|
+
sourceSequence: BigInt(msg.info.streamSequence),
|
|
2907
|
+
deliveryCount: delivery,
|
|
2908
|
+
deliveryProof: jetStreamDeliveryProof(msg),
|
|
2909
|
+
...(replayEnvelope
|
|
2910
|
+
? { replayGeneration: replayEnvelope.generation }
|
|
2911
|
+
: {}),
|
|
2912
|
+
outcome: "exhausted",
|
|
2913
|
+
error: JSON.stringify(handlerValue.error.toSerializable()),
|
|
2914
|
+
});
|
|
2915
|
+
msg.ack();
|
|
2916
|
+
}
|
|
2917
|
+
catch (error) {
|
|
2918
|
+
this.#log.warn({ error, group }, "Delivery report unavailable");
|
|
2919
|
+
}
|
|
2920
|
+
failed = true;
|
|
2921
|
+
break;
|
|
2922
|
+
}
|
|
2923
|
+
const delay = binding.backoffMs[Math.min(Math.max(0, delivery - 1), binding.backoffMs.length - 1)] ?? 0;
|
|
2924
|
+
msg.nak(delay);
|
|
2925
|
+
failed = true;
|
|
2926
|
+
break;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
if (!failed) {
|
|
2930
|
+
if (replayEnvelope) {
|
|
2931
|
+
try {
|
|
2932
|
+
msg.working();
|
|
2933
|
+
await this.#reportConsumerDelivery({
|
|
2934
|
+
resourceId: binding.resourceId,
|
|
2935
|
+
sourceStream: binding.replayBinding.stream,
|
|
2936
|
+
sourceSequence: BigInt(msg.info.streamSequence),
|
|
2937
|
+
deliveryCount: msg.info.deliveryCount,
|
|
2938
|
+
deliveryProof: jetStreamDeliveryProof(msg),
|
|
2939
|
+
replayGeneration: replayEnvelope.generation,
|
|
2940
|
+
outcome: "succeeded",
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
catch (error) {
|
|
2944
|
+
this.#log.warn({ error, group }, "Delivery report unavailable");
|
|
2945
|
+
continue;
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
msg.ack();
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
}
|
|
2953
|
+
async #reportConsumerDelivery(input) {
|
|
2954
|
+
await this.#requestConsumerManagement("Consumers.ReportDelivery", input);
|
|
2955
|
+
}
|
|
2956
|
+
async #requestConsumerManagement(method, input) {
|
|
2957
|
+
const binding = this.#apiBindings[EVENTS_API.identity];
|
|
2958
|
+
const providerDeploymentId = binding && typeof binding === "object"
|
|
2959
|
+
? Reflect.get(binding, "providerDeploymentId")
|
|
2960
|
+
: undefined;
|
|
2961
|
+
const action = method === "Consumers.ReportDelivery"
|
|
2962
|
+
? EVENTS_API.actions["rpc:Consumers.ReportDelivery"]
|
|
2963
|
+
: method === "DeadLetters.Inspect"
|
|
2964
|
+
? EVENTS_API.actions["rpc:DeadLetters.Inspect"]
|
|
2965
|
+
: undefined;
|
|
2966
|
+
if (typeof providerDeploymentId !== "string" || !action) {
|
|
2967
|
+
throw new Error(`Consumer management '${method}' is unavailable`);
|
|
2968
|
+
}
|
|
2969
|
+
return await this.#requestBuiltRpc(method, input, {
|
|
2970
|
+
subject: boundApiSubject("rpc", EVENTS_API.identity, providerDeploymentId, method),
|
|
2971
|
+
input: action.input,
|
|
2972
|
+
output: action.output,
|
|
2973
|
+
callerCapabilities: [],
|
|
2974
|
+
errors: action.errors.map((error) => error.type),
|
|
2975
|
+
declaredErrorTypes: action.errors.map((error) => error.type),
|
|
2976
|
+
runtimeErrors: action.errors,
|
|
2977
|
+
}).orThrow();
|
|
2978
|
+
}
|
|
2979
|
+
#parseEventMessage(event, ctx, msg) {
|
|
2980
|
+
const jsonData = Result.try(() => msg.json());
|
|
2981
|
+
const json = jsonData.take();
|
|
2982
|
+
if (isErr(json)) {
|
|
2983
|
+
this.#log.error({ error: json.error, event, subject: msg.subject }, "Event parse failed");
|
|
2984
|
+
return json;
|
|
2985
|
+
}
|
|
2986
|
+
return parseRuntimeSchema(ctx.event, json);
|
|
2987
|
+
}
|
|
2988
|
+
async #validateEventProof(event, descriptor, msg) {
|
|
2989
|
+
return await verifyLocalAuthorization({
|
|
2990
|
+
kind: "event",
|
|
2991
|
+
cache: this.#auth.authorizationProviderCache,
|
|
2992
|
+
message: msg,
|
|
2993
|
+
permission: descriptor.publishPermission,
|
|
2994
|
+
descriptorIdentity: descriptor.descriptorIdentity,
|
|
2995
|
+
requiredCapabilities: descriptor.publishCapabilities,
|
|
2996
|
+
});
|
|
2997
|
+
}
|
|
2998
|
+
wait() {
|
|
2999
|
+
return this.#tasks.wait();
|
|
3000
|
+
}
|
|
3001
|
+
/** Stops durable event listener loops without closing the underlying transport. */
|
|
3002
|
+
stopEventListeners() {
|
|
3003
|
+
this.#durableEventListenersStopped = true;
|
|
3004
|
+
for (const loop of this.#durableEventLoops.values()) {
|
|
3005
|
+
loop.registrations.splice(0, loop.registrations.length);
|
|
3006
|
+
for (const messages of loop.messages)
|
|
3007
|
+
messages.stop();
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
// FIXME: If are validating things twice in most cases...
|
|
3011
|
+
template(subject, data, allowWildcards = false) {
|
|
3012
|
+
// Find all template placeholders and check if values exist
|
|
3013
|
+
const placeholders = subject.match(/\{([^}]+)\}/g) || [];
|
|
3014
|
+
for (const placeholder of placeholders) {
|
|
3015
|
+
const key = placeholder.slice(1, -1); // Remove { and }
|
|
3016
|
+
const value = Pointer.Get(data, key);
|
|
3017
|
+
if ((value === undefined || value === null) && !allowWildcards) {
|
|
3018
|
+
return err(new ValidationError({
|
|
3019
|
+
errors: [
|
|
3020
|
+
{
|
|
3021
|
+
path: key,
|
|
3022
|
+
message: "Missing required data for subject template",
|
|
3023
|
+
},
|
|
3024
|
+
],
|
|
3025
|
+
context: { key },
|
|
3026
|
+
}));
|
|
3027
|
+
}
|
|
3028
|
+
if (value !== undefined && value !== null && value !== "*" &&
|
|
3029
|
+
(typeof value !== "string" && typeof value !== "number" ||
|
|
3030
|
+
typeof value === "number" &&
|
|
3031
|
+
(!Number.isFinite(value) ||
|
|
3032
|
+
Number.isInteger(value) && !Number.isSafeInteger(value)))) {
|
|
3033
|
+
return err(new ValidationError({
|
|
3034
|
+
errors: [{
|
|
3035
|
+
path: key,
|
|
3036
|
+
message: "Subject template values must be strings or numbers",
|
|
3037
|
+
}],
|
|
3038
|
+
context: { key },
|
|
3039
|
+
}));
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
const result = subject.replace(/\{([^}]+)\}/g, (_, key) => {
|
|
3043
|
+
const value = Pointer.Get(data, key);
|
|
3044
|
+
if (allowWildcards && value === "*") {
|
|
3045
|
+
return "*";
|
|
3046
|
+
}
|
|
3047
|
+
if (allowWildcards && (value === undefined || value === null)) {
|
|
3048
|
+
return "*";
|
|
3049
|
+
}
|
|
3050
|
+
return this.#encodeSubjectToken(`${Object.is(value, -0) ? 0 : value}`);
|
|
3051
|
+
});
|
|
3052
|
+
return ok(result);
|
|
3053
|
+
}
|
|
3054
|
+
#encodeSubjectToken(token) {
|
|
3055
|
+
return encodeEventSubjectParameterToken(token);
|
|
3056
|
+
}
|
|
3057
|
+
#currentIat() {
|
|
3058
|
+
return this.#auth.currentIat?.() ?? Math.floor(Date.now() / 1000);
|
|
3059
|
+
}
|
|
3060
|
+
#contextDigest() {
|
|
3061
|
+
const digest = typeof this.#auth.contextDigest === "function"
|
|
3062
|
+
? this.#auth.contextDigest()
|
|
3063
|
+
: this.#auth.contextDigest;
|
|
3064
|
+
if (digest === undefined) {
|
|
3065
|
+
throw new Error("contextDigest is required to sign v1 request and event proofs");
|
|
3066
|
+
}
|
|
3067
|
+
return digest;
|
|
3068
|
+
}
|
|
3069
|
+
/** Creates a context-bound request proof for an exact subject, payload, and reply. */
|
|
3070
|
+
async createRequestProof(subject, payload, reply) {
|
|
3071
|
+
const contextDigest = this.#contextDigest();
|
|
3072
|
+
const payloadBytes = new TextEncoder().encode(payload);
|
|
3073
|
+
const payloadHash = await sha256(payloadBytes);
|
|
3074
|
+
const iat = this.#currentIat();
|
|
3075
|
+
const requestId = ulid();
|
|
3076
|
+
const input = buildProofInput(contextDigest, subject, reply, payloadHash, iat, requestId);
|
|
3077
|
+
const digest = await sha256(input);
|
|
3078
|
+
const sigBytes = await this.#auth.sign(digest);
|
|
3079
|
+
return {
|
|
3080
|
+
proof: base64urlEncode(sigBytes),
|
|
3081
|
+
iat,
|
|
3082
|
+
requestId,
|
|
3083
|
+
contextDigest,
|
|
3084
|
+
};
|
|
3085
|
+
}
|
|
3086
|
+
async #createEventProof(event) {
|
|
3087
|
+
const contextDigest = this.#contextDigest();
|
|
3088
|
+
const payloadHash = await sha256(new TextEncoder().encode(event.encodedPayload));
|
|
3089
|
+
const input = buildEventProofInput(contextDigest, event.descriptorIdentity, event.subject, payloadHash, event.header.id, event.header.time);
|
|
3090
|
+
const digest = await sha256(input);
|
|
3091
|
+
return {
|
|
3092
|
+
proof: base64urlEncode(await this.#auth.sign(digest)),
|
|
3093
|
+
contextDigest,
|
|
3094
|
+
};
|
|
3095
|
+
}
|
|
3096
|
+
async #requestMessageWithRetry(args) {
|
|
3097
|
+
for (let retry = 0; retry <= this.#noResponderMaxRetries; retry++) {
|
|
3098
|
+
if (args.signal?.aborted) {
|
|
3099
|
+
return err(classifyRequestTransportFailure({
|
|
3100
|
+
method: args.method,
|
|
3101
|
+
subject: args.subject,
|
|
3102
|
+
callerCapabilities: args.callerCapabilities,
|
|
3103
|
+
cause: args.signal.reason ??
|
|
3104
|
+
new DOMException("Request aborted", "AbortError"),
|
|
3105
|
+
}));
|
|
3106
|
+
}
|
|
3107
|
+
if (this.#nats.isClosed()) {
|
|
3108
|
+
return err(requestFailedTransportError({
|
|
3109
|
+
code: "trellis.request.closed",
|
|
3110
|
+
message: "The Trellis connection is closed.",
|
|
3111
|
+
hint: "Connect to Trellis again before making another request.",
|
|
3112
|
+
method: args.method,
|
|
3113
|
+
subject: args.subject,
|
|
3114
|
+
}));
|
|
3115
|
+
}
|
|
3116
|
+
// Create the exact reply inbox before signing so the proof binds the
|
|
3117
|
+
// reply subject the response arrives on.
|
|
3118
|
+
const reply = createInbox(this.#inboxPrefix);
|
|
3119
|
+
const authHeaders = await this.createRequestProof(args.subject, args.payload, reply);
|
|
3120
|
+
const headers = natsHeaders();
|
|
3121
|
+
headers.set("authorization-context", authHeaders.contextDigest);
|
|
3122
|
+
headers.set("session-key", this.#auth.sessionKey);
|
|
3123
|
+
headers.set("proof", authHeaders.proof);
|
|
3124
|
+
headers.set("iat", String(authHeaders.iat));
|
|
3125
|
+
headers.set("request-id", authHeaders.requestId);
|
|
3126
|
+
injectTraceContext(createNatsHeaderCarrier(headers), args.span);
|
|
3127
|
+
const result = await AsyncResult.try(async () => {
|
|
3128
|
+
const response = Promise.withResolvers();
|
|
3129
|
+
const abort = () => response.reject(args.signal?.reason ??
|
|
3130
|
+
new DOMException("Request aborted", "AbortError"));
|
|
3131
|
+
args.signal?.addEventListener("abort", abort, { once: true });
|
|
3132
|
+
const subscription = this.#nats.subscribe(reply, {
|
|
3133
|
+
max: 1,
|
|
3134
|
+
timeout: args.timeout,
|
|
3135
|
+
callback: (error, message) => {
|
|
3136
|
+
if (error)
|
|
3137
|
+
response.reject(error);
|
|
3138
|
+
else if (message.data.length === 0 && message.headers?.code === 503) {
|
|
3139
|
+
response.reject(new Error("no responders"));
|
|
3140
|
+
}
|
|
3141
|
+
else
|
|
3142
|
+
response.resolve(message);
|
|
3143
|
+
},
|
|
3144
|
+
});
|
|
3145
|
+
// NATS noMux requests abandon their promise when connection closure
|
|
3146
|
+
// cancels the subscription timer. Bind settlement to this subscription.
|
|
3147
|
+
subscription.closed.then((error) => {
|
|
3148
|
+
response.reject(error ?? new Error("connection closed before RPC response"));
|
|
3149
|
+
});
|
|
3150
|
+
const initialStatus = this.connection.status;
|
|
3151
|
+
const stopObserving = this.connection.subscribe((status) => {
|
|
3152
|
+
// Publish denials arrive on the connection, not the reply inbox.
|
|
3153
|
+
if (status === initialStatus)
|
|
3154
|
+
return;
|
|
3155
|
+
const error = status.transport?.error;
|
|
3156
|
+
if (error instanceof Error &&
|
|
3157
|
+
((error.name === "PermissionViolationError" &&
|
|
3158
|
+
Reflect.get(error, "operation") === "publish" &&
|
|
3159
|
+
Reflect.get(error, "subject") === args.subject) ||
|
|
3160
|
+
error.name === "AuthorizationError" ||
|
|
3161
|
+
error.name === "UserAuthenticationExpiredError"))
|
|
3162
|
+
response.reject(error);
|
|
3163
|
+
});
|
|
3164
|
+
try {
|
|
3165
|
+
if (args.signal?.aborted) {
|
|
3166
|
+
abort();
|
|
3167
|
+
}
|
|
3168
|
+
else {
|
|
3169
|
+
this.#nats.publish(args.subject, args.payload, {
|
|
3170
|
+
headers,
|
|
3171
|
+
reply,
|
|
3172
|
+
});
|
|
3173
|
+
}
|
|
3174
|
+
return await response.promise;
|
|
3175
|
+
}
|
|
3176
|
+
finally {
|
|
3177
|
+
args.signal?.removeEventListener("abort", abort);
|
|
3178
|
+
stopObserving();
|
|
3179
|
+
subscription.unsubscribe();
|
|
3180
|
+
}
|
|
3181
|
+
});
|
|
3182
|
+
if (result.isOk()) {
|
|
3183
|
+
return ok(result.take());
|
|
3184
|
+
}
|
|
3185
|
+
const cause = result.error.cause;
|
|
3186
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
3187
|
+
const isNoResponders = message.includes("no responders");
|
|
3188
|
+
if (isNoResponders && retry < this.#noResponderMaxRetries) {
|
|
3189
|
+
this.#log.debug({ method: args.method, subject: args.subject, retry }, "No responders, retrying...");
|
|
3190
|
+
await new Promise((resolve) => {
|
|
3191
|
+
const done = () => {
|
|
3192
|
+
clearTimeout(timeout);
|
|
3193
|
+
args.signal?.removeEventListener("abort", done);
|
|
3194
|
+
resolve();
|
|
3195
|
+
};
|
|
3196
|
+
const timeout = setTimeout(done, this.#noResponderRetryMs * (retry + 1));
|
|
3197
|
+
args.signal?.addEventListener("abort", done, { once: true });
|
|
3198
|
+
});
|
|
3199
|
+
continue;
|
|
3200
|
+
}
|
|
3201
|
+
this.#log.warn({ method: args.method, subject: args.subject, error: message }, "NATS request failed");
|
|
3202
|
+
return err(classifyRequestTransportFailure({
|
|
3203
|
+
method: args.method,
|
|
3204
|
+
subject: args.subject,
|
|
3205
|
+
callerCapabilities: args.callerCapabilities,
|
|
3206
|
+
cause,
|
|
3207
|
+
}));
|
|
3208
|
+
}
|
|
3209
|
+
return err(requestFailedTransportError({
|
|
3210
|
+
code: "trellis.request.retry_exhausted",
|
|
3211
|
+
message: "Trellis could not complete the request after retrying.",
|
|
3212
|
+
hint: "Retry the request. If it keeps failing, check that the target service is available.",
|
|
3213
|
+
method: args.method,
|
|
3214
|
+
subject: args.subject,
|
|
3215
|
+
context: { retries: this.#noResponderMaxRetries + 1 },
|
|
3216
|
+
}));
|
|
3217
|
+
}
|
|
3218
|
+
#requestJson(subject, body) {
|
|
3219
|
+
return AsyncResult.from((async () => {
|
|
3220
|
+
const span = startClientSpan(subject, subject);
|
|
3221
|
+
return await withSpanAsync(span, async () => {
|
|
3222
|
+
try {
|
|
3223
|
+
const payload = JSON.stringify(body);
|
|
3224
|
+
const response = (await this.#requestMessageWithRetry({
|
|
3225
|
+
subject,
|
|
3226
|
+
payload,
|
|
3227
|
+
timeout: this.timeout,
|
|
3228
|
+
span,
|
|
3229
|
+
})).take();
|
|
3230
|
+
if (isErr(response)) {
|
|
3231
|
+
span.setStatus({
|
|
3232
|
+
code: SpanStatusCode.ERROR,
|
|
3233
|
+
message: response.error.message,
|
|
3234
|
+
});
|
|
3235
|
+
recordRuntimeError(response.error, {
|
|
3236
|
+
surface: "operation",
|
|
3237
|
+
direction: "client",
|
|
3238
|
+
operation: "requestJson",
|
|
3239
|
+
phase: "request_send",
|
|
3240
|
+
});
|
|
3241
|
+
return response;
|
|
3242
|
+
}
|
|
3243
|
+
const json = safeJson(response).take();
|
|
3244
|
+
if (isErr(json)) {
|
|
3245
|
+
const error = createTransportError({
|
|
3246
|
+
code: "trellis.request.invalid_response",
|
|
3247
|
+
message: "Trellis returned an invalid response.",
|
|
3248
|
+
hint: "Retry the request. If it keeps happening, reconnect to Trellis and try again.",
|
|
3249
|
+
cause: json.error.cause,
|
|
3250
|
+
context: { subject },
|
|
3251
|
+
});
|
|
3252
|
+
span.setStatus({
|
|
3253
|
+
code: SpanStatusCode.ERROR,
|
|
3254
|
+
message: error.message,
|
|
3255
|
+
});
|
|
3256
|
+
recordRuntimeError(error, {
|
|
3257
|
+
surface: "operation",
|
|
3258
|
+
direction: "client",
|
|
3259
|
+
operation: "requestJson",
|
|
3260
|
+
phase: "response_decoding",
|
|
3261
|
+
});
|
|
3262
|
+
return err(error);
|
|
3263
|
+
}
|
|
3264
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
3265
|
+
return ok(json);
|
|
3266
|
+
}
|
|
3267
|
+
catch (cause) {
|
|
3268
|
+
const error = new UnexpectedError({ cause });
|
|
3269
|
+
span.setStatus({
|
|
3270
|
+
code: SpanStatusCode.ERROR,
|
|
3271
|
+
message: error.message,
|
|
3272
|
+
});
|
|
3273
|
+
span.recordException(error);
|
|
3274
|
+
recordRuntimeError(error, {
|
|
3275
|
+
surface: "operation",
|
|
3276
|
+
direction: "client",
|
|
3277
|
+
operation: "requestJson",
|
|
3278
|
+
phase: "unexpected",
|
|
3279
|
+
});
|
|
3280
|
+
return err(error);
|
|
3281
|
+
}
|
|
3282
|
+
finally {
|
|
3283
|
+
span.end();
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
})());
|
|
3287
|
+
}
|
|
3288
|
+
#watchJson(subject, body) {
|
|
3289
|
+
return AsyncResult.from((async () => {
|
|
3290
|
+
const payload = JSON.stringify(body);
|
|
3291
|
+
const inbox = createInbox(this.#inboxPrefix);
|
|
3292
|
+
const authHeaders = await this.createRequestProof(subject, payload, inbox);
|
|
3293
|
+
const headers = natsHeaders();
|
|
3294
|
+
headers.set("proof", authHeaders.proof);
|
|
3295
|
+
headers.set("iat", String(authHeaders.iat));
|
|
3296
|
+
headers.set("request-id", authHeaders.requestId);
|
|
3297
|
+
headers.set("authorization-context", authHeaders.contextDigest);
|
|
3298
|
+
headers.set("session-key", this.#auth.sessionKey);
|
|
3299
|
+
const sub = this.#nats.subscribe(inbox);
|
|
3300
|
+
try {
|
|
3301
|
+
this.#nats.publish(subject, payload, {
|
|
3302
|
+
headers,
|
|
3303
|
+
reply: inbox,
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
catch (cause) {
|
|
3307
|
+
sub.unsubscribe();
|
|
3308
|
+
const error = createTransportError({
|
|
3309
|
+
code: "trellis.watch.failed",
|
|
3310
|
+
message: "Trellis could not start the operation watch.",
|
|
3311
|
+
hint: "Retry watching the operation. If it keeps failing, reconnect to Trellis and try again.",
|
|
3312
|
+
cause,
|
|
3313
|
+
context: { subject },
|
|
3314
|
+
});
|
|
3315
|
+
recordRuntimeError(error, {
|
|
3316
|
+
surface: "operation",
|
|
3317
|
+
direction: "client",
|
|
3318
|
+
operation: "watchJson",
|
|
3319
|
+
phase: "request_send",
|
|
3320
|
+
});
|
|
3321
|
+
return err(error);
|
|
3322
|
+
}
|
|
3323
|
+
const iterator = sub[Symbol.asyncIterator]();
|
|
3324
|
+
let timer;
|
|
3325
|
+
let first;
|
|
3326
|
+
try {
|
|
3327
|
+
first = await Promise.race([
|
|
3328
|
+
iterator.next(),
|
|
3329
|
+
new Promise((_, reject) => {
|
|
3330
|
+
timer = setTimeout(() => reject(new Error("operation watch setup timed out")), this.timeout);
|
|
3331
|
+
}),
|
|
3332
|
+
]);
|
|
3333
|
+
}
|
|
3334
|
+
catch (cause) {
|
|
3335
|
+
sub.unsubscribe();
|
|
3336
|
+
const error = createTransportError({
|
|
3337
|
+
code: "trellis.watch.failed",
|
|
3338
|
+
message: "Trellis could not start the operation watch.",
|
|
3339
|
+
hint: "Retry watching the operation. If it keeps failing, reconnect to Trellis and try again.",
|
|
3340
|
+
cause,
|
|
3341
|
+
context: { subject },
|
|
3342
|
+
});
|
|
3343
|
+
recordRuntimeError(error, {
|
|
3344
|
+
surface: "operation",
|
|
3345
|
+
direction: "client",
|
|
3346
|
+
operation: "watchJson",
|
|
3347
|
+
phase: "response_wait",
|
|
3348
|
+
});
|
|
3349
|
+
return err(error);
|
|
3350
|
+
}
|
|
3351
|
+
finally {
|
|
3352
|
+
if (timer !== undefined)
|
|
3353
|
+
clearTimeout(timer);
|
|
3354
|
+
}
|
|
3355
|
+
return ok((async function* () {
|
|
3356
|
+
try {
|
|
3357
|
+
let next = first;
|
|
3358
|
+
while (!next.done) {
|
|
3359
|
+
const msg = next.value;
|
|
3360
|
+
if (msg.headers?.get("status") === "error") {
|
|
3361
|
+
const error = createTransportError({
|
|
3362
|
+
code: "trellis.watch.failed",
|
|
3363
|
+
message: "Trellis stopped the operation watch.",
|
|
3364
|
+
hint: "Retry watching the operation. If it keeps happening, reconnect to Trellis and try again.",
|
|
3365
|
+
context: { subject, frame: msg.string() },
|
|
3366
|
+
});
|
|
3367
|
+
recordRuntimeError(error, {
|
|
3368
|
+
surface: "operation",
|
|
3369
|
+
direction: "client",
|
|
3370
|
+
operation: "watchJson",
|
|
3371
|
+
phase: "remote_error",
|
|
3372
|
+
});
|
|
3373
|
+
yield err(error);
|
|
3374
|
+
next = await iterator.next();
|
|
3375
|
+
continue;
|
|
3376
|
+
}
|
|
3377
|
+
const json = safeJson(msg).take();
|
|
3378
|
+
if (isErr(json)) {
|
|
3379
|
+
const error = createTransportError({
|
|
3380
|
+
code: "trellis.watch.invalid_response",
|
|
3381
|
+
message: "Trellis returned an invalid watch update.",
|
|
3382
|
+
hint: "Retry watching the operation. If it keeps happening, reconnect to Trellis and try again.",
|
|
3383
|
+
cause: json.error.cause,
|
|
3384
|
+
context: { subject },
|
|
3385
|
+
});
|
|
3386
|
+
recordRuntimeError(error, {
|
|
3387
|
+
surface: "operation",
|
|
3388
|
+
direction: "client",
|
|
3389
|
+
operation: "watchJson",
|
|
3390
|
+
phase: "response_decoding",
|
|
3391
|
+
});
|
|
3392
|
+
yield err(error);
|
|
3393
|
+
next = await iterator.next();
|
|
3394
|
+
continue;
|
|
3395
|
+
}
|
|
3396
|
+
yield ok(json);
|
|
3397
|
+
next = await iterator.next();
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
finally {
|
|
3401
|
+
await iterator.return?.();
|
|
3402
|
+
sub.unsubscribe();
|
|
3403
|
+
}
|
|
3404
|
+
})());
|
|
3405
|
+
})());
|
|
3406
|
+
}
|
|
3407
|
+
}
|