astroidjs 0.6.0 → 0.7.1
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/commerce/adapters.d.ts +64 -4
- package/dist/commerce/adapters.js +78 -5
- package/dist/commerce/checkout-scaffold.js +196 -57
- package/dist/commerce/checkout.d.ts +74 -2
- package/dist/commerce/checkout.js +29 -2
- package/dist/commerce/index.d.ts +2 -2
- package/dist/commerce/index.js +1 -1
- package/dist/config.d.ts +86 -18
- package/dist/config.js +87 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/project/generate.js +13 -1
- package/dist/project/scaffold.js +23 -6
- package/dist/pwa/generate.d.ts +34 -1
- package/dist/pwa/generate.js +33 -4
- package/dist/queues/consumer.d.ts +21 -1
- package/dist/queues/consumer.js +6 -3
- package/dist/queues/messages.js +4 -0
- package/dist/queues/scaffold.js +29 -0
- package/dist/schema/collections.js +9 -4
- package/dist/tenancy/index.d.ts +35 -0
- package/dist/tenancy/index.js +101 -0
- package/dist/worker/generate.js +89 -9
- package/package.json +7 -2
|
@@ -4,10 +4,30 @@ export interface QueueHandlerOptions {
|
|
|
4
4
|
* Re-sync whatever the provider owns — the catalog mirror, a cache. Called
|
|
5
5
|
* for a periodic refresh and for webhooks that touched the catalog.
|
|
6
6
|
*
|
|
7
|
+
* Receives the message that triggered it, so a site running more than one
|
|
8
|
+
* commerce provider can branch on `message.provider` rather than refreshing
|
|
9
|
+
* everything for everything. A zero-argument seam stays valid — the parameter
|
|
10
|
+
* is there to be ignored until it's needed.
|
|
11
|
+
*
|
|
7
12
|
* Throwing marks the message for retry, which is usually right: a failed
|
|
8
13
|
* refresh means the site is serving stale data.
|
|
9
14
|
*/
|
|
10
|
-
refreshCatalog?: () => void | Promise<void>;
|
|
15
|
+
refreshCatalog?: (message: AstroidQueueMessage) => void | Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Which provider owns the catalog `refreshCatalog` re-syncs. Set it and only
|
|
18
|
+
* that provider's webhooks trigger a refresh; leave it unset and any
|
|
19
|
+
* catalog-affecting webhook does, which is correct for the single-provider
|
|
20
|
+
* case and is what every existing consumer gets.
|
|
21
|
+
*
|
|
22
|
+
* This exists because two providers is now a supported configuration, and the
|
|
23
|
+
* seam is provider-blind by construction: a site can run Fourthwall as its
|
|
24
|
+
* storefront and Square as its POS, at which point `refreshCatalog` means
|
|
25
|
+
* "re-pull Fourthwall" while Square emits `inventory.count.updated` on every
|
|
26
|
+
* single sale. Unscoped, a good Saturday becomes a sync storm against an
|
|
27
|
+
* unrelated provider's rate limit — and the periodic refresh is unaffected, so
|
|
28
|
+
* the site looks fine until the day it's busy.
|
|
29
|
+
*/
|
|
30
|
+
catalogProvider?: string;
|
|
11
31
|
/**
|
|
12
32
|
* Anything else this project queues. Runs for every message, after the
|
|
13
33
|
* catalog dispatch above, so a project can add its own kinds without
|
package/dist/queues/consumer.js
CHANGED
|
@@ -25,12 +25,15 @@ import { affectsCatalog } from "./messages.js";
|
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
export function astroidQueueHandler(options = {}) {
|
|
28
|
+
const owns = (provider) => options.catalogProvider === undefined || provider === options.catalogProvider;
|
|
28
29
|
return async (message) => {
|
|
29
30
|
if (message.kind === "catalog_refresh") {
|
|
30
|
-
await options.refreshCatalog?.();
|
|
31
|
+
await options.refreshCatalog?.(message);
|
|
31
32
|
}
|
|
32
|
-
else if (message.kind === "webhook" &&
|
|
33
|
-
|
|
33
|
+
else if (message.kind === "webhook" &&
|
|
34
|
+
owns(message.provider) &&
|
|
35
|
+
affectsCatalog(message.provider, message.type)) {
|
|
36
|
+
await options.refreshCatalog?.(message);
|
|
34
37
|
}
|
|
35
38
|
await options.onMessage?.(message);
|
|
36
39
|
};
|
package/dist/queues/messages.js
CHANGED
|
@@ -44,6 +44,10 @@ export function astroidCrons(config) {
|
|
|
44
44
|
const catalog = astroidCron(config);
|
|
45
45
|
if (catalog)
|
|
46
46
|
crons.push(catalog);
|
|
47
|
+
// Project-declared triggers last, so the two derived ones keep their existing
|
|
48
|
+
// positions and an added cron can't renumber anything.
|
|
49
|
+
for (const cron of config.crons ?? [])
|
|
50
|
+
crons.push(cron.expression);
|
|
47
51
|
return crons;
|
|
48
52
|
}
|
|
49
53
|
/** Binding name for the project's queue producer. */
|
package/dist/queues/scaffold.js
CHANGED
|
@@ -92,6 +92,10 @@ export function generateAstroidQueueSeam(config) {
|
|
|
92
92
|
// invoicing-only provider has nothing for this hook to do.
|
|
93
93
|
const provider = astroidCommerceRoles(config.commerce).storefront;
|
|
94
94
|
const table = astroidCatalogMirror(config).table;
|
|
95
|
+
// Two providers means the catalog belongs to exactly one of them, and the
|
|
96
|
+
// other one's webhooks must not drag it through a re-sync. Scaffold the scope
|
|
97
|
+
// in rather than leaving it to be discovered on a busy day (#294).
|
|
98
|
+
const multiProvider = astroidCommerceProviders(config.commerce).length > 1;
|
|
95
99
|
return [
|
|
96
100
|
"// The queue consumer — what each message actually does.",
|
|
97
101
|
"//",
|
|
@@ -110,6 +114,15 @@ export function generateAstroidQueueSeam(config) {
|
|
|
110
114
|
" message: AstroidQueueMessage,",
|
|
111
115
|
"): Promise<void> {",
|
|
112
116
|
" await astroidQueueHandler({",
|
|
117
|
+
...(multiProvider && provider
|
|
118
|
+
? [
|
|
119
|
+
` // This project runs more than one commerce provider, and the catalog is`,
|
|
120
|
+
` // ${provider}'s. Scoping the refresh keeps the OTHER provider's webhooks from`,
|
|
121
|
+
` // triggering it — Square alone emits an inventory event on every sale, which`,
|
|
122
|
+
` // unscoped would re-sync ${provider} once per transaction.`,
|
|
123
|
+
` catalogProvider: ${JSON.stringify(provider)},`,
|
|
124
|
+
]
|
|
125
|
+
: []),
|
|
113
126
|
" refreshCatalog: async () => {",
|
|
114
127
|
provider
|
|
115
128
|
? ` // TODO: fetch the ${provider} catalog, normalize each item with`
|
|
@@ -122,6 +135,22 @@ export function generateAstroidQueueSeam(config) {
|
|
|
122
135
|
` // const r = await astroidCatalogSync(items, { db: env.DB, table: ${JSON.stringify(table)} });`,
|
|
123
136
|
' // if (r.failed > 0) console.warn("[catalog] skipped", r.failed, r.errors);',
|
|
124
137
|
" //",
|
|
138
|
+
// Nobody is watching a queue message, which flips the retry trade-off that
|
|
139
|
+
// an attended checkout route settles the other way.
|
|
140
|
+
...(provider === "square"
|
|
141
|
+
? [
|
|
142
|
+
" // Nobody is watching this run, so let the client retry: SquareConfig",
|
|
143
|
+
" // takes `retry: { attempts: 3 }`, backing off on 429/5xx inside every",
|
|
144
|
+
" // verb. It is off by default because a checkout route has a customer",
|
|
145
|
+
" // watching a spinner, and there a fast failure beats a slow one. Here",
|
|
146
|
+
" // the opposite holds — a catalog push that gives up halfway is worse.",
|
|
147
|
+
]
|
|
148
|
+
: [
|
|
149
|
+
" // Nobody is watching this run, so ask for backoff on 429/5xx wherever",
|
|
150
|
+
" // the client offers it. Attended paths are better off failing fast; a",
|
|
151
|
+
" // catalog push that gives up halfway is not.",
|
|
152
|
+
]),
|
|
153
|
+
" //",
|
|
125
154
|
" // The sync is idempotent (keyed on the provider's id) and never",
|
|
126
155
|
" // writes an owner-edited column, so it's safe to run on every event.",
|
|
127
156
|
" //",
|
|
@@ -12,10 +12,15 @@
|
|
|
12
12
|
// create-astroid's schema generators, which call this function but never run the
|
|
13
13
|
// beforeChange hook below). Both entries are drizzle-free: `content/define` for
|
|
14
14
|
// the config types/builders, and `content/sections` for the write-time section
|
|
15
|
-
// validators. That second entry is what the Rule-evaluator split
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// validators. That second entry is what the Rule-evaluator split added, so this
|
|
16
|
+
// hook can import the validators STATICALLY instead of the dynamic
|
|
17
|
+
// `import("louise-toolkit/content")` it used to need to keep the CLI's graph
|
|
18
|
+
// drizzle-free.
|
|
19
|
+
//
|
|
20
|
+
// Note the entry named here is the PUBLIC subpath. Astroid must never reach into
|
|
21
|
+
// `louise-toolkit/src/...` — that resolves only because the workspace aliases the
|
|
22
|
+
// package to its source, and would break the moment astroid consumes a published
|
|
23
|
+
// tarball (#327).
|
|
19
24
|
import { defineCollection, } from "louise-toolkit/content/define";
|
|
20
25
|
import { assertValidSections, sanitizeSectionsRichText } from "louise-toolkit/content/sections";
|
|
21
26
|
import { sanitizeRichHtml } from "louise-toolkit/security";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AstroidConfig, TenancyConfig } from "../config.js";
|
|
2
|
+
/** Default internal prefix a tenant request is rewritten to. */
|
|
3
|
+
export declare const ASTROID_TENANT_PREFIX = "/t";
|
|
4
|
+
/**
|
|
5
|
+
* The Cloudflare zone a wildcard pattern belongs to.
|
|
6
|
+
*
|
|
7
|
+
* Defaults to the pattern minus its leading `*.`, which is right whenever the
|
|
8
|
+
* wildcard sits directly under the apex. A deeper pattern needs `zone` set
|
|
9
|
+
* explicitly: `*.shop.example.com` is served by the `example.com` zone, and
|
|
10
|
+
* guessing `shop.example.com` there produces a deploy error naming a zone that
|
|
11
|
+
* does not exist.
|
|
12
|
+
*/
|
|
13
|
+
export declare function tenancyZone(tenancy: TenancyConfig): string;
|
|
14
|
+
/**
|
|
15
|
+
* The subdomain label for a host under the wildcard, or `null` when the host is
|
|
16
|
+
* not a tenant candidate at all.
|
|
17
|
+
*
|
|
18
|
+
* `null` covers three distinct cases that all mean "render the ordinary site":
|
|
19
|
+
* the apex itself (a wildcard does not match its own apex), a host outside the
|
|
20
|
+
* pattern (a preview domain, `localhost`), and a reserved label.
|
|
21
|
+
*
|
|
22
|
+
* Exported and pure so a site can unit-test its own reserved list without
|
|
23
|
+
* standing up a request.
|
|
24
|
+
*/
|
|
25
|
+
export declare function tenantLabel(host: string, tenancy: TenancyConfig): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The scaffold-once `src/tenancy.ts` — the seam holding every decision Astroid
|
|
28
|
+
* refuses to make for a site.
|
|
29
|
+
*
|
|
30
|
+
* Written once and then yours: what a label resolves to, whether the lookup is
|
|
31
|
+
* cached, and what an unknown label means are all questions with site-specific
|
|
32
|
+
* answers, and a framework that guessed them would be wrong in a different way
|
|
33
|
+
* for each project.
|
|
34
|
+
*/
|
|
35
|
+
export declare function generateAstroidTenancy(config: AstroidConfig): string | null;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
|
|
2
|
+
//
|
|
3
|
+
// Wildcard host dispatch: the parts that are the same for every site, and
|
|
4
|
+
// nothing that decides anything.
|
|
5
|
+
//
|
|
6
|
+
// Astroid owns two things a site cannot own on its own — the wildcard Worker
|
|
7
|
+
// route (`hosts` can only express custom domains) and the single middleware file
|
|
8
|
+
// Astro permits. What a subdomain MEANS, whether the lookup is cached, and what
|
|
9
|
+
// an unknown host should do are all site policy, and live in the scaffolded
|
|
10
|
+
// `src/tenancy.ts`.
|
|
11
|
+
/** Default internal prefix a tenant request is rewritten to. */
|
|
12
|
+
export const ASTROID_TENANT_PREFIX = "/t";
|
|
13
|
+
/**
|
|
14
|
+
* The Cloudflare zone a wildcard pattern belongs to.
|
|
15
|
+
*
|
|
16
|
+
* Defaults to the pattern minus its leading `*.`, which is right whenever the
|
|
17
|
+
* wildcard sits directly under the apex. A deeper pattern needs `zone` set
|
|
18
|
+
* explicitly: `*.shop.example.com` is served by the `example.com` zone, and
|
|
19
|
+
* guessing `shop.example.com` there produces a deploy error naming a zone that
|
|
20
|
+
* does not exist.
|
|
21
|
+
*/
|
|
22
|
+
export function tenancyZone(tenancy) {
|
|
23
|
+
return tenancy.zone ?? tenancy.hostPattern.replace(/^\*\./, "");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The subdomain label for a host under the wildcard, or `null` when the host is
|
|
27
|
+
* not a tenant candidate at all.
|
|
28
|
+
*
|
|
29
|
+
* `null` covers three distinct cases that all mean "render the ordinary site":
|
|
30
|
+
* the apex itself (a wildcard does not match its own apex), a host outside the
|
|
31
|
+
* pattern (a preview domain, `localhost`), and a reserved label.
|
|
32
|
+
*
|
|
33
|
+
* Exported and pure so a site can unit-test its own reserved list without
|
|
34
|
+
* standing up a request.
|
|
35
|
+
*/
|
|
36
|
+
export function tenantLabel(host, tenancy) {
|
|
37
|
+
const suffix = tenancy.hostPattern.replace(/^\*\./, "");
|
|
38
|
+
// Strip a port: `acme.example.com:8788` under `wrangler dev`.
|
|
39
|
+
const hostname = host.split(":")[0]?.toLowerCase() ?? "";
|
|
40
|
+
if (!hostname.endsWith(`.${suffix}`))
|
|
41
|
+
return null;
|
|
42
|
+
const label = hostname.slice(0, -(suffix.length + 1));
|
|
43
|
+
// Only a single label is a tenant. `a.b.example.com` under `*.example.com` is
|
|
44
|
+
// not `a.b` — Cloudflare's wildcard matches one level, and treating a dotted
|
|
45
|
+
// string as a slug would put a `/` in a rewrite path.
|
|
46
|
+
if (!label || label.includes("."))
|
|
47
|
+
return null;
|
|
48
|
+
const reserved = tenancy.reserved ?? [];
|
|
49
|
+
return reserved.includes(label) ? null : label;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The scaffold-once `src/tenancy.ts` — the seam holding every decision Astroid
|
|
53
|
+
* refuses to make for a site.
|
|
54
|
+
*
|
|
55
|
+
* Written once and then yours: what a label resolves to, whether the lookup is
|
|
56
|
+
* cached, and what an unknown label means are all questions with site-specific
|
|
57
|
+
* answers, and a framework that guessed them would be wrong in a different way
|
|
58
|
+
* for each project.
|
|
59
|
+
*/
|
|
60
|
+
export function generateAstroidTenancy(config) {
|
|
61
|
+
const tenancy = config.tenancy;
|
|
62
|
+
if (!tenancy)
|
|
63
|
+
return null;
|
|
64
|
+
const prefix = tenancy.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
65
|
+
const example = (tenancy.hostPattern ?? "*.example.com").replace(/^\*\./, "");
|
|
66
|
+
return [
|
|
67
|
+
"// Scaffolded once by astroidjs — yours to edit.",
|
|
68
|
+
"//",
|
|
69
|
+
"// What a subdomain MEANS. The generated middleware has already decided this",
|
|
70
|
+
"// host is a tenant candidate (it matches your wildcard and is not reserved);",
|
|
71
|
+
"// everything after that is your call.",
|
|
72
|
+
"//",
|
|
73
|
+
`// A match rewrites internally to \`${prefix}/<slug>/…\`, so put the pages under`,
|
|
74
|
+
`// \`src/pages${prefix}/[tenant]/\`. The visitor's URL never changes.`,
|
|
75
|
+
"",
|
|
76
|
+
"/** What your pages read off `Astro.locals.tenant`. Widen it freely. */",
|
|
77
|
+
"export interface Tenant {",
|
|
78
|
+
" /** Used to build the internal path, so keep it URL-safe. */",
|
|
79
|
+
" slug: string;",
|
|
80
|
+
"}",
|
|
81
|
+
"",
|
|
82
|
+
"/**",
|
|
83
|
+
" * Resolve a subdomain label to a tenant, or `null` if there isn't one.",
|
|
84
|
+
" *",
|
|
85
|
+
" * `null` falls through to the ordinary site — which is a real choice, not a",
|
|
86
|
+
" * default: a stranger's subdomain then renders your homepage. If that is wrong",
|
|
87
|
+
" * for this project, return null here and refuse it in the middleware's `guard`",
|
|
88
|
+
" * with a 404, so an unknown host is unambiguously not a page.",
|
|
89
|
+
" *",
|
|
90
|
+
" * This runs on EVERY request to a tenant host, so a database lookup here is a",
|
|
91
|
+
" * query per request. Cache it — a module-scope Map is enough within an isolate,",
|
|
92
|
+
" * KV if the tenant set is large or changes without a deploy.",
|
|
93
|
+
" */",
|
|
94
|
+
"export async function resolveTenant(label: string): Promise<Tenant | null> {",
|
|
95
|
+
` // TODO(astroid): look \`label\` up — a D1 table, a KV entry, or a literal map`,
|
|
96
|
+
` // while the set is small. Example: acme.${example} → { slug: "acme" }.`,
|
|
97
|
+
" return { slug: label };",
|
|
98
|
+
"}",
|
|
99
|
+
"",
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
package/dist/worker/generate.js
CHANGED
|
@@ -17,6 +17,7 @@ import { astroidPortal } from "../portal/config.js";
|
|
|
17
17
|
import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/messages.js";
|
|
18
18
|
import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
|
|
19
19
|
import { capturesInquiries } from "../schema/framework.js";
|
|
20
|
+
import { ASTROID_TENANT_PREFIX } from "../tenancy/index.js";
|
|
20
21
|
import { astroidEditorRoutePlan } from "./routes.js";
|
|
21
22
|
// Astroid's default editable site_settings surface — the columns the Settings
|
|
22
23
|
// panel may write, and which of them hold a media-library image URL.
|
|
@@ -62,6 +63,8 @@ export function generateAstroidWorker(config) {
|
|
|
62
63
|
// scaffold, which is exactly how it got caught.
|
|
63
64
|
// Same trap as realtimeRoute: these live outside `louise-toolkit/editor`.
|
|
64
65
|
const realtimeRouteFactories = new Set(["realtimeRoute", "vitalsRoute"]);
|
|
66
|
+
// The routes that take an AI runner, and so need `aiRunner` imported.
|
|
67
|
+
const AI_ROUTES = new Set(["ai", "seoFix", "media"]);
|
|
65
68
|
const editorImports = [
|
|
66
69
|
"DEFAULT_PAGE_FIELDS",
|
|
67
70
|
...new Set(plan.map((route) => route.factory).filter((f) => !realtimeRouteFactories.has(f))),
|
|
@@ -112,15 +115,19 @@ export function generateAstroidWorker(config) {
|
|
|
112
115
|
: "";
|
|
113
116
|
return `settingsRoute({ table: siteSettings, resolveEditor, columns: SETTINGS_COLUMNS, imageKeys: SETTINGS_IMAGE_KEYS, mediaBase: MEDIA_BASE${customArg} })`;
|
|
114
117
|
}
|
|
118
|
+
// `aiRunner` rather than `(env) => env.AI`: it reads the binding AND the
|
|
119
|
+
// LOUISE_AI kill switch, so all three assists share one definition of
|
|
120
|
+
// "is generation on?" instead of each re-deriving it. Embeddings keep
|
|
121
|
+
// binding-presence as their switch — see the helper's comment.
|
|
115
122
|
case "ai":
|
|
116
|
-
return "aiRoute({ resolveEditor, ai:
|
|
123
|
+
return "aiRoute({ resolveEditor, ai: aiRunner })";
|
|
117
124
|
case "seoFix":
|
|
118
|
-
return "seoFixRoute({ table: pages, resolveEditor, ai:
|
|
125
|
+
return "seoFixRoute({ table: pages, resolveEditor, ai: aiRunner })";
|
|
119
126
|
case "media":
|
|
120
127
|
// `altText` fills a new upload's alt from the image itself. Best-effort
|
|
121
128
|
// by contract — a model error or a missing binding never fails the
|
|
122
129
|
// upload — so it costs nothing on a project that doesn't want it.
|
|
123
|
-
return "mediaRoute({ table: media, resolveEditor, referenceSources: MEDIA_REFERENCE_SOURCES, altText:
|
|
130
|
+
return "mediaRoute({ table: media, resolveEditor, referenceSources: MEDIA_REFERENCE_SOURCES, altText: aiRunner })";
|
|
124
131
|
case "editors":
|
|
125
132
|
// The editor instance's user table is `louise_`-prefixed (the editor
|
|
126
133
|
// convention — the unprefixed `user` table is left for a second/portal
|
|
@@ -159,6 +166,11 @@ export function generateAstroidWorker(config) {
|
|
|
159
166
|
p('import { defineForm } from "louise-toolkit/forms";');
|
|
160
167
|
if (queues)
|
|
161
168
|
p('import { processBatch } from "louise-toolkit/queues";');
|
|
169
|
+
// Only when a route actually takes a runner — a project with no AI assists
|
|
170
|
+
// should not import one, and knip would flag it if it did.
|
|
171
|
+
if (plan.some((route) => AI_ROUTES.has(route.name))) {
|
|
172
|
+
p('import { aiRunner } from "louise-toolkit/ai";');
|
|
173
|
+
}
|
|
162
174
|
p('import { checkLinks } from "louise-toolkit/browser";');
|
|
163
175
|
p('import { readHealthSummary, summarizeHealth, writeHealthSummary } from "louise-toolkit/health";');
|
|
164
176
|
p('import { composeWorker, isEditRequest, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
|
|
@@ -393,6 +405,17 @@ export function generateAstroidWorker(config) {
|
|
|
393
405
|
p(" // serves stale data until a human notices. Enqueued rather than run");
|
|
394
406
|
p(" // inline so it takes the same retry + DLQ path as everything else.");
|
|
395
407
|
p(' ctx.waitUntil(env.COMMERCE_QUEUE.send({ kind: "catalog_refresh" }));');
|
|
408
|
+
p(" return;");
|
|
409
|
+
p(" }");
|
|
410
|
+
}
|
|
411
|
+
// Project-declared crons (`config.crons`). Emitted from the same list that
|
|
412
|
+
// feeds `triggers.crons`, so a trigger can't exist with no branch to match it.
|
|
413
|
+
for (const custom of config.crons ?? []) {
|
|
414
|
+
p(` if (controller.cron === ${JSON.stringify(custom.expression)}) {`);
|
|
415
|
+
p(" // Enqueued, not run inline: same retry + DLQ path as everything");
|
|
416
|
+
p(" // else, and a slow job can't hold the scheduled handler open.");
|
|
417
|
+
p(` ctx.waitUntil(env.COMMERCE_QUEUE.send(${JSON.stringify(custom.message)}));`);
|
|
418
|
+
p(" return;");
|
|
396
419
|
p(" }");
|
|
397
420
|
}
|
|
398
421
|
p(" },");
|
|
@@ -437,6 +460,8 @@ export function generateAstroidMiddleware(config) {
|
|
|
437
460
|
// inlined @font-face needs no manual `font-src` entry.
|
|
438
461
|
const cspStyleSrc = "'self' 'unsafe-inline'";
|
|
439
462
|
const portal = astroidPortal(config);
|
|
463
|
+
const tenancy = config.tenancy;
|
|
464
|
+
const rewritePrefix = tenancy?.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
440
465
|
return [
|
|
441
466
|
"// Generated by astroidjs — do not hand-edit.",
|
|
442
467
|
"// The shared Louise middleware: rate-limit the unauthenticated POST surfaces,",
|
|
@@ -446,10 +471,22 @@ export function generateAstroidMiddleware(config) {
|
|
|
446
471
|
"// styles + inlined data: brand font are allowed.",
|
|
447
472
|
'import { env } from "cloudflare:workers";',
|
|
448
473
|
'import { createLouiseMiddleware } from "louise-toolkit/astro";',
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
474
|
+
// One `astroidjs` import, composed from what this config actually uses —
|
|
475
|
+
// two import statements for the same module is legal and reads as an
|
|
476
|
+
// oversight in a file nobody is supposed to hand-edit.
|
|
477
|
+
`import { ${[
|
|
478
|
+
...(portal ? ["astroidPortalGuardConfig"] : []),
|
|
479
|
+
"astroidRateRules",
|
|
480
|
+
...(portal ? ["guardResponse", "portalGuard", "resolvePortalSession"] : []),
|
|
481
|
+
...(tenancy ? ["tenantLabel"] : []),
|
|
482
|
+
].join(", ")} } from "astroidjs";`,
|
|
452
483
|
'import astroidConfig from "../astroid.config.js";',
|
|
484
|
+
...(tenancy
|
|
485
|
+
? [
|
|
486
|
+
"// TODO(astroid): your TENANT seam — what a subdomain maps to (src/tenancy.ts).",
|
|
487
|
+
'import { resolveTenant } from "./tenancy.js";',
|
|
488
|
+
]
|
|
489
|
+
: []),
|
|
453
490
|
"// TODO(astroid): your AUTH seam — same resolveEditor as the generated worker.ts.",
|
|
454
491
|
'import { resolveEditor } from "./auth.js";',
|
|
455
492
|
// The portal's resolver lives in its OWN module, not the editor's auth
|
|
@@ -464,6 +501,14 @@ export function generateAstroidMiddleware(config) {
|
|
|
464
501
|
"// `env.RL` is read per request (a getter) — a KV binding is only valid in",
|
|
465
502
|
"// request scope.",
|
|
466
503
|
"const RATE_RULES = astroidRateRules(astroidConfig);",
|
|
504
|
+
...(tenancy
|
|
505
|
+
? [
|
|
506
|
+
"",
|
|
507
|
+
"// Read from the config rather than restated here, so the reserved list and",
|
|
508
|
+
"// the wildcard pattern can't drift from the Worker route generated for them.",
|
|
509
|
+
"const TENANCY = astroidConfig.tenancy!;",
|
|
510
|
+
]
|
|
511
|
+
: []),
|
|
467
512
|
...(portal
|
|
468
513
|
? [
|
|
469
514
|
"const PORTAL_GUARD = astroidPortalGuardConfig(astroidConfig)!;",
|
|
@@ -478,12 +523,31 @@ export function generateAstroidMiddleware(config) {
|
|
|
478
523
|
"export const onRequest = createLouiseMiddleware({",
|
|
479
524
|
" resolveEditor: (request) => resolveEditor(request),",
|
|
480
525
|
" rateLimit: { rules: RATE_RULES, kv: () => env.RL },",
|
|
481
|
-
|
|
526
|
+
// `extend` runs once and may need to populate BOTH — a tenanted site with a
|
|
527
|
+
// portal resolves a tenant and a customer on the same request.
|
|
528
|
+
...(portal || tenancy
|
|
482
529
|
? [
|
|
483
530
|
" extend: async (context) => {",
|
|
484
|
-
|
|
485
|
-
|
|
531
|
+
...(portal
|
|
532
|
+
? [
|
|
533
|
+
" const user = await resolvePortalSession(context.request, resolvePortalUser);",
|
|
534
|
+
" context.locals.portalUser = user;",
|
|
535
|
+
]
|
|
536
|
+
: []),
|
|
537
|
+
...(tenancy
|
|
538
|
+
? [
|
|
539
|
+
" // The label, or null for the apex / a reserved label / an off-pattern",
|
|
540
|
+
" // host. `resolveTenant` is yours (src/tenancy.ts): it decides what a",
|
|
541
|
+
" // label maps to and whether that lookup is cached.",
|
|
542
|
+
" const label = tenantLabel(context.url.hostname, TENANCY);",
|
|
543
|
+
" context.locals.tenant = label ? await resolveTenant(label) : null;",
|
|
544
|
+
]
|
|
545
|
+
: []),
|
|
486
546
|
" },",
|
|
547
|
+
]
|
|
548
|
+
: []),
|
|
549
|
+
...(portal
|
|
550
|
+
? [
|
|
487
551
|
" // Route guard: the declarative prefix→roles table from your config.",
|
|
488
552
|
" // An /api/* route always answers in JSON — redirecting fetch() to an",
|
|
489
553
|
" // HTML login page returns 200 and markup, which reads as success.",
|
|
@@ -499,6 +563,22 @@ export function generateAstroidMiddleware(config) {
|
|
|
499
563
|
" },",
|
|
500
564
|
]
|
|
501
565
|
: []),
|
|
566
|
+
...(tenancy
|
|
567
|
+
? [
|
|
568
|
+
" // Host dispatch: map the resolved tenant onto an internal path prefix.",
|
|
569
|
+
" // Runs after `guard`, so route policy stays written against the PUBLIC",
|
|
570
|
+
" // path. The visitor's URL is unchanged — an internal rewrite, not a",
|
|
571
|
+
" // redirect — so links built from Astro.url stay correct.",
|
|
572
|
+
" //",
|
|
573
|
+
" // An unknown subdomain is YOUR decision: `resolveTenant` returning null",
|
|
574
|
+
" // falls through to the ordinary site below. Return a 404 from `guard`",
|
|
575
|
+
" // instead if a stranger's subdomain should not render your homepage.",
|
|
576
|
+
" rewrite: (context) => {",
|
|
577
|
+
" const tenant = context.locals.tenant;",
|
|
578
|
+
` return tenant ? \`${rewritePrefix}/\${tenant.slug}\${context.url.pathname}\` : undefined;`,
|
|
579
|
+
" },",
|
|
580
|
+
]
|
|
581
|
+
: []),
|
|
502
582
|
" // Rewrite Astro's hash-based style-src (owned by astroidSecurity in",
|
|
503
583
|
' // astro.config.mjs) to permit Louise\'s data-driven style="" + editor styles.',
|
|
504
584
|
` cspStyleSrc: ${JSON.stringify(cspStyleSrc)},`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astroidjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
@@ -60,19 +60,24 @@
|
|
|
60
60
|
"access": "public"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"louise-toolkit": "0.
|
|
63
|
+
"louise-toolkit": "0.24.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/node": "^24.13.3",
|
|
67
67
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
68
|
+
"astro": "^7.0.9",
|
|
68
69
|
"solid-js": "^1.9.14",
|
|
69
70
|
"typescript": "^5.8.0",
|
|
70
71
|
"vitest": "^4.1.10"
|
|
71
72
|
},
|
|
72
73
|
"peerDependencies": {
|
|
74
|
+
"astro": "^7.0.9",
|
|
73
75
|
"solid-js": "^1.9.0"
|
|
74
76
|
},
|
|
75
77
|
"peerDependenciesMeta": {
|
|
78
|
+
"astro": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
76
81
|
"solid-js": {
|
|
77
82
|
"optional": true
|
|
78
83
|
}
|