astroidjs 0.7.1 → 0.8.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 CHANGED
@@ -268,6 +268,43 @@ export interface TenancyConfig {
268
268
  * built from `Astro.url` stay public and correct.
269
269
  */
270
270
  rewritePrefix?: string;
271
+ /**
272
+ * First-party apps on their own labels, mapped to the internal path prefix
273
+ * each serves from — `{ studio: "/studio" }` serves
274
+ * `studio.example.com/<path>` from `src/pages/studio/<path>`.
275
+ *
276
+ * This is the missing half of {@link PwaConfig.emitDir}'s subdomain story:
277
+ * an app label is not a tenant (there is no lookup — the studio exists
278
+ * whether or not any tenant does) and not merely reserved (a reserved label
279
+ * renders the ordinary site, which turns the admin host into a second copy
280
+ * of the marketing homepage). It is a static rewrite, decided at config time.
281
+ *
282
+ * An app label is implicitly reserved: `tenantLabel` never offers it to
283
+ * `resolveTenant`, and listing it in `reserved` too is refused — one list
284
+ * per fact, or the two drift.
285
+ *
286
+ * Same internal-rewrite semantics as tenants: the visitor's URL never
287
+ * changes, and the path form stays reachable on the apex — which is what
288
+ * makes local dev work, since `wrangler dev` cannot serve subdomains.
289
+ */
290
+ apps?: Record<string, string>;
291
+ /**
292
+ * What a syntactically-valid tenant host whose label resolves to NOTHING
293
+ * (`resolveTenant` returned `null`) should get.
294
+ *
295
+ * `"fallthrough"` (the default) renders the ordinary site — which means a
296
+ * stranger who points a CNAME at your zone gets your homepage. `"404"` emits
297
+ * a guard that refuses the request instead, which is the right answer the
298
+ * moment tenant hosts are commercial surfaces: an unknown storefront must be
299
+ * unambiguously *not a page*, not a copy of the marketing site under someone
300
+ * else's name.
301
+ *
302
+ * Either way the decision stays visible in config rather than buried in the
303
+ * seam: `resolveTenant` decides *what exists*; this decides what not-existing
304
+ * means. Reserved labels, app labels, the apex, and off-pattern hosts are
305
+ * never affected — they aren't tenant candidates at all.
306
+ */
307
+ unknown?: "fallthrough" | "404";
271
308
  }
272
309
  export interface AstroidCron {
273
310
  /** Standard 5-field cron, UTC — e.g. `"*&#47;15 * * * *"`. */
package/dist/config.js CHANGED
@@ -141,6 +141,24 @@ function assertTenancy(config) {
141
141
  "A wildcard route does not match its own apex, so the apex would 404. " +
142
142
  `Add "${apex}" to \`hosts\`.`);
143
143
  }
144
+ // App labels: each failure below otherwise surfaces as a rewrite to a path
145
+ // that renders the wrong page, with nothing pointing back at the config.
146
+ for (const [label, prefix] of Object.entries(tenancy.apps ?? {})) {
147
+ if (!label || label.includes(".")) {
148
+ throw new AstroidConfigError(`\`tenancy.apps\` label "${label}" must be a single subdomain label — ` +
149
+ "Cloudflare's wildcard matches one level.");
150
+ }
151
+ if ((tenancy.reserved ?? []).includes(label)) {
152
+ throw new AstroidConfigError(`"${label}" is in both \`tenancy.apps\` and \`tenancy.reserved\`. ` +
153
+ "An app label is implicitly reserved — keep it in `apps` only, or the " +
154
+ "two lists drift and `reserved` silently wins.");
155
+ }
156
+ if (!prefix.startsWith("/") || prefix === "/" || prefix.endsWith("/")) {
157
+ throw new AstroidConfigError(`\`tenancy.apps.${label}\` must be an internal path prefix like "/studio" ` +
158
+ `(leading slash, no trailing slash, not "/") — got "${prefix}". ` +
159
+ 'The rewrite is `prefix + pathname`, so "/" or a trailing slash produces "//…".');
160
+ }
161
+ }
144
162
  }
