@voyantjs/workflows-cloud-adapter 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -0
- package/NOTICE +4 -0
- package/README.md +132 -0
- package/package.json +57 -0
- package/src/index.ts +468 -0
package/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Apache-2.0
|
package/NOTICE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @voyantjs/workflows-cloud-adapter
|
|
2
|
+
|
|
3
|
+
Tenant Worker adapter for Voyant Cloud Workflows projects. It wraps the
|
|
4
|
+
lower-level Cloudflare orchestrator primitives so a workflow Worker can
|
|
5
|
+
export the public `/api/*` run surface and `WorkflowRunDO` without
|
|
6
|
+
hand-wiring dispatchers, step handlers, R2 bundle signing, or local
|
|
7
|
+
fallback behavior.
|
|
8
|
+
|
|
9
|
+
## Worker entry
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import "./workflows";
|
|
13
|
+
import { createCloudOrchestrator } from "@voyantjs/workflows-cloud-adapter";
|
|
14
|
+
|
|
15
|
+
export default createCloudOrchestrator();
|
|
16
|
+
export { WorkflowRunDO } from "@voyantjs/workflows-cloud-adapter";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If your build exports a workflow bundle value, passing it is harmless;
|
|
20
|
+
workflow registration still happens through module imports:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import workflows from "./workflows";
|
|
24
|
+
import { createCloudOrchestrator } from "@voyantjs/workflows-cloud-adapter";
|
|
25
|
+
|
|
26
|
+
export const { fetch, WorkflowRunDO } = createCloudOrchestrator(workflows);
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
When passing adapter options such as `services`, `now`, or `logger`,
|
|
30
|
+
export `WorkflowRunDO` from the returned object as shown above. The
|
|
31
|
+
returned class is bound to the same options used by the Worker fetch
|
|
32
|
+
handler.
|
|
33
|
+
|
|
34
|
+
## Hybrid apps
|
|
35
|
+
|
|
36
|
+
For Hono/itty-style apps, mount the workflows routes alongside your
|
|
37
|
+
existing routes:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { Hono } from "hono";
|
|
41
|
+
import "./workflows";
|
|
42
|
+
import { mountWorkflows } from "@voyantjs/workflows-cloud-adapter";
|
|
43
|
+
|
|
44
|
+
const app = new Hono<{ Bindings: Env }>();
|
|
45
|
+
|
|
46
|
+
app.get("/health", (c) => c.json({ ok: true }));
|
|
47
|
+
mountWorkflows(app);
|
|
48
|
+
|
|
49
|
+
export default app;
|
|
50
|
+
export { WorkflowRunDO } from "@voyantjs/workflows-cloud-adapter";
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`mountWorkflows(app)` registers `/api/*` when the app exposes
|
|
54
|
+
`all(path, handler)`. If the app only exposes `fetch`, the adapter wraps
|
|
55
|
+
that fetch method and intercepts `/api/*`.
|
|
56
|
+
|
|
57
|
+
## Runtime behavior
|
|
58
|
+
|
|
59
|
+
- When `STEP_RUNNER` is present and bundle env vars are configured,
|
|
60
|
+
`runtime: "node"` steps dispatch to the platform step-runner
|
|
61
|
+
Container fleet. The binding may point at the shared
|
|
62
|
+
`voyant-step-runner` Worker or a platform-operated per-org dedicated
|
|
63
|
+
runner; the adapter does not distinguish between them.
|
|
64
|
+
- When `STEP_RUNNER` is absent, `runtime: "node"` steps run inline in
|
|
65
|
+
the tenant Worker isolate. This keeps `wrangler dev` usable without
|
|
66
|
+
Docker, R2, or platform-injected bindings.
|
|
67
|
+
- Edge steps always run in the tenant Worker isolate.
|
|
68
|
+
|
|
69
|
+
## Tenant wrangler.jsonc
|
|
70
|
+
|
|
71
|
+
Tenants author the run Durable Object binding. Voyant Cloud overlays the
|
|
72
|
+
platform bindings and secrets at publish time.
|
|
73
|
+
|
|
74
|
+
```jsonc
|
|
75
|
+
{
|
|
76
|
+
"name": "my-voyant-workflows",
|
|
77
|
+
"main": "src/worker.ts",
|
|
78
|
+
"compatibility_date": "2026-05-01",
|
|
79
|
+
"durable_objects": {
|
|
80
|
+
"bindings": [
|
|
81
|
+
{
|
|
82
|
+
"name": "WORKFLOW_RUN_DO",
|
|
83
|
+
"class_name": "WorkflowRunDO"
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
"migrations": [
|
|
88
|
+
{
|
|
89
|
+
"tag": "v1",
|
|
90
|
+
"new_sqlite_classes": ["WorkflowRunDO"]
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
At publish time the platform injects the step-runner namespace binding:
|
|
97
|
+
|
|
98
|
+
```jsonc
|
|
99
|
+
{
|
|
100
|
+
"durable_objects": {
|
|
101
|
+
"bindings": [
|
|
102
|
+
{
|
|
103
|
+
"name": "STEP_RUNNER",
|
|
104
|
+
"class_name": "StepRunner",
|
|
105
|
+
"script_name": "voyant-step-runner"
|
|
106
|
+
}
|
|
107
|
+
// Enterprise tenants may receive script_name:
|
|
108
|
+
// "voyant-step-runner-{org}".
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Env contract
|
|
115
|
+
|
|
116
|
+
| Name | Required | Source | Purpose |
|
|
117
|
+
|---|---:|---|---|
|
|
118
|
+
| `WORKFLOW_RUN_DO` | yes | Tenant wrangler | Durable Object namespace for per-run state. |
|
|
119
|
+
| `STEP_RUNNER` | production node steps | Platform-injected | Durable Object namespace for the shared or dedicated step-runner Container fleet. |
|
|
120
|
+
| `WORKFLOW_MANIFESTS` | optional | Tenant/platform | KV namespace enabling `/api/manifests*` and `/api/events`. |
|
|
121
|
+
| `VOYANT_API_TOKENS` | production API | Tenant/platform | Comma-separated bearer tokens for public `/api/*` access. |
|
|
122
|
+
| `VOYANT_WORKFLOW_BUNDLE_URL_PREFIX` | with `STEP_RUNNER` | Platform-injected | R2 S3 API prefix: `https://<account>.r2.cloudflarestorage.com/<bucket>`. |
|
|
123
|
+
| `VOYANT_WORKFLOW_BUNDLE_KEY` | with `STEP_RUNNER` | Platform-injected | R2 object key for this version's `container.mjs`. |
|
|
124
|
+
| `VOYANT_WORKFLOW_BUNDLE_HASH` | with `STEP_RUNNER` | Platform-injected | SHA-256 hash for the bundle bytes. |
|
|
125
|
+
| `VOYANT_WORKFLOW_BUNDLE_R2_ACCESS_KEY_ID` | with `STEP_RUNNER` | Secret | Read-only R2 access key id. |
|
|
126
|
+
| `VOYANT_WORKFLOW_BUNDLE_R2_SECRET_ACCESS_KEY` | with `STEP_RUNNER` | Secret | Read-only R2 secret access key. |
|
|
127
|
+
| `VOYANT_WORKFLOW_STEP_AUTH_SECRET` | recommended with `STEP_RUNNER` | Secret | HMAC secret for `x-voyant-step-auth` on step dispatches. |
|
|
128
|
+
| `VOYANT_WORKFLOW_BUNDLE_URL_TTL_SECONDS` | optional | Platform-injected | Signed bundle URL TTL. Defaults to `300`. |
|
|
129
|
+
|
|
130
|
+
`VOYANT_WORKFLOW_BUNDLE_R2_ACCOUNT_ID` and
|
|
131
|
+
`VOYANT_WORKFLOW_BUNDLE_R2_BUCKET` can override the account id and
|
|
132
|
+
bucket parsed from `VOYANT_WORKFLOW_BUNDLE_URL_PREFIX`.
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voyantjs/workflows-cloud-adapter",
|
|
3
|
+
"version": "0.37.0",
|
|
4
|
+
"description": "Tenant Worker adapter for Voyant Cloud Workflows deployments. Wires WorkflowRunDO, inline local dispatch, and platform step-runner bindings from env.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/voyantjs/voyant.git",
|
|
9
|
+
"directory": "packages/workflows-cloud-adapter"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://voyant.cloud/workflows",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"import": "./src/index.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src",
|
|
24
|
+
"!**/*.test.*",
|
|
25
|
+
"!**/*.spec.*",
|
|
26
|
+
"NOTICE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "pnpm -C ../.. --filter @voyantjs/workflows-orchestrator-cloudflare build && tsc -p tsconfig.json",
|
|
30
|
+
"check-types": "pnpm -C ../.. --filter @voyantjs/workflows-orchestrator-cloudflare build && tsc --noEmit",
|
|
31
|
+
"dev": "tsc -w -p tsconfig.json",
|
|
32
|
+
"test": "pnpm -C ../.. --filter @voyantjs/workflows-orchestrator-cloudflare build && vitest run",
|
|
33
|
+
"test:watch": "vitest"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@voyantjs/workflows": "workspace:*",
|
|
37
|
+
"@voyantjs/workflows-orchestrator": "workspace:*",
|
|
38
|
+
"@voyantjs/workflows-orchestrator-cloudflare": "workspace:*"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^20.12.0",
|
|
42
|
+
"@voyantjs/voyant-typescript-config": "workspace:*",
|
|
43
|
+
"typescript": "^5.9.2",
|
|
44
|
+
"vitest": "^4.1.2"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"exports": {
|
|
49
|
+
".": {
|
|
50
|
+
"types": "./dist/index.d.ts",
|
|
51
|
+
"import": "./dist/index.js"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"main": "./dist/index.js",
|
|
55
|
+
"types": "./dist/index.d.ts"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// @voyantjs/workflows-cloud-adapter
|
|
2
|
+
//
|
|
3
|
+
// Tenant Worker adapter for Voyant Cloud's workflows runtime. The package
|
|
4
|
+
// keeps tenant entrypoints small while preserving the same Cloudflare
|
|
5
|
+
// Durable Object run model used by the lower-level orchestrator adapter.
|
|
6
|
+
|
|
7
|
+
import { createBearerVerifier, createHmacSigner } from "@voyantjs/workflows/auth"
|
|
8
|
+
import {
|
|
9
|
+
handleStepRequest,
|
|
10
|
+
type StepJournalEntry,
|
|
11
|
+
type StepRunner,
|
|
12
|
+
} from "@voyantjs/workflows/handler"
|
|
13
|
+
import { createInMemoryRateLimiter } from "@voyantjs/workflows/rate-limit"
|
|
14
|
+
import type { StepHandler } from "@voyantjs/workflows-orchestrator"
|
|
15
|
+
import {
|
|
16
|
+
type ContainerNamespaceLike,
|
|
17
|
+
createCfContainerStepRunner,
|
|
18
|
+
createInlineDispatcher,
|
|
19
|
+
createKvManifestStore,
|
|
20
|
+
createR2Presigner,
|
|
21
|
+
type DurableObjectNamespaceLike,
|
|
22
|
+
type DurableObjectStorageLike,
|
|
23
|
+
handleDurableObjectAlarm,
|
|
24
|
+
handleDurableObjectRequest,
|
|
25
|
+
handleWorkerRequest,
|
|
26
|
+
type KvNamespaceLike,
|
|
27
|
+
type StepDispatcher,
|
|
28
|
+
type WorkerFetchDeps,
|
|
29
|
+
} from "@voyantjs/workflows-orchestrator-cloudflare"
|
|
30
|
+
|
|
31
|
+
export interface CloudWorkflowsEnv {
|
|
32
|
+
/** Per-run Durable Object namespace declared by the tenant Worker. */
|
|
33
|
+
WORKFLOW_RUN_DO: DurableObjectNamespaceLike
|
|
34
|
+
/**
|
|
35
|
+
* Platform-injected namespace for the node step-runner Container fleet.
|
|
36
|
+
* The binding may target the shared platform fleet or a platform-operated
|
|
37
|
+
* per-org dedicated runner; the tenant adapter treats both the same.
|
|
38
|
+
*/
|
|
39
|
+
STEP_RUNNER?: ContainerNamespaceLike
|
|
40
|
+
/**
|
|
41
|
+
* Optional KV namespace for workflow manifests. When present,
|
|
42
|
+
* `/api/manifests*` and `/api/events` are enabled.
|
|
43
|
+
*/
|
|
44
|
+
WORKFLOW_MANIFESTS?: KvNamespaceLike
|
|
45
|
+
/**
|
|
46
|
+
* Comma-separated bearer tokens for public `/api/*` routes. Omit for local
|
|
47
|
+
* development only.
|
|
48
|
+
*/
|
|
49
|
+
VOYANT_API_TOKENS?: string
|
|
50
|
+
/**
|
|
51
|
+
* Prefix for the R2 S3 API URL that hosts the container bundle.
|
|
52
|
+
* Expected form: `https://<account>.r2.cloudflarestorage.com/<bucket>`.
|
|
53
|
+
*/
|
|
54
|
+
VOYANT_WORKFLOW_BUNDLE_URL_PREFIX?: string
|
|
55
|
+
/** R2 object key for this tenant Worker version's `container.mjs`. */
|
|
56
|
+
VOYANT_WORKFLOW_BUNDLE_KEY?: string
|
|
57
|
+
/** SHA-256 hex, or `sha256:<hex>`, for the container bundle bytes. */
|
|
58
|
+
VOYANT_WORKFLOW_BUNDLE_HASH?: string
|
|
59
|
+
/** R2 read-only access key id used to mint short-lived signed bundle URLs. */
|
|
60
|
+
VOYANT_WORKFLOW_BUNDLE_R2_ACCESS_KEY_ID?: string
|
|
61
|
+
/** R2 read-only secret access key used to mint short-lived signed bundle URLs. */
|
|
62
|
+
VOYANT_WORKFLOW_BUNDLE_R2_SECRET_ACCESS_KEY?: string
|
|
63
|
+
/** Optional explicit R2 account id. Defaults to parsing URL_PREFIX. */
|
|
64
|
+
VOYANT_WORKFLOW_BUNDLE_R2_ACCOUNT_ID?: string
|
|
65
|
+
/** Optional explicit R2 bucket. Defaults to parsing URL_PREFIX. */
|
|
66
|
+
VOYANT_WORKFLOW_BUNDLE_R2_BUCKET?: string
|
|
67
|
+
/** Optional signed URL TTL in seconds. Defaults to 300. */
|
|
68
|
+
VOYANT_WORKFLOW_BUNDLE_URL_TTL_SECONDS?: string
|
|
69
|
+
/** Shared secret used to sign dispatches to the platform step runner. */
|
|
70
|
+
VOYANT_WORKFLOW_STEP_AUTH_SECRET?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DurableObjectStateLike {
|
|
74
|
+
storage: DurableObjectStorageLike
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CloudOrchestratorOptions<Env extends CloudWorkflowsEnv = CloudWorkflowsEnv> {
|
|
78
|
+
verifyRequest?: WorkerFetchDeps["verifyRequest"]
|
|
79
|
+
logger?: WorkerFetchDeps["logger"]
|
|
80
|
+
idGenerator?: WorkerFetchDeps["idGenerator"]
|
|
81
|
+
now?: () => number
|
|
82
|
+
tenantMeta?: WorkerFetchDeps["tenantMeta"]
|
|
83
|
+
services?: import("@voyantjs/workflows/driver").ServiceResolver
|
|
84
|
+
resolveEnv?: (env: Env) => Env
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CloudOrchestrator<Env extends CloudWorkflowsEnv = CloudWorkflowsEnv> {
|
|
88
|
+
fetch: (request: Request, env?: Env) => Promise<Response>
|
|
89
|
+
WorkflowRunDO: WorkflowRunDOClass<Env>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type EnvCache = {
|
|
93
|
+
dispatcher?: StepDispatcher
|
|
94
|
+
dispatcherOptions?: CloudExecutionOptions<CloudWorkflowsEnv>
|
|
95
|
+
stepHandler?: StepHandler
|
|
96
|
+
stepHandlerOptions?: CloudExecutionOptions<CloudWorkflowsEnv>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const envCache = new WeakMap<object, EnvCache>()
|
|
100
|
+
const defaultExecutionOptions: CloudExecutionOptions<CloudWorkflowsEnv> = {}
|
|
101
|
+
|
|
102
|
+
export type WorkflowRunDOClass<Env extends CloudWorkflowsEnv = CloudWorkflowsEnv> = new (
|
|
103
|
+
state: DurableObjectStateLike,
|
|
104
|
+
env: Env,
|
|
105
|
+
) => WorkflowRunDO<Env>
|
|
106
|
+
|
|
107
|
+
type CloudExecutionOptions<Env extends CloudWorkflowsEnv = CloudWorkflowsEnv> = Pick<
|
|
108
|
+
CloudOrchestratorOptions<Env>,
|
|
109
|
+
"services" | "now" | "logger"
|
|
110
|
+
>
|
|
111
|
+
|
|
112
|
+
export function createCloudOrchestrator<Env extends CloudWorkflowsEnv = CloudWorkflowsEnv>(
|
|
113
|
+
workflows?: unknown,
|
|
114
|
+
boundEnv?: Env,
|
|
115
|
+
options: CloudOrchestratorOptions<Env> = {},
|
|
116
|
+
): CloudOrchestrator<Env> {
|
|
117
|
+
void workflows
|
|
118
|
+
const WorkflowRunDOWithOptions = createWorkflowRunDOClass<Env>(options)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
fetch(request, requestEnv) {
|
|
122
|
+
const env = resolveBoundEnv(boundEnv, requestEnv, options)
|
|
123
|
+
return handleCloudFetch(request, env, options)
|
|
124
|
+
},
|
|
125
|
+
WorkflowRunDO: WorkflowRunDOWithOptions,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function mountWorkflows<
|
|
130
|
+
App extends {
|
|
131
|
+
all?: (path: string, handler: (...args: unknown[]) => Response | Promise<Response>) => unknown
|
|
132
|
+
fetch?: (request: Request, env?: unknown, ctx?: unknown) => Response | Promise<Response>
|
|
133
|
+
},
|
|
134
|
+
Env extends CloudWorkflowsEnv = CloudWorkflowsEnv,
|
|
135
|
+
>(app: App, env?: Env, options: CloudOrchestratorOptions<Env> & { pathPrefix?: string } = {}): App {
|
|
136
|
+
const orchestrator = createCloudOrchestrator(undefined, env, options)
|
|
137
|
+
const pathPrefix = normalizePathPrefix(options.pathPrefix ?? "/api")
|
|
138
|
+
|
|
139
|
+
if (typeof app.all === "function") {
|
|
140
|
+
app.all(`${pathPrefix}/*`, (...args) => {
|
|
141
|
+
const request = extractRequest(args)
|
|
142
|
+
const requestEnv = extractEnv<Env>(args, env)
|
|
143
|
+
return orchestrator.fetch(request, requestEnv)
|
|
144
|
+
})
|
|
145
|
+
return app
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (typeof app.fetch === "function") {
|
|
149
|
+
const originalFetch = app.fetch.bind(app)
|
|
150
|
+
;(app as { fetch: typeof app.fetch }).fetch = (request, requestEnv, ctx) => {
|
|
151
|
+
if (isMountedPath(new URL(request.url).pathname, pathPrefix)) {
|
|
152
|
+
return orchestrator.fetch(request, (requestEnv as Env | undefined) ?? env)
|
|
153
|
+
}
|
|
154
|
+
return originalFetch(request, requestEnv, ctx)
|
|
155
|
+
}
|
|
156
|
+
return app
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
throw new Error(
|
|
160
|
+
"mountWorkflows: app must expose either all(path, handler) or fetch(request, env)",
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function handleCloudFetch<Env extends CloudWorkflowsEnv = CloudWorkflowsEnv>(
|
|
165
|
+
request: Request,
|
|
166
|
+
env: Env,
|
|
167
|
+
options: CloudOrchestratorOptions<Env> = {},
|
|
168
|
+
): Promise<Response> {
|
|
169
|
+
const resolvedEnv = options.resolveEnv?.(env) ?? env
|
|
170
|
+
const tokens = (resolvedEnv.VOYANT_API_TOKENS ?? "")
|
|
171
|
+
.split(",")
|
|
172
|
+
.map((s) => s.trim())
|
|
173
|
+
.filter((s) => s.length > 0)
|
|
174
|
+
|
|
175
|
+
return handleWorkerRequest(request, {
|
|
176
|
+
runDO: resolvedEnv.WORKFLOW_RUN_DO,
|
|
177
|
+
verifyRequest:
|
|
178
|
+
options.verifyRequest ?? (tokens.length > 0 ? createBearerVerifier(tokens) : undefined),
|
|
179
|
+
logger: options.logger,
|
|
180
|
+
idGenerator: options.idGenerator,
|
|
181
|
+
now: options.now,
|
|
182
|
+
tenantMeta: options.tenantMeta,
|
|
183
|
+
manifestStore: resolvedEnv.WORKFLOW_MANIFESTS
|
|
184
|
+
? createKvManifestStore({ kv: resolvedEnv.WORKFLOW_MANIFESTS })
|
|
185
|
+
: undefined,
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export class WorkflowRunDO<Env extends CloudWorkflowsEnv = CloudWorkflowsEnv> {
|
|
190
|
+
private readonly state: DurableObjectStateLike
|
|
191
|
+
private readonly env: Env
|
|
192
|
+
|
|
193
|
+
constructor(state: DurableObjectStateLike, env: Env) {
|
|
194
|
+
this.state = state
|
|
195
|
+
this.env = env
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async fetch(request: Request): Promise<Response> {
|
|
199
|
+
return handleDurableObjectRequest(request, {
|
|
200
|
+
storage: this.state.storage,
|
|
201
|
+
dispatcher: resolveDispatcher(this.env, this.executionOptions()),
|
|
202
|
+
now: this.executionOptions().now,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async alarm(): Promise<void> {
|
|
207
|
+
await handleDurableObjectAlarm({
|
|
208
|
+
storage: this.state.storage,
|
|
209
|
+
dispatcher: resolveDispatcher(this.env, this.executionOptions()),
|
|
210
|
+
now: this.executionOptions().now,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
protected executionOptions(): CloudExecutionOptions<Env> {
|
|
215
|
+
return defaultExecutionOptions as CloudExecutionOptions<Env>
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function createCloudStepDispatcher<Env extends CloudWorkflowsEnv = CloudWorkflowsEnv>(
|
|
220
|
+
env: Env,
|
|
221
|
+
options: CloudExecutionOptions<Env> = defaultExecutionOptions as CloudExecutionOptions<Env>,
|
|
222
|
+
): StepDispatcher {
|
|
223
|
+
return createInlineDispatcher(resolveStepHandler(env, options))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function createWorkflowRunDOClass<Env extends CloudWorkflowsEnv>(
|
|
227
|
+
options: CloudExecutionOptions<Env>,
|
|
228
|
+
): WorkflowRunDOClass<Env> {
|
|
229
|
+
return class CloudWorkflowRunDO extends WorkflowRunDO<Env> {
|
|
230
|
+
protected override executionOptions(): CloudExecutionOptions<Env> {
|
|
231
|
+
return options
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function resolveDispatcher<Env extends CloudWorkflowsEnv>(
|
|
237
|
+
env: Env,
|
|
238
|
+
options: CloudExecutionOptions<Env> = defaultExecutionOptions as CloudExecutionOptions<Env>,
|
|
239
|
+
): StepDispatcher {
|
|
240
|
+
const cache = cacheFor(env)
|
|
241
|
+
if (!cache.dispatcher || cache.dispatcherOptions !== options) {
|
|
242
|
+
cache.dispatcherOptions = options
|
|
243
|
+
cache.dispatcher = createCloudStepDispatcher(env, options)
|
|
244
|
+
}
|
|
245
|
+
return cache.dispatcher
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveStepHandler<Env extends CloudWorkflowsEnv>(
|
|
249
|
+
env: Env,
|
|
250
|
+
options: CloudExecutionOptions<Env> = defaultExecutionOptions as CloudExecutionOptions<Env>,
|
|
251
|
+
): StepHandler {
|
|
252
|
+
const cache = cacheFor(env)
|
|
253
|
+
if (!cache.stepHandler || cache.stepHandlerOptions !== options) {
|
|
254
|
+
cache.stepHandlerOptions = options
|
|
255
|
+
const handlerPromise = buildStepHandler(env, options)
|
|
256
|
+
cache.stepHandler = (req, stepOptions) =>
|
|
257
|
+
handlerPromise.then((handler) => handler(req, stepOptions))
|
|
258
|
+
}
|
|
259
|
+
return cache.stepHandler
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function buildStepHandler<Env extends CloudWorkflowsEnv>(
|
|
263
|
+
env: Env,
|
|
264
|
+
options: CloudExecutionOptions<Env> = defaultExecutionOptions as CloudExecutionOptions<Env>,
|
|
265
|
+
): Promise<StepHandler> {
|
|
266
|
+
const nodeStepRunner = await createNodeStepRunner(env, options)
|
|
267
|
+
const rateLimiter = createInMemoryRateLimiter()
|
|
268
|
+
|
|
269
|
+
return (req, stepOptions) =>
|
|
270
|
+
handleStepRequest(
|
|
271
|
+
req,
|
|
272
|
+
{
|
|
273
|
+
rateLimiter,
|
|
274
|
+
nodeStepRunner,
|
|
275
|
+
services: options.services,
|
|
276
|
+
now: options.now,
|
|
277
|
+
logger: options.logger,
|
|
278
|
+
},
|
|
279
|
+
stepOptions,
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function createNodeStepRunner<Env extends CloudWorkflowsEnv>(
|
|
284
|
+
env: Env,
|
|
285
|
+
options: CloudExecutionOptions<Env>,
|
|
286
|
+
): Promise<StepRunner> {
|
|
287
|
+
if (!env.STEP_RUNNER) {
|
|
288
|
+
return createInlineNodeStepRunner(options.now)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const bundle = resolveBundleConfig(env)
|
|
292
|
+
const presign = createR2Presigner({
|
|
293
|
+
accountId: bundle.accountId,
|
|
294
|
+
accessKeyId: bundle.accessKeyId,
|
|
295
|
+
secretAccessKey: bundle.secretAccessKey,
|
|
296
|
+
bucket: bundle.bucket,
|
|
297
|
+
})
|
|
298
|
+
const sign = env.VOYANT_WORKFLOW_STEP_AUTH_SECRET
|
|
299
|
+
? await createHmacSigner(env.VOYANT_WORKFLOW_STEP_AUTH_SECRET)
|
|
300
|
+
: undefined
|
|
301
|
+
|
|
302
|
+
return createCfContainerStepRunner({
|
|
303
|
+
namespace: env.STEP_RUNNER,
|
|
304
|
+
sign,
|
|
305
|
+
logger: options.logger,
|
|
306
|
+
resolveBundle: async () => ({
|
|
307
|
+
url: await presign({
|
|
308
|
+
key: bundle.key,
|
|
309
|
+
expiresIn: bundle.expiresIn,
|
|
310
|
+
}),
|
|
311
|
+
hash: bundle.hash,
|
|
312
|
+
}),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function createInlineNodeStepRunner(now = () => Date.now()): StepRunner {
|
|
317
|
+
return async ({ attempt, fn, stepCtx }): Promise<StepJournalEntry> => {
|
|
318
|
+
const startedAt = now()
|
|
319
|
+
try {
|
|
320
|
+
return {
|
|
321
|
+
attempt,
|
|
322
|
+
status: "ok",
|
|
323
|
+
output: await fn(stepCtx),
|
|
324
|
+
startedAt,
|
|
325
|
+
finishedAt: now(),
|
|
326
|
+
}
|
|
327
|
+
} catch (err) {
|
|
328
|
+
const e = err as Error
|
|
329
|
+
return {
|
|
330
|
+
attempt,
|
|
331
|
+
status: "err",
|
|
332
|
+
startedAt,
|
|
333
|
+
finishedAt: now(),
|
|
334
|
+
error: {
|
|
335
|
+
category: "USER_ERROR",
|
|
336
|
+
code:
|
|
337
|
+
typeof (err as { code?: unknown }).code === "string"
|
|
338
|
+
? (err as { code: string }).code
|
|
339
|
+
: "UNKNOWN",
|
|
340
|
+
message: e?.message ?? String(err),
|
|
341
|
+
name: e?.name,
|
|
342
|
+
stack: e?.stack,
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function resolveBundleConfig(env: CloudWorkflowsEnv): {
|
|
350
|
+
accountId: string
|
|
351
|
+
bucket: string
|
|
352
|
+
accessKeyId: string
|
|
353
|
+
secretAccessKey: string
|
|
354
|
+
key: string
|
|
355
|
+
hash: string
|
|
356
|
+
expiresIn: number
|
|
357
|
+
} {
|
|
358
|
+
const parsedPrefix = parseBundleUrlPrefix(env.VOYANT_WORKFLOW_BUNDLE_URL_PREFIX)
|
|
359
|
+
const accountId = env.VOYANT_WORKFLOW_BUNDLE_R2_ACCOUNT_ID ?? parsedPrefix.accountId
|
|
360
|
+
const bucket = env.VOYANT_WORKFLOW_BUNDLE_R2_BUCKET ?? parsedPrefix.bucket
|
|
361
|
+
const accessKeyId = env.VOYANT_WORKFLOW_BUNDLE_R2_ACCESS_KEY_ID
|
|
362
|
+
const secretAccessKey = env.VOYANT_WORKFLOW_BUNDLE_R2_SECRET_ACCESS_KEY
|
|
363
|
+
const key = env.VOYANT_WORKFLOW_BUNDLE_KEY
|
|
364
|
+
const hash = env.VOYANT_WORKFLOW_BUNDLE_HASH
|
|
365
|
+
|
|
366
|
+
const missing = [
|
|
367
|
+
["VOYANT_WORKFLOW_BUNDLE_R2_ACCESS_KEY_ID", accessKeyId],
|
|
368
|
+
["VOYANT_WORKFLOW_BUNDLE_R2_SECRET_ACCESS_KEY", secretAccessKey],
|
|
369
|
+
["VOYANT_WORKFLOW_BUNDLE_KEY", key],
|
|
370
|
+
["VOYANT_WORKFLOW_BUNDLE_HASH", hash],
|
|
371
|
+
].filter(([, value]) => typeof value !== "string" || value.length === 0)
|
|
372
|
+
|
|
373
|
+
if (!env.VOYANT_WORKFLOW_BUNDLE_URL_PREFIX && (!accountId || !bucket)) {
|
|
374
|
+
missing.push(["VOYANT_WORKFLOW_BUNDLE_URL_PREFIX", env.VOYANT_WORKFLOW_BUNDLE_URL_PREFIX])
|
|
375
|
+
}
|
|
376
|
+
if (!accountId) missing.push(["VOYANT_WORKFLOW_BUNDLE_R2_ACCOUNT_ID", accountId])
|
|
377
|
+
if (!bucket) missing.push(["VOYANT_WORKFLOW_BUNDLE_R2_BUCKET", bucket])
|
|
378
|
+
if (missing.length > 0) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`@voyantjs/workflows-cloud-adapter: STEP_RUNNER is configured but bundle env is incomplete: ${missing
|
|
381
|
+
.map(([name]) => name)
|
|
382
|
+
.join(", ")}`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const expiresIn = Number(env.VOYANT_WORKFLOW_BUNDLE_URL_TTL_SECONDS ?? 300)
|
|
387
|
+
if (!Number.isFinite(expiresIn) || expiresIn < 1 || expiresIn > 604_800) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
"@voyantjs/workflows-cloud-adapter: VOYANT_WORKFLOW_BUNDLE_URL_TTL_SECONDS must be 1..604800",
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
accountId: accountId!,
|
|
395
|
+
bucket: bucket!,
|
|
396
|
+
accessKeyId: accessKeyId!,
|
|
397
|
+
secretAccessKey: secretAccessKey!,
|
|
398
|
+
key: key!,
|
|
399
|
+
hash: hash!,
|
|
400
|
+
expiresIn,
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function parseBundleUrlPrefix(prefix: string | undefined): {
|
|
405
|
+
accountId?: string
|
|
406
|
+
bucket?: string
|
|
407
|
+
} {
|
|
408
|
+
if (!prefix) return {}
|
|
409
|
+
const url = new URL(prefix)
|
|
410
|
+
const suffix = ".r2.cloudflarestorage.com"
|
|
411
|
+
const accountId = url.hostname.endsWith(suffix)
|
|
412
|
+
? url.hostname.slice(0, -suffix.length)
|
|
413
|
+
: undefined
|
|
414
|
+
const bucket = url.pathname.replace(/^\/+/, "").split("/")[0]
|
|
415
|
+
return {
|
|
416
|
+
accountId: accountId && accountId.length > 0 ? accountId : undefined,
|
|
417
|
+
bucket: bucket && bucket.length > 0 ? bucket : undefined,
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function cacheFor(env: object): EnvCache {
|
|
422
|
+
let cache = envCache.get(env)
|
|
423
|
+
if (!cache) {
|
|
424
|
+
cache = {}
|
|
425
|
+
envCache.set(env, cache)
|
|
426
|
+
}
|
|
427
|
+
return cache
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function resolveBoundEnv<Env extends CloudWorkflowsEnv>(
|
|
431
|
+
boundEnv: Env | undefined,
|
|
432
|
+
requestEnv: Env | undefined,
|
|
433
|
+
options: CloudOrchestratorOptions<Env>,
|
|
434
|
+
): Env {
|
|
435
|
+
const env = requestEnv ?? boundEnv
|
|
436
|
+
if (!env) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
"@voyantjs/workflows-cloud-adapter: env must be passed to fetch(request, env) or createCloudOrchestrator(workflows, env)",
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
return options.resolveEnv?.(env) ?? env
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function normalizePathPrefix(prefix: string): string {
|
|
445
|
+
if (prefix === "/") return ""
|
|
446
|
+
return `/${prefix.replace(/^\/+|\/+$/g, "")}`
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function isMountedPath(pathname: string, prefix: string): boolean {
|
|
450
|
+
return pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function extractRequest(args: readonly unknown[]): Request {
|
|
454
|
+
const first = args[0]
|
|
455
|
+
if (first instanceof Request) return first
|
|
456
|
+
const raw = (first as { req?: { raw?: unknown } } | undefined)?.req?.raw
|
|
457
|
+
if (raw instanceof Request) return raw
|
|
458
|
+
throw new Error("mountWorkflows: could not resolve Request from route handler arguments")
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function extractEnv<Env extends CloudWorkflowsEnv>(
|
|
462
|
+
args: readonly unknown[],
|
|
463
|
+
boundEnv: Env | undefined,
|
|
464
|
+
): Env | undefined {
|
|
465
|
+
if (boundEnv) return boundEnv
|
|
466
|
+
const firstEnv = (args[0] as { env?: unknown } | undefined)?.env
|
|
467
|
+
return (firstEnv as Env | undefined) ?? (args[1] as Env | undefined)
|
|
468
|
+
}
|