@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,258 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { InternalError } from "../error/pithyError";
|
|
6
|
+
import type { Logger } from "../logger/logger";
|
|
7
|
+
import { createWorkerLogger } from "../logger/worker";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What a capability host worker reads out of its env, and what fills each of it.
|
|
11
|
+
*
|
|
12
|
+
* A host is a Worker nobody authored: `pithy <capability> provision` resolves a committed template
|
|
13
|
+
* (see {@link ./host.ts}) and deploys it. Everything it needs therefore arrives as a binding, a var,
|
|
14
|
+
* a Secrets Store entry or a serialized config blob — and until this module, **none of it was
|
|
15
|
+
* validated anywhere.** `@pithy-sh/email`'s host read fourteen fields raw: a missing `BASE_URL`
|
|
16
|
+
* became a magic link to `undefined/…`, an unparseable `EMAIL_THEME` threw inside a render step
|
|
17
|
+
* three retries deep, and a `SCHEDULER_BATCH_SIZE` somebody typed as `"fifty"` became `NaN`, so the
|
|
18
|
+
* scheduler claimed nothing, quietly, forever. Every one of those is discovered as a mail that did
|
|
19
|
+
* not arrive.
|
|
20
|
+
*
|
|
21
|
+
* So a host declares its env once, as a Zod object with a `.describe()` per field, and pairs each
|
|
22
|
+
* field with the thing that provides it. Two consumers read that one declaration:
|
|
23
|
+
*
|
|
24
|
+
* - the host itself, at boot, through {@link requireHostEnv} — one legible block naming every
|
|
25
|
+
* unusable value and what fills it, then a refusal. A host that cannot work does not wait to be
|
|
26
|
+
* asked.
|
|
27
|
+
* - `pithy doctor`, statically, through {@link hostEnvFields} and {@link checkHostEnv} — the same
|
|
28
|
+
* schema, so the check an operator runs and the check the host runs cannot disagree.
|
|
29
|
+
*
|
|
30
|
+
* ## Kept importable from the CLI
|
|
31
|
+
*
|
|
32
|
+
* No `cloudflare:` import, no Node builtin, no filesystem — the same constraint {@link ./host.ts}
|
|
33
|
+
* carries, and for the same reason: `pithy doctor` runs under node and imports this to read a
|
|
34
|
+
* capability's declaration without executing its Worker.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The kinds of thing that can fill a host env field. One of these names is what an operator acts on. */
|
|
38
|
+
export const HostEnvProviderKind = z
|
|
39
|
+
.enum(["binding", "var", "secret", "config"])
|
|
40
|
+
.describe(
|
|
41
|
+
"Where a host env field comes from: a Worker `binding`, a wrangler `var`, a `secret` read through @pithy-sh/secrets, or a `config` key in the adopter's pithy.config.ts.",
|
|
42
|
+
);
|
|
43
|
+
export type HostEnvProviderKind = z.infer<typeof HostEnvProviderKind>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What provides one field. The whole point of the declaration: a report that says `BASE_URL is
|
|
47
|
+
* missing` sends a developer looking, and a report that says `Var BASE_URL in the host's
|
|
48
|
+
* wrangler.jsonc. Run pithy email provision --env dev.` ends the search.
|
|
49
|
+
*/
|
|
50
|
+
export const HostEnvProvider = z
|
|
51
|
+
.object({
|
|
52
|
+
kind: HostEnvProviderKind.describe("Which kind of thing fills this field."),
|
|
53
|
+
name: z
|
|
54
|
+
.string()
|
|
55
|
+
.min(1)
|
|
56
|
+
.describe("The binding name, var name, secret name, or config key — spelled exactly as it is authored."),
|
|
57
|
+
command: z
|
|
58
|
+
.string()
|
|
59
|
+
.min(1)
|
|
60
|
+
.optional()
|
|
61
|
+
.describe("The `pithy` command that fills it, where one does. Rendered as the report's action line."),
|
|
62
|
+
})
|
|
63
|
+
.describe("What fills one field of a host worker's env, so a failure names the thing an operator changes.");
|
|
64
|
+
export type HostEnvProvider = z.infer<typeof HostEnvProvider>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A host's env declaration: the capability that owns it, the schema, and a provider per field.
|
|
68
|
+
*
|
|
69
|
+
* **This type is the contract `pithy doctor` reads** (pithy-sh/pithy#411). A capability exports one
|
|
70
|
+
* of these as an ordinary value, so the CLI can import it, walk the schema, and validate a resolved
|
|
71
|
+
* configuration against the very object the deployed Worker validates itself against.
|
|
72
|
+
*/
|
|
73
|
+
export interface HostEnvDeclaration<Env extends z.ZodObject = z.ZodObject> {
|
|
74
|
+
/** The capability that owns the host — the `<capability>` segment of its worker name. */
|
|
75
|
+
readonly capability: string;
|
|
76
|
+
/** The env schema. `z.output` is what the host runs on; coercions and JSON parses belong in here. */
|
|
77
|
+
readonly env: Env;
|
|
78
|
+
/** Field name → what fills it. Every field of {@link env} has an entry; nothing else may. */
|
|
79
|
+
readonly provided: Readonly<Record<string, HostEnvProvider>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Declare a host's env. The mapped `provided` type makes an unaccounted field a compile error; the
|
|
84
|
+
* runtime check below makes it an error for a declaration the CLI reads back off a package it never
|
|
85
|
+
* compiled, which is the case `pithy doctor` is entirely made of.
|
|
86
|
+
*/
|
|
87
|
+
export function defineHostEnv<Env extends z.ZodObject>(declaration: {
|
|
88
|
+
capability: string;
|
|
89
|
+
env: Env;
|
|
90
|
+
provided: { readonly [Field in keyof Env["shape"] & string]: HostEnvProvider };
|
|
91
|
+
}): HostEnvDeclaration<Env> {
|
|
92
|
+
const fields = Object.keys(declaration.env.shape);
|
|
93
|
+
const unaccounted = fields.filter((field) => !(field in declaration.provided));
|
|
94
|
+
if (unaccounted.length > 0) {
|
|
95
|
+
throw new InternalError({
|
|
96
|
+
message: `The ${declaration.capability} host env declares fields nothing provides.`,
|
|
97
|
+
action: "Add a provider for each field: the binding, var, secret, or config key that fills it.",
|
|
98
|
+
detail: `Unaccounted fields: ${unaccounted.join(", ")}.`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return declaration;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One field of a host env that is missing or unparseable, with what would fix it. */
|
|
105
|
+
export interface HostEnvProblem {
|
|
106
|
+
/** The field name, as declared. */
|
|
107
|
+
field: string;
|
|
108
|
+
/** Why it is unusable — the Zod issue's own message, unedited. */
|
|
109
|
+
reason: string;
|
|
110
|
+
/** What provides it. */
|
|
111
|
+
provider: HostEnvProvider;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The outcome of checking one env against one declaration. `value` is present exactly when `ok`. */
|
|
115
|
+
export interface HostEnvReport<Value> {
|
|
116
|
+
/** Whether every declared field parsed. */
|
|
117
|
+
ok: boolean;
|
|
118
|
+
/** The parsed env — the shape the host runs on — or `undefined` when anything failed. */
|
|
119
|
+
value: Value | undefined;
|
|
120
|
+
/** Every unusable field, one entry each, in declaration order. */
|
|
121
|
+
problems: HostEnvProblem[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The provider reported for a field the declaration never accounted for. Should be unreachable. */
|
|
125
|
+
function undeclaredProvider(field: string): HostEnvProvider {
|
|
126
|
+
return { kind: "var", name: field };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check an env against a declaration. Pure: it parses, it reports, and it neither logs nor throws —
|
|
131
|
+
* which is what lets `pithy doctor` run the host's own check without a Worker.
|
|
132
|
+
*
|
|
133
|
+
* One problem per field, even where Zod raises several issues for it: an operator fixes a field, not
|
|
134
|
+
* an issue, and three lines about `EMAIL_THEME` push the field they have not read yet off the block.
|
|
135
|
+
*/
|
|
136
|
+
export function checkHostEnv<Env extends z.ZodObject>(
|
|
137
|
+
declaration: HostEnvDeclaration<Env>,
|
|
138
|
+
env: unknown,
|
|
139
|
+
): HostEnvReport<z.output<Env>> {
|
|
140
|
+
// An env that is not an object at all — `undefined` in a runtime that never bound one — still has to
|
|
141
|
+
// report per field rather than as one "expected object": the operator's next move is the same either
|
|
142
|
+
// way, and it is named in the fields.
|
|
143
|
+
const subject = typeof env === "object" && env !== null ? env : {};
|
|
144
|
+
const parsed = declaration.env.safeParse(subject);
|
|
145
|
+
if (parsed.success) return { ok: true, value: parsed.data, problems: [] };
|
|
146
|
+
|
|
147
|
+
const reasons = new Map<string, string>();
|
|
148
|
+
for (const issue of parsed.error.issues) {
|
|
149
|
+
const field = issue.path[0];
|
|
150
|
+
if (typeof field !== "string" || reasons.has(field)) continue;
|
|
151
|
+
reasons.set(field, issue.message);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const problems: HostEnvProblem[] = [];
|
|
155
|
+
for (const field of Object.keys(declaration.env.shape)) {
|
|
156
|
+
const reason = reasons.get(field);
|
|
157
|
+
if (reason === undefined) continue;
|
|
158
|
+
problems.push({ field, reason, provider: declaration.provided[field] ?? undeclaredProvider(field) });
|
|
159
|
+
}
|
|
160
|
+
return { ok: false, value: undefined, problems };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One sentence naming the thing an operator changes, plus the command that writes it where there is one. */
|
|
164
|
+
export function hostEnvProviderSentence(provider: HostEnvProvider): string {
|
|
165
|
+
const where =
|
|
166
|
+
provider.kind === "binding"
|
|
167
|
+
? `Binding ${provider.name} in the host's wrangler.jsonc.`
|
|
168
|
+
: provider.kind === "var"
|
|
169
|
+
? `Var ${provider.name} in the host's wrangler.jsonc.`
|
|
170
|
+
: provider.kind === "secret"
|
|
171
|
+
? `Secret ${provider.name}, read through @pithy-sh/secrets.`
|
|
172
|
+
: `Config key ${provider.name}.`;
|
|
173
|
+
return provider.command ? `${where} Run ${provider.command}.` : where;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The block a host logs before it refuses. One headline, then one line per field: the field, why it
|
|
178
|
+
* is unusable, and what fills it. Brand voice — short sentences, deliberate periods, no decoration
|
|
179
|
+
* that a log aggregator would have to strip.
|
|
180
|
+
*/
|
|
181
|
+
export function renderHostEnvProblems(capability: string, problems: readonly HostEnvProblem[]): string {
|
|
182
|
+
const lines = problems.map(
|
|
183
|
+
(problem) => ` ${problem.field} — ${problem.reason} ${hostEnvProviderSentence(problem.provider)}`,
|
|
184
|
+
);
|
|
185
|
+
const count = problems.length === 1 ? "1 setting is" : `${problems.length} settings are`;
|
|
186
|
+
return [`The ${capability} host cannot start. ${count} missing or unusable.`, ...lines].join("\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Every env object this process has already reported on, so a boot path that asks twice logs once.
|
|
191
|
+
*
|
|
192
|
+
* A module-scope call in a Worker happens once per isolate and needs no help; a host that validates
|
|
193
|
+
* inside `fetch` — or a `scheduled` handler firing every minute — would otherwise write the same
|
|
194
|
+
* block into Workers Logs forever, which is how a legible block becomes noise nobody reads.
|
|
195
|
+
*/
|
|
196
|
+
const reported = new WeakSet<object>();
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Validate a host's env, or say exactly what is wrong and refuse.
|
|
200
|
+
*
|
|
201
|
+
* Called at boot, before the host serves anything. The block goes to the log (the operator's
|
|
202
|
+
* audience, so it carries the provider lines) and the refusal is `core/internal`: a host that cannot
|
|
203
|
+
* parse config *we* generated is our failure, and our logs are where the answer is (CLAUDE.md
|
|
204
|
+
* §Errors). Nothing about the values themselves is logged — a provider name is a location, never a
|
|
205
|
+
* secret.
|
|
206
|
+
*/
|
|
207
|
+
export function requireHostEnv<Env extends z.ZodObject>(
|
|
208
|
+
declaration: HostEnvDeclaration<Env>,
|
|
209
|
+
env: unknown,
|
|
210
|
+
log: Logger = createWorkerLogger(),
|
|
211
|
+
): z.output<Env> {
|
|
212
|
+
const report = checkHostEnv(declaration, env);
|
|
213
|
+
if (report.ok && report.value !== undefined) return report.value;
|
|
214
|
+
|
|
215
|
+
const block = renderHostEnvProblems(declaration.capability, report.problems);
|
|
216
|
+
if (typeof env !== "object" || env === null || !reported.has(env)) {
|
|
217
|
+
if (typeof env === "object" && env !== null) reported.add(env);
|
|
218
|
+
log.error(block, {
|
|
219
|
+
capability: declaration.capability,
|
|
220
|
+
fields: report.problems.map((problem) => problem.field),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
throw new InternalError({
|
|
225
|
+
message: `The ${declaration.capability} host is not configured.`,
|
|
226
|
+
action: "Read the startup log: it names every unusable setting and what fills it.",
|
|
227
|
+
detail: block,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One declared field, flattened for a reader that never runs the host — `pithy doctor`'s row. */
|
|
232
|
+
export interface HostEnvField {
|
|
233
|
+
/** The field name, as the env carries it. */
|
|
234
|
+
field: string;
|
|
235
|
+
/** The field's own `.describe()`, or `undefined` where the schema carries none. */
|
|
236
|
+
description: string | undefined;
|
|
237
|
+
/** What provides it. */
|
|
238
|
+
provider: HostEnvProvider;
|
|
239
|
+
/** Whether the host works without it — i.e. whether the field's schema accepts `undefined`. */
|
|
240
|
+
optional: boolean;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Read a declaration statically: what the host needs, what documents each field, and what fills it.
|
|
245
|
+
*
|
|
246
|
+
* The static half of this module, and the one `pithy doctor` builds its report from (#411). Field
|
|
247
|
+
* order is the schema's, so a report reads in the order the capability chose to explain itself.
|
|
248
|
+
*/
|
|
249
|
+
export function hostEnvFields(declaration: HostEnvDeclaration): HostEnvField[] {
|
|
250
|
+
return Object.entries(declaration.env.shape).map(([field, schema]) => ({
|
|
251
|
+
field,
|
|
252
|
+
description: (schema as z.ZodType).description,
|
|
253
|
+
provider: declaration.provided[field] ?? undeclaredProvider(field),
|
|
254
|
+
// Asked of the schema rather than read off its wrapper chain: `.optional()`, `.default()` and a
|
|
255
|
+
// union with `undefined` all mean the same thing to an operator, and only the parse knows.
|
|
256
|
+
optional: (schema as z.ZodType).safeParse(undefined).success,
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { UpstreamError, UpstreamTimeoutError } from "../error/pithyError";
|
|
5
|
+
import { type WorkflowDispatchRequest, workflowDispatchPath } from "./schemas";
|
|
6
|
+
import type { WorkflowBinding } from "./spec";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The loopback dispatcher: the one-method dispatch seam, carried over a sibling's origin instead of
|
|
10
|
+
* a cross-script binding.
|
|
11
|
+
*
|
|
12
|
+
* `pithy dev` composes this in place of `env.EMAIL_SENDER` (and its eight siblings). Nothing at the
|
|
13
|
+
* call site changes — `enqueueEmail` takes a `create({ id, params })` and does not care what is
|
|
14
|
+
* behind it — which is the whole reason the seam was one method to begin with. The address comes
|
|
15
|
+
* from `<STEM>_ORIGIN`, which the dev orchestrator exports for every member of the dev set, pinned
|
|
16
|
+
* at feature-create and stable for the life of the feature.
|
|
17
|
+
*
|
|
18
|
+
* ## Failures are the sibling's, and they say so
|
|
19
|
+
*
|
|
20
|
+
* A worker that is not up, a port nothing is listening on, a host that refuses the dispatch: every
|
|
21
|
+
* one of those is a hop into something this process does not control, so it is
|
|
22
|
+
* `core/upstream_failed` (502) or `core/upstream_timeout` (504) — never `core/internal`, which would
|
|
23
|
+
* send an operator to read *our* logs about *their* Worker (CLAUDE.md §Errors). The `action` names
|
|
24
|
+
* the capability and the origin, because "is the email host running?" is the next question and the
|
|
25
|
+
* terminal already knows the answer.
|
|
26
|
+
*
|
|
27
|
+
* The timeout is not decoration. `enqueue` awaits this call on a request path, and a sibling that
|
|
28
|
+
* accepts a connection and never answers would otherwise hold a sign-in open until the platform
|
|
29
|
+
* killed it. A timed-out dispatch is reported as one — separately from a failed one — because the
|
|
30
|
+
* instance may have started regardless, and the scheduler's re-drive asks that question of the
|
|
31
|
+
* batch id rather than assuming an answer.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** How the dispatcher reaches the sibling. Structural, so a test hands over a plain function. */
|
|
35
|
+
export type LoopbackFetch = (url: string, init: RequestInit) => Promise<Response>;
|
|
36
|
+
|
|
37
|
+
/** What the dispatcher needs to stand in for one cross-script Workflow binding. */
|
|
38
|
+
export interface LoopbackWorkflowOptions {
|
|
39
|
+
/**
|
|
40
|
+
* The sibling worker's origin — `http://localhost:8797`, as `<STEM>_ORIGIN` carries it. A trailing
|
|
41
|
+
* slash is tolerated; nothing else about it is rewritten.
|
|
42
|
+
*/
|
|
43
|
+
origin: string;
|
|
44
|
+
/** The Workflow binding to start on, named exactly as the host's own env binds it. */
|
|
45
|
+
binding: string;
|
|
46
|
+
/** The capability that owns the host. Named in the action line, so a failure says which worker. */
|
|
47
|
+
capability?: string;
|
|
48
|
+
/** The transport. Defaults to the runtime's `fetch`; injectable for tests. */
|
|
49
|
+
fetch?: LoopbackFetch;
|
|
50
|
+
/** How long to wait for the sibling. Defaults to 10 s — a local hop, so anything slower is stuck. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 10 seconds. A loopback dispatch either lands immediately or is not going to. */
|
|
55
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
56
|
+
|
|
57
|
+
/** How much of a failing answer's body is worth carrying into a log line. */
|
|
58
|
+
const BODY_SNIPPET = 300;
|
|
59
|
+
|
|
60
|
+
/** Whether a rejection is the deadline rather than the connection. */
|
|
61
|
+
function isTimeout(cause: unknown): boolean {
|
|
62
|
+
const name = (cause as { name?: unknown } | null | undefined)?.name;
|
|
63
|
+
return name === "TimeoutError" || name === "AbortError";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A short, safe rendering of a failing answer's body. Never thrown from — a failure is already in hand. */
|
|
67
|
+
async function bodySnippet(response: Response): Promise<string> {
|
|
68
|
+
try {
|
|
69
|
+
return (await response.text()).slice(0, BODY_SNIPPET);
|
|
70
|
+
} catch {
|
|
71
|
+
return "<unreadable>";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a {@link WorkflowBinding} that dispatches over loopback to a sibling worker's dispatch route.
|
|
77
|
+
*
|
|
78
|
+
* Returns the plain seam, so it drops into any env slot a cross-script binding occupied — including
|
|
79
|
+
* `@pithy-sh/email`'s narrower `SendWorkflowBinding`, which is a subtype of this one.
|
|
80
|
+
*/
|
|
81
|
+
export function loopbackWorkflowBinding(options: LoopbackWorkflowOptions): WorkflowBinding {
|
|
82
|
+
const origin = options.origin.replace(/\/+$/, "");
|
|
83
|
+
const url = `${origin}${workflowDispatchPath(options.binding)}`;
|
|
84
|
+
const owner = options.capability ?? options.binding;
|
|
85
|
+
const send = options.fetch ?? ((target: string, init: RequestInit) => fetch(target, init));
|
|
86
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
87
|
+
const running = `Check that the ${owner} host is running under pithy dev at ${origin}.`;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
async create(instance?: { id?: string; params?: unknown }): Promise<unknown> {
|
|
91
|
+
// The body is the route's own request schema, built as that type rather than as a literal, so a
|
|
92
|
+
// change to the contract is a compile error on both ends of the wire at once.
|
|
93
|
+
const body: WorkflowDispatchRequest = { id: instance?.id, params: instance?.params };
|
|
94
|
+
|
|
95
|
+
let response: Response;
|
|
96
|
+
try {
|
|
97
|
+
response = await send(url, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "content-type": "application/json" },
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
102
|
+
});
|
|
103
|
+
} catch (cause) {
|
|
104
|
+
if (isTimeout(cause)) {
|
|
105
|
+
throw new UpstreamTimeoutError(
|
|
106
|
+
{
|
|
107
|
+
message: "The workflow host did not answer in time.",
|
|
108
|
+
action: running,
|
|
109
|
+
detail: `POST ${url} exceeded ${timeoutMs} ms.`,
|
|
110
|
+
},
|
|
111
|
+
{ cause },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
throw new UpstreamError(
|
|
115
|
+
{
|
|
116
|
+
message: "The workflow host could not be reached.",
|
|
117
|
+
action: running,
|
|
118
|
+
detail: `POST ${url} failed: ${String(cause)}`,
|
|
119
|
+
},
|
|
120
|
+
{ cause },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new UpstreamError({
|
|
126
|
+
message: "The workflow host refused the dispatch.",
|
|
127
|
+
action: running,
|
|
128
|
+
detail: `POST ${url} answered ${response.status}. ${await bodySnippet(response)}`,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The host answers `WorkflowDispatchResponse`; it is relayed rather than parsed, because the
|
|
133
|
+
// seam's own return type is `unknown` and no caller reads it. A body that is not JSON is still
|
|
134
|
+
// the sibling's fault, so it reports as one.
|
|
135
|
+
try {
|
|
136
|
+
return await response.json();
|
|
137
|
+
} catch (cause) {
|
|
138
|
+
throw new UpstreamError(
|
|
139
|
+
{
|
|
140
|
+
message: "The workflow host answered something that is not a dispatch result.",
|
|
141
|
+
action: running,
|
|
142
|
+
detail: `POST ${url} answered ${response.status} with an unreadable body.`,
|
|
143
|
+
},
|
|
144
|
+
{ cause },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { InternalError } from "../error/pithyError";
|
|
5
|
+
import { MAX_CAPABILITY_JOB, NAMESPACE_LIMITS } from "../naming/limits";
|
|
6
|
+
import { assertValidProjectName, kebab } from "../naming/resource";
|
|
7
|
+
import { NAME_SEGMENT } from "../naming/segment";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every name a durable job answers to, in one module. The CLI writes these into a host worker's
|
|
11
|
+
* resolved `wrangler.jsonc`, the provisioner deploys under them, and the dispatcher resolves
|
|
12
|
+
* against them — so they are derived here once rather than formatted at three call sites that
|
|
13
|
+
* would drift.
|
|
14
|
+
*
|
|
15
|
+
* The shape is the project-scoped rule from `@pithy-sh/core/src/naming/resource`:
|
|
16
|
+
* **`<project>-<env>-<capability>`** for a host worker, and that plus `-<job>` for a Workflow.
|
|
17
|
+
* Worker script names and Workflow names are both **account-scoped** in Cloudflare, so without the
|
|
18
|
+
* project segment a second Pithy project's deploy does not share a name — it overwrites the first
|
|
19
|
+
* project's running Worker. The project segment is what makes two projects in one account possible.
|
|
20
|
+
*
|
|
21
|
+
* Renaming a Workflow orphans every instance running under the old name, so these names are a
|
|
22
|
+
* contract once anything is deployed. That is also why an over-long name is **refused rather than
|
|
23
|
+
* truncated** here: a Workflow whose name silently changed shape is worse than a build error
|
|
24
|
+
* telling you to shorten a capability name.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cloudflare caps a Workflow name at 64 bytes.
|
|
29
|
+
*
|
|
30
|
+
* This is the number `WORKFLOW_DERIVED_PROJECT_NAME` comes from: a project is capped at whatever this
|
|
31
|
+
* leaves after the longest environment and the longest `<capability>-<job>`, so the refusal lands at
|
|
32
|
+
* `pithy init` rather than here, at the first deploy, with resources already provisioned.
|
|
33
|
+
* `naming.test.ts` pins the two together.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_WORKFLOW_NAME_BYTES = NAMESPACE_LIMITS.workflow.maxLength;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A Worker script name is **63**, not 64 — a different resource with a different number.
|
|
39
|
+
*
|
|
40
|
+
* Cloudflare allows 255 in general and 63 once a workers.dev subdomain is on. Pithy holds the 63:
|
|
41
|
+
* it is the only value that stays true after an adopter enables workers.dev, and a script cannot be
|
|
42
|
+
* renamed once anything points at it. The one-character gap between this and
|
|
43
|
+
* {@link MAX_WORKFLOW_NAME_BYTES} is real; collapsing the two would loosen this one.
|
|
44
|
+
*/
|
|
45
|
+
export const MAX_WORKER_NAME_BYTES = NAMESPACE_LIMITS.worker.maxLength;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reject a segment that cannot legally appear in a Cloudflare resource name, attributed to its role.
|
|
49
|
+
*
|
|
50
|
+
* The shape is {@link NAME_SEGMENT}, the one segment rule — a Workflow name and a Worker script name
|
|
51
|
+
* accept exactly what every other composed name does, so this reads the rule rather than restating it.
|
|
52
|
+
*/
|
|
53
|
+
function assertSegment(value: string, role: string): void {
|
|
54
|
+
if (!NAME_SEGMENT.test(value)) {
|
|
55
|
+
throw new InternalError({
|
|
56
|
+
message: `Invalid ${role} "${value}" in a workflow name.`,
|
|
57
|
+
action: "Use lowercase letters, digits, and single hyphens, starting with a letter.",
|
|
58
|
+
detail: `A ${role} must match ${NAME_SEGMENT.source} to be a legal Cloudflare resource-name segment.`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reject a composed name Cloudflare would refuse, naming the limit rather than letting the deploy fail.
|
|
65
|
+
*
|
|
66
|
+
* The limit is passed in because the two names this module composes have two different ones: a Workflow
|
|
67
|
+
* gets 64, the Worker script hosting it gets 63.
|
|
68
|
+
*/
|
|
69
|
+
function assertLength(name: string, limit: number): string {
|
|
70
|
+
if (new TextEncoder().encode(name).length > limit) {
|
|
71
|
+
throw new InternalError({
|
|
72
|
+
message: `The name "${name}" is longer than Cloudflare's ${limit}-byte limit.`,
|
|
73
|
+
action: "Shorten the project, capability, or job name.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reject a `<capability>-<job>` tail longer than {@link MAX_CAPABILITY_JOB} — the tail
|
|
81
|
+
* `MAX_PROJECT_NAME` was derived against.
|
|
82
|
+
*
|
|
83
|
+
* The project cap is only true while no registry declares a longer tail than the longest one that
|
|
84
|
+
* existed when it was computed. A capability that would invalidate it fails here, at the name it
|
|
85
|
+
* would deploy under, rather than quietly shortening the project name every existing project was
|
|
86
|
+
* already accepted under — a change that cannot be made after the fact, because a project name is
|
|
87
|
+
* stable forever once anything is provisioned.
|
|
88
|
+
*/
|
|
89
|
+
function assertCapabilityJob(capability: string, job: string): void {
|
|
90
|
+
const tail = `${capability}-${job}`;
|
|
91
|
+
if (tail.length > MAX_CAPABILITY_JOB) {
|
|
92
|
+
throw new InternalError({
|
|
93
|
+
message: `The job "${capability}/${job}" is ${tail.length} characters. A <capability>-<job> stops at ${MAX_CAPABILITY_JOB}.`,
|
|
94
|
+
action: "Shorten the capability or job name.",
|
|
95
|
+
detail: `MAX_PROJECT_NAME is derived against a ${MAX_CAPABILITY_JOB}-character tail. Raising MAX_CAPABILITY_JOB shortens every adopter's legal project name, and a provisioned project cannot be renamed.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The `<project>-<env>` head every deployed name shares. The project is held to core's one project rule
|
|
102
|
+
* — charset *and* length — because it comes from the adopter's `pithy.config.ts` and is the one segment
|
|
103
|
+
* a human typed; the rest is author-controlled code and is asserted rather than mangled, so a bad
|
|
104
|
+
* capability name is a build error, not a surprise.
|
|
105
|
+
*/
|
|
106
|
+
function head(project: string, env: string): string {
|
|
107
|
+
assertValidProjectName(project);
|
|
108
|
+
assertSegment(env, "environment");
|
|
109
|
+
return `${kebab(project)}-${env}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The identity of a capability's deployment in one environment. */
|
|
113
|
+
export interface WorkflowHostNameParts {
|
|
114
|
+
/** The project name — the root `pithy.config.ts` `name`, resolved by `requireProjectName` and never guessed. */
|
|
115
|
+
project: string;
|
|
116
|
+
/** The capability that owns the host worker (e.g. `email`). */
|
|
117
|
+
capability: string;
|
|
118
|
+
/** The deployment environment (e.g. `staging`). */
|
|
119
|
+
env: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The identity of one durable job's Workflow in one environment. */
|
|
123
|
+
export interface WorkflowScriptNameParts extends WorkflowHostNameParts {
|
|
124
|
+
/** The job within the capability (e.g. `send`). */
|
|
125
|
+
job: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The deployed name of one job's Workflow in one environment — `<project>-<env>-<capability>-<job>`,
|
|
130
|
+
* e.g. `acme-staging-media-image-to-text`. This is what the CLI writes as the `name` of a
|
|
131
|
+
* `workflows` entry, and what `CloudflareWorkflowsClient` addresses when triggering from outside
|
|
132
|
+
* the Worker.
|
|
133
|
+
*
|
|
134
|
+
* Takes an options object rather than positional arguments deliberately: every part is a `string`,
|
|
135
|
+
* so a swapped project and capability would type-check silently and deploy under a name teardown
|
|
136
|
+
* could never find again.
|
|
137
|
+
*/
|
|
138
|
+
export function workflowScriptName(parts: WorkflowScriptNameParts): string {
|
|
139
|
+
assertSegment(parts.capability, "capability");
|
|
140
|
+
assertSegment(parts.job, "job");
|
|
141
|
+
assertCapabilityJob(parts.capability, parts.job);
|
|
142
|
+
return assertLength(`${head(parts.project, parts.env)}-${parts.capability}-${parts.job}`, MAX_WORKFLOW_NAME_BYTES);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The deployed name of a capability's prebuilt host worker in one environment —
|
|
147
|
+
* `<project>-<env>-<capability>`. Same options-object rationale as {@link workflowScriptName}.
|
|
148
|
+
*
|
|
149
|
+
* Held to {@link MAX_WORKER_NAME_BYTES}, one character tighter than the Workflow it hosts: this is a
|
|
150
|
+
* Worker script name, and a workers.dev subdomain caps those at 63.
|
|
151
|
+
*/
|
|
152
|
+
export function workflowHostName(parts: WorkflowHostNameParts): string {
|
|
153
|
+
assertSegment(parts.capability, "capability");
|
|
154
|
+
return assertLength(`${head(parts.project, parts.env)}-${parts.capability}`, MAX_WORKER_NAME_BYTES);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The dispatch key a caller passes to `c.var.workflows.trigger(...)` — `<capability>/<job>`.
|
|
159
|
+
* Namespaced by capability so two capabilities may each own a job called `sweep`.
|
|
160
|
+
*
|
|
161
|
+
* **Deliberately not project-scoped.** This keys the in-Worker registry, and a Worker only ever
|
|
162
|
+
* hosts one project's capabilities — adding the project would put a value every call site already
|
|
163
|
+
* knows into a string every call site would have to thread. It is a dispatch key, not a
|
|
164
|
+
* Cloudflare resource name, so it shares none of the account-flatness problem the names above have.
|
|
165
|
+
*/
|
|
166
|
+
export function workflowKey(capability: string, job: string): string {
|
|
167
|
+
assertSegment(capability, "capability");
|
|
168
|
+
assertSegment(job, "job");
|
|
169
|
+
return `${capability}/${job}`;
|
|
170
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "../capability/capability";
|
|
5
|
+
import { InternalError } from "../error/pithyError";
|
|
6
|
+
import { workflowKey } from "./naming";
|
|
7
|
+
import type { RegisteredWorkflow, WorkflowRegistry } from "./spec";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registration: every capability's `workflows` map merged into one project-wide registry keyed
|
|
11
|
+
* `<capability>/<job>`. The peer of `composeDatabases` and `composeKv`, and deliberately simpler —
|
|
12
|
+
* a job belongs to exactly one capability, so there is no cross-capability merge to arbitrate,
|
|
13
|
+
* only a duplicate to reject.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Merge every capability's declared jobs into one registry. A capability declaring the same job
|
|
18
|
+
* name twice is impossible (it is a map key); two capabilities colliding is impossible (the key is
|
|
19
|
+
* capability-namespaced). What remains is one real conflict — the same capability `name` composed
|
|
20
|
+
* twice — which is an assembly-time author error, caught here rather than surfacing as a job that
|
|
21
|
+
* silently dispatches to the wrong binding.
|
|
22
|
+
*/
|
|
23
|
+
export function composeWorkflows(capabilities: readonly Capability[]): WorkflowRegistry {
|
|
24
|
+
const registry: WorkflowRegistry = {};
|
|
25
|
+
for (const capability of capabilities) {
|
|
26
|
+
for (const [job, spec] of Object.entries(capability.workflows ?? {})) {
|
|
27
|
+
const key = workflowKey(capability.name, job);
|
|
28
|
+
if (key in registry) {
|
|
29
|
+
throw new InternalError({
|
|
30
|
+
message: `Duplicate workflow "${key}".`,
|
|
31
|
+
action: "Compose each capability once — two capabilities are registering the same name.",
|
|
32
|
+
detail: `The capability "${capability.name}" is composed more than once, or two capabilities share the name.`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
registry[key] = { key, capability: capability.name, job, spec };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return registry;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every registered job carrying a cron — what `createEntrypoint` fires on a `scheduled` event. */
|
|
42
|
+
export function scheduledWorkflows(registry: WorkflowRegistry): RegisteredWorkflow[] {
|
|
43
|
+
return Object.values(registry).filter((entry) => entry.spec.schedule !== undefined);
|
|
44
|
+
}
|