145
163
  export function defineAstroid(config) {
146
164
  if (!config.key || config.key.trim().length === 0) {
@@ -15,14 +15,26 @@ export declare function tenancyZone(tenancy: TenancyConfig): string;
15
15
  * The subdomain label for a host under the wildcard, or `null` when the host is
16
16
  * not a tenant candidate at all.
17
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.
18
+ * `null` covers four distinct cases that all mean "not a tenant": the apex
19
+ * itself (a wildcard does not match its own apex), a host outside the pattern
20
+ * (a preview domain, `localhost`), a reserved label, and an app label — which
21
+ * has its own static rewrite via {@link appPrefix} instead of a lookup.
21
22
  *
22
23
  * Exported and pure so a site can unit-test its own reserved list without
23
24
  * standing up a request.
24
25
  */
25
26
  export declare function tenantLabel(host: string, tenancy: TenancyConfig): string | null;
27
+ /**
28
+ * The internal path prefix an app host rewrites to, or `null` when the host is
29
+ * not an app host — `appPrefix("studio.example.com", …)` → `"/studio"` under
30
+ * `apps: { studio: "/studio" }`.
31
+ *
32
+ * Static by design: an app exists whether or not any tenant does, so there is
33
+ * no per-request lookup and nothing to cache. Same host handling as
34
+ * {@link tenantLabel} (port and case ignored, one label only), so `wrangler
35
+ * dev` behaves like production.
36
+ */
37
+ export declare function appPrefix(host: string, tenancy: TenancyConfig): string | null;
26
38
  /**
27
39
  * The scaffold-once `src/tenancy.ts` — the seam holding every decision Astroid
28
40
  * refuses to make for a site.
@@ -23,30 +23,61 @@ export function tenancyZone(tenancy) {
23
23
  return tenancy.zone ?? tenancy.hostPattern.replace(/^\*\./, "");
24
24
  }
25
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.
26
+ * The single subdomain label under the wildcard, before any policy — or `null`
27
+ * for the apex, an off-pattern host, or a dotted label. Shared by
28
+ * {@link tenantLabel} and {@link appPrefix} so their host handling (port,
29
+ * case, one-level-only) cannot drift.
35
30
  */
36
- export function tenantLabel(host, tenancy) {
31
+ function hostLabel(host, tenancy) {
37
32
  const suffix = tenancy.hostPattern.replace(/^\*\./, "");
38
33
  // Strip a port: `acme.example.com:8788` under `wrangler dev`.
39
34
  const hostname = host.split(":")[0]?.toLowerCase() ?? "";
40
35
  if (!hostname.endsWith(`.${suffix}`))
41
36
  return null;
42
37
  const label = hostname.slice(0, -(suffix.length + 1));
43
- // Only a single label is a tenant. `a.b.example.com` under `*.example.com` is
38
+ // Only a single label counts. `a.b.example.com` under `*.example.com` is
44
39
  // not `a.b` — Cloudflare's wildcard matches one level, and treating a dotted
45
40
  // string as a slug would put a `/` in a rewrite path.
46
41
  if (!label || label.includes("."))
47
42
  return null;
48
- const reserved = tenancy.reserved ?? [];
49
- return reserved.includes(label) ? null : label;
43
+ return label;
44
+ }
45
+ /**
46
+ * The subdomain label for a host under the wildcard, or `null` when the host is
47
+ * not a tenant candidate at all.
48
+ *
49
+ * `null` covers four distinct cases that all mean "not a tenant": the apex
50
+ * itself (a wildcard does not match its own apex), a host outside the pattern
51
+ * (a preview domain, `localhost`), a reserved label, and an app label — which
52
+ * has its own static rewrite via {@link appPrefix} instead of a lookup.
53
+ *
54
+ * Exported and pure so a site can unit-test its own reserved list without
55
+ * standing up a request.
56
+ */
57
+ export function tenantLabel(host, tenancy) {
58
+ const label = hostLabel(host, tenancy);
59
+ if (!label)
60
+ return null;
61
+ if ((tenancy.reserved ?? []).includes(label))
62
+ return null;
63
+ return tenancy.apps && label in tenancy.apps ? null : label;
64
+ }
65
+ /**
66
+ * The internal path prefix an app host rewrites to, or `null` when the host is
67
+ * not an app host — `appPrefix("studio.example.com", …)` → `"/studio"` under
68
+ * `apps: { studio: "/studio" }`.
69
+ *
70
+ * Static by design: an app exists whether or not any tenant does, so there is
71
+ * no per-request lookup and nothing to cache. Same host handling as
72
+ * {@link tenantLabel} (port and case ignored, one label only), so `wrangler
73
+ * dev` behaves like production.
74
+ */
75
+ export function appPrefix(host, tenancy) {
76
+ const apps = tenancy.apps;
77
+ if (!apps)
78
+ return null;
79
+ const label = hostLabel(host, tenancy);
80
+ return label ? (apps[label] ?? null) : null;
50
81
  }
51
82
  /**
52
83
  * The scaffold-once `src/tenancy.ts` — the seam holding every decision Astroid
@@ -84,8 +115,8 @@ export function generateAstroidTenancy(config) {
84
115
  " *",
85
116
  " * `null` falls through to the ordinary site — which is a real choice, not a",
86
117
  " * 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.",
118
+ ' * for this project, set `tenancy.unknown: "404"` in astroid.config.ts and the',
119
+ " * generated middleware refuses the host instead — unambiguously not a page.",
89
120
  " *",
90
121
  " * This runs on EVERY request to a tenant host, so a database lookup here is a",
91
122
  " * query per request. Cache it — a module-scope Map is enough within an isolate,",
@@ -475,6 +475,7 @@ export function generateAstroidMiddleware(config) {
475
475
  // two import statements for the same module is legal and reads as an
476
476
  // oversight in a file nobody is supposed to hand-edit.
477
477
  `import { ${[
478
+ ...(tenancy && Object.keys(tenancy.apps ?? {}).length ? ["appPrefix"] : []),
478
479
  ...(portal ? ["astroidPortalGuardConfig"] : []),
479
480
  "astroidRateRules",
480
481
  ...(portal ? ["guardResponse", "portalGuard", "resolvePortalSession"] : []),
@@ -546,20 +547,36 @@ export function generateAstroidMiddleware(config) {
546
547
  " },",
547
548
  ]
548
549
  : []),
549
- ...(portal
550
+ ...(portal || tenancy?.unknown === "404"
550
551
  ? [
551
- " // Route guard: the declarative prefix→roles table from your config.",
552
- " // An /api/* route always answers in JSON — redirecting fetch() to an",
553
- " // HTML login page returns 200 and markup, which reads as success.",
554
552
  " guard: (context) => {",
555
- " const decision = portalGuard(",
556
- " context.url.pathname,",
557
- " context.locals.portalUser,",
558
- " PORTAL_GUARD,",
559
- " );",
560
- " if (!decision) return undefined;",
561
- ' if (decision.kind === "redirect") return context.redirect(decision.location);',
562
- " return guardResponse(decision) ?? undefined;",
553
+ ...(tenancy?.unknown === "404"
554
+ ? [
555
+ " // A syntactically-valid tenant host whose label resolved to nothing is",
556
+ ' // NOT a page (config `tenancy.unknown: "404"`). Falling through would',
557
+ " // render the marketing homepage on a stranger's subdomain. Reserved",
558
+ " // labels, app labels, and the apex never reach here — tenantLabel",
559
+ " // returns null for all of them, so they are not tenant candidates.",
560
+ " if (tenantLabel(context.url.hostname, TENANCY) && !context.locals.tenant) {",
561
+ ' return new Response("Not found", { status: 404 });',
562
+ " }",
563
+ ]
564
+ : []),
565
+ ...(portal
566
+ ? [
567
+ " // Route guard: the declarative prefix→roles table from your config.",
568
+ " // An /api/* route always answers in JSON — redirecting fetch() to an",
569
+ " // HTML login page returns 200 and markup, which reads as success.",
570
+ " const decision = portalGuard(",
571
+ " context.url.pathname,",
572
+ " context.locals.portalUser,",
573
+ " PORTAL_GUARD,",
574
+ " );",
575
+ " if (!decision) return undefined;",
576
+ ' if (decision.kind === "redirect") return context.redirect(decision.location);',
577
+ " return guardResponse(decision) ?? undefined;",
578
+ ]
579
+ : [" return undefined;"]),
563
580
  " },",
564
581
  ]
565
582
  : []),
@@ -571,9 +588,18 @@ export function generateAstroidMiddleware(config) {
571
588
  " // redirect — so links built from Astro.url stay correct.",
572
589
  " //",
573
590
  " // 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.",
591
+ " // falls through to the ordinary site below — or 404s first, when the",
592
+ ' // config sets `tenancy.unknown: "404"` (see the guard above).',
576
593
  " rewrite: (context) => {",
594
+ ...(Object.keys(tenancy.apps ?? {}).length
595
+ ? [
596
+ " // First-party app hosts (config `tenancy.apps`): a static label→prefix",
597
+ " // map, checked before the tenant — an app exists whether or not any",
598
+ " // tenant does, and needs no lookup.",
599
+ " const app = appPrefix(context.url.hostname, TENANCY);",
600
+ " if (app) return `${app}${context.url.pathname}`;",
601
+ ]
602
+ : []),
577
603
  " const tenant = context.locals.tenant;",
578
604
  ` return tenant ? \`${rewritePrefix}/\${tenant.slug}\${context.url.pathname}\` : undefined;`,
579
605
  " },",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astroidjs",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
@@ -60,7 +60,7 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "louise-toolkit": "0.24.0"
63
+ "louise-toolkit": "0.25.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^24.13.3",