astroidjs 0.19.0 → 0.20.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 +32 -8
- package/dist/commerce/secrets.d.ts +7 -0
- package/dist/commerce/secrets.js +12 -3
- package/dist/config.d.ts +54 -2
- package/dist/config.js +84 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/project/generate.js +96 -52
- package/dist/project/scaffold.js +27 -16
- package/dist/queues/index.d.ts +1 -1
- package/dist/queues/index.js +1 -1
- package/dist/queues/messages.d.ts +6 -0
- package/dist/queues/messages.js +17 -2
- package/dist/queues/scaffold.js +5 -1
- package/dist/schema/generate.js +48 -4
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/rate-rules.d.ts +9 -2
- package/dist/security/rate-rules.js +46 -22
- package/dist/seo/routes.js +3 -2
- package/dist/shape.d.ts +6 -0
- package/dist/shape.js +14 -0
- package/dist/status.js +22 -10
- package/dist/worker/generate.js +142 -10
- package/package.json +3 -3
- package/src/components/Credit.astro +70 -0
package/dist/worker/generate.js
CHANGED
|
@@ -18,6 +18,7 @@ import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "../queues/m
|
|
|
18
18
|
import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, usesRealtime, } from "../realtime/scaffold.js";
|
|
19
19
|
import { capturesInquiries } from "../schema/framework.js";
|
|
20
20
|
import { astroidCspStyleSrc } from "../security/csp-origins.js";
|
|
21
|
+
import { astroidHasEditor } from "../shape.js";
|
|
21
22
|
import { ASTROID_REWRITE_EXCLUDE, ASTROID_TENANT_PREFIX } from "../tenancy/index.js";
|
|
22
23
|
import { astroidEditorRoutePlan } from "./routes.js";
|
|
23
24
|
// Astroid's default editable site_settings surface—the columns the Settings
|
|
@@ -52,6 +53,8 @@ export const ASTROID_SETTINGS_IMAGE_KEYS = ["logoUrl", "faviconUrl", "defaultOgI
|
|
|
52
53
|
* emitted only when a brand captures inquiries.
|
|
53
54
|
*/
|
|
54
55
|
export function generateAstroidWorker(config) {
|
|
56
|
+
if (!astroidHasEditor(config))
|
|
57
|
+
return generateAppWorker(config);
|
|
55
58
|
const inquiries = capturesInquiries(config);
|
|
56
59
|
const queues = astroidUsesQueues(config);
|
|
57
60
|
const cron = astroidCron(config);
|
|
@@ -564,6 +567,111 @@ export function generateAstroidWorker(config) {
|
|
|
564
567
|
}
|
|
565
568
|
return lines.join("\n");
|
|
566
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* The worker of an app with no editor (`editor: false`): the public status
|
|
572
|
+
* route, the SSR fallback, and whatever the config's modules add (a queue
|
|
573
|
+
* consumer, the crons), with no editor route and no auth seam.
|
|
574
|
+
*
|
|
575
|
+
* The gate stays, with a resolver that never finds an editor. Nothing under
|
|
576
|
+
* `/api/louise` belongs to one here, so it refuses everything there except the
|
|
577
|
+
* public status probe, and it gives route responses the security headers the
|
|
578
|
+
* middleware never sees.
|
|
579
|
+
*/
|
|
580
|
+
function generateAppWorker(config) {
|
|
581
|
+
const queues = astroidUsesQueues(config);
|
|
582
|
+
const cron = astroidCron(config);
|
|
583
|
+
const customCrons = config.crons ?? [];
|
|
584
|
+
const scheduled = cron !== null || customCrons.length > 0;
|
|
585
|
+
const lines = [];
|
|
586
|
+
const p = (s = "") => lines.push(s);
|
|
587
|
+
p("// Generated by astroidjs—do not hand-edit.");
|
|
588
|
+
p("// Source: your defineAstroid config. This app has no editor (`editor: false`),");
|
|
589
|
+
p("// so it serves no editor routes and resolves no editor session.");
|
|
590
|
+
p('import { handle } from "@astrojs/cloudflare/handler";');
|
|
591
|
+
p('import { d1Check, statusRoute } from "louise-toolkit/editor";');
|
|
592
|
+
if (queues)
|
|
593
|
+
p('import { processBatch } from "louise-toolkit/queues";');
|
|
594
|
+
p('import { composeWorker, type WorkerRoute, withEdgeCache } from "louise-toolkit/worker";');
|
|
595
|
+
if (queues)
|
|
596
|
+
p('import type { AstroidQueueMessage } from "astroidjs";');
|
|
597
|
+
if (config.status?.checks) {
|
|
598
|
+
p("// Your STATUS seam: the app's own checks for the public status route.");
|
|
599
|
+
p("// Scaffolded once and yours to edit.");
|
|
600
|
+
p('import { statusChecks } from "./status-checks.js";');
|
|
601
|
+
}
|
|
602
|
+
if (queues) {
|
|
603
|
+
p("// Your QUEUE seam: what each message actually does. Scaffolded once and");
|
|
604
|
+
p("// yours to edit.");
|
|
605
|
+
p('import { handleQueueMessage } from "./queue.js";');
|
|
606
|
+
}
|
|
607
|
+
p();
|
|
608
|
+
p("// --- public status ---------------------------------------------------------");
|
|
609
|
+
p("// What statusRoute answers an outside probe with: 200 when every check");
|
|
610
|
+
p("// passes, 503 when any fails, throws, or takes over two seconds. Anyone can");
|
|
611
|
+
p("// make these run, so each is one cheap read, and a burst of probes reuses one");
|
|
612
|
+
p("// result per isolate for STATUS_REUSE_MS.");
|
|
613
|
+
p("const STATUS_REUSE_MS = 10_000;");
|
|
614
|
+
p("const STATUS_CHECKS = {");
|
|
615
|
+
p(" // D1 answers `SELECT 1`.");
|
|
616
|
+
p(" d1: d1Check((env: CloudflareEnv) => env.DB),");
|
|
617
|
+
if (config.status?.checks) {
|
|
618
|
+
p(" // The app's own, from src/status-checks.ts. Spread last, so a check with");
|
|
619
|
+
p(" // the same name replaces Astroid's on purpose.");
|
|
620
|
+
p(" ...statusChecks,");
|
|
621
|
+
}
|
|
622
|
+
p("};");
|
|
623
|
+
p();
|
|
624
|
+
p("const routes: WorkerRoute<CloudflareEnv>[] = [");
|
|
625
|
+
p(" // Public status for an outside probe (GET/HEAD /api/louise/status). A");
|
|
626
|
+
p(" // publicRoute, so the gate below lets an anonymous probe through.");
|
|
627
|
+
p(" statusRoute({ checks: STATUS_CHECKS, reuseMs: STATUS_REUSE_MS }),");
|
|
628
|
+
p("];");
|
|
629
|
+
p();
|
|
630
|
+
p(queues
|
|
631
|
+
? "export default composeWorker<CloudflareEnv, AstroidQueueMessage>({"
|
|
632
|
+
: "export default composeWorker<CloudflareEnv>({");
|
|
633
|
+
p(" routes,");
|
|
634
|
+
p(" // No editor, so no request under /api/louise resolves to one. The gate");
|
|
635
|
+
p(" // refuses everything there except the public status probe, and gives route");
|
|
636
|
+
p(" // responses the security headers the middleware never sees.");
|
|
637
|
+
p(" gate: { resolveEditor: () => null },");
|
|
638
|
+
p(" // The SSR fallback, in the Worker cache (ADR 0004). It stores only a");
|
|
639
|
+
p(" // response that opts in with `Astro.cache.set(...)`, and it strips the CDN");
|
|
640
|
+
p(" // directive from every other one, so Cloudflare's cookie-blind edge cache");
|
|
641
|
+
p(" // never stores a signed-in customer's page.");
|
|
642
|
+
p(" fetch: withEdgeCache((request, env, ctx) => handle(request, env, ctx)),");
|
|
643
|
+
if (queues) {
|
|
644
|
+
p(" // Queue consumer. `processBatch` acks or retries each message");
|
|
645
|
+
p(" // INDEPENDENTLY, so one poisoned message can't block the rest of the");
|
|
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
|
+
}
|
|
649
|
+
if (scheduled) {
|
|
650
|
+
p(" // Cron. Cloudflare fires this for EVERY trigger in wrangler.jsonc and");
|
|
651
|
+
p(" // identifies which by `controller.cron`, so dispatch on it.");
|
|
652
|
+
p(" scheduled: (controller, env, ctx) => {");
|
|
653
|
+
if (cron) {
|
|
654
|
+
p(` if (controller.cron === ${JSON.stringify(cron)}) {`);
|
|
655
|
+
p(" // Catalog safety net: a missed webhook leaves the catalog stale only");
|
|
656
|
+
p(" // until the next tick. Enqueued, so it takes the retry + DLQ path.");
|
|
657
|
+
p(' ctx.waitUntil(env.COMMERCE_QUEUE.send({ kind: "catalog_refresh" }));');
|
|
658
|
+
p(" return;");
|
|
659
|
+
p(" }");
|
|
660
|
+
}
|
|
661
|
+
for (const custom of customCrons) {
|
|
662
|
+
p(` if (controller.cron === ${JSON.stringify(custom.expression)}) {`);
|
|
663
|
+
p(" // Enqueued, not run inline: same retry + DLQ path as everything");
|
|
664
|
+
p(" // else, and a slow job can't hold the scheduled handler open.");
|
|
665
|
+
p(` ctx.waitUntil(env.COMMERCE_QUEUE.send(${JSON.stringify(custom.message)}));`);
|
|
666
|
+
p(" return;");
|
|
667
|
+
p(" }");
|
|
668
|
+
}
|
|
669
|
+
p(" },");
|
|
670
|
+
}
|
|
671
|
+
p("});");
|
|
672
|
+
p();
|
|
673
|
+
return lines.join("\n");
|
|
674
|
+
}
|
|
567
675
|
/**
|
|
568
676
|
* Generate the Astro middleware (`middleware.ts`) from an Astroid config: the
|
|
569
677
|
* shared Louise flow (rate-limit the unauthenticated POST surface → resolve editor
|
|
@@ -592,6 +700,8 @@ export function generateAstroidMiddleware(config) {
|
|
|
592
700
|
// origins (the Square SDK's stylesheet) are baked in here; `astroid build`
|
|
593
701
|
// regenerates this file, so a config change lands on the next deploy.
|
|
594
702
|
const cspStyleSrc = astroidCspStyleSrc(config);
|
|
703
|
+
// An app with no editor has no auth seam and no pages to redirect between.
|
|
704
|
+
const editor = astroidHasEditor(config);
|
|
595
705
|
const portal = astroidPortal(config);
|
|
596
706
|
const tenancy = config.tenancy;
|
|
597
707
|
const rewritePrefix = tenancy?.rewritePrefix ?? ASTROID_TENANT_PREFIX;
|
|
@@ -599,13 +709,17 @@ export function generateAstroidMiddleware(config) {
|
|
|
599
709
|
return [
|
|
600
710
|
"// Generated by astroidjs—do not hand-edit.",
|
|
601
711
|
"// The shared Louise middleware: rate-limit the unauthenticated POST surfaces,",
|
|
602
|
-
|
|
712
|
+
...(editor
|
|
713
|
+
? ["// then resolve the editor session + sticky ?louise edit mode, then apply"]
|
|
714
|
+
: ["// then (this app has no editor, so there's no session to resolve) apply"]),
|
|
603
715
|
"// content-freshness + transport-security headers, and rewrite the style-src of",
|
|
604
716
|
"// the CSP header Astro's security.csp emits so Louise's data-driven inline",
|
|
605
717
|
"// styles + inlined data: brand font are allowed.",
|
|
606
718
|
'import { env } from "cloudflare:workers";',
|
|
607
719
|
'import { createLouiseMiddleware } from "@louise-toolkit/astro";',
|
|
608
|
-
|
|
720
|
+
...(editor
|
|
721
|
+
? ['import { db, pageRedirects, resolvePageRedirect } from "louise-toolkit/db";']
|
|
722
|
+
: []),
|
|
609
723
|
// One `astroidjs` import, composed from what this config actually uses—two
|
|
610
724
|
// import statements for the same module is legal and reads as an
|
|
611
725
|
// oversight in a file nobody is supposed to hand-edit.
|
|
@@ -623,15 +737,21 @@ export function generateAstroidMiddleware(config) {
|
|
|
623
737
|
'import { resolveTenant } from "./tenancy.js";',
|
|
624
738
|
]
|
|
625
739
|
: []),
|
|
626
|
-
|
|
627
|
-
|
|
740
|
+
...(editor
|
|
741
|
+
? [
|
|
742
|
+
"// TODO(astroid): your AUTH seam—same resolveEditor as the generated worker.ts.",
|
|
743
|
+
'import { resolveEditor } from "./auth.js";',
|
|
744
|
+
]
|
|
745
|
+
: []),
|
|
628
746
|
// The portal's resolver lives in its OWN module, not the editor's auth
|
|
629
747
|
// seam—they're separate Better Auth instances and must not share a file.
|
|
630
748
|
...(portal ? ['import { resolvePortalUser } from "./portal-auth.js";'] : []),
|
|
631
749
|
"",
|
|
632
750
|
"// Rate-limit the public, unauthenticated POST surface, keyed by client IP",
|
|
633
751
|
"// (fixed-window KV counter that fails open). Derived from your config: the",
|
|
634
|
-
|
|
752
|
+
...(editor
|
|
753
|
+
? ["// editor magic-link always, plus the portal credential surfaces and checkout"]
|
|
754
|
+
: ["// versioned API under /api/v1, plus the portal credential surfaces and checkout"]),
|
|
635
755
|
"// when those are enabled. Add your own via `security.rateRules` in the config—",
|
|
636
756
|
"// they're matched first, so they can also override a default's budget.",
|
|
637
757
|
"// `env.RL` is read per request (a getter)—a KV binding is only valid in",
|
|
@@ -657,15 +777,27 @@ export function generateAstroidMiddleware(config) {
|
|
|
657
777
|
: []),
|
|
658
778
|
"",
|
|
659
779
|
"export const onRequest = createLouiseMiddleware({",
|
|
660
|
-
|
|
780
|
+
...(editor
|
|
781
|
+
? [" resolveEditor: (request) => resolveEditor(request),"]
|
|
782
|
+
: [
|
|
783
|
+
" // No editor (`editor: false`), so no request resolves to one and edit",
|
|
784
|
+
" // mode never turns on.",
|
|
785
|
+
" resolveEditor: () => null,",
|
|
786
|
+
]),
|
|
661
787
|
" rateLimit: { rules: RATE_RULES, kv: () => env.RL },",
|
|
662
788
|
" // The same deny-by-default gate for /api/louise routes that reach Astro—",
|
|
663
789
|
" // anything the worker's routes didn't answer. A second check behind the",
|
|
664
|
-
|
|
790
|
+
editor
|
|
791
|
+
? " // worker's gate, and free: the editor is resolved here on every request."
|
|
792
|
+
: " // worker's gate: with no editor, it refuses all but the public routes.",
|
|
665
793
|
" apiGate: true,",
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
794
|
+
...(editor
|
|
795
|
+
? [
|
|
796
|
+
" // A renamed page's old URL answers a 301 to the new one. It runs only after",
|
|
797
|
+
" // the page answered 404, so a page later created on the old path wins.",
|
|
798
|
+
" redirectFor: (path) => resolvePageRedirect(db(env.DB), pageRedirects, path),",
|
|
799
|
+
]
|
|
800
|
+
: []),
|
|
669
801
|
// `extend` runs once and may need to populate BOTH—a tenanted site with a
|
|
670
802
|
// portal resolves a tenant and a customer on the same request.
|
|
671
803
|
...(portal || tenancy
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astroidjs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|
|
@@ -64,14 +64,14 @@
|
|
|
64
64
|
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
65
65
|
"@vitest/coverage-v8": "4.1.11",
|
|
66
66
|
"astro": "^7.2.9",
|
|
67
|
-
"louise-toolkit": "^0.
|
|
67
|
+
"louise-toolkit": "^0.37.0",
|
|
68
68
|
"solid-js": "^1.9.15",
|
|
69
69
|
"typescript": "^6.0.3",
|
|
70
70
|
"vitest": "^4.1.11"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"astro": "^7.0.9",
|
|
74
|
-
"louise-toolkit": "^0.
|
|
74
|
+
"louise-toolkit": "^0.37.0",
|
|
75
75
|
"solid-js": "^1.9.0"
|
|
76
76
|
},
|
|
77
77
|
"peerDependenciesMeta": {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
// `<Credit>`—the agency credit from `config.credit`, for the site's footer.
|
|
3
|
+
// Renders nothing when the config has no `credit`, so it's safe to leave in a
|
|
4
|
+
// footer permanently.
|
|
5
|
+
//
|
|
6
|
+
// The mark is a CSS mask filled with `currentColor`, not an `<img>`. An SVG
|
|
7
|
+
// loaded through `<img>` renders in its own document and can't see the page's
|
|
8
|
+
// text color, so it would stay whatever color it was drawn in on every theme.
|
|
9
|
+
// As a mask, only its shape counts and the fill follows the theme. It's
|
|
10
|
+
// `aria-hidden` because the name beside it already says who it is, so a screen
|
|
11
|
+
// reader reads the name once.
|
|
12
|
+
//
|
|
13
|
+
// The styles are scoped here rather than written as Tailwind utilities: a site
|
|
14
|
+
// may not scan node_modules, and the credit has to look the same either way.
|
|
15
|
+
import type { AstroidConfig } from "astroidjs";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
config: AstroidConfig;
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { config, class: className } = Astro.props;
|
|
23
|
+
const credit = config.credit;
|
|
24
|
+
// `defineAstroid` limits `logo` to a root-relative path or an https URL, and
|
|
25
|
+
// JSON quoting keeps a space or parenthesis in the path from ending the `url()`.
|
|
26
|
+
const markStyle = credit?.logo ? `--astroid-credit-mark:url(${JSON.stringify(credit.logo)})` : "";
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
credit && (
|
|
31
|
+
<p class:list={["astroid-credit", className]}>
|
|
32
|
+
<a href={credit.href} rel={credit.rel}>
|
|
33
|
+
{credit.logo && <span class="astroid-credit__mark" aria-hidden="true" style={markStyle} />}
|
|
34
|
+
<span>
|
|
35
|
+
{credit.label ?? "Site by"} {credit.name}
|
|
36
|
+
</span>
|
|
37
|
+
</a>
|
|
38
|
+
</p>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
.astroid-credit {
|
|
44
|
+
margin: 0;
|
|
45
|
+
font-size: 0.75rem;
|
|
46
|
+
line-height: 1.5;
|
|
47
|
+
}
|
|
48
|
+
/* Low contrast, not illegible: 70% of the theme's text color stays above
|
|
49
|
+
WCAG AA on daisyUI's light and dark base colors. */
|
|
50
|
+
.astroid-credit a {
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
align-items: center;
|
|
53
|
+
gap: 0.4em;
|
|
54
|
+
color: inherit;
|
|
55
|
+
opacity: 0.7;
|
|
56
|
+
text-decoration: none;
|
|
57
|
+
}
|
|
58
|
+
.astroid-credit a:hover,
|
|
59
|
+
.astroid-credit a:focus-visible {
|
|
60
|
+
opacity: 1;
|
|
61
|
+
text-decoration: underline;
|
|
62
|
+
}
|
|
63
|
+
.astroid-credit__mark {
|
|
64
|
+
flex: none;
|
|
65
|
+
width: 1.25em;
|
|
66
|
+
height: 1.25em;
|
|
67
|
+
background-color: currentColor;
|
|
68
|
+
mask: var(--astroid-credit-mark) center / contain no-repeat;
|
|
69
|
+
}
|
|
70
|
+
</style>
|