astroidjs 0.6.0 → 0.7.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/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
|
@@ -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:
|
|
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:
|
|
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,
|
|
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 {
|
package/dist/commerce/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/commerce/index.js
CHANGED
|
@@ -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. `"*/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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
package/dist/index.js
CHANGED
package/dist/project/generate.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/project/scaffold.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
package/dist/pwa/generate.d.ts
CHANGED
|
@@ -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`.
|
package/dist/pwa/generate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
`${base}/sw.js`,
|
|
221
250
|
" Cache-Control: no-cache",
|
|
222
251
|
"",
|
|
223
|
-
|
|
252
|
+
`${base}/manifest.webmanifest`,
|
|
224
253
|
" Content-Type: application/manifest+json",
|
|
225
254
|
" Cache-Control: public, max-age=3600",
|
|
226
255
|
"",
|