astroidjs 0.20.0 → 0.21.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/dist/config.d.ts +20 -0
- package/dist/incidents/index.d.ts +2 -0
- package/dist/incidents/index.js +12 -0
- package/dist/incidents/names.d.ts +17 -0
- package/dist/incidents/names.js +55 -0
- package/dist/incidents/sentry.d.ts +20 -0
- package/dist/incidents/sentry.js +118 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pages/index.d.ts +19 -0
- package/dist/pages/index.js +34 -0
- package/dist/project/generate.js +15 -2
- package/dist/project/scaffold.js +14 -0
- package/dist/schema/generate.js +19 -0
- package/dist/worker/generate.js +98 -16
- package/package.json +8 -1
package/dist/config.d.ts
CHANGED
|
@@ -495,6 +495,24 @@ export interface StatusConfig {
|
|
|
495
495
|
*/
|
|
496
496
|
checks?: boolean;
|
|
497
497
|
}
|
|
498
|
+
/**
|
|
499
|
+
* Incident capture (louise-toolkit ADR 0022). Every Astroid site counts its
|
|
500
|
+
* failures into its own D1 and an Analytics Engine dataset; these settings add
|
|
501
|
+
* to that.
|
|
502
|
+
*/
|
|
503
|
+
export interface IncidentsConfig {
|
|
504
|
+
/**
|
|
505
|
+
* The failures that should alert: dotted names (`commerce.checkout`) and
|
|
506
|
+
* path prefixes (`/cart`). A site fact, so there's no default list.
|
|
507
|
+
*/
|
|
508
|
+
critical?: string[];
|
|
509
|
+
/**
|
|
510
|
+
* Also send each incident to Sentry, the operator's issue system for a
|
|
511
|
+
* Monitored or Supported site. It reads the DSN from the `SENTRY_DSN`
|
|
512
|
+
* secret, and stays dormant while that's unset or a placeholder.
|
|
513
|
+
*/
|
|
514
|
+
sentry?: boolean;
|
|
515
|
+
}
|
|
498
516
|
export interface DeployConfig {
|
|
499
517
|
platform: "cloudflare";
|
|
500
518
|
/** Media base for R2 + `cf-image` resizing—matches Louise's media route
|
|
@@ -623,6 +641,8 @@ export interface AstroidConfig {
|
|
|
623
641
|
pages?: PagesConfig;
|
|
624
642
|
/** The public status route's site-owned checks. */
|
|
625
643
|
status?: StatusConfig;
|
|
644
|
+
/** Incident capture settings: what alerts, and whether Sentry gets a copy. */
|
|
645
|
+
incidents?: IncidentsConfig;
|
|
626
646
|
/**
|
|
627
647
|
* Force the contact form + `inquiries` table on or off. Omit to detect from
|
|
628
648
|
* the config (a `contact` section, or a wholesale-inquiry module). Set `true`
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Incident capture, Astroid's side (louise-toolkit ADR 0022, amended).
|
|
4
|
+
//
|
|
5
|
+
// louise-toolkit counts every failure into the site's own D1 and, optionally,
|
|
6
|
+
// Analytics Engine. What's opinion, and so lives here: that every Astroid site
|
|
7
|
+
// captures incidents by default, the binding and dataset names, the migration
|
|
8
|
+
// an existing site needs, and the Sentry sink. Sentry is the operator's issue
|
|
9
|
+
// system for a Monitored or Supported site; it's never the record, and it's
|
|
10
|
+
// never in louise-toolkit's zero-dependency core (ADR 0016 § 7).
|
|
11
|
+
export * from "./names.js";
|
|
12
|
+
export * from "./sentry.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AstroidConfig } from "../config.js";
|
|
2
|
+
/** The Analytics Engine binding incident counts go to. */
|
|
3
|
+
export declare const ASTROID_INCIDENT_EVENTS_BINDING = "INCIDENT_EVENTS";
|
|
4
|
+
/** The version metadata binding a report's `release` comes from. */
|
|
5
|
+
export declare const ASTROID_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
|
|
6
|
+
/** The binding the Sentry sink reads its DSN from: a Worker secret or a
|
|
7
|
+
* Secrets Store binding. This is its name, not the DSN. */
|
|
8
|
+
export declare const ASTROID_SENTRY_DSN_BINDING = "SENTRY_DSN";
|
|
9
|
+
/** The incident counts dataset: `<key>_incidents`, apart from the Core Web Vitals one. */
|
|
10
|
+
export declare function astroidIncidentEventsDataset(config: AstroidConfig): string;
|
|
11
|
+
/**
|
|
12
|
+
* `migrations/0006_incidents.sql`: the `incidents` and `dead_letters` tables
|
|
13
|
+
* (louise-toolkit/incidents). The same DDL drizzle-kit writes for them, with
|
|
14
|
+
* `IF NOT EXISTS`, since a site that already added a table by hand must not
|
|
15
|
+
* fail on it.
|
|
16
|
+
*/
|
|
17
|
+
export declare const ASTROID_INCIDENTS_MIGRATION: string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Incident capture's names and migration (louise-toolkit ADR 0022). No
|
|
4
|
+
// runtime imports, so the CLI's generators can read them without loading any
|
|
5
|
+
// louise-toolkit subpath.
|
|
6
|
+
/** The Analytics Engine binding incident counts go to. */
|
|
7
|
+
export const ASTROID_INCIDENT_EVENTS_BINDING = "INCIDENT_EVENTS";
|
|
8
|
+
/** The version metadata binding a report's `release` comes from. */
|
|
9
|
+
export const ASTROID_VERSION_METADATA_BINDING = "CF_VERSION_METADATA";
|
|
10
|
+
/** The binding the Sentry sink reads its DSN from: a Worker secret or a
|
|
11
|
+
* Secrets Store binding. This is its name, not the DSN. */
|
|
12
|
+
export const ASTROID_SENTRY_DSN_BINDING = "SENTRY_DSN";
|
|
13
|
+
/** The incident counts dataset: `<key>_incidents`, apart from the Core Web Vitals one. */
|
|
14
|
+
export function astroidIncidentEventsDataset(config) {
|
|
15
|
+
return `${config.key.replace(/[^a-z0-9_]/gi, "_")}_incidents`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `migrations/0006_incidents.sql`: the `incidents` and `dead_letters` tables
|
|
19
|
+
* (louise-toolkit/incidents). The same DDL drizzle-kit writes for them, with
|
|
20
|
+
* `IF NOT EXISTS`, since a site that already added a table by hand must not
|
|
21
|
+
* fail on it.
|
|
22
|
+
*/
|
|
23
|
+
export const ASTROID_INCIDENTS_MIGRATION = [
|
|
24
|
+
"-- Incidents (louise-toolkit ADR 0022): one row per fingerprint, counted by the",
|
|
25
|
+
"-- worker's d1Incidents sink and read by the Health panel and Watchtower. Dead",
|
|
26
|
+
"-- letters: each message a queue gave up on, kept for a runbook to replay.",
|
|
27
|
+
"-- Scaffolded by astroidjs.",
|
|
28
|
+
"CREATE TABLE IF NOT EXISTS `incidents` (",
|
|
29
|
+
"\t`fingerprint` text PRIMARY KEY NOT NULL,",
|
|
30
|
+
"\t`kind` text NOT NULL,",
|
|
31
|
+
"\t`name` text NOT NULL,",
|
|
32
|
+
"\t`code` text,",
|
|
33
|
+
"\t`message` text NOT NULL,",
|
|
34
|
+
"\t`path` text,",
|
|
35
|
+
"\t`host` text,",
|
|
36
|
+
"\t`release` text,",
|
|
37
|
+
"\t`critical` integer DEFAULT false NOT NULL,",
|
|
38
|
+
"\t`count` integer DEFAULT 1 NOT NULL,",
|
|
39
|
+
"\t`first_seen` integer NOT NULL,",
|
|
40
|
+
"\t`last_seen` integer NOT NULL,",
|
|
41
|
+
"\t`resolved_at` integer,",
|
|
42
|
+
"\t`reopened_at` integer",
|
|
43
|
+
");",
|
|
44
|
+
"CREATE INDEX IF NOT EXISTS `incidents_last_seen` ON `incidents` (`last_seen`);",
|
|
45
|
+
"CREATE TABLE IF NOT EXISTS `dead_letters` (",
|
|
46
|
+
"\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,",
|
|
47
|
+
"\t`queue` text NOT NULL,",
|
|
48
|
+
"\t`message_id` text NOT NULL,",
|
|
49
|
+
"\t`body` text NOT NULL,",
|
|
50
|
+
"\t`attempts` integer NOT NULL,",
|
|
51
|
+
"\t`received_at` integer NOT NULL",
|
|
52
|
+
");",
|
|
53
|
+
"CREATE INDEX IF NOT EXISTS `dead_letters_queue` ON `dead_letters` (`queue`);",
|
|
54
|
+
"",
|
|
55
|
+
].join("\n");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { IncidentReport, IncidentSink } from "louise-toolkit/incidents";
|
|
2
|
+
import { type SecretSource } from "../secrets.js";
|
|
3
|
+
export interface SentryIncidentsOptions {
|
|
4
|
+
/** A tag naming the site, so one Sentry organization can tell sites apart. */
|
|
5
|
+
site?: string;
|
|
6
|
+
/** Sentry's `environment`. Default `"production"`. */
|
|
7
|
+
environment?: string;
|
|
8
|
+
/** Injected for tests. */
|
|
9
|
+
fetch?: typeof fetch;
|
|
10
|
+
}
|
|
11
|
+
/** The Sentry event for a report: its redacted message, its fingerprint, and the
|
|
12
|
+
* cause's stack frames. Never the raw message, the query string, or a body. */
|
|
13
|
+
export declare function sentryEvent(report: IncidentReport, cause: unknown, options?: Pick<SentryIncidentsOptions, "site" | "environment">): Record<string, unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* A sink that sends each report to Sentry, through its envelope endpoint, with
|
|
16
|
+
* no SDK: `sendDefaultPii` has nothing to turn off, because nothing but the
|
|
17
|
+
* redacted report and the stack's frames is sent. Dormant while the DSN is
|
|
18
|
+
* unset or a placeholder, like every Astroid module.
|
|
19
|
+
*/
|
|
20
|
+
export declare function sentryIncidents<Env>(dsn: (env: Env) => SecretSource | undefined, options?: SentryIncidentsOptions): IncidentSink<Env>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// The Sentry sink (louise-toolkit ADR 0022, amended): Sentry is the operator's
|
|
4
|
+
// issue system for a Monitored or Supported site. It's never the record, and
|
|
5
|
+
// never in louise-toolkit's zero-dependency core (ADR 0016 § 7).
|
|
6
|
+
import { readModuleSecret } from "../secrets.js";
|
|
7
|
+
function parseDsn(dsn) {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(dsn);
|
|
10
|
+
const project = url.pathname.replace(/^\/+|\/+$/g, "");
|
|
11
|
+
if (!url.username || !/^\d+$/.test(project))
|
|
12
|
+
return null;
|
|
13
|
+
return { key: url.username, origin: `${url.protocol}//${url.host}`, project };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const V8_FRAME = /^\s*at (?:(.+?) \()?(.+?)(?::(\d+))?(?::(\d+))?\)?$/;
|
|
20
|
+
/** The cause's stack as Sentry frames, oldest call first, or none. Function
|
|
21
|
+
* names and file positions only: no message, no values. */
|
|
22
|
+
function frames(cause) {
|
|
23
|
+
let stack;
|
|
24
|
+
try {
|
|
25
|
+
stack = cause?.stack;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
if (typeof stack !== "string")
|
|
31
|
+
return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const line of stack.split("\n")) {
|
|
34
|
+
if (!line.trimStart().startsWith("at "))
|
|
35
|
+
continue;
|
|
36
|
+
const match = V8_FRAME.exec(line);
|
|
37
|
+
if (!match)
|
|
38
|
+
continue;
|
|
39
|
+
const [, fn, filename, lineno, colno] = match;
|
|
40
|
+
out.push({
|
|
41
|
+
...(fn ? { function: fn } : {}),
|
|
42
|
+
filename: filename,
|
|
43
|
+
...(lineno ? { lineno: Number(lineno) } : {}),
|
|
44
|
+
...(colno ? { colno: Number(colno) } : {}),
|
|
45
|
+
in_app: !filename.includes("node_modules"),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return out.slice(0, 50).reverse();
|
|
49
|
+
}
|
|
50
|
+
/** The Sentry event for a report: its redacted message, its fingerprint, and the
|
|
51
|
+
* cause's stack frames. Never the raw message, the query string, or a body. */
|
|
52
|
+
export function sentryEvent(report, cause, options = {}) {
|
|
53
|
+
const stack = frames(cause);
|
|
54
|
+
return {
|
|
55
|
+
event_id: crypto.randomUUID().replace(/-/g, ""),
|
|
56
|
+
timestamp: report.at / 1000,
|
|
57
|
+
platform: "javascript",
|
|
58
|
+
level: report.critical ? "fatal" : "error",
|
|
59
|
+
logger: "louise",
|
|
60
|
+
environment: options.environment ?? "production",
|
|
61
|
+
...(report.release ? { release: report.release } : {}),
|
|
62
|
+
// One D1 row, one Sentry issue: Watchtower joins them on this.
|
|
63
|
+
fingerprint: [report.fingerprint],
|
|
64
|
+
tags: {
|
|
65
|
+
louise_fingerprint: report.fingerprint,
|
|
66
|
+
kind: report.kind,
|
|
67
|
+
critical: String(report.critical),
|
|
68
|
+
...(options.site ? { site: options.site } : {}),
|
|
69
|
+
...(report.code ? { code: report.code } : {}),
|
|
70
|
+
},
|
|
71
|
+
...(report.kind === "fetch" && report.host && report.path
|
|
72
|
+
? { request: { url: `https://${report.host}${report.path}` } }
|
|
73
|
+
: {}),
|
|
74
|
+
...(report.path ? { transaction: report.path } : {}),
|
|
75
|
+
exception: {
|
|
76
|
+
values: [
|
|
77
|
+
{
|
|
78
|
+
type: report.name,
|
|
79
|
+
value: report.message,
|
|
80
|
+
...(stack.length > 0 ? { stacktrace: { frames: stack } } : {}),
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A sink that sends each report to Sentry, through its envelope endpoint, with
|
|
88
|
+
* no SDK: `sendDefaultPii` has nothing to turn off, because nothing but the
|
|
89
|
+
* redacted report and the stack's frames is sent. Dormant while the DSN is
|
|
90
|
+
* unset or a placeholder, like every Astroid module.
|
|
91
|
+
*/
|
|
92
|
+
export function sentryIncidents(dsn, options = {}) {
|
|
93
|
+
return async (report, { env, cause }) => {
|
|
94
|
+
const source = dsn(env);
|
|
95
|
+
if (source === undefined)
|
|
96
|
+
return;
|
|
97
|
+
const value = await readModuleSecret(source);
|
|
98
|
+
const parsed = value ? parseDsn(value) : null;
|
|
99
|
+
if (!parsed)
|
|
100
|
+
return;
|
|
101
|
+
const event = sentryEvent(report, cause, options);
|
|
102
|
+
const body = [
|
|
103
|
+
JSON.stringify({ event_id: event.event_id, sent_at: new Date().toISOString() }),
|
|
104
|
+
JSON.stringify({ type: "event" }),
|
|
105
|
+
JSON.stringify(event),
|
|
106
|
+
].join("\n");
|
|
107
|
+
const response = await (options.fetch ?? fetch)(`${parsed.origin}/api/${parsed.project}/envelope/`, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
"content-type": "application/x-sentry-envelope",
|
|
111
|
+
"x-sentry-auth": `Sentry sentry_version=7, sentry_key=${parsed.key}, sentry_client=astroidjs`,
|
|
112
|
+
},
|
|
113
|
+
body,
|
|
114
|
+
});
|
|
115
|
+
if (!response.ok)
|
|
116
|
+
throw new Error(`Sentry answered ${response.status}`);
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./commerce/index.js";
|
|
|
4
4
|
export * from "./config.js";
|
|
5
5
|
export * from "./email/index.js";
|
|
6
6
|
export * from "./errors.js";
|
|
7
|
+
export * from "./incidents/index.js";
|
|
7
8
|
export * from "./secrets.js";
|
|
8
9
|
export * from "./map/index.js";
|
|
9
10
|
export * from "./portal/index.js";
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export * from "./commerce/index.js";
|
|
|
9
9
|
export * from "./config.js";
|
|
10
10
|
export * from "./email/index.js";
|
|
11
11
|
export * from "./errors.js";
|
|
12
|
+
export * from "./incidents/index.js";
|
|
12
13
|
export * from "./secrets.js";
|
|
13
14
|
export * from "./map/index.js";
|
|
14
15
|
export * from "./portal/index.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { D1Client } from "louise-toolkit/db";
|
|
2
|
+
import { type DraftBufferKV } from "louise-toolkit/editor";
|
|
3
|
+
import type { AstroidConfig } from "../config.js";
|
|
4
|
+
/** The bindings a page draft read uses: the database, and the draft buffer. */
|
|
5
|
+
export interface AstroidPageDraftEnv {
|
|
6
|
+
DB: D1Client;
|
|
7
|
+
/** The draft buffer the generated routes save through, when bound. */
|
|
8
|
+
DRAFTS?: DraftBufferKV;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The editor's work-in-progress snapshot of the page with id `pageId`, or
|
|
12
|
+
* `null` when there's none (render the live row). The buffer comes first, then
|
|
13
|
+
* the newest pending draft in D1, the same order a save builds on.
|
|
14
|
+
*
|
|
15
|
+
* Returns the whole snapshot: which fields a page renders from it (`sections`,
|
|
16
|
+
* `body`, `title`) is the site's call. Call it only in edit mode; a visitor
|
|
17
|
+
* sees the live row.
|
|
18
|
+
*/
|
|
19
|
+
export declare function astroidPageDraft(config: AstroidConfig, env: AstroidPageDraftEnv, pageId: number): Promise<Record<string, unknown> | null>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// `astroidjs/pages`—the editor's work-in-progress on a page, for a page route
|
|
4
|
+
// in edit mode. A subpath of its own because it reads through louise-toolkit's
|
|
5
|
+
// editor and Drizzle, which the main entry (loaded by astroid.config.ts and the
|
|
6
|
+
// CLI) stays free of.
|
|
7
|
+
//
|
|
8
|
+
// The generated worker saves pages through the DRAFTS buffer, keyed by the pages
|
|
9
|
+
// collection's slug, and flushes to `pages_versions` in D1 behind it. A page
|
|
10
|
+
// route has to read the same way, buffer first, or an editor who reloads before
|
|
11
|
+
// the flush sees an older page than the one they just saved. Every site that
|
|
12
|
+
// read D1 alone hit that, and each wrapped louise-toolkit's `resumeDraft` in the
|
|
13
|
+
// same few lines to fix it. This is those lines, with the slug and the versions
|
|
14
|
+
// table derived from the config rather than restated.
|
|
15
|
+
import { collectionVersionsTable } from "louise-toolkit/content";
|
|
16
|
+
import { resumeDraft } from "louise-toolkit/editor";
|
|
17
|
+
import { astroidPagesCollection } from "../schema/collections.js";
|
|
18
|
+
/**
|
|
19
|
+
* The editor's work-in-progress snapshot of the page with id `pageId`, or
|
|
20
|
+
* `null` when there's none (render the live row). The buffer comes first, then
|
|
21
|
+
* the newest pending draft in D1, the same order a save builds on.
|
|
22
|
+
*
|
|
23
|
+
* Returns the whole snapshot: which fields a page renders from it (`sections`,
|
|
24
|
+
* `body`, `title`) is the site's call. Call it only in edit mode; a visitor
|
|
25
|
+
* sees the live row.
|
|
26
|
+
*/
|
|
27
|
+
export function astroidPageDraft(config, env, pageId) {
|
|
28
|
+
const collection = astroidPagesCollection(config);
|
|
29
|
+
return resumeDraft(env.DB, {
|
|
30
|
+
versionsTable: collectionVersionsTable(collection),
|
|
31
|
+
collection: collection.slug,
|
|
32
|
+
bufferKv: env.DRAFTS,
|
|
33
|
+
}, { id: pageId });
|
|
34
|
+
}
|
package/dist/project/generate.js
CHANGED
|
@@ -18,6 +18,7 @@ import { ASTROID_VITALS_BINDING, astroidVitalsDataset } from "../analytics/index
|
|
|
18
18
|
import { astroidCheckoutVars } from "../commerce/checkout-scaffold.js";
|
|
19
19
|
import { astroidCommerceProviders } from "../commerce/roles.js";
|
|
20
20
|
import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceProviderWebhookSecrets, commerceSecretNames, } from "../commerce/secrets.js";
|
|
21
|
+
import { ASTROID_INCIDENT_EVENTS_BINDING, ASTROID_VERSION_METADATA_BINDING, astroidIncidentEventsDataset, } from "../incidents/names.js";
|
|
21
22
|
import { ASTROID_QUEUE_BINDING, ASTROID_QUEUE_RETRY_DELAY, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
|
|
22
23
|
import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, usesRealtime, } from "../realtime/scaffold.js";
|
|
23
24
|
import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
|
|
@@ -218,6 +219,10 @@ export function generateAstroidWrangler(config) {
|
|
|
218
219
|
p(` "retry_delay": ${config.queues?.retryDelay ?? ASTROID_QUEUE_RETRY_DELAY},`);
|
|
219
220
|
p(` "dead_letter_queue": ${JSON.stringify(dlq)},`);
|
|
220
221
|
p(" },");
|
|
222
|
+
p(" // The dead-letter queue's own consumer: the worker keeps each message");
|
|
223
|
+
p(" // in the `dead_letters` table and counts it as an incident, so a");
|
|
224
|
+
p(" // failed event is never lost unseen. No retries of its own.");
|
|
225
|
+
p(` { "queue": ${JSON.stringify(dlq)}, "max_batch_size": 10, "max_retries": 0 },`);
|
|
221
226
|
p(" ],");
|
|
222
227
|
p(" },");
|
|
223
228
|
}
|
|
@@ -229,6 +234,13 @@ export function generateAstroidWrangler(config) {
|
|
|
229
234
|
p(" // D1: this app's tables (src/schema.ts), or the database of the app that");
|
|
230
235
|
p(" // owns the schema, bound by its id. Create one: `wrangler d1 create <name>`.");
|
|
231
236
|
}
|
|
237
|
+
p(" // The deployed version's ID, which each incident records as its release.");
|
|
238
|
+
p(` "version_metadata": { "binding": ${JSON.stringify(ASTROID_VERSION_METADATA_BINDING)} },`);
|
|
239
|
+
if (!editor) {
|
|
240
|
+
// The editor shape lists this dataset beside the Core Web Vitals one below.
|
|
241
|
+
p(" // Analytics Engine: incident counts over time (louise-toolkit ADR 0022).");
|
|
242
|
+
p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_INCIDENT_EVENTS_BINDING)}, "dataset": ${JSON.stringify(astroidIncidentEventsDataset(config))} }],`);
|
|
243
|
+
}
|
|
232
244
|
p(' "d1_databases": [');
|
|
233
245
|
p(" {");
|
|
234
246
|
p(' "binding": "DB",');
|
|
@@ -250,8 +262,9 @@ export function generateAstroidWrangler(config) {
|
|
|
250
262
|
p(" // Analytics Engine: real-visitor Core Web Vitals. Free, and the ingest");
|
|
251
263
|
p(" // route accepts-and-drops without it, so it costs nothing unused. Reading");
|
|
252
264
|
p(" // the p75 back out needs CF_ACCOUNT_ID + CF_API_TOKEN (see .env.example)—");
|
|
253
|
-
p(" // until those are real the Health badge reads 'not measured yet'.");
|
|
254
|
-
p(
|
|
265
|
+
p(" // until those are real the Health badge reads 'not measured yet'. The");
|
|
266
|
+
p(" // second dataset counts incidents over time (louise-toolkit ADR 0022).");
|
|
267
|
+
p(` "analytics_engine_datasets": [{ "binding": ${JSON.stringify(ASTROID_VITALS_BINDING)}, "dataset": ${JSON.stringify(astroidVitalsDataset(config))} }, { "binding": ${JSON.stringify(ASTROID_INCIDENT_EVENTS_BINDING)}, "dataset": ${JSON.stringify(astroidIncidentEventsDataset(config))} }],`);
|
|
255
268
|
p(" // Workers AI. Powers the editor's rewrite + SEO-suggest buttons and alt-text");
|
|
256
269
|
p(" // generation on upload—all of which SHIP IN THE EDITOR DRAWER already and,");
|
|
257
270
|
p(" // without this binding, were permanently invisible: their routes answer 503");
|
package/dist/project/scaffold.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { generateAstroidCheckoutRoute, generateAstroidSquareCard, } from "../commerce/checkout-scaffold.js";
|
|
27
27
|
import { generateCatalogMigrationSql } from "../commerce/mirror.js";
|
|
28
28
|
import { generateAstroidVitalsBeacon } from "../analytics/index.js";
|
|
29
|
+
import { ASTROID_INCIDENTS_MIGRATION } from "../incidents/names.js";
|
|
29
30
|
import { generateAstroidActions } from "./actions.js";
|
|
30
31
|
import { cwvBeaconScript } from "louise-toolkit/analytics";
|
|
31
32
|
import { generateMapEmbedComponent, generateMapTileRoute } from "../map/scaffold.js";
|
|
@@ -186,6 +187,19 @@ export function generateAstroidScaffoldFiles(config) {
|
|
|
186
187
|
migration: true,
|
|
187
188
|
});
|
|
188
189
|
}
|
|
190
|
+
// --- incident capture's tables -------------------------------------------
|
|
191
|
+
// For every shape: the generated worker counts failures into `incidents`
|
|
192
|
+
// and keeps dead letters in `dead_letters` (louise-toolkit ADR 0022). Written
|
|
193
|
+
// into an existing site by `astroid generate`, like the two above, and
|
|
194
|
+
// renumbered past the site's own migrations. Not when another app migrates
|
|
195
|
+
// this database (`deploy.migrations: false`); that app owns the tables.
|
|
196
|
+
if (config.deploy?.migrations !== false) {
|
|
197
|
+
files.push({
|
|
198
|
+
path: "migrations/0006_incidents.sql",
|
|
199
|
+
contents: ASTROID_INCIDENTS_MIGRATION,
|
|
200
|
+
migration: true,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
189
203
|
// --- the CWV beacon -------------------------------------------------------
|
|
190
204
|
// A static file under public/, so it is same-origin and covered by
|
|
191
205
|
// `script-src 'self'`—an inline script carrying generated content could not
|
package/dist/schema/generate.js
CHANGED
|
@@ -20,6 +20,23 @@ function catalogBuilders(catalog) {
|
|
|
20
20
|
builders.push("real");
|
|
21
21
|
return builders.sort();
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The incident tables every shape re-exports (louise-toolkit ADR 0022): the
|
|
25
|
+
* worker counts failures into `incidents` and keeps dead-lettered messages in
|
|
26
|
+
* `dead_letters`, so an app owns both, even when it owns nothing else. Not when
|
|
27
|
+
* another app migrates its database (`deploy.migrations: false`): that app owns
|
|
28
|
+
* them, and drizzle-kit here must not write migrations for them.
|
|
29
|
+
*/
|
|
30
|
+
function incidentTables(config) {
|
|
31
|
+
return config.deploy?.migrations === false ? [] : INCIDENT_TABLES;
|
|
32
|
+
}
|
|
33
|
+
const INCIDENT_TABLES = [
|
|
34
|
+
"// Incident capture's tables (louise-toolkit ADR 0022): the generated worker",
|
|
35
|
+
"// counts failures into `incidents` and keeps dead-lettered queue messages in",
|
|
36
|
+
"// `dead_letters`.",
|
|
37
|
+
'export { deadLetters, incidents } from "louise-toolkit/incidents";',
|
|
38
|
+
"",
|
|
39
|
+
];
|
|
23
40
|
/**
|
|
24
41
|
* The schema of an app with no editor (`editor: false`): no `pages`, no
|
|
25
42
|
* versions, and no framework tables, because nothing here edits them. What's
|
|
@@ -50,6 +67,7 @@ function generateAppSchema(config) {
|
|
|
50
67
|
catalog,
|
|
51
68
|
]
|
|
52
69
|
: [""]),
|
|
70
|
+
...incidentTables(config),
|
|
53
71
|
"// Site-owned tables, declared in src/schema.site.ts and re-exported here so",
|
|
54
72
|
"// drizzle-kit and the worker see them. Empty until the app adds one.",
|
|
55
73
|
'export * from "./schema.site.js";',
|
|
@@ -97,6 +115,7 @@ export function generateAstroidSchema(config) {
|
|
|
97
115
|
...(catalog ? [catalog] : []),
|
|
98
116
|
`export { ${framework.join(", ")} };`,
|
|
99
117
|
"",
|
|
118
|
+
...incidentTables(config),
|
|
100
119
|
"// Site-owned tables (the ones Astroid doesn't manage): a project declares",
|
|
101
120
|
"// its own Drizzle tables in src/schema.site.ts and they're re-exported here",
|
|
102
121
|
"// so drizzle-kit sees them and the generated worker can import them. The file",
|
package/dist/worker/generate.js
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// beforeChange hook, and pagesRoute (which takes no collection config) through
|
|
13
13
|
// the `astroidPagesWriteHooks` spread, so both write paths enforce one contract.
|
|
14
14
|
import { ASTROID_VITALS_BINDING, generateAstroidCwvQuery } from "../analytics/index.js";
|
|
15
|
+
import { ASTROID_INCIDENT_EVENTS_BINDING, ASTROID_SENTRY_DSN_BINDING, ASTROID_VERSION_METADATA_BINDING, } from "../incidents/names.js";
|
|
15
16
|
import { astroidEditorTable } from "../auth/index.js";
|
|
16
17
|
import { astroidPortal } from "../portal/config.js";
|
|
17
|
-
import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
|
|
18
|
+
import { ASTROID_HEALTH_CRON, astroidCron, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
|
|
18
19
|
import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
|
|
19
20
|
import { capturesInquiries } from "../schema/framework.js";
|
|
20
21
|
import { astroidCspStyleSrc } from "../security/csp-origins.js";
|
|
@@ -208,6 +209,7 @@ export function generateAstroidWorker(config) {
|
|
|
208
209
|
p('import { cwvSqlQuery, parseCwvRows, summarizeCwv, vitalsRoute } from "louise-toolkit/analytics";');
|
|
209
210
|
if (inquiries)
|
|
210
211
|
p('import { defineForm } from "louise-toolkit/forms";');
|
|
212
|
+
p(incidentsImport(queues));
|
|
211
213
|
if (queues)
|
|
212
214
|
p('import { processBatch } from "louise-toolkit/queues";');
|
|
213
215
|
// Only when a route actually takes a runner—a project with no AI assists
|
|
@@ -230,6 +232,7 @@ export function generateAstroidWorker(config) {
|
|
|
230
232
|
"setAstroidMediaBase",
|
|
231
233
|
...(inquiries ? ["sendInquiryMail"] : []),
|
|
232
234
|
...(queues ? ["type AstroidQueueMessage"] : []),
|
|
235
|
+
...(config.incidents?.sentry ? ["sentryIncidents", "type SecretSource"] : []),
|
|
233
236
|
].sort();
|
|
234
237
|
p(`import { ${astroidImports.join(", ")} } from "astroidjs";`);
|
|
235
238
|
// The config lives at the PROJECT ROOT (create-astroid writes it there); this
|
|
@@ -475,6 +478,7 @@ export function generateAstroidWorker(config) {
|
|
|
475
478
|
p(" return new Response(obj.body, { headers });");
|
|
476
479
|
p("};");
|
|
477
480
|
p();
|
|
481
|
+
emitIncidentPreamble(p, config, queues);
|
|
478
482
|
// The queue message type parameter is what gives the `queue` consumer below a
|
|
479
483
|
// typed `MessageBatch` instead of `MessageBatch<unknown>`.
|
|
480
484
|
p(queues
|
|
@@ -510,13 +514,9 @@ export function generateAstroidWorker(config) {
|
|
|
510
514
|
p(" // An editor never reads from, and never writes to, the shared entry.");
|
|
511
515
|
p(" bypass: isEditRequest,");
|
|
512
516
|
p(" }),");
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
p
|
|
516
|
-
p(" // batch from acking; Cloudflare routes it to the DLQ once it exceeds");
|
|
517
|
-
p(" // max_retries (see wrangler.jsonc).");
|
|
518
|
-
p(" queue: (batch, env) => processBatch(batch, (message) => handleQueueMessage(env, message)),");
|
|
519
|
-
}
|
|
517
|
+
emitIncidentOption(p, config);
|
|
518
|
+
if (queues)
|
|
519
|
+
emitQueueOption(p, config);
|
|
520
520
|
// ONE scheduled handler for every cron, dispatching on `controller.cron`.
|
|
521
521
|
// Cloudflare gives no other way to tell them apart, and the strings here have
|
|
522
522
|
// to match `astroidCrons` exactly—which is why both read the same constants
|
|
@@ -589,11 +589,20 @@ function generateAppWorker(config) {
|
|
|
589
589
|
p("// so it serves no editor routes and resolves no editor session.");
|
|
590
590
|
p('import { handle } from "@astrojs/cloudflare/handler";');
|
|
591
591
|
p('import { d1Check, statusRoute } from "louise-toolkit/editor";');
|
|
592
|
+
p(incidentsImport(queues));
|
|
592
593
|
if (queues)
|
|
593
594
|
p('import { processBatch } from "louise-toolkit/queues";');
|
|
594
595
|
p('import { composeWorker, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
|
|
595
|
-
|
|
596
|
-
|
|
596
|
+
const astroidImports = [
|
|
597
|
+
...(queues ? ["type AstroidQueueMessage"] : []),
|
|
598
|
+
...(config.incidents?.sentry ? ["sentryIncidents", "type SecretSource"] : []),
|
|
599
|
+
].sort();
|
|
600
|
+
if (astroidImports.length > 0) {
|
|
601
|
+
const typeOnly = astroidImports.every((name) => name.startsWith("type "));
|
|
602
|
+
p(typeOnly
|
|
603
|
+
? `import type { ${astroidImports.map((name) => name.slice(5)).join(", ")} } from "astroidjs";`
|
|
604
|
+
: `import { ${astroidImports.join(", ")} } from "astroidjs";`);
|
|
605
|
+
}
|
|
597
606
|
if (config.status?.checks) {
|
|
598
607
|
p("// Your STATUS seam: the app's own checks for the public status route.");
|
|
599
608
|
p("// Scaffolded once and yours to edit.");
|
|
@@ -627,6 +636,7 @@ function generateAppWorker(config) {
|
|
|
627
636
|
p(" statusRoute({ checks: STATUS_CHECKS, reuseMs: STATUS_REUSE_MS }),");
|
|
628
637
|
p("];");
|
|
629
638
|
p();
|
|
639
|
+
emitIncidentPreamble(p, config, queues);
|
|
630
640
|
p(queues
|
|
631
641
|
? "export default composeWorker<CloudflareEnv, AstroidQueueMessage>({"
|
|
632
642
|
: "export default composeWorker<CloudflareEnv>({");
|
|
@@ -640,12 +650,9 @@ function generateAppWorker(config) {
|
|
|
640
650
|
p(" // directive from every other one, so Cloudflare's cookie-blind edge cache");
|
|
641
651
|
p(" // never stores a signed-in customer's page.");
|
|
642
652
|
p(" fetch: withEdgeCache((request, env, ctx) => handle(request, env, ctx)),");
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
p
|
|
646
|
-
p(" // batch; Cloudflare routes it to the DLQ once it exceeds max_retries.");
|
|
647
|
-
p(" queue: (batch, env) => processBatch(batch, (message) => handleQueueMessage(env, message)),");
|
|
648
|
-
}
|
|
653
|
+
emitIncidentOption(p, config);
|
|
654
|
+
if (queues)
|
|
655
|
+
emitQueueOption(p, config);
|
|
649
656
|
if (scheduled) {
|
|
650
657
|
p(" // Cron. Cloudflare fires this for EVERY trigger in wrangler.jsonc and");
|
|
651
658
|
p(" // identifies which by `controller.cron`, so dispatch on it.");
|
|
@@ -897,3 +904,78 @@ export function generateAstroidMiddleware(config) {
|
|
|
897
904
|
"",
|
|
898
905
|
].join("\n");
|
|
899
906
|
}
|
|
907
|
+
/** The `louise-toolkit/incidents` import both worker shapes emit. */
|
|
908
|
+
function incidentsImport(queues) {
|
|
909
|
+
const names = ["analyticsIncidents", "d1Incidents", ...(queues ? ["deadLetterConsumer"] : [])];
|
|
910
|
+
return `import { ${names.join(", ")} } from "louise-toolkit/incidents";`;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* The declarations incident capture needs ahead of `composeWorker`, shared by
|
|
914
|
+
* both shapes (louise-toolkit ADR 0022). Its bindings are read through a local
|
|
915
|
+
* type rather than `CloudflareEnv`, because `src/env.d.ts` is scaffold-once: a
|
|
916
|
+
* site made before them still type-checks, and each sink skips what isn't bound.
|
|
917
|
+
*/
|
|
918
|
+
function emitIncidentPreamble(p, config, queues) {
|
|
919
|
+
p("// --- incidents --------------------------------------------------------------");
|
|
920
|
+
p("// Every failure this worker sees becomes an incident: counted into the site's");
|
|
921
|
+
p("// own D1 (the `incidents` table), and into Analytics Engine for counts over");
|
|
922
|
+
p("// time (louise-toolkit ADR 0022). These bindings are optional. A site whose");
|
|
923
|
+
p("// wrangler.jsonc predates them still type-checks, and a sink skips what isn't");
|
|
924
|
+
p("// bound.");
|
|
925
|
+
p("type IncidentBindings = {");
|
|
926
|
+
p(` ${ASTROID_INCIDENT_EVENTS_BINDING}?: AnalyticsEngineDataset;`);
|
|
927
|
+
p(` ${ASTROID_VERSION_METADATA_BINDING}?: WorkerVersionMetadata;`);
|
|
928
|
+
if (config.incidents?.sentry)
|
|
929
|
+
p(` ${ASTROID_SENTRY_DSN_BINDING}?: SecretSource;`);
|
|
930
|
+
p("};");
|
|
931
|
+
p("const incidentBindings = (env: CloudflareEnv) => env as CloudflareEnv & IncidentBindings;");
|
|
932
|
+
if (queues) {
|
|
933
|
+
const { dlq } = astroidQueueNames(config);
|
|
934
|
+
p("// The dead-letter queue's consumer: it keeps each message the queue gave up on");
|
|
935
|
+
p("// in the `dead_letters` table, counts it as an incident, and acks it, so a");
|
|
936
|
+
p("// failed webhook event is never lost unseen.");
|
|
937
|
+
p(`const DEAD_LETTER_QUEUE = ${JSON.stringify(dlq)};`);
|
|
938
|
+
p("const keepDeadLetters = deadLetterConsumer<CloudflareEnv, AstroidQueueMessage>(");
|
|
939
|
+
p(" (env) => env.DB,");
|
|
940
|
+
p(");");
|
|
941
|
+
}
|
|
942
|
+
p();
|
|
943
|
+
}
|
|
944
|
+
/** The `onIncident` option both shapes pass `composeWorker`. */
|
|
945
|
+
function emitIncidentOption(p, config) {
|
|
946
|
+
const critical = config.incidents?.critical ?? [];
|
|
947
|
+
p(" // Incident capture (louise-toolkit ADR 0022). A throw is reported, then");
|
|
948
|
+
p(" // re-thrown, so responses don't change; the sinks run after the response.");
|
|
949
|
+
p(" onIncident: {");
|
|
950
|
+
p(" sinks: [");
|
|
951
|
+
p(" // The record: one row per failure, with a count, in the site's own D1.");
|
|
952
|
+
p(" d1Incidents((env: CloudflareEnv) => env.DB),");
|
|
953
|
+
p(` analyticsIncidents((env: CloudflareEnv) => incidentBindings(env).${ASTROID_INCIDENT_EVENTS_BINDING}),`);
|
|
954
|
+
if (config.incidents?.sentry) {
|
|
955
|
+
p(" // A copy for the operator's issue system, with the stack. Dormant until");
|
|
956
|
+
p(` // the ${ASTROID_SENTRY_DSN_BINDING} secret holds a real DSN.`);
|
|
957
|
+
p(` sentryIncidents((env: CloudflareEnv) => incidentBindings(env).${ASTROID_SENTRY_DSN_BINDING}, { site: ${JSON.stringify(config.key)} }),`);
|
|
958
|
+
}
|
|
959
|
+
p(" ],");
|
|
960
|
+
if (critical.length > 0) {
|
|
961
|
+
p(" // What alerts, from `incidents.critical` in your config.");
|
|
962
|
+
p(` critical: ${JSON.stringify(critical)},`);
|
|
963
|
+
}
|
|
964
|
+
p(` release: (env) => incidentBindings(env).${ASTROID_VERSION_METADATA_BINDING}?.id,`);
|
|
965
|
+
p(" },");
|
|
966
|
+
}
|
|
967
|
+
/** The queue consumer both shapes emit: the dead-letter queue's batches to its
|
|
968
|
+
* consumer, and the rest through `processBatch`, which reports a message's
|
|
969
|
+
* last failed delivery as an incident. */
|
|
970
|
+
function emitQueueOption(p, config) {
|
|
971
|
+
p(" // Queue consumer. `processBatch` acks or retries each message");
|
|
972
|
+
p(" // INDEPENDENTLY, so one poisoned message can't block the rest of the");
|
|
973
|
+
p(" // batch; Cloudflare routes it to the DLQ once it exceeds max_retries,");
|
|
974
|
+
p(" // which `maxRetries` matches, so its last failure counts as an incident.");
|
|
975
|
+
p(" queue: (batch, env, ctx) =>");
|
|
976
|
+
p(" batch.queue === DEAD_LETTER_QUEUE");
|
|
977
|
+
p(" ? keepDeadLetters(batch, env, ctx)");
|
|
978
|
+
p(" : processBatch(batch, (message) => handleQueueMessage(env, message), {");
|
|
979
|
+
p(` maxRetries: ${config.queues?.maxRetries ?? 5},`);
|
|
980
|
+
p(" }),");
|
|
981
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astroidjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Astroid — an opinionated meta-framework over Louise Toolkit and Astro for building editable, multi-editor sites on Cloudflare Workers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
"import": "./dist/astro/index.js",
|
|
38
38
|
"default": "./dist/astro/index.js"
|
|
39
39
|
},
|
|
40
|
+
"./pages": {
|
|
41
|
+
"types": "./dist/pages/index.d.ts",
|
|
42
|
+
"import": "./dist/pages/index.js",
|
|
43
|
+
"default": "./dist/pages/index.js"
|
|
44
|
+
},
|
|
40
45
|
"./components/*.astro": "./src/components/*.astro",
|
|
41
46
|
"./components/Collection": {
|
|
42
47
|
"types": "./src/components/Collection.tsx",
|
|
@@ -64,6 +69,7 @@
|
|
|
64
69
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
65
70
|
"@vitest/coverage-v8": "4.1.11",
|
|
66
71
|
"astro": "^7.2.9",
|
|
72
|
+
"drizzle-orm": "^0.45.2",
|
|
67
73
|
"louise-toolkit": "^0.37.0",
|
|
68
74
|
"solid-js": "^1.9.15",
|
|
69
75
|
"typescript": "^6.0.3",
|
|
@@ -71,6 +77,7 @@
|
|
|
71
77
|
},
|
|
72
78
|
"peerDependencies": {
|
|
73
79
|
"astro": "^7.0.9",
|
|
80
|
+
"drizzle-orm": "^0.45.0",
|
|
74
81
|
"louise-toolkit": "^0.37.0",
|
|
75
82
|
"solid-js": "^1.9.0"
|
|
76
83
|
},
|