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.
@@ -17,18 +17,83 @@ export interface VerifiedLine {
17
17
  unitPriceCents: number;
18
18
  subtotalCents: number;
19
19
  }
20
+ /**
21
+ * Why a cart was refused.
22
+ *
23
+ * `"unavailable"` and `"out-of-stock"` are deliberately distinct even though
24
+ * both come back as "the lookup didn't price it". They are different facts about
25
+ * the world and want different words in front of a customer: a delisted item is
26
+ * gone and should leave the cart, while a sold-out one is coming back and is
27
+ * worth a notify-me. Collapsing them tells someone to remove an item the shop
28
+ * will restock on Tuesday.
29
+ *
30
+ * A lookup can only produce `"out-of-stock"` by saying so — see
31
+ * {@link ScopedPriceLookup} — because a bare `Map` has no way to distinguish
32
+ * them and guessing would put the wrong sentence on the screen.
33
+ */
34
+ export type CheckoutRefusal = "empty" | "unavailable" | "out-of-stock" | "price-changed" | "invalid";
20
35
  export type CheckoutVerification = {
21
36
  ok: true;
22
37
  lines: VerifiedLine[];
23
38
  subtotalCents: number;
24
39
  } | {
25
40
  ok: false;
26
- reason: "empty" | "unavailable" | "price-changed" | "invalid";
41
+ reason: CheckoutRefusal;
27
42
  message: string;
28
43
  };
29
44
  /** Look up current prices, in minor units, keyed by variant id. Anything the
30
45
  * map omits is treated as no longer purchasable. */
31
46
  export type PriceLookup = (variantIds: string[]) => Promise<Map<string, number>>;
47
+ /** Where the sale is happening. Optional, and providers without a location
48
+ * dimension ignore it — a single-merchant Square account or Fourthwall store
49
+ * passes nothing and behaves exactly as before. */
50
+ export interface CheckoutScope {
51
+ /** Provider location id (Square) or equivalent merchant key. */
52
+ locationId?: string;
53
+ }
54
+ /**
55
+ * A richer lookup result, for providers that can tell "delisted" from
56
+ * "sold out".
57
+ *
58
+ * A lookup may return either a plain `Map<string, number>` (prices only, an
59
+ * omission means unavailable) or this. Both are accepted, so the plain form
60
+ * stays valid and nothing existing has to change.
61
+ */
62
+ export interface ScopedPrices {
63
+ /** variantId → unit price in minor units, at the requested scope. */
64
+ prices: Map<string, number>;
65
+ /** Variant ids that exist and are priced but cannot be sold right now. */
66
+ outOfStock?: Iterable<string>;
67
+ }
68
+ /**
69
+ * A price lookup that knows WHERE the sale is happening.
70
+ *
71
+ * This is the multi-merchant checkout guard. One shared catalog sold through
72
+ * several merchants carries a different price per location — each shop's
73
+ * commission is absorbed in its own override — so re-pricing a cart against
74
+ * base prices lets a customer pay the cheapest merchant's price at the dearest
75
+ * merchant's storefront. That is not a rounding error; it is the same class of
76
+ * bug as trusting the client's `unitPriceCents`, just one level further back.
77
+ *
78
+ * `scope` is optional so a {@link PriceLookup} is still assignable here — an
79
+ * existing single-location lookup simply ignores the extra argument, which is
80
+ * exactly what a function of lower arity does in JavaScript.
81
+ *
82
+ * Return the map alone, or a {@link ScopedPrices} when the provider can
83
+ * distinguish sold-out from delisted:
84
+ *
85
+ * ```ts
86
+ * const lookup: ScopedPriceLookup = async (ids, scope) => {
87
+ * const money = await retrieveVariationPricesAt(sq, ids, scope?.locationId ?? DEFAULT);
88
+ * return new Map([...money].map(([id, m]) => [id, m.amount]));
89
+ * };
90
+ * ```
91
+ */
92
+ export type ScopedPriceLookup = (variantIds: string[], scope?: CheckoutScope) => Promise<Map<string, number> | ScopedPrices>;
93
+ export interface VerifyCheckoutOptions {
94
+ /** Passed through to the lookup. Omit for a single-location store. */
95
+ scope?: CheckoutScope;
96
+ }
32
97
  /**
33
98
  * Re-price a cart against the provider and decide whether it may proceed.
34
99
  *
@@ -37,8 +102,15 @@ export type PriceLookup = (variantIds: string[]) => Promise<Map<string, number>>
37
102
  * if (!check.ok) return json({ error: check.message }, 409);
38
103
  * await charge(check.subtotalCents); // the SERVER's number
39
104
  * ```
105
+ *
106
+ * With a per-merchant catalog, pass the location the order is being placed at
107
+ * and use a lookup that resolves overrides:
108
+ *
109
+ * ```ts
110
+ * const check = await verifyCheckout(body.lines, pricesAt, { scope: { locationId } });
111
+ * ```
40
112
  */
