astroidjs 0.16.0 → 0.18.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/bin/astroid.mjs +221 -82
- package/dist/auth/index.js +2 -2
- package/dist/commerce/roles.js +1 -1
- package/dist/commerce/sync.js +2 -1
- package/dist/config.d.ts +26 -0
- package/dist/config.js +9 -9
- package/dist/project/build-output.d.ts +18 -0
- package/dist/project/build-output.js +64 -0
- package/dist/project/generate.d.ts +19 -0
- package/dist/project/generate.js +38 -4
- package/dist/project/index.d.ts +2 -0
- package/dist/project/index.js +2 -0
- package/dist/project/provision.d.ts +52 -1
- package/dist/project/provision.js +77 -7
- package/dist/project/release.d.ts +6 -0
- package/dist/project/release.js +41 -2
- package/dist/project/scaffold.d.ts +17 -0
- package/dist/project/scaffold.js +73 -0
- package/dist/project/ship.d.ts +42 -0
- package/dist/project/ship.js +115 -0
- package/dist/queues/index.d.ts +2 -2
- package/dist/queues/index.js +2 -2
- package/dist/queues/messages.d.ts +6 -0
- package/dist/queues/messages.js +6 -0
- package/dist/queues/scaffold.js +30 -17
- package/dist/queues/webhook.d.ts +31 -2
- package/dist/queues/webhook.js +31 -0
- package/dist/schema/framework.d.ts +4 -3
- package/dist/schema/framework.js +7 -6
- package/dist/worker/gateway.d.ts +12 -0
- package/dist/worker/gateway.js +32 -0
- package/dist/worker/generate.js +70 -13
- package/dist/worker/index.d.ts +1 -0
- package/dist/worker/index.js +1 -0
- package/dist/worker/routes.d.ts +1 -1
- package/dist/worker/routes.js +6 -1
- package/package.json +3 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// What `astroid ship` runs, as a plan: Workers Builds calls
|
|
4
|
+
// `astroid ship production` on the `deploy/production` build and
|
|
5
|
+
// `astroid ship preview` on every other branch.
|
|
6
|
+
//
|
|
7
|
+
// By default both targets apply D1 migrations first. An app whose database
|
|
8
|
+
// another app migrates sets `deploy.migrations: false`, and then neither target
|
|
9
|
+
// touches the migrations ledger. Two apps on one database that both migrated
|
|
10
|
+
// would race: one release tag deploys both Workers in no guaranteed order,
|
|
11
|
+
// nothing serializes two `wrangler d1 migrations apply` runs, and a
|
|
12
|
+
// non-idempotent statement such as `ALTER TABLE … ADD COLUMN` then fails the
|
|
13
|
+
// second deploy.
|
|
14
|
+
//
|
|
15
|
+
// Pure: config and wrangler.jsonc text in, steps out, so it's tested directly
|
|
16
|
+
// and the CLI only runs them.
|
|
17
|
+
import { parseJsonc } from "./previews.js";
|
|
18
|
+
/** Whether this app applies D1 migrations when it deploys. Default `true`. */
|
|
19
|
+
export function astroidRunsMigrations(config) {
|
|
20
|
+
return config.deploy?.migrations !== false;
|
|
21
|
+
}
|
|
22
|
+
/** Where `ship preview` writes the config that names the staging database. */
|
|
23
|
+
export const ASTROID_PREVIEW_MIGRATIONS_CONFIG = ".wrangler/astroid-preview-migrations.jsonc";
|
|
24
|
+
/** Printed in place of the migrations step when `deploy.migrations` is `false`. */
|
|
25
|
+
export const ASTROID_SKIP_MIGRATIONS_NOTE = "(deploy.migrations is false, so this app applies no migrations: another app owns this database's schema)";
|
|
26
|
+
/** The steps `astroid ship <target>` runs for a project. */
|
|
27
|
+
export function astroidShipPlan(target, config, { wrangler, root, branch }) {
|
|
28
|
+
const migrate = astroidRunsMigrations(config);
|
|
29
|
+
if (target === "production") {
|
|
30
|
+
return [
|
|
31
|
+
migrate
|
|
32
|
+
? { run: ["d1", "migrations", "apply", "DB", "--remote"] }
|
|
33
|
+
: { note: ASTROID_SKIP_MIGRATIONS_NOTE },
|
|
34
|
+
{ run: ["deploy"] },
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
const steps = [];
|
|
38
|
+
if (!migrate) {
|
|
39
|
+
steps.push({ note: ASTROID_SKIP_MIGRATIONS_NOTE });
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
// The staging database is declared only inside `previews`, and wrangler's
|
|
43
|
+
// migrations command reads top-level `d1_databases`. So write a throwaway
|
|
44
|
+
// config naming it, derived from wrangler.jsonc on every run, rather than a
|
|
45
|
+
// second committed file that could drift from the binding.
|
|
46
|
+
const parsed = parseJsonc(wrangler);
|
|
47
|
+
const prodDb = (parsed.d1_databases ?? []).find((d) => d.binding === "DB");
|
|
48
|
+
const stagingDb = (parsed.previews?.d1_databases ?? []).find((d) => d.binding === "DB");
|
|
49
|
+
if (stagingDb && prodDb) {
|
|
50
|
+
steps.push({
|
|
51
|
+
write: {
|
|
52
|
+
path: ASTROID_PREVIEW_MIGRATIONS_CONFIG,
|
|
53
|
+
contents: JSON.stringify({
|
|
54
|
+
d1_databases: [
|
|
55
|
+
{
|
|
56
|
+
binding: "PREVIEW_DB",
|
|
57
|
+
database_name: stagingDb.database_name,
|
|
58
|
+
database_id: stagingDb.database_id,
|
|
59
|
+
// Absolute, so it doesn't depend on how Wrangler resolves a
|
|
60
|
+
// relative path in a config that lives in `.wrangler/`.
|
|
61
|
+
migrations_dir: fromRoot(root, prodDb.migrations_dir ?? "migrations"),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}, null, 2),
|
|
65
|
+
},
|
|
66
|
+
}, {
|
|
67
|
+
run: [
|
|
68
|
+
"d1",
|
|
69
|
+
"migrations",
|
|
70
|
+
"apply",
|
|
71
|
+
"PREVIEW_DB",
|
|
72
|
+
"--remote",
|
|
73
|
+
"--config",
|
|
74
|
+
ASTROID_PREVIEW_MIGRATIONS_CONFIG,
|
|
75
|
+
],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
steps.push({ note: "(no staging D1 in `previews`, so no staging migrations to apply)" });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Workers Builds names the branch in WORKERS_CI_BRANCH; a Preview name is a
|
|
83
|
+
// DNS label, so a branch like feature/12-login becomes feature-12-login.
|
|
84
|
+
const name = branch
|
|
85
|
+
? branch
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
88
|
+
.replace(/^-+|-+$/g, "")
|
|
89
|
+
.slice(0, 63)
|
|
90
|
+
: undefined;
|
|
91
|
+
steps.push({ run: ["preview", ...(name ? ["--name", name] : [])] });
|
|
92
|
+
return steps;
|
|
93
|
+
}
|
|
94
|
+
/** A project-relative path made absolute. An absolute one is kept. */
|
|
95
|
+
function fromRoot(root, path) {
|
|
96
|
+
if (path.startsWith("/"))
|
|
97
|
+
return path;
|
|
98
|
+
const relative = path.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
99
|
+
return `${root.replace(/\/+$/, "")}/${relative}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* `astroid doctor`'s check on who migrates this app's database. Returns an
|
|
103
|
+
* error when `deploy.migrations` is `false` but `wrangler.jsonc` still names a
|
|
104
|
+
* `migrations_dir` for `DB`, since that contradiction means someone expects
|
|
105
|
+
* this app to migrate. `null` when there's nothing to report.
|
|
106
|
+
*/
|
|
107
|
+
export function migrationsOwnershipError(config, wrangler) {
|
|
108
|
+
if (astroidRunsMigrations(config))
|
|
109
|
+
return null;
|
|
110
|
+
if (!/"migrations_dir"\s*:/.test(wrangler))
|
|
111
|
+
return null;
|
|
112
|
+
return ("astroid.config sets deploy.migrations to false, but wrangler.jsonc still declares a " +
|
|
113
|
+
"`migrations_dir`. Remove `migrations_dir` if another app owns this database's schema, " +
|
|
114
|
+
"or drop `migrations: false` if this app does.");
|
|
115
|
+
}
|
package/dist/queues/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { astroidQueueHandler, type QueueHandlerOptions } from "./consumer.js";
|
|
2
|
-
export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, type AstroidQueueMessage, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, type CatalogRefreshMessage, type WebhookMessage, } from "./messages.js";
|
|
2
|
+
export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, type AstroidQueueMessage, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, type CatalogRefreshMessage, type WebhookMessage, } from "./messages.js";
|
|
3
3
|
export { generateAstroidEnvBindings, generateAstroidQueueSeam, generateAstroidWebhookRoute, generateAstroidWebhookRoutes, } from "./scaffold.js";
|
|
4
|
-
export { handleWebhook, type QueueProducer, type WebhookRouteOptions, type WebhookVerifyInput, } from "./webhook.js";
|
|
4
|
+
export { astroidQueue, handleWebhook, type QueueProducer, type WebhookRouteOptions, type WebhookVerifyInput, } from "./webhook.js";
|
package/dist/queues/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
2
|
export { astroidQueueHandler } from "./consumer.js";
|
|
3
|
-
export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "./messages.js";
|
|
3
|
+
export { affectsCatalog, ASTROID_DEFAULT_CRON, ASTROID_HEALTH_CRON, ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "./messages.js";
|
|
4
4
|
export { generateAstroidEnvBindings, generateAstroidQueueSeam, generateAstroidWebhookRoute, generateAstroidWebhookRoutes, } from "./scaffold.js";
|
|
5
|
-
export { handleWebhook, } from "./webhook.js";
|
|
5
|
+
export { astroidQueue, handleWebhook, } from "./webhook.js";
|
|
@@ -31,6 +31,12 @@ export declare const ASTROID_HEALTH_CRON = "17 4 * * *";
|
|
|
31
31
|
export declare function astroidCrons(config: AstroidConfig): string[];
|
|
32
32
|
/** Binding name for the project's queue producer. */
|
|
33
33
|
export declare const ASTROID_QUEUE_BINDING = "COMMERCE_QUEUE";
|
|
34
|
+
/**
|
|
35
|
+
* Seconds Cloudflare waits before redelivering a failed message, when the
|
|
36
|
+
* config sets no `queues.retryDelay`. A consumer that sets its own delay per
|
|
37
|
+
* message, as `processBatch` can, overrides it.
|
|
38
|
+
*/
|
|
39
|
+
export declare const ASTROID_QUEUE_RETRY_DELAY = 30;
|
|
34
40
|
/** Queue names derived from the project key—the main queue and its DLQ. */
|
|
35
41
|
export declare function astroidQueueNames(config: AstroidConfig): {
|
|
36
42
|
queue: string;
|
package/dist/queues/messages.js
CHANGED
|
@@ -52,6 +52,12 @@ export function astroidCrons(config) {
|
|
|
52
52
|
}
|
|
53
53
|
/** Binding name for the project's queue producer. */
|
|
54
54
|
export const ASTROID_QUEUE_BINDING = "COMMERCE_QUEUE";
|
|
55
|
+
/**
|
|
56
|
+
* Seconds Cloudflare waits before redelivering a failed message, when the
|
|
57
|
+
* config sets no `queues.retryDelay`. A consumer that sets its own delay per
|
|
58
|
+
* message, as `processBatch` can, overrides it.
|
|
59
|
+
*/
|
|
60
|
+
export const ASTROID_QUEUE_RETRY_DELAY = 30;
|
|
55
61
|
/** Queue names derived from the project key—the main queue and its DLQ. */
|
|
56
62
|
export function astroidQueueNames(config) {
|
|
57
63
|
return { queue: `${config.key}-commerce`, dlq: `${config.key}-commerce-dlq` };
|
package/dist/queues/scaffold.js
CHANGED
|
@@ -105,9 +105,20 @@ export function generateAstroidQueueSeam(config) {
|
|
|
105
105
|
"// MEANS for this project.",
|
|
106
106
|
"//",
|
|
107
107
|
"// Throwing marks the message for retry. That's usually right—a failed",
|
|
108
|
-
"// refresh means the site is serving stale data—and
|
|
109
|
-
"//
|
|
110
|
-
|
|
108
|
+
"// refresh means the site is serving stale data—and the queue owns the",
|
|
109
|
+
"// retries: it waits `retry_delay` between deliveries and routes the message",
|
|
110
|
+
"// to the DLQ once it exceeds max_retries (wrangler.jsonc).",
|
|
111
|
+
'import { astroidQueue, astroidQueueHandler, type AstroidQueueMessage } from "astroidjs";',
|
|
112
|
+
"",
|
|
113
|
+
`// Send every message through this, never \`env.${ASTROID_QUEUE_BINDING}\` directly: the`,
|
|
114
|
+
"// queue binding when there is one, otherwise `handleQueueMessage` run in the",
|
|
115
|
+
"// request. A staging Preview has no queue, because a consumer can't target a",
|
|
116
|
+
"// Preview, so a producer that sends straight to the binding throws there.",
|
|
117
|
+
"// The inline path outlasts nothing but the request, with no retry and no",
|
|
118
|
+
"// DLQ, so it's a Preview fallback, not a production path.",
|
|
119
|
+
"export function commerceQueue(env: CloudflareEnv) {",
|
|
120
|
+
` return astroidQueue(env.${ASTROID_QUEUE_BINDING}, (message) => handleQueueMessage(env, message));`,
|
|
121
|
+
"}",
|
|
111
122
|
"",
|
|
112
123
|
"export async function handleQueueMessage(",
|
|
113
124
|
" env: CloudflareEnv,",
|
|
@@ -135,20 +146,22 @@ export function generateAstroidQueueSeam(config) {
|
|
|
135
146
|
` // const r = await astroidCatalogSync(items, { db: env.DB, table: ${JSON.stringify(table)} });`,
|
|
136
147
|
' // if (r.failed > 0) console.warn("[catalog] skipped", r.failed, r.errors);',
|
|
137
148
|
" //",
|
|
138
|
-
//
|
|
139
|
-
//
|
|
149
|
+
// One layer owns retries, and it's the queue: it survives the isolate,
|
|
150
|
+
// waits between deliveries, and ends in the DLQ. Retries inside the handler
|
|
151
|
+
// multiply with the queue's (#61).
|
|
140
152
|
...(provider === "square"
|
|
141
153
|
? [
|
|
142
|
-
" //
|
|
143
|
-
" //
|
|
144
|
-
" //
|
|
145
|
-
" //
|
|
146
|
-
" //
|
|
154
|
+
" // Leave SquareConfig's `retry` off here. The queue already redelivers",
|
|
155
|
+
" // a failed message, `retry_delay` apart, up to max_retries times",
|
|
156
|
+
" // (wrangler.jsonc). Client retries multiply with those deliveries:",
|
|
157
|
+
" // `retry: { attempts: 3 }` on a queue with max_retries 5 is up to 24",
|
|
158
|
+
" // Square calls for one message, against a provider that's already",
|
|
159
|
+
" // failing or rate limiting.",
|
|
147
160
|
]
|
|
148
161
|
: [
|
|
149
|
-
" //
|
|
150
|
-
" //
|
|
151
|
-
" //
|
|
162
|
+
" // Leave the client's own retries off here. The queue already",
|
|
163
|
+
" // redelivers a failed message, `retry_delay` apart, up to max_retries",
|
|
164
|
+
" // times (wrangler.jsonc), and client retries multiply with those.",
|
|
152
165
|
]),
|
|
153
166
|
" //",
|
|
154
167
|
" // The sync is idempotent (keyed on the provider's id) and never",
|
|
@@ -198,12 +211,13 @@ export function generateAstroidWebhookRoute(config, forProvider) {
|
|
|
198
211
|
"// secret land afterwards instead of being lost.",
|
|
199
212
|
"//",
|
|
200
213
|
"// A staging Preview has no queue, since a consumer can't target one, so",
|
|
201
|
-
"//
|
|
214
|
+
"// `commerceQueue` runs the event in the request through the consumer's own",
|
|
215
|
+
"// handler instead (src/queue.ts).",
|
|
202
216
|
'import type { APIRoute } from "astro";',
|
|
203
217
|
'import { handleWebhook, readModuleSecret } from "astroidjs";',
|
|
204
218
|
'import { env } from "cloudflare:workers";',
|
|
205
219
|
`import { ${p.verifier} } from ${JSON.stringify(p.module)};`,
|
|
206
|
-
'import {
|
|
220
|
+
'import { commerceQueue } from "../../../queue";',
|
|
207
221
|
"",
|
|
208
222
|
"export const prerender = false;",
|
|
209
223
|
"",
|
|
@@ -214,8 +228,7 @@ export function generateAstroidWebhookRoute(config, forProvider) {
|
|
|
214
228
|
" return handleWebhook(request, url, {",
|
|
215
229
|
` provider: ${JSON.stringify(provider)},`,
|
|
216
230
|
` secret: await readModuleSecret(env.${COMMERCE_PROVIDER_SECRETS[provider].webhook}),`,
|
|
217
|
-
|
|
218
|
-
" inline: (message) => handleQueueMessage(env, message),",
|
|
231
|
+
" queue: commerceQueue(env),",
|
|
219
232
|
` verify: ({ raw, secret }) => ${p.call},`,
|
|
220
233
|
" });",
|
|
221
234
|
"};",
|
package/dist/queues/webhook.d.ts
CHANGED
|
@@ -10,6 +10,29 @@ import type { AstroidQueueMessage } from "./messages.js";
|
|
|
10
10
|
export interface QueueProducer<T = AstroidQueueMessage> {
|
|
11
11
|
send(message: T): Promise<unknown>;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The queue binding when it's bound, and otherwise a stand-in producer that
|
|
15
|
+
* runs `handler` on each message in the request.
|
|
16
|
+
*
|
|
17
|
+
* A staging Preview leaves the queue unbound, because a queue consumer can't
|
|
18
|
+
* target a Preview (louise-toolkit ADR 0017), so every producer that sends
|
|
19
|
+
* straight to the binding throws there. Send through this instead, with the
|
|
20
|
+
* handler the queue consumer runs, and the same code works on both:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* export const commerceQueue = (env: CloudflareEnv) =>
|
|
24
|
+
* astroidQueue(env.COMMERCE_QUEUE, (message) => handleQueueMessage(env, message));
|
|
25
|
+
*
|
|
26
|
+
* await commerceQueue(env).send({ kind: "catalog_refresh" });
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* It's a Preview fallback, not a production path. A message run inline lasts
|
|
30
|
+
* only as long as the request, gets no retry and no dead-letter queue, and a
|
|
31
|
+
* slow one holds the response open. The stand-in's `send` throws whatever the
|
|
32
|
+
* handler throws, where a real `send` would have succeeded and left the failure
|
|
33
|
+
* to the consumer. The binding always wins when it's there.
|
|
34
|
+
*/
|
|
35
|
+
export declare function astroidQueue<T = AstroidQueueMessage>(queue: QueueProducer<T> | null | undefined, handler: (message: T) => Promise<void>): QueueProducer<T>;
|
|
13
36
|
export interface WebhookVerifyInput {
|
|
14
37
|
/** The raw request body, exactly as received. */
|
|
15
38
|
raw: string;
|
|
@@ -28,10 +51,16 @@ export interface WebhookRouteOptions {
|
|
|
28
51
|
secret: string | null;
|
|
29
52
|
/** Signature check over the raw body—for example, `verifySquareSignature`. */
|
|
30
53
|
verify: (input: WebhookVerifyInput) => boolean | Promise<boolean>;
|
|
31
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* The queue binding, or null/undefined when Queues aren't provisioned. Pass
|
|
56
|
+
* {@link astroidQueue}'s producer to run the message in the request when the
|
|
57
|
+
* binding is absent, such as on a staging Preview.
|
|
58
|
+
*/
|
|
32
59
|
queue?: QueueProducer | null;
|
|
33
60
|
/**
|
|
34
|
-
* Run a message in the request instead, when `queue` is absent.
|
|
61
|
+
* Run a message in the request instead, when `queue` is absent. Prefer
|
|
62
|
+
* passing {@link astroidQueue}'s producer as `queue`, which covers every
|
|
63
|
+
* producer in a site rather than this one route. A staging
|
|
35
64
|
* Preview leaves the queue unbound, because a queue consumer can't target a
|
|
36
65
|
* Preview (louise-toolkit ADR 0017), so without this every webhook a Preview
|
|
37
66
|
* receives answers 503 and the provider retries it forever.
|
package/dist/queues/webhook.js
CHANGED
|
@@ -14,6 +14,37 @@
|
|
|
14
14
|
// available: a 5xx means "try again", a 4xx means "never again", and returning
|
|
15
15
|
// the wrong one either loses the event permanently or pins the provider in a
|
|
16
16
|
// retry loop. Each code below is chosen for what it tells the sender to do.
|
|
17
|
+
/**
|
|
18
|
+
* The queue binding when it's bound, and otherwise a stand-in producer that
|
|
19
|
+
* runs `handler` on each message in the request.
|
|
20
|
+
*
|
|
21
|
+
* A staging Preview leaves the queue unbound, because a queue consumer can't
|
|
22
|
+
* target a Preview (louise-toolkit ADR 0017), so every producer that sends
|
|
23
|
+
* straight to the binding throws there. Send through this instead, with the
|
|
24
|
+
* handler the queue consumer runs, and the same code works on both:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* export const commerceQueue = (env: CloudflareEnv) =>
|
|
28
|
+
* astroidQueue(env.COMMERCE_QUEUE, (message) => handleQueueMessage(env, message));
|
|
29
|
+
*
|
|
30
|
+
* await commerceQueue(env).send({ kind: "catalog_refresh" });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* It's a Preview fallback, not a production path. A message run inline lasts
|
|
34
|
+
* only as long as the request, gets no retry and no dead-letter queue, and a
|
|
35
|
+
* slow one holds the response open. The stand-in's `send` throws whatever the
|
|
36
|
+
* handler throws, where a real `send` would have succeeded and left the failure
|
|
37
|
+
* to the consumer. The binding always wins when it's there.
|
|
38
|
+
*/
|
|
39
|
+
export function astroidQueue(queue, handler) {
|
|
40
|
+
if (queue)
|
|
41
|
+
return queue;
|
|
42
|
+
return {
|
|
43
|
+
send: async (message) => {
|
|
44
|
+
await handler(message);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
17
48
|
const text = (body, status) => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
18
49
|
/** Default event-type reader: a top-level `type` string. */
|
|
19
50
|
function defaultEventType(payload) {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { AstroidConfig } from "../config.js";
|
|
2
2
|
/** A ready-made table Astroid re-exports from `louise-toolkit/db`. */
|
|
3
|
-
export type AstroidFrameworkTable = "inquiries" | "media" | "siteSettings";
|
|
3
|
+
export type AstroidFrameworkTable = "inquiries" | "media" | "pageRedirects" | "siteSettings";
|
|
4
4
|
/** True when the site captures inquiries—a contact section or a
|
|
5
5
|
* wholesale-inquiry module. Shared by table selection (here) and route selection
|
|
6
6
|
* (the worker route plan). */
|
|
7
7
|
export declare function capturesInquiries(config: AstroidConfig): boolean;
|
|
8
8
|
/**
|
|
9
9
|
* The framework tables this project needs, sorted alphabetically (so the emitted
|
|
10
|
-
* import/export lists are stable). `media`
|
|
11
|
-
* when a brand captures them.
|
|
10
|
+
* import/export lists are stable). `media`, `pageRedirects`, and `siteSettings`
|
|
11
|
+
* always; `inquiries` when a brand captures them. `pageRedirects` keeps a
|
|
12
|
+
* renamed page's old URL working, which every site with a Pages panel needs.
|
|
12
13
|
*/
|
|
13
14
|
export declare function astroidFrameworkTables(config: AstroidConfig): AstroidFrameworkTable[];
|
package/dist/schema/framework.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Which ready-made Louise tables (louise-toolkit/db) an Astroid project needs.
|
|
4
4
|
// Astroid re-exports these rather than redefining them, so the core content
|
|
5
|
-
// tables never drift from Louise. `media` and `siteSettings`
|
|
6
|
-
// `inquiries` is pulled in only when a brand actually captures
|
|
7
|
-
// contact section, or a wholesale-inquiry module).
|
|
5
|
+
// tables never drift from Louise. `media`, `pageRedirects`, and `siteSettings`
|
|
6
|
+
// are universal; `inquiries` is pulled in only when a brand actually captures
|
|
7
|
+
// inquiries (a contact section, or a wholesale-inquiry module).
|
|
8
8
|
/** True when the site captures inquiries—a contact section or a
|
|
9
9
|
* wholesale-inquiry module. Shared by table selection (here) and route selection
|
|
10
10
|
* (the worker route plan). */
|
|
@@ -21,11 +21,12 @@ export function capturesInquiries(config) {
|
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* The framework tables this project needs, sorted alphabetically (so the emitted
|
|
24
|
-
* import/export lists are stable). `media`
|
|
25
|
-
* when a brand captures them.
|
|
24
|
+
* import/export lists are stable). `media`, `pageRedirects`, and `siteSettings`
|
|
25
|
+
* always; `inquiries` when a brand captures them. `pageRedirects` keeps a
|
|
26
|
+
* renamed page's old URL working, which every site with a Pages panel needs.
|
|
26
27
|
*/
|
|
27
28
|
export function astroidFrameworkTables(config) {
|
|
28
|
-
const tables = ["media", "siteSettings"];
|
|
29
|
+
const tables = ["media", "pageRedirects", "siteSettings"];
|
|
29
30
|
if (capturesInquiries(config))
|
|
30
31
|
tables.push("inquiries");
|
|
31
32
|
return tables.sort();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AiGatewayOptions } from "louise-toolkit/ai";
|
|
2
|
+
/** The `vars` entry that names the site's AI Gateway. */
|
|
3
|
+
export declare const ASTROID_AI_GATEWAY_VAR = "AI_GATEWAY_ID";
|
|
4
|
+
/**
|
|
5
|
+
* The AI Gateway options for this request's environment, or `undefined` to
|
|
6
|
+
* call Workers AI directly. Reads `AI_GATEWAY_ID`: unset, empty, or the
|
|
7
|
+
* placeholder sentinel all mean off.
|
|
8
|
+
*
|
|
9
|
+
* Takes `env` as `unknown`, like the toolkit's `aiRunner`, so a site whose
|
|
10
|
+
* `CloudflareEnv` doesn't declare the variable still compiles.
|
|
11
|
+
*/
|
|
12
|
+
export declare function astroidAiGateway(env: unknown): AiGatewayOptions | undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// AI Gateway for the editor's AI assists, off until a site names one.
|
|
4
|
+
//
|
|
5
|
+
// Every louise-toolkit AI route takes a `gateway` option, and without one no
|
|
6
|
+
// successful AI call is logged anywhere: no latency, no error rate, and no
|
|
7
|
+
// cache. A gateway gives all three with no toolkit change. It's a variable,
|
|
8
|
+
// not config, so production can log through one while a Preview, which sets
|
|
9
|
+
// no variable, calls Workers AI directly.
|
|
10
|
+
//
|
|
11
|
+
// The log holds the text an editor sends to the assists. Say so on the site's
|
|
12
|
+
// privacy page before you set the variable.
|
|
13
|
+
import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
|
|
14
|
+
/** The `vars` entry that names the site's AI Gateway. */
|
|
15
|
+
export const ASTROID_AI_GATEWAY_VAR = "AI_GATEWAY_ID";
|
|
16
|
+
/**
|
|
17
|
+
* The AI Gateway options for this request's environment, or `undefined` to
|
|
18
|
+
* call Workers AI directly. Reads `AI_GATEWAY_ID`: unset, empty, or the
|
|
19
|
+
* placeholder sentinel all mean off.
|
|
20
|
+
*
|
|
21
|
+
* Takes `env` as `unknown`, like the toolkit's `aiRunner`, so a site whose
|
|
22
|
+
* `CloudflareEnv` doesn't declare the variable still compiles.
|
|
23
|
+
*/
|
|
24
|
+
export function astroidAiGateway(env) {
|
|
25
|
+
const id = env?.[ASTROID_AI_GATEWAY_VAR];
|
|
26
|
+
if (typeof id !== "string")
|
|
27
|
+
return undefined;
|
|
28
|
+
const trimmed = id.trim();
|
|
29
|
+
if (!trimmed || trimmed === ASTROID_SECRET_PLACEHOLDER)
|
|
30
|
+
return undefined;
|
|
31
|
+
return { id: trimmed };
|
|
32
|
+
}
|
package/dist/worker/generate.js
CHANGED
|
@@ -68,10 +68,14 @@ export function generateAstroidWorker(config) {
|
|
|
68
68
|
const AI_ROUTES = new Set(["ai", "seoFix", "media"]);
|
|
69
69
|
const editorImports = [
|
|
70
70
|
"DEFAULT_PAGE_FIELDS",
|
|
71
|
+
"type PagesWrite",
|
|
72
|
+
"MEDIA_ALT_MISSING_SQL",
|
|
73
|
+
"d1Check",
|
|
71
74
|
...new Set(plan.map((route) => route.factory).filter((f) => !realtimeRouteFactories.has(f))),
|
|
72
75
|
].sort();
|
|
73
76
|
const tables = [
|
|
74
77
|
"media",
|
|
78
|
+
"pageRedirects",
|
|
75
79
|
"pages",
|
|
76
80
|
"pagesVersions",
|
|
77
81
|
"siteSettings",
|
|
@@ -89,10 +93,13 @@ export function generateAstroidWorker(config) {
|
|
|
89
93
|
return `vitalsRoute({ dataset: (env) => env.${ASTROID_VITALS_BINDING} })`;
|
|
90
94
|
case "health":
|
|
91
95
|
return "healthRoute({ resolveEditor, read: readSiteHealth })";
|
|
96
|
+
case "status":
|
|
97
|
+
return "statusRoute({ checks: STATUS_CHECKS, reuseMs: STATUS_REUSE_MS })";
|
|
92
98
|
case "realtime":
|
|
93
99
|
return `realtimeRoute({ resolveEditor, namespace: (env) => env.${ASTROID_REALTIME_BINDING} })`;
|
|
94
100
|
case "versions":
|
|
95
|
-
|
|
101
|
+
// `redirects` records `/old → /new` when a publish changes the slug.
|
|
102
|
+
return "versionsRoute({ table: pages, versionsTable: pagesVersions, config: pagesCollection, resolveEditor, bufferKv: (env) => env.DRAFTS, redirects: pageRedirects })";
|
|
96
103
|
case "search":
|
|
97
104
|
return "searchRoute({ table: pages, config: pagesCollection, resolveEditor })";
|
|
98
105
|
case "pages":
|
|
@@ -104,9 +111,17 @@ export function generateAstroidWorker(config) {
|
|
|
104
111
|
//
|
|
105
112
|
// `versionsTable` makes a DELETE remove the page's version snapshots,
|
|
106
113
|
// which have no foreign key to the page and would otherwise orphan.
|
|
107
|
-
// `afterWrite`
|
|
108
|
-
// stale.
|
|
109
|
-
|
|
114
|
+
// `afterWrite` syncs the written page's search index entry, which plain
|
|
115
|
+
// CRUD writes leave stale.
|
|
116
|
+
//
|
|
117
|
+
// `drafts` carries a Pages panel update into the page's pending draft,
|
|
118
|
+
// with the same config and buffer versionsRoute uses. Without it, a
|
|
119
|
+
// rename made while a draft was pending came undone at the next
|
|
120
|
+
// publish, which copies the whole draft snapshot onto the live row.
|
|
121
|
+
//
|
|
122
|
+
// `redirects` records `/old → /new` in the same batch as a slug change,
|
|
123
|
+
// and the middleware's `redirectFor` serves it.
|
|
124
|
+
return 'pagesRoute({ table: pages, versionsTable: pagesVersions, drafts: { config: pagesCollection, bufferKv: (env) => env.DRAFTS }, redirects: pageRedirects, resolveEditor, fields: [...DEFAULT_PAGE_FIELDS, "sections"], ...pagesWriteHooks, afterWrite: reindexPagesSearch })';
|
|
110
125
|
case "save":
|
|
111
126
|
// No `bufferKv` here, deliberately: `saveRoute` has no such option. It
|
|
112
127
|
// writes live field saves (title, SEO) straight through, and the draft
|
|
@@ -130,10 +145,12 @@ export function generateAstroidWorker(config) {
|
|
|
130
145
|
// LOUISE_AI kill switch, so all three assists share one definition of
|
|
131
146
|
// "is generation on?" instead of each re-deriving it. Embeddings keep
|
|
132
147
|
// binding-presence as their switch—see the helper's comment.
|
|
148
|
+
// `gateway` routes both through AI Gateway when the site sets
|
|
149
|
+
// `AI_GATEWAY_ID`, and calls Workers AI directly when it doesn't.
|
|
133
150
|
case "ai":
|
|
134
|
-
return "aiRoute({ resolveEditor, ai: aiRunner })";
|
|
151
|
+
return "aiRoute({ resolveEditor, ai: aiRunner, gateway: astroidAiGateway })";
|
|
135
152
|
case "seoFix":
|
|
136
|
-
return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner })";
|
|
153
|
+
return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner, gateway: astroidAiGateway })";
|
|
137
154
|
case "media": {
|
|
138
155
|
// `altText` fills a new upload's alt from the image itself. Best-effort
|
|
139
156
|
// by contract—a model error or a missing binding never fails the
|
|
@@ -175,7 +192,7 @@ export function generateAstroidWorker(config) {
|
|
|
175
192
|
p('import { env } from "cloudflare:workers";');
|
|
176
193
|
p('import { handle } from "@astrojs/cloudflare/handler";');
|
|
177
194
|
p('import type { EditorSession } from "louise-toolkit/auth";');
|
|
178
|
-
p('import {
|
|
195
|
+
p('import { reindexDoc } from "louise-toolkit/content";');
|
|
179
196
|
p(inquiries
|
|
180
197
|
? 'import { db, inquiriesForm } from "louise-toolkit/db";'
|
|
181
198
|
: 'import { db } from "louise-toolkit/db";');
|
|
@@ -196,10 +213,14 @@ export function generateAstroidWorker(config) {
|
|
|
196
213
|
p('import { aiRunner } from "louise-toolkit/ai";');
|
|
197
214
|
}
|
|
198
215
|
p('import { checkLinks } from "louise-toolkit/browser";');
|
|
216
|
+
p('import { reportDegraded } from "louise-toolkit/errors";');
|
|
199
217
|
p('import { readHealthSummary, summarizeHealth, writeHealthSummary } from "louise-toolkit/health";');
|
|
200
218
|
p('import { composeWorker, isEditRequest, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
|
|
201
219
|
p(`import { ${tables.join(", ")} } from "./schema.js";`);
|
|
202
220
|
const astroidImports = [
|
|
221
|
+
...(plan.some((route) => route.name === "ai" || route.name === "seoFix")
|
|
222
|
+
? ["astroidAiGateway"]
|
|
223
|
+
: []),
|
|
203
224
|
"astroidPagesCollection",
|
|
204
225
|
"astroidPagesWriteHooks",
|
|
205
226
|
"readModuleSecret",
|
|
@@ -220,6 +241,11 @@ export function generateAstroidWorker(config) {
|
|
|
220
241
|
p("// route. Scaffolded once and yours to edit.");
|
|
221
242
|
p('import { pagesHooks } from "./pages-hooks.js";');
|
|
222
243
|
}
|
|
244
|
+
if (config.status?.checks) {
|
|
245
|
+
p("// Your STATUS seam: the site's own checks for the public status route,");
|
|
246
|
+
p("// such as a catalog snapshot's age. Scaffolded once and yours to edit.");
|
|
247
|
+
p('import { statusChecks } from "./status-checks.js";');
|
|
248
|
+
}
|
|
223
249
|
if (config.settings?.hooks) {
|
|
224
250
|
p("// Your SETTINGS seam: per-key sanitizers and a GET transform for the");
|
|
225
251
|
p("// Settings panel. Scaffolded once and yours to edit.");
|
|
@@ -251,10 +277,12 @@ export function generateAstroidWorker(config) {
|
|
|
251
277
|
: "const pagesWriteHooks = astroidPagesWriteHooks(astroidConfig);");
|
|
252
278
|
p();
|
|
253
279
|
p("// pagesRoute writes with plain Drizzle, so the full-text index doesn't see a");
|
|
254
|
-
p("// title or slug change until something
|
|
255
|
-
p("//
|
|
256
|
-
p("
|
|
257
|
-
p("
|
|
280
|
+
p("// title or slug change until something syncs it. This syncs only the page the");
|
|
281
|
+
p("// write touched, rather than rebuilding the whole index on every save. After a");
|
|
282
|
+
p("// delete the row is gone, and reindexDoc removes its entry. Best-effort:");
|
|
283
|
+
p("// pagesRoute swallows a throw here, so a stale index never fails the write.");
|
|
284
|
+
p("async function reindexPagesSearch(_editor: EditorSession, { id }: PagesWrite): Promise<void> {");
|
|
285
|
+
p(" await reindexDoc(db(env.DB), pages, pagesCollection, id);");
|
|
258
286
|
p("}");
|
|
259
287
|
p();
|
|
260
288
|
p("// Editable site_settings columns the Settings panel may write, and which of");
|
|
@@ -311,7 +339,9 @@ export function generateAstroidWorker(config) {
|
|
|
311
339
|
p(" const origin = env.SITE_URL ?? mediaBaseOf(env);");
|
|
312
340
|
p(" const [brokenLinks, missingAlt, seoGaps] = await Promise.all([");
|
|
313
341
|
p(' checkLinks({ base: origin, paths: ["/"] }).catch(() => []),');
|
|
314
|
-
p("
|
|
342
|
+
p(" // Only an unwritten alt (NULL) is missing. An empty one is an image the");
|
|
343
|
+
p(" // owner marked decorative, which HTML says to skip.");
|
|
344
|
+
p(" countRows(env, `SELECT COUNT(*) AS n FROM media WHERE ${MEDIA_ALT_MISSING_SQL}`),");
|
|
315
345
|
p(" countRows(");
|
|
316
346
|
p(" env,");
|
|
317
347
|
p(" \"SELECT COUNT(*) AS n FROM pages WHERE status = 'published'\" +");
|
|
@@ -327,6 +357,27 @@ export function generateAstroidWorker(config) {
|
|
|
327
357
|
p(" return summary;");
|
|
328
358
|
p("}");
|
|
329
359
|
p();
|
|
360
|
+
p("// --- public status ---------------------------------------------------------");
|
|
361
|
+
p("// What statusRoute answers an outside probe with: 200 when every check");
|
|
362
|
+
p("// passes, 503 when any fails, throws, or takes over two seconds. Anyone can");
|
|
363
|
+
p("// make these run, so each is one cheap read, and a burst of probes reuses one");
|
|
364
|
+
p("// result per isolate for STATUS_REUSE_MS.");
|
|
365
|
+
p("const STATUS_REUSE_MS = 10_000;");
|
|
366
|
+
p("const STATUS_CHECKS = {");
|
|
367
|
+
p(" // D1 answers `SELECT 1`.");
|
|
368
|
+
p(" d1: d1Check((env: CloudflareEnv) => env.DB),");
|
|
369
|
+
p(" // The public home page reads real content, not the seed-me fallback the");
|
|
370
|
+
p(" // page renders when its row is missing (an unseeded or wiped database,");
|
|
371
|
+
p(" // or a migration that never ran, which throws and fails the check too).");
|
|
372
|
+
p(" content: async (env: CloudflareEnv) =>");
|
|
373
|
+
p(` (await env.DB.prepare("SELECT 1 FROM pages WHERE slug = 'home'").first()) !== null,`);
|
|
374
|
+
if (config.status?.checks) {
|
|
375
|
+
p(" // The site's own, from src/status-checks.ts. Spread last, so a site");
|
|
376
|
+
p(" // check with the same name replaces Astroid's on purpose.");
|
|
377
|
+
p(" ...statusChecks,");
|
|
378
|
+
}
|
|
379
|
+
p("};");
|
|
380
|
+
p();
|
|
330
381
|
p("/** One COUNT, degrading to 0—a missing table must not abort the scan. */");
|
|
331
382
|
p("async function countRows(env: CloudflareEnv, sql: string): Promise<number> {");
|
|
332
383
|
p(" try {");
|
|
@@ -473,7 +524,9 @@ export function generateAstroidWorker(config) {
|
|
|
473
524
|
p(` if (controller.cron === ${JSON.stringify(ASTROID_HEALTH_CRON)}) {`);
|
|
474
525
|
p(" // Daily site-health scan. `waitUntil` because the crawl outlives the");
|
|
475
526
|
p(" // handler's return, and a scan that throws must not retry the cron.");
|
|
476
|
-
p("
|
|
527
|
+
p(" // Reported rather than swallowed: the Health panel keeps showing the");
|
|
528
|
+
p(" // last good scan, so this log line is the only sign that one failed.");
|
|
529
|
+
p(' ctx.waitUntil(runHealthScan(env).catch((error) => reportDegraded("health.scan", error)));');
|
|
477
530
|
p(" return;");
|
|
478
531
|
p(" }");
|
|
479
532
|
if (cron) {
|
|
@@ -552,6 +605,7 @@ export function generateAstroidMiddleware(config) {
|
|
|
552
605
|
"// styles + inlined data: brand font are allowed.",
|
|
553
606
|
'import { env } from "cloudflare:workers";',
|
|
554
607
|
'import { createLouiseMiddleware } from "@louise-toolkit/astro";',
|
|
608
|
+
'import { db, pageRedirects, resolvePageRedirect } from "louise-toolkit/db";',
|
|
555
609
|
// One `astroidjs` import, composed from what this config actually uses—two
|
|
556
610
|
// import statements for the same module is legal and reads as an
|
|
557
611
|
// oversight in a file nobody is supposed to hand-edit.
|
|
@@ -609,6 +663,9 @@ export function generateAstroidMiddleware(config) {
|
|
|
609
663
|
" // anything the worker's routes didn't answer. A second check behind the",
|
|
610
664
|
" // worker's gate, and free: the editor is resolved here on every request.",
|
|
611
665
|
" apiGate: true,",
|
|
666
|
+
" // A renamed page's old URL answers a 301 to the new one. It runs only after",
|
|
667
|
+
" // the page answered 404, so a page later created on the old path wins.",
|
|
668
|
+
" redirectFor: (path) => resolvePageRedirect(db(env.DB), pageRedirects, path),",
|
|
612
669
|
// `extend` runs once and may need to populate BOTH—a tenanted site with a
|
|
613
670
|
// portal resolves a tenant and a customer on the same request.
|
|
614
671
|
...(portal || tenancy
|
package/dist/worker/index.d.ts
CHANGED
package/dist/worker/index.js
CHANGED