@pithy-sh/core 0.1.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 +21 -0
- package/package.json +47 -0
- package/pithy.manifest.json +74 -0
- package/src/address/address.ts +83 -0
- package/src/audit/auditEvent.ts +130 -0
- package/src/audit/recorder.ts +22 -0
- package/src/capability/bindings.ts +196 -0
- package/src/capability/capability.ts +555 -0
- package/src/capability/client.ts +136 -0
- package/src/capability/compose.ts +76 -0
- package/src/capability/composition.ts +98 -0
- package/src/capability/config.ts +19 -0
- package/src/capability/devSecret.ts +42 -0
- package/src/capability/manifest.ts +580 -0
- package/src/capability/secretOrigin.ts +253 -0
- package/src/capability/settings.ts +155 -0
- package/src/capability/validateBindings.ts +43 -0
- package/src/capability/vanishingKey.ts +92 -0
- package/src/cloudflare-test.d.ts +20 -0
- package/src/controlPlane/audit/actions.ts +81 -0
- package/src/controlPlane/capability.ts +228 -0
- package/src/controlPlane/config/config.ts +195 -0
- package/src/controlPlane/context.ts +63 -0
- package/src/controlPlane/data/connection.ts +123 -0
- package/src/controlPlane/data/keyLifecycle.ts +159 -0
- package/src/controlPlane/data/replay.ts +39 -0
- package/src/controlPlane/data/tables.ts +51 -0
- package/src/controlPlane/discovery/adminRoute.ts +250 -0
- package/src/controlPlane/discovery/configuration.ts +280 -0
- package/src/controlPlane/discovery/drift.ts +100 -0
- package/src/controlPlane/discovery/health.ts +213 -0
- package/src/controlPlane/discovery/healthSummary.ts +486 -0
- package/src/controlPlane/error/errors.ts +125 -0
- package/src/controlPlane/http/cors.ts +244 -0
- package/src/controlPlane/http/guard.ts +223 -0
- package/src/controlPlane/http/handlers.ts +346 -0
- package/src/controlPlane/http/responses.ts +92 -0
- package/src/controlPlane/http/routes.ts +115 -0
- package/src/controlPlane/http/schemas.ts +70 -0
- package/src/controlPlane/http/verify.ts +198 -0
- package/src/controlPlane/migrations/0001_init.ts +105 -0
- package/src/controlPlane/replay/d1Guard.ts +87 -0
- package/src/controlPlane/replay/guard.ts +55 -0
- package/src/controlPlane/replay/kvGuard.ts +143 -0
- package/src/controlPlane/scope/scope.ts +102 -0
- package/src/controlPlane/token/base64url.ts +65 -0
- package/src/controlPlane/token/claims.ts +151 -0
- package/src/controlPlane/token/digest.ts +63 -0
- package/src/controlPlane/token/jws.ts +112 -0
- package/src/controlPlane/token/mint.ts +93 -0
- package/src/controlPlane/wire.ts +138 -0
- package/src/createBackend.ts +292 -0
- package/src/createEntrypoint.ts +125 -0
- package/src/data/boundParameters.ts +197 -0
- package/src/data/codecs.ts +160 -0
- package/src/data/cursor.ts +127 -0
- package/src/data/databases.ts +84 -0
- package/src/data/db.ts +53 -0
- package/src/data/withD1Retry.ts +176 -0
- package/src/entitlement/entitlement.ts +191 -0
- package/src/entitlement/gateScan.ts +107 -0
- package/src/entitlement/require.ts +199 -0
- package/src/env/ambient.ts +67 -0
- package/src/env/ci.ts +43 -0
- package/src/env/stem.ts +34 -0
- package/src/error/cause.ts +208 -0
- package/src/error/client.ts +43 -0
- package/src/error/extend.ts +135 -0
- package/src/error/http.ts +92 -0
- package/src/error/payload.ts +2195 -0
- package/src/error/pithyError.ts +281 -0
- package/src/error/terminal.ts +36 -0
- package/src/http/authContext.ts +29 -0
- package/src/http/routeContract.ts +115 -0
- package/src/http/sameOrigin.ts +67 -0
- package/src/http/signedWebhook.ts +415 -0
- package/src/http/validation.ts +41 -0
- package/src/http/verification.ts +25 -0
- package/src/i18n/acceptLanguage.ts +70 -0
- package/src/i18n/catalog.ts +113 -0
- package/src/i18n/locale.ts +153 -0
- package/src/i18n/localeMarker.ts +116 -0
- package/src/i18n/match.ts +111 -0
- package/src/i18n/registry.ts +78 -0
- package/src/i18n/translator.ts +168 -0
- package/src/index.ts +116 -0
- package/src/kv/kv.ts +437 -0
- package/src/kv/namespaces.ts +102 -0
- package/src/logger/local.ts +91 -0
- package/src/logger/logger.ts +145 -0
- package/src/logger/record.ts +83 -0
- package/src/logger/worker.ts +117 -0
- package/src/migrations/batch.ts +226 -0
- package/src/migrations/bookkeeping.ts +85 -0
- package/src/migrations/owner.ts +166 -0
- package/src/migrations/registry.ts +121 -0
- package/src/migrations/runner.ts +295 -0
- package/src/naming/domains.ts +194 -0
- package/src/naming/environment.ts +224 -0
- package/src/naming/feature.ts +162 -0
- package/src/naming/limits.ts +223 -0
- package/src/naming/provisionScope.ts +143 -0
- package/src/naming/resource.ts +266 -0
- package/src/naming/resourceNames.ts +174 -0
- package/src/naming/segment.ts +32 -0
- package/src/projection/asRead.ts +211 -0
- package/src/projection/published.ts +210 -0
- package/src/schema/describedness.ts +250 -0
- package/src/seed/compose.ts +94 -0
- package/src/seed/devLogin.ts +67 -0
- package/src/seed/exampleIdentities.ts +43 -0
- package/src/seed/metadata.ts +27 -0
- package/src/seed/seed.ts +306 -0
- package/src/seed/seededRows.ts +41 -0
- package/src/seed/writeD1.ts +103 -0
- package/src/seed/writeKv.ts +99 -0
- package/src/semver/semver.ts +156 -0
- package/src/text/comments.ts +165 -0
- package/src/version.generated.ts +16 -0
- package/src/worker/health.ts +42 -0
- package/src/worker/identity.ts +243 -0
- package/src/workflow/bindings.ts +58 -0
- package/src/workflow/dispatch.ts +240 -0
- package/src/workflow/dispatchRoute.ts +184 -0
- package/src/workflow/faults.ts +219 -0
- package/src/workflow/host.ts +307 -0
- package/src/workflow/hostEntry.ts +71 -0
- package/src/workflow/hostEnv.ts +258 -0
- package/src/workflow/loopback.ts +149 -0
- package/src/workflow/naming.ts +170 -0
- package/src/workflow/register.ts +44 -0
- package/src/workflow/schemas.ts +84 -0
- package/src/workflow/spec.ts +86 -0
- package/src/workflow/stepMessage.ts +160 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { d1TransientFault } from "../data/withD1Retry";
|
|
5
|
+
import { PithyError } from "../error/pithyError";
|
|
6
|
+
import { encodeWorkflowStepMessage } from "./stepMessage";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **Every fault that reaches a durable step is classified — retryable or terminal — at a stated site.**
|
|
10
|
+
*
|
|
11
|
+
* A Workflow is chosen *because of* its retry semantics: durable execution, a journal, steps that resume,
|
|
12
|
+
* backoff that survives an eviction. A step that inherits the platform default has not decided; it has
|
|
13
|
+
* deferred, and the default is retry-everything. So a deterministic refusal — a name that already exists,
|
|
14
|
+
* a value too large, a payload that will not parse — is answered slowly instead of immediately, the retry
|
|
15
|
+
* budget burns down on an answer that cannot change, and an instant refusal looks like an outage
|
|
16
|
+
* (pithy-sh/pithy#338).
|
|
17
|
+
*
|
|
18
|
+
* ## The three layers, and which owns what
|
|
19
|
+
*
|
|
20
|
+
* 1. **The capability states its own codes.** A {@link WorkflowRetryPolicy} names the `PithyError` codes
|
|
21
|
+
* that capability retries, each with the reason a second attempt can answer differently. One statement
|
|
22
|
+
* per capability, in one file, the way `@pithy-sh/email`'s `errorMapping.ts` already states the Email
|
|
23
|
+
* Service's `E_*` codes.
|
|
24
|
+
* 2. **Core owns D1.** `withD1Retry` already classifies D1's fault vocabulary, so this asks it rather than
|
|
25
|
+
* growing a second copy — and that is also the no-double-retry rule: a fault the inner layer refused to
|
|
26
|
+
* retry (a constraint violation, a SQL error) is terminal here too, while a transient one stays
|
|
27
|
+
* retryable so the step re-drives it with backoff far longer than the inner loop's.
|
|
28
|
+
* 3. **Everything else is terminal.** Retry is opted into, never inherited. The platform default is the
|
|
29
|
+
* opposite, and it is the wrong way round: a terminal fault wrongly called transient burns the whole
|
|
30
|
+
* budget before surfacing, while a transient one wrongly called terminal surfaces immediately with the
|
|
31
|
+
* fault written on it. One of those is a diagnosis; the other is a hang.
|
|
32
|
+
*
|
|
33
|
+
* ## Where the wrapping happens, and why it cannot be anywhere else
|
|
34
|
+
*
|
|
35
|
+
* {@link classifiedSteps} converts a terminal fault **inside** the step callback. The platform's retry loop
|
|
36
|
+
* lives inside `step.do`: by the time a `step.do` promise rejects, every retry has already been spent. A
|
|
37
|
+
* wrapper around the call would classify a fault the engine had already re-driven five times — correct
|
|
38
|
+
* looking, and exactly as slow as doing nothing.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Whether the platform should try a step's body again, or stop with the answer it has. */
|
|
42
|
+
export type WorkflowFaultDisposition = "retry" | "terminal";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One capability's stated retry classification — the whole of what it retries, and why.
|
|
46
|
+
*
|
|
47
|
+
* A record rather than a set, because the value is the argument: "what would a second attempt see that
|
|
48
|
+
* this one did not" is the question that decides, and writing the answer down is what keeps the next code
|
|
49
|
+
* from being added to the list by reflex.
|
|
50
|
+
*/
|
|
51
|
+
export interface WorkflowRetryPolicy {
|
|
52
|
+
/** The capability whose Workflows this governs — `secrets`, `payments`, `email`. Named in the fault's reason. */
|
|
53
|
+
capability: string;
|
|
54
|
+
/**
|
|
55
|
+
* `PithyError` code → why a retry can answer differently. **Everything absent is terminal**, including
|
|
56
|
+
* codes another capability retries: a 502 from a payment rail is worth re-driving, and a 502 from a
|
|
57
|
+
* store that has already taken the write is not.
|
|
58
|
+
*/
|
|
59
|
+
retryable: Readonly<Record<string, string>>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One fault, classified: what it was, what happens next, and the stated reason for it. */
|
|
63
|
+
export interface WorkflowFault {
|
|
64
|
+
/** Retry, or stop. */
|
|
65
|
+
disposition: WorkflowFaultDisposition;
|
|
66
|
+
/** The `PithyError` code, `d1/<fault>` for a D1 fault class, or `unclassified`. */
|
|
67
|
+
code: string;
|
|
68
|
+
/** Why — the policy's own sentence for a retry, or what made this terminal. */
|
|
69
|
+
reason: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The `code` reported for a throw no layer recognizes. */
|
|
73
|
+
const UNCLASSIFIED = "unclassified";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The code a thrown value carries, or `null` when it is not a `PithyError`.
|
|
77
|
+
*
|
|
78
|
+
* The structural read is not belt-and-braces: `instanceof` is per module instance, and a bundler that
|
|
79
|
+
* gives a host worker two copies of core would silently answer `false` for a perfectly good
|
|
80
|
+
* `PithyError` — which here means classifying a stated retryable fault as terminal. The shape is
|
|
81
|
+
* checked, not assumed.
|
|
82
|
+
*/
|
|
83
|
+
function pithyCodeOf(error: unknown): string | null {
|
|
84
|
+
if (error instanceof PithyError) return error.payload.code;
|
|
85
|
+
if (error && typeof error === "object" && "payload" in error) {
|
|
86
|
+
const payload = (error as { payload: unknown }).payload;
|
|
87
|
+
if (payload && typeof payload === "object" && "code" in payload) {
|
|
88
|
+
const code = (payload as { code: unknown }).code;
|
|
89
|
+
if (typeof code === "string") return code;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The public message of a thrown value, for the terminal error's own text. */
|
|
96
|
+
function messageOf(error: unknown): string {
|
|
97
|
+
return error instanceof Error ? error.message : String(error);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The operator's remedy a thrown value carries, or `undefined` when it states none.
|
|
102
|
+
*
|
|
103
|
+
* Read structurally for the same reason {@link pithyCodeOf} is: `instanceof` is per module instance,
|
|
104
|
+
* and two copies of core in one bundle would answer `false` for a perfectly good `PithyError` — here
|
|
105
|
+
* meaning a remedy the raising error stated is silently dropped at the boundary. That is the bug this
|
|
106
|
+
* function exists to end, so it is not reintroduced by the check.
|
|
107
|
+
*
|
|
108
|
+
* **`detail` is deliberately not read here, and there is no sibling for it.** `action` is already
|
|
109
|
+
* operator-facing — the CLI prints it, `operatorError` includes it. `detail` is throw-site context and
|
|
110
|
+
* stays at the throw site.
|
|
111
|
+
*/
|
|
112
|
+
function pithyActionOf(error: unknown): string | undefined {
|
|
113
|
+
if (error instanceof PithyError) return error.payload.action;
|
|
114
|
+
if (error && typeof error === "object" && "payload" in error) {
|
|
115
|
+
const payload = (error as { payload: unknown }).payload;
|
|
116
|
+
if (payload && typeof payload === "object" && "action" in payload) {
|
|
117
|
+
const action = (payload as { action: unknown }).action;
|
|
118
|
+
if (typeof action === "string") return action;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Classify one fault against a capability's policy. Pure, so a capability's classification is a unit
|
|
126
|
+
* test rather than a claim — and so the same function decides in a test, in Miniflare, and in production.
|
|
127
|
+
*/
|
|
128
|
+
export function classifyWorkflowFault(error: unknown, policy: WorkflowRetryPolicy): WorkflowFault {
|
|
129
|
+
const code = pithyCodeOf(error);
|
|
130
|
+
if (code !== null) {
|
|
131
|
+
const reason = policy.retryable[code];
|
|
132
|
+
if (reason !== undefined) return { disposition: "retry", code, reason };
|
|
133
|
+
return {
|
|
134
|
+
disposition: "terminal",
|
|
135
|
+
code,
|
|
136
|
+
reason: `${policy.capability} does not retry ${code}: a second attempt reaches the same answer.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const d1 = d1TransientFault(error);
|
|
141
|
+
if (d1 !== null) {
|
|
142
|
+
return {
|
|
143
|
+
disposition: "retry",
|
|
144
|
+
code: `d1/${d1}`,
|
|
145
|
+
reason: `D1 reported a transient ${d1} fault; withD1Retry classifies it retryable.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
disposition: "terminal",
|
|
151
|
+
code: UNCLASSIFIED,
|
|
152
|
+
reason: `Nothing classifies this fault, and ${policy.capability} retries only what it states.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The half of a Workflow step this kit uses: run a named body, or serve its journalled result. */
|
|
157
|
+
export interface WorkflowStepLike {
|
|
158
|
+
/** Run a named step, or return its journalled result if this instance already completed it. */
|
|
159
|
+
do<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The platform's terminal-error class — `NonRetryableError` from `cloudflare:workflows`, taken as a
|
|
164
|
+
* parameter.
|
|
165
|
+
*
|
|
166
|
+
* Injected rather than imported because `@pithy-sh/core` is bundled into every adopter's Worker *and*
|
|
167
|
+
* imported by the CLI under node, so it may not import a `cloudflare:` module. The Workflow class is
|
|
168
|
+
* already inside workerd and already imports one; handing the class in costs it a word and keeps the
|
|
169
|
+
* classification itself testable anywhere.
|
|
170
|
+
*
|
|
171
|
+
* **One argument, deliberately.** `NonRetryableError`'s constructor also takes a `name`, and the engine
|
|
172
|
+
* recognizes the error *by* that name: measured against a real Workflow in `wrangler dev`, passing any
|
|
173
|
+
* name but the default turned an instance that failed in 0.7s back into one that retried for 32s and
|
|
174
|
+
* then failed. The signature is narrowed so nobody can reintroduce that by being helpful.
|
|
175
|
+
*/
|
|
176
|
+
export interface TerminalErrorConstructor {
|
|
177
|
+
new (message: string): Error;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Wrap a Workflow's step runner so every fault it raises is classified against `policy`, and a terminal
|
|
182
|
+
* one is re-thrown as the platform's terminal error — **inside the step**, before the retry loop reads it.
|
|
183
|
+
*
|
|
184
|
+
* The result is structurally a step runner, so a body that already takes one (`reconcilePayments`,
|
|
185
|
+
* `runSendBatch`, `runAtRestKeyRotation`) takes this instead with no change: the classification is
|
|
186
|
+
* declared once, at the entrypoint, and every step below it inherits the decision.
|
|
187
|
+
*/
|
|
188
|
+
export function classifiedSteps(
|
|
189
|
+
step: WorkflowStepLike,
|
|
190
|
+
policy: WorkflowRetryPolicy,
|
|
191
|
+
terminalError: TerminalErrorConstructor,
|
|
192
|
+
): WorkflowStepLike {
|
|
193
|
+
return {
|
|
194
|
+
do<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
195
|
+
return step.do(name, async () => {
|
|
196
|
+
try {
|
|
197
|
+
return await fn();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const fault = classifyWorkflowFault(error, policy);
|
|
200
|
+
if (fault.disposition === "retry") throw error;
|
|
201
|
+
// The one sanctioned non-`PithyError` throw in the kit: a platform contract, at the durable
|
|
202
|
+
// boundary, carrying the `PithyError` it replaced as its `cause` so nothing is lost in this
|
|
203
|
+
// process. Across the boundary the text is the whole channel — the engine records it and
|
|
204
|
+
// discards the throw — so what an operator is owed is encoded into it, at one stated site,
|
|
205
|
+
// and read back at one (./stepMessage). The `cause` does not travel; `detail` never did.
|
|
206
|
+
const terminal = new terminalError(
|
|
207
|
+
encodeWorkflowStepMessage({
|
|
208
|
+
code: fault.code,
|
|
209
|
+
message: messageOf(error),
|
|
210
|
+
action: pithyActionOf(error),
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
terminal.cause = error;
|
|
214
|
+
throw terminal;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError } from "../error/pithyError";
|
|
5
|
+
import { ENVIRONMENT_VAR, PROJECT_VAR } from "../worker/identity";
|
|
6
|
+
import { type WorkflowHostNameParts, workflowHostName, workflowScriptName } from "./naming";
|
|
7
|
+
import type { WorkflowRegistry } from "./spec";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The prebuilt-worker host contract.
|
|
11
|
+
*
|
|
12
|
+
* A capability that owns Workflows ships a committed `wrangler.jsonc` **template** beside its
|
|
13
|
+
* worker entry — not an env-stanza file, because staging and production are genuinely separate
|
|
14
|
+
* Workers. `pithy <capability> provision` resolves that template into one complete config per
|
|
15
|
+
* environment and deploys each with `wrangler deploy --config <resolved>`. The adopter authors
|
|
16
|
+
* none of it.
|
|
17
|
+
*
|
|
18
|
+
* Email, secrets, and media each grew their own copy of that resolver. This module is the one they
|
|
19
|
+
* converge on: a template shape wide enough for all three, and a pure resolver that fills it.
|
|
20
|
+
*
|
|
21
|
+
* Pure by construction — no filesystem, no Cloudflare, no `cloudflare:workers`. The caller parses
|
|
22
|
+
* the template and writes the result. That keeps core node-safe (its `.describe()` meta-test
|
|
23
|
+
* imports every module eagerly under node) and keeps the ordering and idempotency logic unit-
|
|
24
|
+
* testable without an account.
|
|
25
|
+
*
|
|
26
|
+
* **Workflows cannot use remote bindings**, so a host always runs locally in `wrangler dev`. Any
|
|
27
|
+
* binding it needs that has no local emulation — Vectorize and Workers AI both lack one — must
|
|
28
|
+
* carry `remote: true`, which is why {@link WorkflowHostParams.remoteBindings} exists rather than
|
|
29
|
+
* the flag being inferred per binding kind.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** A D1 binding in a host template. `database_name` is rewritten only when the caller supplies one. */
|
|
33
|
+
export interface HostD1Binding {
|
|
34
|
+
binding: string;
|
|
35
|
+
database_name: string;
|
|
36
|
+
database_id: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A KV namespace binding in a host template. */
|
|
40
|
+
export interface HostKvBinding {
|
|
41
|
+
binding: string;
|
|
42
|
+
id: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An R2 bucket binding in a host template. */
|
|
46
|
+
export interface HostR2Binding {
|
|
47
|
+
binding: string;
|
|
48
|
+
bucket_name: string;
|
|
49
|
+
remote?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A Vectorize index binding in a host template. Always `remote: true` — Vectorize has no local emulation. */
|
|
53
|
+
export interface HostVectorizeBinding {
|
|
54
|
+
binding: string;
|
|
55
|
+
index_name: string;
|
|
56
|
+
remote?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The Workers AI binding in a host template. Always `remote: true` — Workers AI has no local emulation. */
|
|
60
|
+
export interface HostAiBinding {
|
|
61
|
+
binding: string;
|
|
62
|
+
remote?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A Cloudflare Email Service send binding in a host template. */
|
|
66
|
+
export interface HostSendEmailBinding {
|
|
67
|
+
name: string;
|
|
68
|
+
remote?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A Secrets Store entry in a host template — the master key a host reads its secrets through. */
|
|
72
|
+
export interface HostSecretsStoreBinding {
|
|
73
|
+
binding: string;
|
|
74
|
+
store_id: string;
|
|
75
|
+
secret_name: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One Workflow the host worker hosts: the binding, the deployed name, and the class that runs it. */
|
|
79
|
+
export interface HostWorkflowBinding {
|
|
80
|
+
binding: string;
|
|
81
|
+
name: string;
|
|
82
|
+
class_name: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A capability's committed `wrangler.jsonc` template, typed. Every field is optional but `name` and
|
|
87
|
+
* `main` — a host that binds no D1 is legal, and email binds no R2. Unknown keys survive
|
|
88
|
+
* `structuredClone` untouched, so a template may carry fields this contract does not model.
|
|
89
|
+
*/
|
|
90
|
+
export interface WorkflowHostTemplate {
|
|
91
|
+
name: string;
|
|
92
|
+
main: string;
|
|
93
|
+
compatibility_date?: string;
|
|
94
|
+
compatibility_flags?: string[];
|
|
95
|
+
workers_dev?: boolean;
|
|
96
|
+
d1_databases?: HostD1Binding[];
|
|
97
|
+
kv_namespaces?: HostKvBinding[];
|
|
98
|
+
r2_buckets?: HostR2Binding[];
|
|
99
|
+
vectorize?: HostVectorizeBinding[];
|
|
100
|
+
ai?: HostAiBinding;
|
|
101
|
+
send_email?: HostSendEmailBinding[];
|
|
102
|
+
secrets_store_secrets?: HostSecretsStoreBinding[];
|
|
103
|
+
workflows?: HostWorkflowBinding[];
|
|
104
|
+
triggers?: { crons: string[] };
|
|
105
|
+
vars?: Record<string, string>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What one environment's resolution needs. Everything absent is left exactly as the template had it. */
|
|
109
|
+
export interface WorkflowHostParams {
|
|
110
|
+
/**
|
|
111
|
+
* The project name — the `<project>` segment every derived name leads with. The root
|
|
112
|
+
* `pithy.config.ts` `name`, resolved by `requireProjectName` and **never guessed**: Worker script
|
|
113
|
+
* and Workflow names are account-scoped, so a wrong value here overwrites another project's
|
|
114
|
+
* running Worker rather than colliding with it.
|
|
115
|
+
*/
|
|
116
|
+
project: string;
|
|
117
|
+
/** The capability that owns the host — the `<capability>` segment of every derived name. */
|
|
118
|
+
capability: string;
|
|
119
|
+
/** The target environment (`dev` | `staging` | `production`). */
|
|
120
|
+
env: string;
|
|
121
|
+
/** D1 binding name → database id. An unlisted binding keeps the template's id. */
|
|
122
|
+
databaseIds?: Record<string, string>;
|
|
123
|
+
/**
|
|
124
|
+
* D1 binding name → database name. Rewritten **only** for the bindings listed here. Email
|
|
125
|
+
* deliberately lets `pithy-app`/`pithy-secrets` pass through while secrets rewrites its own, so
|
|
126
|
+
* the choice cannot be a blanket rule.
|
|
127
|
+
*/
|
|
128
|
+
databaseNames?: Record<string, string>;
|
|
129
|
+
/** KV binding name → namespace id. */
|
|
130
|
+
kvNamespaceIds?: Record<string, string>;
|
|
131
|
+
/** R2 binding name → bucket name. */
|
|
132
|
+
r2BucketNames?: Record<string, string>;
|
|
133
|
+
/** Vectorize binding name → index name. */
|
|
134
|
+
vectorizeIndexNames?: Record<string, string>;
|
|
135
|
+
/** The Secrets Store id, set on every `secrets_store_secrets` entry. */
|
|
136
|
+
secretsStoreId?: string;
|
|
137
|
+
/**
|
|
138
|
+
* The environment-scoped name of the master-key secret. Passed in rather than derived: core must
|
|
139
|
+
* not depend on `@pithy-sh/secrets`, which owns that naming.
|
|
140
|
+
*/
|
|
141
|
+
masterKeySecretName?: string;
|
|
142
|
+
/** Vars merged over the template's, e.g. the capability's config serialized as one JSON blob. */
|
|
143
|
+
vars?: Record<string, string>;
|
|
144
|
+
/**
|
|
145
|
+
* Binding names that must carry `remote: true`. A Workflow host runs locally in dev, so any
|
|
146
|
+
* binding without local emulation has to reach the real service.
|
|
147
|
+
*/
|
|
148
|
+
remoteBindings?: readonly string[];
|
|
149
|
+
/**
|
|
150
|
+
* KV binding names to drop. A template declares every binding a capability *might* need; a mode
|
|
151
|
+
* that does not use one must not ship a binding pointing at a namespace that was never created.
|
|
152
|
+
*/
|
|
153
|
+
omitKvBindings?: readonly string[];
|
|
154
|
+
/**
|
|
155
|
+
* The host's `workflows` array, already derived from the capability's registry by
|
|
156
|
+
* {@link hostWorkflowsFor}. **Required whenever the template declares any `workflows`.**
|
|
157
|
+
*
|
|
158
|
+
* The resolver used to build these by appending `-<env>` to the template's own name, which cannot
|
|
159
|
+
* produce a project-scoped name: the template says `pithy-email-send`, and the resolver has no way
|
|
160
|
+
* to recover the job from it. Deriving them from the registry is the only path that knows both the
|
|
161
|
+
* project and the job, so the textual suffix is gone and its absence is an error rather than a
|
|
162
|
+
* silent fallback to an unscoped, account-colliding Workflow name.
|
|
163
|
+
*/
|
|
164
|
+
workflows?: readonly HostWorkflowBinding[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Apply `remote: true` to an entry whose binding the caller listed. */
|
|
168
|
+
function withRemote<T extends { remote?: boolean }>(entry: T, name: string, remote: ReadonlySet<string>): T {
|
|
169
|
+
return remote.has(name) ? { ...entry, remote: true } : entry;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a host template into one environment's complete, standalone config.
|
|
174
|
+
*
|
|
175
|
+
* Structural, never textual: the template is `structuredClone`d and fields are overwritten by
|
|
176
|
+
* binding-name lookup. The `<filled-at-provision>` markers in the committed templates are
|
|
177
|
+
* documentation for whoever reads the file — nothing matches or replaces them, so a template that
|
|
178
|
+
* spells a placeholder differently still resolves correctly.
|
|
179
|
+
*
|
|
180
|
+
* The worker name becomes `<project>-<env>-<capability>` through {@link workflowHostName}, and the
|
|
181
|
+
* `workflows` array is replaced wholesale by `params.workflows` (derived from the registry by
|
|
182
|
+
* {@link hostWorkflowsFor}), so the deployed names cannot drift from what the dispatcher and the
|
|
183
|
+
* CLI compute. `binding` and
|
|
184
|
+
* `class_name` are never rewritten — they are code references, not deployment identities.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveWorkflowHost(template: WorkflowHostTemplate, params: WorkflowHostParams): WorkflowHostTemplate {
|
|
187
|
+
const resolved = structuredClone(template);
|
|
188
|
+
const remote = new Set(params.remoteBindings ?? []);
|
|
189
|
+
|
|
190
|
+
resolved.name = workflowHostName({ project: params.project, capability: params.capability, env: params.env });
|
|
191
|
+
|
|
192
|
+
if (resolved.d1_databases) {
|
|
193
|
+
resolved.d1_databases = resolved.d1_databases.map((entry) => ({
|
|
194
|
+
...entry,
|
|
195
|
+
database_name: params.databaseNames?.[entry.binding] ?? entry.database_name,
|
|
196
|
+
database_id: params.databaseIds?.[entry.binding] ?? entry.database_id,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (resolved.kv_namespaces) {
|
|
201
|
+
const omit = new Set(params.omitKvBindings ?? []);
|
|
202
|
+
const kept = resolved.kv_namespaces.filter((entry) => !omit.has(entry.binding));
|
|
203
|
+
// Drop the key entirely when nothing survives — wrangler treats an empty array as a declaration.
|
|
204
|
+
if (kept.length > 0) {
|
|
205
|
+
resolved.kv_namespaces = kept.map((entry) => ({
|
|
206
|
+
...entry,
|
|
207
|
+
id: params.kvNamespaceIds?.[entry.binding] ?? entry.id,
|
|
208
|
+
}));
|
|
209
|
+
} else {
|
|
210
|
+
resolved.kv_namespaces = undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (resolved.r2_buckets) {
|
|
215
|
+
resolved.r2_buckets = resolved.r2_buckets.map((entry) =>
|
|
216
|
+
withRemote(
|
|
217
|
+
{ ...entry, bucket_name: params.r2BucketNames?.[entry.binding] ?? entry.bucket_name },
|
|
218
|
+
entry.binding,
|
|
219
|
+
remote,
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (resolved.vectorize) {
|
|
225
|
+
resolved.vectorize = resolved.vectorize.map((entry) =>
|
|
226
|
+
withRemote(
|
|
227
|
+
{ ...entry, index_name: params.vectorizeIndexNames?.[entry.binding] ?? entry.index_name },
|
|
228
|
+
entry.binding,
|
|
229
|
+
remote,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (resolved.ai) {
|
|
235
|
+
resolved.ai = withRemote(resolved.ai, resolved.ai.binding, remote);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (resolved.send_email) {
|
|
239
|
+
resolved.send_email = resolved.send_email.map((entry) => withRemote(entry, entry.name, remote));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (resolved.secrets_store_secrets && params.secretsStoreId !== undefined) {
|
|
243
|
+
const storeId = params.secretsStoreId;
|
|
244
|
+
const masterKey = params.masterKeySecretName;
|
|
245
|
+
resolved.secrets_store_secrets = resolved.secrets_store_secrets.map((entry) => ({
|
|
246
|
+
...entry,
|
|
247
|
+
store_id: storeId,
|
|
248
|
+
// Only the master key is environment-scoped; every other entry keeps its declared name.
|
|
249
|
+
secret_name:
|
|
250
|
+
masterKey !== undefined && entry.binding === "SECRETS_ENCRYPTION_KEYS" ? masterKey : entry.secret_name,
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (resolved.workflows) {
|
|
255
|
+
if (!params.workflows) {
|
|
256
|
+
throw new InternalError({
|
|
257
|
+
message: `The ${params.capability} host template declares workflows but none were derived.`,
|
|
258
|
+
action: "Pass `workflows` from hostWorkflowsFor(registry, { project, capability, env }).",
|
|
259
|
+
detail:
|
|
260
|
+
"A Workflow name is account-scoped and must carry the project; the template name alone cannot produce one.",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
resolved.workflows = params.workflows.map((entry) => ({ ...entry }));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Identity last: a caller's vars can add anything, but never restate who this worker is.
|
|
267
|
+
resolved.vars = {
|
|
268
|
+
...resolved.vars,
|
|
269
|
+
...params.vars,
|
|
270
|
+
[ENVIRONMENT_VAR]: params.env,
|
|
271
|
+
[PROJECT_VAR]: params.project,
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return resolved;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Derive a host's `workflows` array and `triggers.crons` from the registry, so a capability's specs
|
|
279
|
+
* — not a hand-maintained block in its template — are the source of both. A spec without a
|
|
280
|
+
* `className` cannot be hosted: there would be no class for wrangler to instantiate.
|
|
281
|
+
*/
|
|
282
|
+
export function hostWorkflowsFor(
|
|
283
|
+
registry: WorkflowRegistry,
|
|
284
|
+
parts: WorkflowHostNameParts,
|
|
285
|
+
): { workflows: HostWorkflowBinding[]; crons: string[] } {
|
|
286
|
+
const { project, capability, env } = parts;
|
|
287
|
+
const owned = Object.values(registry).filter((entry) => entry.capability === capability);
|
|
288
|
+
const workflows: HostWorkflowBinding[] = [];
|
|
289
|
+
const crons: string[] = [];
|
|
290
|
+
|
|
291
|
+
for (const entry of owned) {
|
|
292
|
+
if (!entry.spec.className) {
|
|
293
|
+
throw new InternalError({
|
|
294
|
+
message: `Workflow "${entry.key}" cannot be hosted without a className.`,
|
|
295
|
+
action: "Add className to the spec, naming the exported WorkflowEntrypoint class.",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
workflows.push({
|
|
299
|
+
binding: entry.spec.binding,
|
|
300
|
+
name: workflowScriptName({ project, capability, job: entry.job, env }),
|
|
301
|
+
class_name: entry.spec.className,
|
|
302
|
+
});
|
|
303
|
+
if (entry.spec.schedule) crons.push(entry.spec.schedule);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { workflows, crons };
|
|
307
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { HttpError } from "../error/http";
|
|
5
|
+
import { NotFoundError } from "../error/pithyError";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The default export of a Workflow host that serves nothing over HTTP (#426).
|
|
9
|
+
*
|
|
10
|
+
* ## Why a host needs a default export at all
|
|
11
|
+
*
|
|
12
|
+
* wrangler infers a worker's **module format** from one thing: whether the entry has a default export. A
|
|
13
|
+
* module that exports only classes is read as a service worker, and a service worker may not import
|
|
14
|
+
* `cloudflare:workers` — so the build does not warn and continue, it fails:
|
|
15
|
+
*
|
|
16
|
+
* ▲ [WARNING] The entrypoint … has exports like an ES Module, but hasn't defined a default export
|
|
17
|
+
* like a module worker normally would. Building the worker using "service-worker" format...
|
|
18
|
+
* ✘ [ERROR] Unexpected external import of "cloudflare:workers" and "cloudflare:workflows".
|
|
19
|
+
*
|
|
20
|
+
* `pithy dev` builds each worker separately and carries on past one that fails, so the whole of what an
|
|
21
|
+
* adopter sees is a host that is not there. `support`, `media` and `vector` each shipped that way, and every
|
|
22
|
+
* host that did not was a host with a cron — `export default { async scheduled(…) }`, written for the cron,
|
|
23
|
+
* making the module an ES module by accident. The four authors who satisfied the rule had a reason unrelated
|
|
24
|
+
* to it, and the three who did not had no reason at all. `cli/src/ci/workflowModuleFormat.test.ts` is what
|
|
25
|
+
* now asks the question of the class rather than of the author.
|
|
26
|
+
*
|
|
27
|
+
* ## Why it refuses rather than being empty
|
|
28
|
+
*
|
|
29
|
+
* `export default {}` would build. It would also say nothing: an operator who reaches this Worker in a
|
|
30
|
+
* browser or with a stray `curl` gets an empty 200-shaped nothing from a Worker they were told hosts a job.
|
|
31
|
+
* A Workflow host has no request surface — an app Worker starts its jobs through a cross-script Workflow
|
|
32
|
+
* binding, and a host with a cron fires its own `scheduled` — so an HTTP request arriving at one is a
|
|
33
|
+
* misconfiguration, and the refusal is the sentence that ends the operator's search. `core/not_found`,
|
|
34
|
+
* because that is the truth: this Worker has no HTTP resources. The `action` names the path that does work
|
|
35
|
+
* and is stripped before the body goes out, like every other action.
|
|
36
|
+
*
|
|
37
|
+
* ## The one host this is not for
|
|
38
|
+
*
|
|
39
|
+
* A host that *does* serve HTTP mounts `registerWorkflowDispatchRoute` (./dispatchRoute) and exports its own
|
|
40
|
+
* handler. `@pithy-sh/email` is the case, and its door is the loopback dispatch `pithy dev` knocks on
|
|
41
|
+
* (#410). The other hosts have not been given that door yet; when one is, its `export default` becomes the
|
|
42
|
+
* Hono app and this refusal becomes that app's fallthrough. Nothing here has to change for that.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** What a refusing host exports: a `fetch`, and nothing else. Every other entry a Worker has is absent. */
|
|
46
|
+
export interface WorkflowHostEntry {
|
|
47
|
+
/** Refuse the request. The platform passes a request, an env and a context; none is read, so none is declared. */
|
|
48
|
+
fetch(): Promise<Response>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the default export for a Workflow host with no HTTP surface.
|
|
53
|
+
*
|
|
54
|
+
* Takes the capability so the refusal says which Worker answered — an operator holding a port number and a
|
|
55
|
+
* 404 needs that before anything else.
|
|
56
|
+
*/
|
|
57
|
+
export function workflowHostEntry(capability: string): WorkflowHostEntry {
|
|
58
|
+
return {
|
|
59
|
+
async fetch(): Promise<Response> {
|
|
60
|
+
const refusal = new NotFoundError({
|
|
61
|
+
message: `The ${capability} workflow host serves no HTTP requests.`,
|
|
62
|
+
action: `Start its Workflows through the ${capability} Workflow binding, not over HTTP.`,
|
|
63
|
+
detail: "This Worker hosts Workflow classes and registers no routes.",
|
|
64
|
+
});
|
|
65
|
+
return new Response(JSON.stringify({ error: HttpError.encode(refusal.payload) }), {
|
|
66
|
+
status: refusal.payload.status,
|
|
67
|
+
headers: { "content-type": "application/json" },
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|