41
- export declare function verifyCheckout(lines: unknown, lookup: PriceLookup): Promise<CheckoutVerification>;
113
+ export declare function verifyCheckout(lines: unknown, lookup: ScopedPriceLookup, options?: VerifyCheckoutOptions): Promise<CheckoutVerification>;
42
114
  /**
43
115
  * A deterministic idempotency key for one buyer's checkout attempt.
44
116
  *
@@ -14,6 +14,12 @@
14
14
  // body and anyone can buy anything for a penny.
15
15
  import { AstroidUsageError } from "../errors.js";
16
16
  const MAX_QUANTITY = 999;
17
+ /** Accept either lookup shape without making every caller branch. */
18
+ function normalizeLookup(result) {
19
+ if (result instanceof Map)
20
+ return { prices: result, outOfStock: new Set() };
21
+ return { prices: result.prices, outOfStock: new Set(result.outOfStock ?? []) };
22
+ }
17
23
  /**
18
24
  * Re-price a cart against the provider and decide whether it may proceed.
19
25
  *
@@ -22,8 +28,19 @@ const MAX_QUANTITY = 999;
22
28
  * if (!check.ok) return json({ error: check.message }, 409);
23
29
  * await charge(check.subtotalCents); // the SERVER's number
24
30
  * ```
31
+ *
32
+ * With a per-merchant catalog, pass the location the order is being placed at
33
+ * and use a lookup that resolves overrides:
34
+ *
35
+ * ```ts
36
+ * const check = await verifyCheckout(body.lines, pricesAt, { scope: { locationId } });
37
+ * ```
25
38
  */
26
- export async function verifyCheckout(lines, lookup) {
39
+ export async function verifyCheckout(lines,
40
+ // Typed as the scoped form alone rather than a union: a `PriceLookup` is
41
+ // already structurally assignable here (fewer parameters, narrower return),
42
+ // and a union of call signatures would reject the two-argument call below.
43
+ lookup, options = {}) {
27
44
  if (!Array.isArray(lines) || lines.length === 0) {
28
45
  return { ok: false, reason: "empty", message: "Your cart is empty." };
29
46
  }
@@ -44,10 +61,20 @@ export async function verifyCheckout(lines, lookup) {
44
61
  }
45
62
  parsed.push({ variantId: l.variantId, quantity: l.quantity, unitPriceCents: l.unitPriceCents });
46
63
  }
47
- const prices = await lookup([...new Set(parsed.map((l) => l.variantId))]);
64
+ const { prices, outOfStock } = normalizeLookup(await lookup([...new Set(parsed.map((l) => l.variantId))], options.scope));
48
65
  const verified = [];
49
66
  let subtotalCents = 0;
50
67
  for (const line of parsed) {
68
+ // Checked before the price, because a sold-out variant is usually still
69
+ // priced: reading `prices` first would report it as available and let the
70
+ // charge through.
71
+ if (outOfStock.has(line.variantId)) {
72
+ return {
73
+ ok: false,
74
+ reason: "out-of-stock",
75
+ message: "An item in your cart just sold out.",
76
+ };
77
+ }
51
78
  const serverPrice = prices.get(line.variantId);
52
79
  if (serverPrice === undefined) {
53
80
  return {
@@ -1,5 +1,5 @@
1
- export { catalogNormalizer, type FourthwallProductLike, fourthwallToCatalogItem, type SquareItemLike, squareToCatalogItem, } from "./adapters.js";
2
- export { type CheckoutVerification, checkoutIdempotencyKey, type ClientLine, type PriceLookup, verifyCheckout, type VerifiedLine, } from "./checkout.js";
1
+ export { catalogNormalizer, type FourthwallProductLike, fourthwallToCatalogItem, type SquareAdapterOptions, squareItemSoldAt, type SquareItemLike, type SquareLocationOverrideLike, type SquarePresenceLike, squareToCatalogItem, } from "./adapters.js";
2
+ export { type CheckoutRefusal, type CheckoutScope, type CheckoutVerification, checkoutIdempotencyKey, type ClientLine, type PriceLookup, type ScopedPriceLookup, type ScopedPrices, verifyCheckout, type VerifiedLine, type VerifyCheckoutOptions, } from "./checkout.js";
3
3
  export { astroidCatalogLoaderConfig, type CatalogDatabase, type CatalogProduct, type CatalogReadOptions, readCatalog, readCatalogItem, } from "./loader.js";
4
4
  export { astroidCatalogMirror, BUILT_IN_OWNED, type CatalogMirrorConfig, generateCatalogMigrationSql, generateCatalogTable, type OwnedColumn, PULLED_COLUMNS, } from "./mirror.js";
5
5
  export { COMMERCE_PROVIDER_SECRETS, type CommerceStatus, commerceSecretNames, type ProviderStatus, providerConfigured, resolveCommerceStatus, roleConfigured, } from "./secrets.js";
@@ -1,5 +1,5 @@
1
1
  // Copyright (c) 2026 BowenLabs. Astroid is MIT licensed.
2
- export { catalogNormalizer, fourthwallToCatalogItem, squareToCatalogItem, } from "./adapters.js";
2
+ export { catalogNormalizer, fourthwallToCatalogItem, squareItemSoldAt, squareToCatalogItem, } from "./adapters.js";
3
3
  export { checkoutIdempotencyKey, verifyCheckout, } from "./checkout.js";
4
4
  export { astroidCatalogLoaderConfig, readCatalog, readCatalogItem, } from "./loader.js";
5
5
  export { astroidCatalogMirror, BUILT_IN_OWNED, generateCatalogMigrationSql, generateCatalogTable, PULLED_COLUMNS, } from "./mirror.js";
package/dist/config.d.ts CHANGED
@@ -212,6 +212,76 @@ export interface QueuesConfig {
212
212
  /** Seconds the consumer waits to fill a batch. Default 30. */
213
213
  maxBatchTimeout?: number;
214
214
  }
215
+ /**
216
+ * One project-declared scheduled trigger, beyond the two Astroid derives (the
217
+ * daily health scan and the hourly catalog re-sync).
218
+ *
219
+ * Declaring it here rather than by hand is what keeps `wrangler.jsonc` and the
220
+ * generated `scheduled` dispatch in agreement. Adding a cron to `triggers.crons`
221
+ * alone produces a trigger Cloudflare fires and the dispatch never matches —
222
+ * unreachable code that costs an invocation and does nothing, with no error
223
+ * anywhere. Adding it in the dashboard instead drifts from the config that is
224
+ * supposed to describe the deploy.
225
+ */
226
+ /**
227
+ * Serve `*.example.com` from this one Worker — scoped views of **this brand's**
228
+ * data, narrowed by host. A per-merchant storefront, a per-client gallery.
229
+ *
230
+ * Not multi-brand: see the note at the top of this file. Same theme, same
231
+ * catalog, same editors; the host selects a slice.
232
+ *
233
+ * Astroid provides only the plumbing that cannot live in a site: the wildcard
234
+ * Worker route (which `hosts` cannot express) and the one middleware file Astro
235
+ * permits. **Everything that decides anything stays yours** — what a label maps
236
+ * to, whether the lookup is cached, and what an unknown host should do. Those
237
+ * live in the scaffolded `src/tenancy.ts`, which is yours to edit.
238
+ */
239
+ export interface TenancyConfig {
240
+ /**
241
+ * The wildcard host, e.g. `"*.example.com"`. Emitted as a **zone route**
242
+ * (`{ pattern, zone_name }`), never `custom_domain: true` — a wildcard cannot
243
+ * be a custom domain, which is exactly why `hosts` cannot express this.
244
+ */
245
+ hostPattern: string;
246
+ /**
247
+ * The Cloudflare zone the pattern belongs to. Defaults to `hostPattern` minus
248
+ * its leading `*.`, which is right whenever the wildcard sits directly under
249
+ * the zone apex. Set it explicitly for a deeper pattern — `*.shop.example.com`
250
+ * is served by the `example.com` zone, not a `shop.example.com` one.
251
+ */
252
+ zone?: string;
253
+ /**
254
+ * Labels that are **not** tenants — `www`, `admin`, `studio`, `api`. These
255
+ * skip the tenant lookup entirely and render the ordinary site.
256
+ *
257
+ * Declared here rather than in the seam because the generated middleware needs
258
+ * it before it can decide whether to call the seam at all, and because
259
+ * forgetting `www` is the mistake that turns your marketing homepage into a
260
+ * failed tenant lookup.
261
+ */
262
+ reserved?: string[];
263
+ /**
264
+ * Internal path prefix a tenant request is rewritten to. Default `"/t"`, so
265
+ * `acme.example.com/prints` renders `/t/acme/prints`.
266
+ *
267
+ * The visitor's URL never changes — this is an internal rewrite, so links
268
+ * built from `Astro.url` stay public and correct.
269
+ */
270
+ rewritePrefix?: string;
271
+ }
272
+ export interface AstroidCron {
273
+ /** Standard 5-field cron, UTC — e.g. `"*&#47;15 * * * *"`. */
274
+ expression: string;
275
+ /**
276
+ * The queue message this trigger sends. **Enqueued, never run inline**, so the
277
+ * work takes the same retry and DLQ path as every other message and a slow job
278
+ * can't hold the scheduled handler open.
279
+ *
280
+ * Typed `unknown` because the consumer owns the message vocabulary: whatever
281
+ * you put here arrives at your `handleQueueMessage`'s `onMessage`.
282
+ */
283
+ message: unknown;
284
+ }
215
285
  export interface SeoConfig {
216
286
  /**
217
287
  * `<title>` template, `%s` standing in for the page title. Applied only when
@@ -293,6 +363,12 @@ export interface AstroidConfig {
293
363
  key: string;
294
364
  /** Hostname(s) this site serves (prod + preview), for custom-domain routes. */
295
365
  hosts?: string[];
366
+ /**
367
+ * Serve a wildcard host from this Worker, mapping each subdomain to an
368
+ * internal path prefix. See {@link TenancyConfig} — and note it is an
369
+ * *audiences* axis, not multi-brand.
370
+ */
371
+ tenancy?: TenancyConfig;
296
372
  /** Starting shape; sets section/module/nav defaults the site can override. */
297
373
  archetype: Archetype;
298
374
  /** The single brand's theme (display name + color tokens + font). */
@@ -332,6 +408,16 @@ export interface AstroidConfig {
332
408
  commerce?: CommerceConfig;
333
409
  /** Queue consumer + cron safety net. Defaults on when `commerce` is set. */
334
410
  queues?: QueuesConfig;
411
+ /**
412
+ * Extra scheduled triggers beyond Astroid's two. Each is emitted into **both**
413
+ * `triggers.crons` and the generated `scheduled` dispatch, so the pair cannot
414
+ * drift. See {@link AstroidCron}.
415
+ *
416
+ * Requires the queue consumer, since a cron's work is enqueued rather than run
417
+ * inline; `defineAstroid` refuses the combination rather than generating a
418
+ * `send` against a binding that doesn't exist.
419
+ */
420
+ crons?: AstroidCron[];
335
421
  /** Title template, structured-data type, and social-card attribution. */
336
422
  seo?: SeoConfig;
337
423
  /** Additions to the rate-limit rules + CSP origins Astroid derives. */
@@ -350,22 +436,4 @@ export interface AstroidConfig {
350
436
  pwa?: PwaConfig;
351
437
  deploy?: DeployConfig;
352
438
  }
353
- /**
354
- * Define an Astroid project. An identity function in the shape of Astro's
355
- * `defineConfig`: it returns the config verbatim with full type-checking +
356
- * inference, and validates the invariants that would otherwise fail deep inside
357
- * generation (a non-empty project `key`, since it names the generated bindings;
358
- * a brand `theme.name` + `colors.brand`, since they seed the site and theme).
359
- *
360
- * ```ts
361
- * export default defineAstroid({
362
- * key: "coracle",
363
- * archetype: "storefront",
364
- * theme: { name: "Coracle Coffee", colors: { brand: "#1f6f78" } },
365
- * sections: ["hero", "banner", "productGrid", "locationHours", "contact"],
366
- * commerce: { provider: "square" },
367
- * deploy: { platform: "cloudflare" },
368
- * });
369
- * ```
370
- */
371
439
  export declare function defineAstroid(config: AstroidConfig): AstroidConfig;
package/dist/config.js CHANGED
@@ -10,10 +10,16 @@
10
10
  //
11
11
  // ONE brand per project. Every site Astroid targets (coracle.coffee,
12
12
  // ghostfire.coffee, themidwestartist.com, louise-web) serves a single brand from a
13
- // single deploy — none does host/tenant dispatch. The axis that genuinely
14
- // multiplexes is *editors* (Louise's org plugin, #100) and *audiences* (a gated
15
- // portal alongside the public site), not brands — so both live here as options on
16
- // the one brand, not as a `brands[]` array.
13
+ // single deploy. The axis that genuinely multiplexes is *editors* (Louise's org
14
+ // plugin, #100) and *audiences* — a gated portal alongside the public site, or a
15
+ // per-merchant storefront on its own subdomain (`tenancy`) — not brands. So all
16
+ // of them live here as options on the one brand, not as a `brands[]` array.
17
+ //
18
+ // `tenancy` is worth being precise about, because "serves many hosts" sounds like
19
+ // the thing this paragraph rules out and isn't. It serves scoped VIEWS OF ONE
20
+ // BRAND'S DATA — the same theme, the same catalog, the same editors — narrowed by
21
+ // host. It is the portal's axis, one step further out. A second brand still means
22
+ // a second project.
17
23
  //
18
24
  // The vocabulary below is not invented: `Archetype`, `SectionKind`, and
19
25
  // `ModuleKind` are extracted from the real sites Astroid targets — a storefront
@@ -22,6 +28,12 @@
22
28
  import { assertAuthIsolation } from "./auth/index.js";
23
29
  import { assertCommerceRoles } from "./commerce/roles.js";
24
30
  import { AstroidConfigError } from "./errors.js";
31
+ // The cron facts come from the module that derives them, so the duplicate check
32
+ // can't drift from what `astroidCrons` actually emits. A real import rather than
33
+ // a type-only one, which is safe here: `queues/messages.ts` imports this file
34
+ // type-only, so the cycle erases at build and nothing circular exists at runtime.
35
+ // It is also dependency-free, so `create-astroid`'s graph is unchanged.
36
+ import { ASTROID_HEALTH_CRON, astroidCron, astroidUsesQueues } from "./queues/messages.js";
25
37
  /**
26
38
  * Each archetype's default home-page sections.
27
39
  *
@@ -61,6 +73,75 @@ export const ASTROID_ARCHETYPE_SECTIONS = {
61
73
  * });
62
74
  * ```
63
75
  */
76
+ /**
77
+ * Both ways a project-declared cron silently does nothing.
78
+ *
79
+ * A duplicate expression is the sharper one: the generated dispatch matches on
80
+ * `controller.cron` in order, so a custom trigger colliding with a derived one
81
+ * (or another custom one) never reaches its own branch. Cloudflare fires it, the
82
+ * first branch handles it, and the config reads as though both are live —
83
+ * exactly the unreachable-trigger failure `config.crons` exists to prevent.
84
+ */
85
+ function assertCrons(config) {
86
+ const crons = config.crons ?? [];
87
+ if (crons.length === 0)
88
+ return;
89
+ if (!astroidUsesQueues(config)) {
90
+ throw new AstroidConfigError("`crons` needs the queue consumer: a cron's work is enqueued, not run inline, and " +
91
+ "without it the generated handler would `send` to a binding this project never creates. " +
92
+ "Set `queues: { enabled: true }`, or drop the crons.");
93
+ }
94
+ const seen = new Map([[ASTROID_HEALTH_CRON, "the daily health scan"]]);
95
+ const catalog = astroidCron(config);
96
+ if (catalog)
97
+ seen.set(catalog, "the catalog re-sync (`queues.cron`)");
98
+ for (const cron of crons) {
99
+ const expression = cron.expression?.trim();
100
+ if (!expression) {
101
+ throw new AstroidConfigError("Every entry in `crons` needs a non-empty `expression`");
102
+ }
103
+ const owner = seen.get(expression);
104
+ if (owner) {
105
+ throw new AstroidConfigError(`Duplicate cron \`${expression}\` — it already belongs to ${owner}. One \`scheduled\` ` +
106
+ "handler dispatches on the expression, so the first branch wins and this one would " +
107
+ "never run. Use a different minute.");
108
+ }
109
+ seen.set(expression, "another entry in `crons`");
110
+ }
111
+ }
112
+ /**
113
+ * The tenancy misconfigurations that fail late, or not at all.
114
+ *
115
+ * All three are cheap to state and expensive to discover: two surface as a
116
+ * wrangler deploy error naming a zone or a pattern rather than the config that
117
+ * produced it, and the third never surfaces at all — it just serves the wrong
118
+ * page.
119
+ */
120
+ function assertTenancy(config) {
121
+ const tenancy = config.tenancy;
122
+ if (!tenancy)
123
+ return;
124
+ const pattern = tenancy.hostPattern?.trim();
125
+ if (!pattern) {
126
+ throw new AstroidConfigError('`tenancy.hostPattern` is required, e.g. `"*.example.com"`');
127
+ }
128
+ if (!pattern.startsWith("*.")) {
129
+ throw new AstroidConfigError(`\`tenancy.hostPattern\` must be a wildcard starting with "*." — got "${pattern}". ` +
130
+ "A fixed host is a custom domain: put it in `hosts` instead.");
131
+ }
132
+ const apex = pattern.slice(2);
133
+ if (!apex.includes(".")) {
134
+ throw new AstroidConfigError(`\`tenancy.hostPattern\` "${pattern}" has no domain after the wildcard`);
135
+ }
136
+ // A wildcard does NOT match its own apex, so the apex needs its own route.
137
+ // Without one it 404s — and the symptom is "the marketing site is down"
138
+ // immediately after enabling a feature that reads like it only adds hosts.
139
+ if (!(config.hosts ?? []).some((host) => host.toLowerCase() === apex.toLowerCase())) {
140
+ throw new AstroidConfigError(`\`tenancy.hostPattern\` is "${pattern}", but "${apex}" is not in \`hosts\`. ` +
141
+ "A wildcard route does not match its own apex, so the apex would 404. " +
142
+ `Add "${apex}" to \`hosts\`.`);
143
+ }
144
+ }
64
145
  export function defineAstroid(config) {
65
146
  if (!config.key || config.key.trim().length === 0) {
66
147
  throw new AstroidConfigError("Astroid config requires a non-empty `key` (it names the generated worker/D1/R2 bindings)");
@@ -90,6 +171,8 @@ export function defineAstroid(config) {
90
171
  // control that silently does nothing is strictly worse than one that isn't
91
172
  // offered: the first gives false confidence, the second sends you looking for
92
173
  // an answer. Fail loudly, at config load, naming the workaround.
174
+ assertCrons(config);
175
+ assertTenancy(config);
93
176
  if (config.portal?.gated) {
94
177
  throw new AstroidConfigError("`portal.gated` is not implemented — it is accepted but wires no guard, so the site " +
95
178
  "would be fully public while appearing gated. Remove it, and gate the whole site by " +
package/dist/index.d.ts CHANGED
@@ -16,5 +16,6 @@ export * from "./schema/index.js";
16
16
  export * from "./security/index.js";
17
17
  export * from "./seo/index.js";
18
18
  export * from "./status.js";
19
+ export * from "./tenancy/index.js";
19
20
  export * from "./worker/index.js";
20
21
  export * from "./workflow/index.js";
package/dist/index.js CHANGED
@@ -21,5 +21,6 @@ export * from "./schema/index.js";
21
21
  export * from "./security/index.js";
22
22
  export * from "./seo/index.js";
23
23
  export * from "./status.js";
24
+ export * from "./tenancy/index.js";
24
25
  export * from "./worker/index.js";
25
26
  export * from "./workflow/index.js";
@@ -21,6 +21,7 @@ import { COMMERCE_PROVIDER_SECRETS, COMMERCE_PROVIDER_SETUP, commerceSecretNames
21
21
  import { ASTROID_QUEUE_BINDING, astroidCron, astroidCrons, astroidQueueNames, astroidUsesQueues, } from "../queues/messages.js";
22
22
  import { ASTROID_EDIT_SESSION_CLASS, ASTROID_REALTIME_BINDING, ASTROID_REALTIME_MIGRATION_TAG, usesRealtime, } from "../realtime/scaffold.js";
23
23
  import { ASTROID_SECRET_PLACEHOLDER } from "../secrets.js";
24
+ import { tenancyZone } from "../tenancy/index.js";
24
25
  import { generateAstroidSchema } from "../schema/generate.js";
25
26
  import { generateAstroidMiddleware, generateAstroidWorker } from "../worker/generate.js";
26
27
  /**
@@ -105,12 +106,23 @@ export function generateAstroidWrangler(config) {
105
106
  p(' "compatibility_flags": ["nodejs_compat"],');
106
107
  p(" // @astrojs/cloudflare builds this entry and wires the static assets under dist/.");
107
108
  p(' "main": "src/worker.ts",');
108
- if (primaryHost) {
109
+ const tenancy = config.tenancy;
110
+ if (primaryHost || tenancy) {
109
111
  p(" // Custom domains this Worker serves (from your defineAstroid `hosts`).");
110
112
  p(' "routes": [');
111
113
  for (const host of hosts) {
112
114
  p(` { "pattern": ${JSON.stringify(host)}, "custom_domain": true },`);
113
115
  }
116
+ if (tenancy) {
117
+ // A wildcard is a ZONE route, never a custom domain — Cloudflare rejects
118
+ // `custom_domain: true` on a pattern containing `*`, which is precisely
119
+ // why `hosts` cannot express this.
120
+ p(" // Wildcard tenant hosts (`tenancy.hostPattern`). A zone route, NOT a");
121
+ p(" // custom_domain — Cloudflare refuses a wildcard custom domain. The");
122
+ p(" // apex is routed separately above: `*.example.com` does NOT match");
123
+ p(" // `example.com`, so without a `hosts` entry the apex would 404.");
124
+ p(` { "pattern": ${JSON.stringify(`${tenancy.hostPattern}/*`)}, "zone_name": ${JSON.stringify(tenancyZone(tenancy))} },`);
125
+ }
114
126
  p(" ],");
115
127
  }
116
128
  else {
@@ -32,8 +32,9 @@ import { generateMapEmbedComponent, generateMapTileRoute } from "../map/scaffold
32
32
  import { generateAstroidGalleryPage } from "../portfolio/scaffold.js";
33
33
  import { astroidPortal } from "../portal/config.js";
34
34
  import { generateAstroidPortalAuth, generateAstroidPortalAuthRoute } from "../portal/scaffold.js";
35
+ import { generateAstroidTenancy } from "../tenancy/index.js";
35
36
  import { generateAstroidEditSession } from "../realtime/scaffold.js";
36
- import { generatePwaHeaders, generateServiceWorker, generateWebManifest } from "../pwa/generate.js";
37
+ import { generatePwaHeaders, generateServiceWorker, generateWebManifest, resolvePwa, } from "../pwa/generate.js";
37
38
  import { astroidUsesQueues } from "../queues/messages.js";
38
39
  import { generateAstroidQueueSeam, generateAstroidWebhookRoutes } from "../queues/scaffold.js";
39
40
  /**
@@ -110,19 +111,29 @@ export function generateAstroidScaffoldFiles(config) {
110
111
  // bundled, and `_headers` is shared with whatever else writes to it.
111
112
  const sw = generateServiceWorker(config);
112
113
  if (sw) {
113
- files.push({ path: "public/sw.js", contents: sw });
114
+ // `emitDir` puts them where the BROWSER will ask for them. A PWA on its own
115
+ // subdomain that rewrites to a path prefix (studio.example.com/ → /studio/)
116
+ // fetches /sw.js at its own origin root, which rewrites to /studio/sw.js —
117
+ // so a worker emitted at the public root is a 404 nothing explains.
118
+ const pwaDir = resolvePwa(config).emitDir;
119
+ const publicBase = pwaDir ? `public/${pwaDir}` : "public";
120
+ files.push({ path: `${publicBase}/sw.js`, contents: sw });
114
121
  const manifest = generateWebManifest(config);
115
- if (manifest)
116
- files.push({ path: "public/manifest.webmanifest", contents: manifest });
122
+ if (manifest) {
123
+ files.push({ path: `${publicBase}/manifest.webmanifest`, contents: manifest });
124
+ }
117
125
  const headers = generatePwaHeaders(config);
118
126
  if (headers) {
119
127
  files.push({
128
+ // `_headers` stays at the public root wherever the worker lives — it is
129
+ // one file for the whole site, and Cloudflare only reads it there.
120
130
  path: "public/_headers",
121
131
  contents: headers,
122
132
  apply: "append-once",
123
133
  // The service-worker path is the one token this stanza always contains
124
- // and nothing else in a `_headers` file would.
125
- marker: "/sw.js",
134
+ // and nothing else in a `_headers` file would. Includes the emit dir, so
135
+ // moving the worker rewrites the stanza rather than appending a second.
136
+ marker: `${pwaDir ? `/${pwaDir}` : ""}/sw.js`,
126
137
  });
127
138
  }
128
139
  }
@@ -141,6 +152,12 @@ export function generateAstroidScaffoldFiles(config) {
141
152
  const editSession = generateAstroidEditSession(config);
142
153
  if (editSession)
143
154
  files.push({ path: "src/edit-session.ts", contents: editSession });
155
+ // --- tenancy: what a subdomain maps to ------------------------------------
156
+ // Astroid owns the wildcard route and the middleware wiring; this file owns
157
+ // every decision — the lookup, its caching, and what an unknown host means.
158
+ const tenancy = generateAstroidTenancy(config);
159
+ if (tenancy)
160
+ files.push({ path: "src/tenancy.ts", contents: tenancy });
144
161
  // --- portal: the second auth instance + its mounted catch-all -------------
145
162
  // A site edits the reset email and the role a new account gets, but not the
146
163
  // mount/cookie/table prefixes that keep the two instances isolated.
@@ -20,12 +20,45 @@ export interface PwaConfig {
20
20
  themeColor?: string;
21
21
  /** Extra paths to precache alongside the scope root. */
22
22
  shell?: string[];
23
+ /**
24
+ * A prerendered page to serve when a navigation fails offline, e.g.
25
+ * `"/offline"`.
26
+ *
27
+ * Without it the fallback is the scope root — the *dynamic* app shell, which
28
+ * is exactly the wrong thing to precache when the app is auth-gated: that
29
+ * response carries `Cache-Control: no-store`, so either nothing is cached and
30
+ * the fallback is empty, or a signed-in shell is stored and later served to
31
+ * whoever opens the app next.
32
+ *
33
+ * Point it at a page with no session-specific markup. It is precached with the
34
+ * shell, so it must be prerendered — a dynamic route here fails at exactly the
35
+ * moment it is needed.
36
+ */
37
+ offlineFallback?: string;
38
+ /**
39
+ * Subdirectory under `public/` to emit `sw.js` and the manifest into, e.g.
40
+ * `"studio"`. Default: the public root.
41
+ *
42
+ * For a PWA served from its own subdomain that rewrites to a path prefix —
43
+ * `studio.example.com/` → `/studio/` — the browser fetches `/sw.js` at *its*
44
+ * origin root, which rewrites to `/studio/sw.js`. Emitting at the public root
45
+ * puts the file where nothing will ask for it.
46
+ *
47
+ * Set this to the same prefix the host rewrites to. With `scope` equal to the
48
+ * serving path, no `Service-Worker-Allowed` header is needed — a worker may
49
+ * always control its own directory and below.
50
+ */
51
+ emitDir?: string;
23
52
  }
24
53
  /** True when this project switched the PWA on. */
25
54
  export declare const usesPwa: (config: AstroidConfig) => boolean;
26
55
  /** Resolved PWA settings — config over derivation over default. */
27
- export declare function resolvePwa(config: AstroidConfig): Required<Omit<PwaConfig, "shell">> & {
56
+ export declare function resolvePwa(config: AstroidConfig): Required<Omit<PwaConfig, "shell" | "offlineFallback" | "emitDir">> & {
28
57
  shell: string[];
58
+ /** `null` when the app falls back to the scope root (see the config note). */
59
+ offlineFallback: string | null;
60
+ /** Normalized to no leading/trailing slash; `""` for the public root. */
61
+ emitDir: string;
29
62
  };
30
63
  /**
31
64
  * `public/manifest.webmanifest`.
@@ -39,9 +39,24 @@ export function resolvePwa(config) {
39
39
  orientation: pwa.orientation ?? "any",
40
40
  backgroundColor: pwa.backgroundColor ?? "#ffffff",
41
41
  themeColor: pwa.themeColor ?? config.theme.colors.brand,
42
- shell: [scope, "/manifest.webmanifest", ...(pwa.shell ?? [])],
42
+ offlineFallback: pwa.offlineFallback ?? null,
43
+ emitDir: (pwa.emitDir ?? "").replace(/^\/+|\/+$/g, ""),
44
+ // The offline page is precached with the shell — a fallback fetched on
45
+ // demand is a fallback that isn't there when the network is.
46
+ shell: [
47
+ scope,
48
+ `${assetBase(pwa.emitDir)}/manifest.webmanifest`,
49
+ ...(pwa.offlineFallback ? [pwa.offlineFallback] : []),
50
+ ...(pwa.shell ?? []),
51
+ ],
43
52
  };
44
53
  }
54
+ /** URL prefix the emitted `sw.js` + manifest are served from — `""` at the
55
+ * public root, `"/studio"` under an `emitDir`. */
56
+ function assetBase(emitDir) {
57
+ const dir = (emitDir ?? "").replace(/^\/+|\/+$/g, "");
58
+ return dir ? `/${dir}` : "";
59
+ }
45
60
  /**
46
61
  * `public/manifest.webmanifest`.
47
62
  *
@@ -108,6 +123,14 @@ export function generateServiceWorker(config) {
108
123
  "// present as 'my changes don't save'",
109
124
  `const CACHE = ${JSON.stringify(cacheName)};`,
110
125
  `const SCOPE = ${JSON.stringify(pwa.scope)};`,
126
+ ...(pwa.offlineFallback
127
+ ? [
128
+ "// A prerendered page with no session-specific markup. The scope root is",
129
+ "// the app SHELL, which on an auth-gated app is `Cache-Control: no-store`",
130
+ "// — so falling back to it serves either nothing or someone else's shell.",
131
+ `const OFFLINE = ${JSON.stringify(pwa.offlineFallback)};`,
132
+ ]
133
+ : []),
111
134
  `const SHELL = ${JSON.stringify([...new Set(pwa.shell)])};`,
112
135
  "",
113
136
  "self.addEventListener('install', (event) => {",
@@ -175,7 +198,9 @@ export function generateServiceWorker(config) {
175
198
  " .catch(() => {});",
176
199
  " return res;",
177
200
  " })",
178
- " .catch(() => caches.match(req).then((r) => r || caches.match(SCOPE))),",
201
+ pwa.offlineFallback
202
+ ? " .catch(() => caches.match(req).then((r) => r || caches.match(OFFLINE))),"
203
+ : " .catch(() => caches.match(req).then((r) => r || caches.match(SCOPE))),",
179
204
  " );",
180
205
  " return;",
181
206
  " }",
@@ -213,14 +238,18 @@ export function generateServiceWorker(config) {
213
238
  export function generatePwaHeaders(config) {
214
239
  if (!usesPwa(config))
215
240
  return null;
241
+ // Paths must match where the files are actually emitted — a stanza for
242
+ // `/sw.js` while the worker lives at `/studio/sw.js` sets headers on nothing,
243
+ // and the no-cache rule is what stops a bad worker sticking around.
244
+ const base = assetBase(config.pwa?.emitDir);
216
245
  return [
217
246
  "",
218
247
  "# The service worker must revalidate on every load, or a bad worker sticks",
219
248
  "# around until its cache entry expires — and it controls every page in scope.",
220
- "/sw.js",
249
+ `${base}/sw.js`,
221
250
  " Cache-Control: no-cache",
222
251
  "",
223
- "/manifest.webmanifest",
252
+ `${base}/manifest.webmanifest`,
224
253
  " Content-Type: application/manifest+json",
225
254
  " Cache-Control: public, max-age=3600",
226
255
  "",