@brandlab-digital/site-next 3.12.0 → 3.13.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.
Files changed (61) hide show
  1. package/README.md +53 -0
  2. package/dist/estate-segment.d.ts +48 -0
  3. package/dist/estate-segment.d.ts.map +1 -0
  4. package/dist/estate-segment.js +54 -0
  5. package/dist/estate-segment.js.map +1 -0
  6. package/dist/google-business-reviews-revalidate.d.ts +61 -0
  7. package/dist/google-business-reviews-revalidate.d.ts.map +1 -0
  8. package/dist/google-business-reviews-revalidate.js +151 -0
  9. package/dist/google-business-reviews-revalidate.js.map +1 -0
  10. package/dist/google-business-reviews-server.d.ts +21 -2
  11. package/dist/google-business-reviews-server.d.ts.map +1 -1
  12. package/dist/google-business-reviews-server.js +21 -4
  13. package/dist/google-business-reviews-server.js.map +1 -1
  14. package/dist/headers.d.ts.map +1 -1
  15. package/dist/headers.js +6 -0
  16. package/dist/headers.js.map +1 -1
  17. package/dist/image-runtime.js +3 -14
  18. package/dist/image-runtime.js.map +1 -1
  19. package/dist/next-sharp.d.ts +14 -0
  20. package/dist/next-sharp.d.ts.map +1 -0
  21. package/dist/next-sharp.js +39 -0
  22. package/dist/next-sharp.js.map +1 -0
  23. package/dist/robots.d.ts +26 -4
  24. package/dist/robots.d.ts.map +1 -1
  25. package/dist/robots.js +49 -2
  26. package/dist/robots.js.map +1 -1
  27. package/dist/ui-kit/prefetch.d.ts +50 -0
  28. package/dist/ui-kit/prefetch.d.ts.map +1 -0
  29. package/dist/ui-kit/prefetch.js +50 -0
  30. package/dist/ui-kit/prefetch.js.map +1 -0
  31. package/dist/ui-kit/primitives/index.d.ts +1 -0
  32. package/dist/ui-kit/primitives/index.d.ts.map +1 -1
  33. package/dist/ui-kit/primitives/index.js +1 -0
  34. package/dist/ui-kit/primitives/index.js.map +1 -1
  35. package/dist/ui-kit/primitives/links.d.ts +13 -2
  36. package/dist/ui-kit/primitives/links.d.ts.map +1 -1
  37. package/dist/ui-kit/primitives/links.js +7 -6
  38. package/dist/ui-kit/primitives/links.js.map +1 -1
  39. package/dist/ui-kit/primitives/quote-button.d.ts +4 -1
  40. package/dist/ui-kit/primitives/quote-button.d.ts.map +1 -1
  41. package/dist/ui-kit/primitives/quote-button.js +3 -2
  42. package/dist/ui-kit/primitives/quote-button.js.map +1 -1
  43. package/dist/ui-kit/sections/site-footer.d.ts.map +1 -1
  44. package/dist/ui-kit/sections/site-footer.js +5 -2
  45. package/dist/ui-kit/sections/site-footer.js.map +1 -1
  46. package/dist/ui-kit/shell/current-link.d.ts +15 -1
  47. package/dist/ui-kit/shell/current-link.d.ts.map +1 -1
  48. package/dist/ui-kit/shell/current-link.js +10 -1
  49. package/dist/ui-kit/shell/current-link.js.map +1 -1
  50. package/dist/ui-kit/shell/index.d.ts +1 -0
  51. package/dist/ui-kit/shell/index.d.ts.map +1 -1
  52. package/dist/ui-kit/shell/index.js +1 -0
  53. package/dist/ui-kit/shell/index.js.map +1 -1
  54. package/dist/ui-kit/shell/intent-prefetch.d.ts +4 -0
  55. package/dist/ui-kit/shell/intent-prefetch.d.ts.map +1 -0
  56. package/dist/ui-kit/shell/intent-prefetch.js +61 -0
  57. package/dist/ui-kit/shell/intent-prefetch.js.map +1 -0
  58. package/dist/ui-kit/shell/site-header.d.ts.map +1 -1
  59. package/dist/ui-kit/shell/site-header.js +2 -3
  60. package/dist/ui-kit/shell/site-header.js.map +1 -1
  61. package/package.json +6 -2
package/README.md CHANGED
@@ -2,6 +2,59 @@
2
2
 
3
3
  Proprietary software © Brandlab Digital Marketing. All rights reserved. Published for Brandlab client sites.
4
4
 
5
+ ## Reviews: refresh on change, not every day (3.13.0)
6
+
7
+ The shared reviews fetch (`getBusinessProfileReviews`, `./google-business-reviews/server`) carries the tags
8
+ `brandlab-reviews` and `brandlab-reviews:<site>`. By default it still keeps a successful answer for one day
9
+ (`BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS`), so a site that installs 3.13.0 for another reason keeps daily reviews.
10
+
11
+ A site opts into refresh on change by passing `refresh: 'on-change'`, in the same call that already names the site,
12
+ once it has the route below deployed with its secret and is registered with the daily reviews checker:
13
+
14
+ ```ts
15
+ // src/lib/reviews.ts
16
+ getBusinessProfileReviews({ site: 'hr-kitchens', refresh: 'on-change' })
17
+ ```
18
+
19
+ The answer is then kept for 7 days (`BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS`) as a safety net only.
20
+ Once a day the checker hashes each site's feed and, when the reviews have really changed, calls the site's signed
21
+ revalidate route, which refreshes the tag. A new review still shows within a day; pages are rewritten only after a
22
+ real change. If a call fails, pages refresh themselves within the week. The choice sits on the fetch, not in
23
+ brandlab.config.ts, because the fetch is where Next reads it at build time and a site's config file is not imported
24
+ at runtime.
25
+
26
+ The route, at `src/app/api/brandlab/reviews-revalidate/route.ts`:
27
+
28
+ ```ts
29
+ import { createBrandlabReviewsRevalidateRoute } from '@brandlab-digital/site-next/google-business-reviews/server'
30
+
31
+ export const { POST } = createBrandlabReviewsRevalidateRoute({ site: 'hr-kitchens' })
32
+ ```
33
+
34
+ Set `BRANDLAB_REVALIDATE_SECRET` (at least 32 characters) in the site's Vercel project and on the checker; never in
35
+ git. The call contract:
36
+
37
+ | | |
38
+ | --- | --- |
39
+ | Request | `POST /api/brandlab/reviews-revalidate`, body `{"site":"<slug>","hash":"<64 hex>"}` (1 KB at most) |
40
+ | Signature | `x-brandlab-timestamp: <Unix seconds>` and `x-brandlab-signature: v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`; site-core `createBusinessProfileReviewsRevalidateRequest` builds both |
41
+ | Hash | site-core `hashBusinessProfileReviews(feed)`: the rating, the count and each review's id, rating, text, author, times and reply, sorted by id |
42
+ | Who decides | The checker keeps the last hash each site accepted with a 200 and calls only when today's differs. The route also skips a hash it already applied, but only per server instance |
43
+ | 200 | `{ ok: true, revalidated: true }` after `revalidateTag('brandlab-reviews:<site>', 'max')`, or `revalidated: false, reason: 'unchanged'` |
44
+ | Errors | 400 malformed body or another site's slug; 401 missing or wrong signature, or a timestamp more than 5 minutes off; 413 body over 1 KB; 429 with `Retry-After` past 10 requests per IP in 10 minutes; 503 secret not set; 500 refresh failed. The checker records the hash only after a 200 |
45
+
46
+ Refused requests log one line without the secret or signature, for example
47
+ `{"event":"brandlab_reviews_revalidate","site":"hr-kitchens","result":"refused","reason":"signature"}`.
48
+
49
+ ### Estate segment preset (`./estate-segment`)
50
+
51
+ Service-in-suburb and suburb pages export `revalidate = false`, written literally because Next reads segment config
52
+ statically, and a site test calls `assertBrandlabEstateSegment(page)`. Deploys and the reviews tag rewrite them. A
53
+ page that shows reviews follows the reviews fetch: daily by default, weekly with `refresh: 'on-change'`. Ready-gate
54
+ check W3 (full tier, from 3.13.0) fails an estate family that refreshes more often than every 7 days, so a full-tier
55
+ estate that shows reviews adopts `on-change` with the route, or records the family in `siteSystem.refreshExceptions`
56
+ in brandlab.config.ts: `{ family, revalidateSeconds, reason, decisionId }`.
57
+
5
58
  ## Contact route: abuse limits and the test marker (3.10.0)
6
59
 
7
60
  `createBrandlabContactRouteHandler` limits distinct enquiries per client IP and per email address where enquiries are
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The route segment preset for the location estate's leaf families (3.13.0): service-in-suburb pages and suburb
3
+ * (location) pages. They are the bulk of a big estate (Panther builds over 5,600), so a timed refresh multiplies.
4
+ *
5
+ * The preset is `revalidate = false`: a leaf page is rewritten by a deploy, which rebuilds it, or by the reviews tag
6
+ * when its reviews change, never on a timer. Next gives a page the shortest revalidate of its fetches, so a page that
7
+ * shows reviews follows the reviews fetch: daily by default, which W3 fails on a full-tier estate, or weekly as a
8
+ * safety net once the site passes `refresh: 'on-change'` to `getBusinessProfileReviews` (with the revalidate route
9
+ * deployed). Chosen over 30 days because a leaf page holds nothing else that changes between deploys; a timer would
10
+ * only add function runs and cache writes for identical pages.
11
+ *
12
+ * Next reads segment config statically, so a route writes the value as a literal (an imported constant is not read):
13
+ *
14
+ * ```ts
15
+ * // src/app/services/[service]/[suburb]/page.tsx
16
+ * // Brandlab estate preset (@brandlab-digital/site-next/estate-segment): refreshed by deploys and the reviews tag.
17
+ * export const revalidate = false
18
+ * ```
19
+ *
20
+ * and a site test keeps it honest:
21
+ *
22
+ * ```ts
23
+ * import { assertBrandlabEstateSegment } from '@brandlab-digital/site-next/estate-segment'
24
+ * import * as page from '@/app/services/[service]/[suburb]/page'
25
+ * assertBrandlabEstateSegment(page)
26
+ * ```
27
+ *
28
+ * Check W3 (full tier) reads the built routes and fails an estate family that refreshes more often than every 7 days
29
+ * unless `siteSystem.refreshExceptions` in brandlab.config.ts records why, with a decision id.
30
+ */
31
+ /** The leaf families the preset is for. */
32
+ export declare const BRANDLAB_ESTATE_LEAF_FAMILIES: readonly ["service-in-suburb", "suburb"];
33
+ /** The segment settings an estate leaf route exports, written literally. */
34
+ export declare const BRANDLAB_ESTATE_SEGMENT: Readonly<{
35
+ revalidate: false;
36
+ }>;
37
+ /** The shortest timed refresh check W3 accepts on an estate page without a recorded exception: 7 days. */
38
+ export declare const BRANDLAB_ESTATE_MIN_REVALIDATE_SECONDS = 604800;
39
+ /**
40
+ * Throws unless the route module exports the preset (`revalidate = false`), or, with `allowSeconds`, a timed refresh
41
+ * no shorter than that (for a family with a recorded W3 exception, or a deliberate weekly or slower refresh).
42
+ */
43
+ export declare function assertBrandlabEstateSegment(route: {
44
+ readonly revalidate?: unknown;
45
+ }, options?: {
46
+ allowSeconds?: number;
47
+ }): void;
48
+ //# sourceMappingURL=estate-segment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"estate-segment.d.ts","sourceRoot":"","sources":["../src/estate-segment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,2CAA2C;AAC3C,eAAO,MAAM,6BAA6B,0CAA2C,CAAA;AAErF,4EAA4E;AAC5E,eAAO,MAAM,uBAAuB;;EAAgD,CAAA;AAEpF,0GAA0G;AAC1G,eAAO,MAAM,sCAAsC,SAAU,CAAA;AAE7D;;;GAGG;AACH,wBAAgB,2BAA2B,CACzC,KAAK,EAAE;IAAE,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,EACxC,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAA;CAAO,GACtC,IAAI,CAYN"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The route segment preset for the location estate's leaf families (3.13.0): service-in-suburb pages and suburb
3
+ * (location) pages. They are the bulk of a big estate (Panther builds over 5,600), so a timed refresh multiplies.
4
+ *
5
+ * The preset is `revalidate = false`: a leaf page is rewritten by a deploy, which rebuilds it, or by the reviews tag
6
+ * when its reviews change, never on a timer. Next gives a page the shortest revalidate of its fetches, so a page that
7
+ * shows reviews follows the reviews fetch: daily by default, which W3 fails on a full-tier estate, or weekly as a
8
+ * safety net once the site passes `refresh: 'on-change'` to `getBusinessProfileReviews` (with the revalidate route
9
+ * deployed). Chosen over 30 days because a leaf page holds nothing else that changes between deploys; a timer would
10
+ * only add function runs and cache writes for identical pages.
11
+ *
12
+ * Next reads segment config statically, so a route writes the value as a literal (an imported constant is not read):
13
+ *
14
+ * ```ts
15
+ * // src/app/services/[service]/[suburb]/page.tsx
16
+ * // Brandlab estate preset (@brandlab-digital/site-next/estate-segment): refreshed by deploys and the reviews tag.
17
+ * export const revalidate = false
18
+ * ```
19
+ *
20
+ * and a site test keeps it honest:
21
+ *
22
+ * ```ts
23
+ * import { assertBrandlabEstateSegment } from '@brandlab-digital/site-next/estate-segment'
24
+ * import * as page from '@/app/services/[service]/[suburb]/page'
25
+ * assertBrandlabEstateSegment(page)
26
+ * ```
27
+ *
28
+ * Check W3 (full tier) reads the built routes and fails an estate family that refreshes more often than every 7 days
29
+ * unless `siteSystem.refreshExceptions` in brandlab.config.ts records why, with a decision id.
30
+ */
31
+ /** The leaf families the preset is for. */
32
+ export const BRANDLAB_ESTATE_LEAF_FAMILIES = ['service-in-suburb', 'suburb'];
33
+ /** The segment settings an estate leaf route exports, written literally. */
34
+ export const BRANDLAB_ESTATE_SEGMENT = Object.freeze({ revalidate: false });
35
+ /** The shortest timed refresh check W3 accepts on an estate page without a recorded exception: 7 days. */
36
+ export const BRANDLAB_ESTATE_MIN_REVALIDATE_SECONDS = 604_800;
37
+ /**
38
+ * Throws unless the route module exports the preset (`revalidate = false`), or, with `allowSeconds`, a timed refresh
39
+ * no shorter than that (for a family with a recorded W3 exception, or a deliberate weekly or slower refresh).
40
+ */
41
+ export function assertBrandlabEstateSegment(route, options = {}) {
42
+ const value = route?.revalidate;
43
+ if (value === BRANDLAB_ESTATE_SEGMENT.revalidate)
44
+ return;
45
+ const floor = options.allowSeconds;
46
+ if (floor !== undefined && (!Number.isSafeInteger(floor) || floor < 1)) {
47
+ throw new TypeError('allowSeconds must be a whole number of seconds.');
48
+ }
49
+ if (typeof value === 'number' && floor !== undefined && value >= floor)
50
+ return;
51
+ const found = value === undefined ? 'no revalidate export' : `revalidate = ${String(value)}`;
52
+ throw new Error(`An estate leaf route must export revalidate = false (the Brandlab estate preset)${floor !== undefined ? ` or at least ${floor}` : ''}; found ${found}.`);
53
+ }
54
+ //# sourceMappingURL=estate-segment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"estate-segment.js","sourceRoot":"","sources":["../src/estate-segment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,2CAA2C;AAC3C,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,mBAAmB,EAAE,QAAQ,CAAU,CAAA;AAErF,4EAA4E;AAC5E,MAAM,CAAC,MAAM,uBAAuB,GAAG,MAAM,CAAC,MAAM,CAAC,EAAE,UAAU,EAAE,KAAc,EAAE,CAAC,CAAA;AAEpF,0GAA0G;AAC1G,MAAM,CAAC,MAAM,sCAAsC,GAAG,OAAO,CAAA;AAE7D;;;GAGG;AACH,MAAM,UAAU,2BAA2B,CACzC,KAAwC,EACxC,UAAqC,EAAE;IAEvC,MAAM,KAAK,GAAG,KAAK,EAAE,UAAU,CAAA;IAC/B,IAAI,KAAK,KAAK,uBAAuB,CAAC,UAAU;QAAE,OAAM;IACxD,MAAM,KAAK,GAAG,OAAO,CAAC,YAAY,CAAA;IAClC,IAAI,KAAK,KAAK,SAAS,IAAI,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,CAAC,EAAE,CAAC;QACvE,MAAM,IAAI,SAAS,CAAC,iDAAiD,CAAC,CAAA;IACxE,CAAC;IACD,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,IAAI,KAAK;QAAE,OAAM;IAC9E,MAAM,KAAK,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC,CAAC,gBAAgB,MAAM,CAAC,KAAK,CAAC,EAAE,CAAA;IAC5F,MAAM,IAAI,KAAK,CACb,mFAAmF,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,gBAAgB,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,KAAK,GAAG,CACzJ,CAAA;AACH,CAAC","sourcesContent":["/**\n * The route segment preset for the location estate's leaf families (3.13.0): service-in-suburb pages and suburb\n * (location) pages. They are the bulk of a big estate (Panther builds over 5,600), so a timed refresh multiplies.\n *\n * The preset is `revalidate = false`: a leaf page is rewritten by a deploy, which rebuilds it, or by the reviews tag\n * when its reviews change, never on a timer. Next gives a page the shortest revalidate of its fetches, so a page that\n * shows reviews follows the reviews fetch: daily by default, which W3 fails on a full-tier estate, or weekly as a\n * safety net once the site passes `refresh: 'on-change'` to `getBusinessProfileReviews` (with the revalidate route\n * deployed). Chosen over 30 days because a leaf page holds nothing else that changes between deploys; a timer would\n * only add function runs and cache writes for identical pages.\n *\n * Next reads segment config statically, so a route writes the value as a literal (an imported constant is not read):\n *\n * ```ts\n * // src/app/services/[service]/[suburb]/page.tsx\n * // Brandlab estate preset (@brandlab-digital/site-next/estate-segment): refreshed by deploys and the reviews tag.\n * export const revalidate = false\n * ```\n *\n * and a site test keeps it honest:\n *\n * ```ts\n * import { assertBrandlabEstateSegment } from '@brandlab-digital/site-next/estate-segment'\n * import * as page from '@/app/services/[service]/[suburb]/page'\n * assertBrandlabEstateSegment(page)\n * ```\n *\n * Check W3 (full tier) reads the built routes and fails an estate family that refreshes more often than every 7 days\n * unless `siteSystem.refreshExceptions` in brandlab.config.ts records why, with a decision id.\n */\n\n/** The leaf families the preset is for. */\nexport const BRANDLAB_ESTATE_LEAF_FAMILIES = ['service-in-suburb', 'suburb'] as const\n\n/** The segment settings an estate leaf route exports, written literally. */\nexport const BRANDLAB_ESTATE_SEGMENT = Object.freeze({ revalidate: false as const })\n\n/** The shortest timed refresh check W3 accepts on an estate page without a recorded exception: 7 days. */\nexport const BRANDLAB_ESTATE_MIN_REVALIDATE_SECONDS = 604_800\n\n/**\n * Throws unless the route module exports the preset (`revalidate = false`), or, with `allowSeconds`, a timed refresh\n * no shorter than that (for a family with a recorded W3 exception, or a deliberate weekly or slower refresh).\n */\nexport function assertBrandlabEstateSegment(\n route: { readonly revalidate?: unknown },\n options: { allowSeconds?: number } = {},\n): void {\n const value = route?.revalidate\n if (value === BRANDLAB_ESTATE_SEGMENT.revalidate) return\n const floor = options.allowSeconds\n if (floor !== undefined && (!Number.isSafeInteger(floor) || floor < 1)) {\n throw new TypeError('allowSeconds must be a whole number of seconds.')\n }\n if (typeof value === 'number' && floor !== undefined && value >= floor) return\n const found = value === undefined ? 'no revalidate export' : `revalidate = ${String(value)}`\n throw new Error(\n `An estate leaf route must export revalidate = false (the Brandlab estate preset)${floor !== undefined ? ` or at least ${floor}` : ''}; found ${found}.`,\n )\n}\n"]}
@@ -0,0 +1,61 @@
1
+ import { type BrandlabContactRateLimitRule } from './contact/rate-limit.js';
2
+ /**
3
+ * The signed route that refreshes a site's reviews the day they change (3.13.0). Serve it at
4
+ * `src/app/api/brandlab/reviews-revalidate/route.ts`:
5
+ *
6
+ * ```ts
7
+ * import { createBrandlabReviewsRevalidateRoute } from '@brandlab-digital/site-next/google-business-reviews/server'
8
+ *
9
+ * export const { POST } = createBrandlabReviewsRevalidateRoute({ site: 'hr-kitchens' })
10
+ * ```
11
+ *
12
+ * Once the route is deployed with its secret and the site is registered with the daily checker, the site passes
13
+ * `refresh: 'on-change'` to `getBusinessProfileReviews`, which lengthens the fetch's time-based refresh from a day to
14
+ * a 7-day safety net. Until then the fetch stays daily, so a site is never left on weekly reviews by a version bump.
15
+ *
16
+ * The call contract (the daily reviews checker's side):
17
+ * - The checker hashes the feed with site-core `hashBusinessProfileReviews` and keeps, per site, the last hash the route
18
+ * accepted with a 200. It calls only when today's hash differs, so the feed side decides what is a change; the route
19
+ * keeps the last hash in memory too, but only per server instance, so that is a second guard, not the record.
20
+ * - `POST /api/brandlab/reviews-revalidate`, body `{"site":"<slug>","hash":"<64 hex>"}` (at most 1 KB), headers
21
+ * `x-brandlab-timestamp: <Unix seconds>` and `x-brandlab-signature: v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`
22
+ * under `BRANDLAB_REVALIDATE_SECRET`. site-core `createBusinessProfileReviewsRevalidateRequest` builds both.
23
+ * - Answers: 200 `{ ok: true, revalidated: true }` after `revalidateTag('brandlab-reviews:<site>', 'max')`; 200
24
+ * `{ ok: true, revalidated: false, reason: 'unchanged' }` when this instance already applied that hash; 400 for a
25
+ * malformed body or another site's slug; 401 for a missing or wrong signature or a timestamp more than 5 minutes
26
+ * off; 413 for a body over 1 KB; 429 with `Retry-After` once one client IP has failed those checks past the rate limit;
27
+ * 503 when the secret is not set; 500 when the refresh itself failed. The checker records the hash only after a 200
28
+ * and retries the rest the next day.
29
+ * - The signature and timestamp are checked before any rate limit, and only failed attempts count, keyed on the
30
+ * platform's `x-real-ip` (never the client-controlled `x-forwarded-for`). A correctly signed request is never
31
+ * refused with 429, and without `x-real-ip` nothing is rate-limited.
32
+ */
33
+ export interface BrandlabReviewsRevalidateRouteOptions {
34
+ /** The site's slug in the reviews feed, the same one `getBusinessProfileReviews` uses. */
35
+ site: string;
36
+ /** Defaults to `process.env`, read on each request: `BRANDLAB_REVALIDATE_SECRET` (at least 32 characters). */
37
+ environment?: Readonly<Record<string, string | undefined>>;
38
+ /** Defaults to Next's `revalidateTag` from `next/cache`. */
39
+ revalidateTag?: (tag: string, profile: 'max') => void | Promise<void>;
40
+ /**
41
+ * Failed attempts (bad signature, stale timestamp, oversized body) per client IP (`x-real-ip`) before the route answers
42
+ * 429, held in memory per server instance (default 10 in 10 minutes); `false` turns it off. Signed requests never count.
43
+ */
44
+ rateLimit?: BrandlabContactRateLimitRule | false;
45
+ /** Defaults to `Date.now`. */
46
+ now?: () => number;
47
+ }
48
+ export interface BrandlabReviewsRevalidateRoute {
49
+ POST(request: Request): Promise<Response>;
50
+ }
51
+ /** Failed attempts per client IP before the route answers 429; a correctly signed request is never limited. */
52
+ export declare const BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT: Readonly<{
53
+ limit: 10;
54
+ windowMs: number;
55
+ }>;
56
+ /**
57
+ * Creates the signed reviews revalidate route for one site. Create it once at module level, so its rate-limit window
58
+ * and last-applied hash outlive each request.
59
+ */
60
+ export declare function createBrandlabReviewsRevalidateRoute(options: BrandlabReviewsRevalidateRouteOptions): BrandlabReviewsRevalidateRoute;
61
+ //# sourceMappingURL=google-business-reviews-revalidate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google-business-reviews-revalidate.d.ts","sourceRoot":"","sources":["../src/google-business-reviews-revalidate.ts"],"names":[],"mappings":"AAQA,OAAO,EAAoC,KAAK,4BAA4B,EAAE,MAAM,yBAAyB,CAAA;AAE7G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,WAAW,qCAAqC;IACpD,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAA;IACZ,8GAA8G;IAC9G,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;IAC1D,4DAA4D;IAC5D,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACrE;;;OAGG;IACH,SAAS,CAAC,EAAE,4BAA4B,GAAG,KAAK,CAAA;IAChD,8BAA8B;IAC9B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,8BAA8B;IAC7C,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC1C;AAED,+GAA+G;AAC/G,eAAO,MAAM,sCAAsC;;;EAA0D,CAAA;AAiE7G;;;GAGG;AACH,wBAAgB,oCAAoC,CAClD,OAAO,EAAE,qCAAqC,GAC7C,8BAA8B,CAgFhC"}
@@ -0,0 +1,151 @@
1
+ import { BUSINESS_PROFILE_REVIEWS_HASH_PATTERN, BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS, BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECRET_NAME, businessProfileReviewsSiteTag, normaliseBusinessProfileReviewsRevalidateSecret, signBusinessProfileReviewsRevalidate, } from '@brandlab-digital/site-core/google-business-reviews';
2
+ import { createBrandlabContactRateLimiter } from './contact/rate-limit.js';
3
+ /** Failed attempts per client IP before the route answers 429; a correctly signed request is never limited. */
4
+ export const BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT = Object.freeze({ limit: 10, windowMs: 10 * 60 * 1_000 });
5
+ const MAX_BODY_BYTES = 1_024;
6
+ const MAX_CLOCK_SKEW_SECONDS = 300;
7
+ const MIN_SECRET_LENGTH = 32;
8
+ const LOG_EVENT = 'brandlab_reviews_revalidate';
9
+ function json(status, body, headers = {}) {
10
+ return Response.json(body, { status, headers: { 'cache-control': 'no-store', ...headers } });
11
+ }
12
+ /** One JSON line per refused or applied request: never the secret, signature or body. */
13
+ function log(site, result, extra = {}) {
14
+ const line = JSON.stringify({ event: LOG_EVENT, site, result, ...extra });
15
+ if (result === 'revalidated' || result === 'unchanged')
16
+ console.info(line);
17
+ else
18
+ console.error(line);
19
+ }
20
+ /** Equal-length texts are compared in time that does not depend on where they differ. */
21
+ function sameText(a, b) {
22
+ let difference = a.length ^ b.length;
23
+ const reference = a.length === b.length ? b : a;
24
+ for (let index = 0; index < a.length; index += 1)
25
+ difference |= a.charCodeAt(index) ^ reference.charCodeAt(index);
26
+ return difference === 0;
27
+ }
28
+ /** The platform-set client IP (Vercel's `x-real-ip`); `x-forwarded-for` can be written by the client, so it is not read. */
29
+ function clientIp(request) {
30
+ const candidate = request.headers.get('x-real-ip')?.trim();
31
+ return candidate && candidate.length <= 64 && /^[0-9A-Fa-f:.]+$/.test(candidate) ? candidate : undefined;
32
+ }
33
+ /**
34
+ * The body as text, read at most `limit` bytes into the stream: a larger (or chunked, undeclared) body is cancelled
35
+ * as soon as it passes the limit, never buffered whole. `null` means it was too large.
36
+ */
37
+ async function readCappedText(request, limit) {
38
+ if (!request.body)
39
+ return '';
40
+ const reader = request.body.getReader();
41
+ const chunks = [];
42
+ let size = 0;
43
+ for (;;) {
44
+ const { done, value } = await reader.read();
45
+ if (done)
46
+ break;
47
+ size += value.byteLength;
48
+ if (size > limit) {
49
+ await reader.cancel().catch(() => undefined);
50
+ return null;
51
+ }
52
+ chunks.push(value);
53
+ }
54
+ const bytes = new Uint8Array(size);
55
+ let offset = 0;
56
+ for (const chunk of chunks) {
57
+ bytes.set(chunk, offset);
58
+ offset += chunk.byteLength;
59
+ }
60
+ return new TextDecoder().decode(bytes);
61
+ }
62
+ async function nextRevalidateTag(tag, profile) {
63
+ const { revalidateTag } = await import('next/cache');
64
+ revalidateTag(tag, profile);
65
+ }
66
+ /**
67
+ * Creates the signed reviews revalidate route for one site. Create it once at module level, so its rate-limit window
68
+ * and last-applied hash outlive each request.
69
+ */
70
+ export function createBrandlabReviewsRevalidateRoute(options) {
71
+ if (!options || typeof options !== 'object')
72
+ throw new TypeError('Reviews revalidate route options must be an object.');
73
+ const site = options.site;
74
+ const tag = businessProfileReviewsSiteTag(site);
75
+ const rule = options.rateLimit === undefined ? BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT : options.rateLimit;
76
+ const limiter = rule === false ? undefined : createBrandlabContactRateLimiter({ perIp: rule, perAddress: false });
77
+ const revalidate = options.revalidateTag ?? nextRevalidateTag;
78
+ const now = options.now ?? Date.now;
79
+ let lastHash;
80
+ return Object.freeze({
81
+ async POST(request) {
82
+ const at = now();
83
+ // Only failed attempts count towards the limit, so a correctly signed checker is never locked out.
84
+ const refuse = (status, error, reason) => {
85
+ const ip = limiter ? clientIp(request) : undefined;
86
+ if (limiter && ip) {
87
+ // Each failed attempt counts once: a fresh id, since the route has no submission id to dedupe on.
88
+ const decision = limiter.check({ ipKey: ip, submissionId: crypto.randomUUID(), now: at });
89
+ if (!decision.ok) {
90
+ log(site, 'refused', { reason: 'rate-limited' });
91
+ return json(429, { ok: false, error: 'rate-limited' }, { 'retry-after': String(decision.retryAfterSeconds) });
92
+ }
93
+ }
94
+ log(site, 'refused', { reason });
95
+ return json(status, { ok: false, error });
96
+ };
97
+ const environment = options.environment ?? process.env;
98
+ const secret = normaliseBusinessProfileReviewsRevalidateSecret(environment[BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECRET_NAME] ?? '');
99
+ if (!secret || secret.length < MIN_SECRET_LENGTH) {
100
+ log(site, 'refused', { reason: 'not-configured' });
101
+ return json(503, { ok: false, error: 'not-configured' });
102
+ }
103
+ const declaredLength = Number(request.headers.get('content-length') ?? '0');
104
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES)
105
+ return refuse(413, 'too-large', 'too-large');
106
+ const body = await readCappedText(request, MAX_BODY_BYTES);
107
+ if (body === null)
108
+ return refuse(413, 'too-large', 'too-large');
109
+ const timestampText = request.headers.get(BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS.timestamp)?.trim() ?? '';
110
+ const signature = request.headers.get(BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS.signature)?.trim() ?? '';
111
+ const timestamp = /^\d{1,12}$/.test(timestampText) ? Number(timestampText) : Number.NaN;
112
+ if (!Number.isSafeInteger(timestamp) || Math.abs(at / 1_000 - timestamp) > MAX_CLOCK_SKEW_SECONDS) {
113
+ return refuse(401, 'unauthorised', 'timestamp');
114
+ }
115
+ const expected = await signBusinessProfileReviewsRevalidate({ secret, body, timestamp });
116
+ if (!sameText(signature, expected))
117
+ return refuse(401, 'unauthorised', 'signature');
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(body);
121
+ }
122
+ catch {
123
+ return json(400, { ok: false, error: 'invalid-body' });
124
+ }
125
+ const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
126
+ if (typeof record.hash !== 'string' || !BUSINESS_PROFILE_REVIEWS_HASH_PATTERN.test(record.hash)) {
127
+ return json(400, { ok: false, error: 'invalid-body' });
128
+ }
129
+ if (record.site !== site) {
130
+ log(site, 'refused', { reason: 'wrong-site' });
131
+ return json(400, { ok: false, error: 'wrong-site' });
132
+ }
133
+ const hash = record.hash;
134
+ if (hash === lastHash) {
135
+ log(site, 'unchanged', { hash: hash.slice(0, 12) });
136
+ return json(200, { ok: true, revalidated: false, reason: 'unchanged' });
137
+ }
138
+ try {
139
+ await revalidate(tag, 'max');
140
+ }
141
+ catch (error) {
142
+ log(site, 'failed', { reason: error instanceof Error ? error.name : 'error' });
143
+ return json(500, { ok: false, error: 'revalidate-failed' });
144
+ }
145
+ lastHash = hash;
146
+ log(site, 'revalidated', { hash: hash.slice(0, 12) });
147
+ return json(200, { ok: true, revalidated: true });
148
+ },
149
+ });
150
+ }
151
+ //# sourceMappingURL=google-business-reviews-revalidate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google-business-reviews-revalidate.js","sourceRoot":"","sources":["../src/google-business-reviews-revalidate.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n BUSINESS_PROFILE_REVIEWS_HASH_PATTERN,\n BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS,\n BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECRET_NAME,\n businessProfileReviewsSiteTag,\n normaliseBusinessProfileReviewsRevalidateSecret,\n signBusinessProfileReviewsRevalidate,\n} from '@brandlab-digital/site-core/google-business-reviews'\nimport { createBrandlabContactRateLimiter, type BrandlabContactRateLimitRule } from './contact/rate-limit.js'\n\n/**\n * The signed route that refreshes a site's reviews the day they change (3.13.0). Serve it at\n * `src/app/api/brandlab/reviews-revalidate/route.ts`:\n *\n * ```ts\n * import { createBrandlabReviewsRevalidateRoute } from '@brandlab-digital/site-next/google-business-reviews/server'\n *\n * export const { POST } = createBrandlabReviewsRevalidateRoute({ site: 'hr-kitchens' })\n * ```\n *\n * Once the route is deployed with its secret and the site is registered with the daily checker, the site passes\n * `refresh: 'on-change'` to `getBusinessProfileReviews`, which lengthens the fetch's time-based refresh from a day to\n * a 7-day safety net. Until then the fetch stays daily, so a site is never left on weekly reviews by a version bump.\n *\n * The call contract (the daily reviews checker's side):\n * - The checker hashes the feed with site-core `hashBusinessProfileReviews` and keeps, per site, the last hash the route\n * accepted with a 200. It calls only when today's hash differs, so the feed side decides what is a change; the route\n * keeps the last hash in memory too, but only per server instance, so that is a second guard, not the record.\n * - `POST /api/brandlab/reviews-revalidate`, body `{\"site\":\"<slug>\",\"hash\":\"<64 hex>\"}` (at most 1 KB), headers\n * `x-brandlab-timestamp: <Unix seconds>` and `x-brandlab-signature: v1=<hex HMAC-SHA256 of \"<timestamp>.<body>\">`\n * under `BRANDLAB_REVALIDATE_SECRET`. site-core `createBusinessProfileReviewsRevalidateRequest` builds both.\n * - Answers: 200 `{ ok: true, revalidated: true }` after `revalidateTag('brandlab-reviews:<site>', 'max')`; 200\n * `{ ok: true, revalidated: false, reason: 'unchanged' }` when this instance already applied that hash; 400 for a\n * malformed body or another site's slug; 401 for a missing or wrong signature or a timestamp more than 5 minutes\n * off; 413 for a body over 1 KB; 429 with `Retry-After` once one client IP has failed those checks past the rate limit;\n * 503 when the secret is not set; 500 when the refresh itself failed. The checker records the hash only after a 200\n * and retries the rest the next day.\n * - The signature and timestamp are checked before any rate limit, and only failed attempts count, keyed on the\n * platform's `x-real-ip` (never the client-controlled `x-forwarded-for`). A correctly signed request is never\n * refused with 429, and without `x-real-ip` nothing is rate-limited.\n */\n\nexport interface BrandlabReviewsRevalidateRouteOptions {\n /** The site's slug in the reviews feed, the same one `getBusinessProfileReviews` uses. */\n site: string\n /** Defaults to `process.env`, read on each request: `BRANDLAB_REVALIDATE_SECRET` (at least 32 characters). */\n environment?: Readonly<Record<string, string | undefined>>\n /** Defaults to Next's `revalidateTag` from `next/cache`. */\n revalidateTag?: (tag: string, profile: 'max') => void | Promise<void>\n /**\n * Failed attempts (bad signature, stale timestamp, oversized body) per client IP (`x-real-ip`) before the route answers\n * 429, held in memory per server instance (default 10 in 10 minutes); `false` turns it off. Signed requests never count.\n */\n rateLimit?: BrandlabContactRateLimitRule | false\n /** Defaults to `Date.now`. */\n now?: () => number\n}\n\nexport interface BrandlabReviewsRevalidateRoute {\n POST(request: Request): Promise<Response>\n}\n\n/** Failed attempts per client IP before the route answers 429; a correctly signed request is never limited. */\nexport const BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT = Object.freeze({ limit: 10, windowMs: 10 * 60 * 1_000 })\n\nconst MAX_BODY_BYTES = 1_024\nconst MAX_CLOCK_SKEW_SECONDS = 300\nconst MIN_SECRET_LENGTH = 32\nconst LOG_EVENT = 'brandlab_reviews_revalidate'\n\nfunction json(status: number, body: Record<string, unknown>, headers: Record<string, string> = {}) {\n return Response.json(body, { status, headers: { 'cache-control': 'no-store', ...headers } })\n}\n\n/** One JSON line per refused or applied request: never the secret, signature or body. */\nfunction log(site: string, result: string, extra: Record<string, unknown> = {}) {\n const line = JSON.stringify({ event: LOG_EVENT, site, result, ...extra })\n if (result === 'revalidated' || result === 'unchanged') console.info(line)\n else console.error(line)\n}\n\n/** Equal-length texts are compared in time that does not depend on where they differ. */\nfunction sameText(a: string, b: string) {\n let difference = a.length ^ b.length\n const reference = a.length === b.length ? b : a\n for (let index = 0; index < a.length; index += 1) difference |= a.charCodeAt(index) ^ reference.charCodeAt(index)\n return difference === 0\n}\n\n/** The platform-set client IP (Vercel's `x-real-ip`); `x-forwarded-for` can be written by the client, so it is not read. */\nfunction clientIp(request: Request) {\n const candidate = request.headers.get('x-real-ip')?.trim()\n return candidate && candidate.length <= 64 && /^[0-9A-Fa-f:.]+$/.test(candidate) ? candidate : undefined\n}\n\n/**\n * The body as text, read at most `limit` bytes into the stream: a larger (or chunked, undeclared) body is cancelled\n * as soon as it passes the limit, never buffered whole. `null` means it was too large.\n */\nasync function readCappedText(request: Request, limit: number): Promise<string | null> {\n if (!request.body) return ''\n const reader = request.body.getReader()\n const chunks: Uint8Array[] = []\n let size = 0\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n size += value.byteLength\n if (size > limit) {\n await reader.cancel().catch(() => undefined)\n return null\n }\n chunks.push(value)\n }\n const bytes = new Uint8Array(size)\n let offset = 0\n for (const chunk of chunks) {\n bytes.set(chunk, offset)\n offset += chunk.byteLength\n }\n return new TextDecoder().decode(bytes)\n}\n\nasync function nextRevalidateTag(tag: string, profile: 'max') {\n const { revalidateTag } = await import('next/cache')\n revalidateTag(tag, profile)\n}\n\n/**\n * Creates the signed reviews revalidate route for one site. Create it once at module level, so its rate-limit window\n * and last-applied hash outlive each request.\n */\nexport function createBrandlabReviewsRevalidateRoute(\n options: BrandlabReviewsRevalidateRouteOptions,\n): BrandlabReviewsRevalidateRoute {\n if (!options || typeof options !== 'object') throw new TypeError('Reviews revalidate route options must be an object.')\n const site = options.site\n const tag = businessProfileReviewsSiteTag(site)\n const rule = options.rateLimit === undefined ? BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT : options.rateLimit\n const limiter = rule === false ? undefined : createBrandlabContactRateLimiter({ perIp: rule, perAddress: false })\n const revalidate = options.revalidateTag ?? nextRevalidateTag\n const now = options.now ?? Date.now\n let lastHash: string | undefined\n\n return Object.freeze({\n async POST(request: Request): Promise<Response> {\n const at = now()\n // Only failed attempts count towards the limit, so a correctly signed checker is never locked out.\n const refuse = (status: number, error: string, reason: string) => {\n const ip = limiter ? clientIp(request) : undefined\n if (limiter && ip) {\n // Each failed attempt counts once: a fresh id, since the route has no submission id to dedupe on.\n const decision = limiter.check({ ipKey: ip, submissionId: crypto.randomUUID(), now: at })\n if (!decision.ok) {\n log(site, 'refused', { reason: 'rate-limited' })\n return json(429, { ok: false, error: 'rate-limited' }, { 'retry-after': String(decision.retryAfterSeconds) })\n }\n }\n log(site, 'refused', { reason })\n return json(status, { ok: false, error })\n }\n\n const environment = options.environment ?? process.env\n const secret = normaliseBusinessProfileReviewsRevalidateSecret(environment[BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECRET_NAME] ?? '')\n if (!secret || secret.length < MIN_SECRET_LENGTH) {\n log(site, 'refused', { reason: 'not-configured' })\n return json(503, { ok: false, error: 'not-configured' })\n }\n\n const declaredLength = Number(request.headers.get('content-length') ?? '0')\n if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) return refuse(413, 'too-large', 'too-large')\n const body = await readCappedText(request, MAX_BODY_BYTES)\n if (body === null) return refuse(413, 'too-large', 'too-large')\n\n const timestampText = request.headers.get(BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS.timestamp)?.trim() ?? ''\n const signature = request.headers.get(BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS.signature)?.trim() ?? ''\n const timestamp = /^\\d{1,12}$/.test(timestampText) ? Number(timestampText) : Number.NaN\n if (!Number.isSafeInteger(timestamp) || Math.abs(at / 1_000 - timestamp) > MAX_CLOCK_SKEW_SECONDS) {\n return refuse(401, 'unauthorised', 'timestamp')\n }\n const expected = await signBusinessProfileReviewsRevalidate({ secret, body, timestamp })\n if (!sameText(signature, expected)) return refuse(401, 'unauthorised', 'signature')\n\n let parsed: unknown\n try {\n parsed = JSON.parse(body)\n } catch {\n return json(400, { ok: false, error: 'invalid-body' })\n }\n const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}\n if (typeof record.hash !== 'string' || !BUSINESS_PROFILE_REVIEWS_HASH_PATTERN.test(record.hash)) {\n return json(400, { ok: false, error: 'invalid-body' })\n }\n if (record.site !== site) {\n log(site, 'refused', { reason: 'wrong-site' })\n return json(400, { ok: false, error: 'wrong-site' })\n }\n\n const hash = record.hash\n if (hash === lastHash) {\n log(site, 'unchanged', { hash: hash.slice(0, 12) })\n return json(200, { ok: true, revalidated: false, reason: 'unchanged' })\n }\n try {\n await revalidate(tag, 'max')\n } catch (error) {\n log(site, 'failed', { reason: error instanceof Error ? error.name : 'error' })\n return json(500, { ok: false, error: 'revalidate-failed' })\n }\n lastHash = hash\n log(site, 'revalidated', { hash: hash.slice(0, 12) })\n return json(200, { ok: true, revalidated: true })\n },\n })\n}\n"]}
@@ -1,7 +1,22 @@
1
1
  import 'server-only';
2
2
  import { type BusinessProfileReviews } from '@brandlab-digital/site-core/google-business-reviews';
3
- /** Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */
3
+ export * from './google-business-reviews-revalidate.js';
4
+ /** By default Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */
4
5
  export declare const BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS = 86400;
6
+ /**
7
+ * With `refresh: 'on-change'` (3.13.0) a successful response is kept for 7 days, as a safety net only. Fresh reviews
8
+ * then arrive through the `brandlab-reviews:<site>` tag: the daily reviews checker calls the site's revalidate route
9
+ * when the feed changes, so a new review still shows within a day, and a failed call heals within a week. Seven days
10
+ * is also the shortest refresh check W3 allows an estate page; Next gives a page the shortest revalidate of its
11
+ * fetches, so only an on-change site keeps an estate page that shows reviews within W3.
12
+ */
13
+ export declare const BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS = 604800;
14
+ /**
15
+ * How a site's reviews stay fresh: `daily` (the default) re-reads the feed once a day; `on-change` relies on the
16
+ * signed revalidate route and keeps the weekly safety net. Choose `on-change` only once the route is deployed with
17
+ * `BRANDLAB_REVALIDATE_SECRET` and the site is registered with the daily reviews checker.
18
+ */
19
+ export type BusinessProfileReviewsRefresh = 'daily' | 'on-change';
5
20
  /** The Brandlab reviews feed; `?site=<slug>` names the site. */
6
21
  export declare const BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT = "https://brandlab-reviews-feed.vercel.app/api/reviews";
7
22
  export declare const BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES: {
@@ -17,13 +32,17 @@ export interface GetBusinessProfileReviewsOptions {
17
32
  fetcher?: typeof fetch;
18
33
  /** Overrides `REVIEWS_FEED_URL` and the default feed address. Must be https. */
19
34
  feedUrl?: string;
35
+ /** `daily` (default) or `on-change` once the revalidate route and the daily checker are wired (3.13.0). */
36
+ refresh?: BusinessProfileReviewsRefresh;
20
37
  }
21
38
  /**
22
39
  * Google reviews from the Business Profile API, served by the Brandlab reviews feed, for a
23
40
  * server component. Visitors never trigger a Google request and the site holds no Google
24
41
  * credential: only the feed's bearer token. Returns null without `REVIEWS_FEED_TOKEN` and on
25
42
  * any failure (network, timeout after 5 seconds, an error status, an unexpected body), so
26
- * the page shows its no-reviews state. Next caches only a successful response, for a day.
43
+ * the page shows its no-reviews state. Next caches only a successful response, for a day (7 days with
44
+ * `refresh: 'on-change'`), tagged `brandlab-reviews` and `brandlab-reviews:<site>` so the revalidate route can
45
+ * refresh it the day the reviews change.
27
46
  * A `site` that is not lowercase kebab-case is a configuration error and throws.
28
47
  */
29
48
  export declare function getBusinessProfileReviews(options: GetBusinessProfileReviewsOptions): Promise<BusinessProfileReviews | null>;
@@ -1 +1 @@
1
- {"version":3,"file":"google-business-reviews-server.d.ts","sourceRoot":"","sources":["../src/google-business-reviews-server.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EAEL,KAAK,sBAAsB,EAC5B,MAAM,qDAAqD,CAAA;AAE5D,4GAA4G;AAC5G,eAAO,MAAM,2CAA2C,QAAS,CAAA;AAEjE,gEAAgE;AAChE,eAAO,MAAM,sCAAsC,yDAAyD,CAAA;AAE5G,eAAO,MAAM,0CAA0C;;;CAG7C,CAAA;AAKV,MAAM,WAAW,gCAAgC;IAC/C,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAA;IACZ,iGAAiG;IACjG,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;IAC1D,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAA;IACtB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAgBD;;;;;;;GAOG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAyBxC"}
1
+ {"version":3,"file":"google-business-reviews-server.d.ts","sourceRoot":"","sources":["../src/google-business-reviews-server.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,qDAAqD,CAAA;AAE5D,cAAc,yCAAyC,CAAA;AAEvD,uHAAuH;AACvH,eAAO,MAAM,2CAA2C,QAAS,CAAA;AAEjE;;;;;;GAMG;AACH,eAAO,MAAM,qDAAqD,SAAU,CAAA;AAE5E;;;;GAIG;AACH,MAAM,MAAM,6BAA6B,GAAG,OAAO,GAAG,WAAW,CAAA;AAEjE,gEAAgE;AAChE,eAAO,MAAM,sCAAsC,yDAAyD,CAAA;AAE5G,eAAO,MAAM,0CAA0C;;;CAG7C,CAAA;AAKV,MAAM,WAAW,gCAAgC;IAC/C,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAA;IACZ,iGAAiG;IACjG,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;IAC1D,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAA;IACtB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,2GAA2G;IAC3G,OAAO,CAAC,EAAE,6BAA6B,CAAA;CACxC;AAgBD;;;;;;;;;GASG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CA+BxC"}
@@ -1,7 +1,16 @@
1
1
  import 'server-only';
2
- import { parseBusinessProfileReviews, } from '@brandlab-digital/site-core/google-business-reviews';
3
- /** Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */
2
+ import { businessProfileReviewsTags, parseBusinessProfileReviews, } from '@brandlab-digital/site-core/google-business-reviews';
3
+ export * from './google-business-reviews-revalidate.js';
4
+ /** By default Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */
4
5
  export const BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS = 86_400;
6
+ /**
7
+ * With `refresh: 'on-change'` (3.13.0) a successful response is kept for 7 days, as a safety net only. Fresh reviews
8
+ * then arrive through the `brandlab-reviews:<site>` tag: the daily reviews checker calls the site's revalidate route
9
+ * when the feed changes, so a new review still shows within a day, and a failed call heals within a week. Seven days
10
+ * is also the shortest refresh check W3 allows an estate page; Next gives a page the shortest revalidate of its
11
+ * fetches, so only an on-change site keeps an estate page that shows reviews within W3.
12
+ */
13
+ export const BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS = 604_800;
5
14
  /** The Brandlab reviews feed; `?site=<slug>` names the site. */
6
15
  export const BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT = 'https://brandlab-reviews-feed.vercel.app/api/reviews';
7
16
  export const BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES = {
@@ -28,13 +37,21 @@ function httpsUrl(value) {
28
37
  * server component. Visitors never trigger a Google request and the site holds no Google
29
38
  * credential: only the feed's bearer token. Returns null without `REVIEWS_FEED_TOKEN` and on
30
39
  * any failure (network, timeout after 5 seconds, an error status, an unexpected body), so
31
- * the page shows its no-reviews state. Next caches only a successful response, for a day.
40
+ * the page shows its no-reviews state. Next caches only a successful response, for a day (7 days with
41
+ * `refresh: 'on-change'`), tagged `brandlab-reviews` and `brandlab-reviews:<site>` so the revalidate route can
42
+ * refresh it the day the reviews change.
32
43
  * A `site` that is not lowercase kebab-case is a configuration error and throws.
33
44
  */
34
45
  export async function getBusinessProfileReviews(options) {
35
46
  if (typeof options.site !== 'string' || !SITE_SLUG.test(options.site)) {
36
47
  throw new Error('Business Profile reviews site must be a lowercase kebab-case slug.');
37
48
  }
49
+ if (options.refresh !== undefined && options.refresh !== 'daily' && options.refresh !== 'on-change') {
50
+ throw new Error("Business Profile reviews refresh must be 'daily' or 'on-change'.");
51
+ }
52
+ const revalidate = options.refresh === 'on-change'
53
+ ? BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS
54
+ : BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS;
38
55
  const environment = options.environment ?? process.env;
39
56
  const token = setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.token]);
40
57
  if (!token)
@@ -48,7 +65,7 @@ export async function getBusinessProfileReviews(options) {
48
65
  try {
49
66
  const response = await (options.fetcher ?? fetch)(url, {
50
67
  headers: { authorization: `Bearer ${token}`, accept: 'application/json' },
51
- next: { revalidate: BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS },
68
+ next: { revalidate, tags: businessProfileReviewsTags(options.site) },
52
69
  signal: AbortSignal.timeout(TIMEOUT_MS),
53
70
  });
54
71
  if (!response.ok)
@@ -1 +1 @@
1
- {"version":3,"file":"google-business-reviews-server.js","sourceRoot":"","sources":["../src/google-business-reviews-server.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EACL,2BAA2B,GAE5B,MAAM,qDAAqD,CAAA;AAE5D,4GAA4G;AAC5G,MAAM,CAAC,MAAM,2CAA2C,GAAG,MAAM,CAAA;AAEjE,gEAAgE;AAChE,MAAM,CAAC,MAAM,sCAAsC,GAAG,sDAAsD,CAAA;AAE5G,MAAM,CAAC,MAAM,0CAA0C,GAAG;IACxD,KAAK,EAAE,oBAAoB;IAC3B,OAAO,EAAE,kBAAkB;CACnB,CAAA;AAEV,MAAM,UAAU,GAAG,KAAK,CAAA;AACxB,MAAM,SAAS,GAAG,4BAA4B,CAAA;AAa9C,SAAS,OAAO,CAAC,KAAyB;IACxC,MAAM,OAAO,GAAG,KAAK,EAAE,IAAI,EAAE,CAAA;IAC7B,OAAO,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;AACtC,CAAC;AAED,SAAS,QAAQ,CAAC,KAAa;IAC7B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAA;QAC1B,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;IAC/D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,OAAyC;IAEzC,IAAI,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACtE,MAAM,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAA;IACvF,CAAC;IACD,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,OAAO,CAAC,GAAG,CAAA;IACtD,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,CAAC,0CAA0C,CAAC,KAAK,CAAC,CAAC,CAAA;IACpF,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAA;IACvB,kDAAkD;IAClD,MAAM,GAAG,GAAG,QAAQ,CAClB,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC;QACtB,OAAO,CAAC,WAAW,CAAC,0CAA0C,CAAC,OAAO,CAAC,CAAC;QACxE,GAAG,sCAAsC,SAAS,OAAO,CAAC,IAAI,EAAE,CACnE,CAAA;IACD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAA;IACrB,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,IAAI,KAAK,CAAC,CAAC,GAAG,EAAE;YACrD,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE;YACzE,IAAI,EAAE,EAAE,UAAU,EAAE,2CAA2C,EAAE;YACjE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,UAAU,CAAC;SACzB,CAAC,CAAA;QACjB,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,OAAO,IAAI,CAAA;QAC7B,OAAO,2BAA2B,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IAC3D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC","sourcesContent":["import 'server-only'\n\nimport {\n parseBusinessProfileReviews,\n type BusinessProfileReviews,\n} from '@brandlab-digital/site-core/google-business-reviews'\n\n/** Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */\nexport const BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS = 86_400\n\n/** The Brandlab reviews feed; `?site=<slug>` names the site. */\nexport const BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT = 'https://brandlab-reviews-feed.vercel.app/api/reviews'\n\nexport const BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES = {\n token: 'REVIEWS_FEED_TOKEN',\n feedUrl: 'REVIEWS_FEED_URL',\n} as const\n\nconst TIMEOUT_MS = 5_000\nconst SITE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n\nexport interface GetBusinessProfileReviewsOptions {\n /** The site's slug in the reviews feed (lowercase kebab-case), for example `hr-kitchens`. */\n site: string\n /** Defaults to `process.env`: reads `REVIEWS_FEED_TOKEN` and the optional `REVIEWS_FEED_URL`. */\n environment?: Readonly<Record<string, string | undefined>>\n /** Defaults to the global `fetch`. */\n fetcher?: typeof fetch\n /** Overrides `REVIEWS_FEED_URL` and the default feed address. Must be https. */\n feedUrl?: string\n}\n\nfunction setting(value: string | undefined) {\n const trimmed = value?.trim()\n return trimmed ? trimmed : undefined\n}\n\nfunction httpsUrl(value: string) {\n try {\n const url = new URL(value)\n return url.protocol === 'https:' ? url.toString() : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * Google reviews from the Business Profile API, served by the Brandlab reviews feed, for a\n * server component. Visitors never trigger a Google request and the site holds no Google\n * credential: only the feed's bearer token. Returns null without `REVIEWS_FEED_TOKEN` and on\n * any failure (network, timeout after 5 seconds, an error status, an unexpected body), so\n * the page shows its no-reviews state. Next caches only a successful response, for a day.\n * A `site` that is not lowercase kebab-case is a configuration error and throws.\n */\nexport async function getBusinessProfileReviews(\n options: GetBusinessProfileReviewsOptions,\n): Promise<BusinessProfileReviews | null> {\n if (typeof options.site !== 'string' || !SITE_SLUG.test(options.site)) {\n throw new Error('Business Profile reviews site must be a lowercase kebab-case slug.')\n }\n const environment = options.environment ?? process.env\n const token = setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.token])\n if (!token) return null\n // The bearer token goes only to an https address.\n const url = httpsUrl(\n setting(options.feedUrl) ??\n setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.feedUrl]) ??\n `${BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT}?site=${options.site}`,\n )\n if (!url) return null\n try {\n const response = await (options.fetcher ?? fetch)(url, {\n headers: { authorization: `Bearer ${token}`, accept: 'application/json' },\n next: { revalidate: BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS },\n signal: AbortSignal.timeout(TIMEOUT_MS),\n } as RequestInit)\n if (!response.ok) return null\n return parseBusinessProfileReviews(await response.json())\n } catch {\n return null\n }\n}\n"]}
1
+ {"version":3,"file":"google-business-reviews-server.js","sourceRoot":"","sources":["../src/google-business-reviews-server.ts"],"names":[],"mappings":"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","sourcesContent":["import 'server-only'\n\nimport {\n businessProfileReviewsTags,\n parseBusinessProfileReviews,\n type BusinessProfileReviews,\n} from '@brandlab-digital/site-core/google-business-reviews'\n\nexport * from './google-business-reviews-revalidate.js'\n\n/** By default Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */\nexport const BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS = 86_400\n\n/**\n * With `refresh: 'on-change'` (3.13.0) a successful response is kept for 7 days, as a safety net only. Fresh reviews\n * then arrive through the `brandlab-reviews:<site>` tag: the daily reviews checker calls the site's revalidate route\n * when the feed changes, so a new review still shows within a day, and a failed call heals within a week. Seven days\n * is also the shortest refresh check W3 allows an estate page; Next gives a page the shortest revalidate of its\n * fetches, so only an on-change site keeps an estate page that shows reviews within W3.\n */\nexport const BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS = 604_800\n\n/**\n * How a site's reviews stay fresh: `daily` (the default) re-reads the feed once a day; `on-change` relies on the\n * signed revalidate route and keeps the weekly safety net. Choose `on-change` only once the route is deployed with\n * `BRANDLAB_REVALIDATE_SECRET` and the site is registered with the daily reviews checker.\n */\nexport type BusinessProfileReviewsRefresh = 'daily' | 'on-change'\n\n/** The Brandlab reviews feed; `?site=<slug>` names the site. */\nexport const BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT = 'https://brandlab-reviews-feed.vercel.app/api/reviews'\n\nexport const BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES = {\n token: 'REVIEWS_FEED_TOKEN',\n feedUrl: 'REVIEWS_FEED_URL',\n} as const\n\nconst TIMEOUT_MS = 5_000\nconst SITE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n\nexport interface GetBusinessProfileReviewsOptions {\n /** The site's slug in the reviews feed (lowercase kebab-case), for example `hr-kitchens`. */\n site: string\n /** Defaults to `process.env`: reads `REVIEWS_FEED_TOKEN` and the optional `REVIEWS_FEED_URL`. */\n environment?: Readonly<Record<string, string | undefined>>\n /** Defaults to the global `fetch`. */\n fetcher?: typeof fetch\n /** Overrides `REVIEWS_FEED_URL` and the default feed address. Must be https. */\n feedUrl?: string\n /** `daily` (default) or `on-change` once the revalidate route and the daily checker are wired (3.13.0). */\n refresh?: BusinessProfileReviewsRefresh\n}\n\nfunction setting(value: string | undefined) {\n const trimmed = value?.trim()\n return trimmed ? trimmed : undefined\n}\n\nfunction httpsUrl(value: string) {\n try {\n const url = new URL(value)\n return url.protocol === 'https:' ? url.toString() : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * Google reviews from the Business Profile API, served by the Brandlab reviews feed, for a\n * server component. Visitors never trigger a Google request and the site holds no Google\n * credential: only the feed's bearer token. Returns null without `REVIEWS_FEED_TOKEN` and on\n * any failure (network, timeout after 5 seconds, an error status, an unexpected body), so\n * the page shows its no-reviews state. Next caches only a successful response, for a day (7 days with\n * `refresh: 'on-change'`), tagged `brandlab-reviews` and `brandlab-reviews:<site>` so the revalidate route can\n * refresh it the day the reviews change.\n * A `site` that is not lowercase kebab-case is a configuration error and throws.\n */\nexport async function getBusinessProfileReviews(\n options: GetBusinessProfileReviewsOptions,\n): Promise<BusinessProfileReviews | null> {\n if (typeof options.site !== 'string' || !SITE_SLUG.test(options.site)) {\n throw new Error('Business Profile reviews site must be a lowercase kebab-case slug.')\n }\n if (options.refresh !== undefined && options.refresh !== 'daily' && options.refresh !== 'on-change') {\n throw new Error(\"Business Profile reviews refresh must be 'daily' or 'on-change'.\")\n }\n const revalidate = options.refresh === 'on-change'\n ? BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS\n : BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS\n const environment = options.environment ?? process.env\n const token = setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.token])\n if (!token) return null\n // The bearer token goes only to an https address.\n const url = httpsUrl(\n setting(options.feedUrl) ??\n setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.feedUrl]) ??\n `${BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT}?site=${options.site}`,\n )\n if (!url) return null\n try {\n const response = await (options.fetcher ?? fetch)(url, {\n headers: { authorization: `Bearer ${token}`, accept: 'application/json' },\n next: { revalidate, tags: businessProfileReviewsTags(options.site) },\n signal: AbortSignal.timeout(TIMEOUT_MS),\n } as RequestInit)\n if (!response.ok) return null\n return parseBusinessProfileReviews(await response.json())\n } catch {\n return null\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAEL,KAAK,kBAAkB,EAExB,MAAM,kCAAkC,CAAA;AAEzC,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,qHAAqH;IACrH,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC5C;AAED,eAAO,MAAM,mCAAmC,6CACJ,CAAA;AAE5C,eAAO,MAAM,6BAA6B,kEACuB,CAAA;AAEjE;;;GAGG;AACH,eAAO,MAAM,wCAAwC,YAAY,CAAA;AAUjE;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,kBAAkB,CAYnF;AAED,eAAO,MAAM,6BAA6B,iCAAiC,CAAA;AAE3E,eAAO,MAAM,8BAA8B,iCAAiC,CAAA;AAE5E,eAAO,MAAM,mBAAmB,qBACZ,CAAA;AAEpB,iFAAiF;AACjF,eAAO,MAAM,mCAAmC,wCAAwC,CAAA;AAExF,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,CAAA;CAC1B;AAoBD,MAAM,WAAW,uCAAuC;IACtD,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,6BAA6B;IAC5C,mDAAmD;IACnD,UAAU,EAAE,kBAAkB,CAAA;IAC9B,IAAI,CAAC,EAAE,qBAAqB,CAAA;IAC5B,wEAAwE;IACxE,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;IACzE,2FAA2F;IAC3F,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,wEAAwE;IACxE,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;CACzC;AAcD,wBAAgB,kCAAkC,CAAC,OAAO,EAAE;IAC1D,UAAU,EAAE,kBAAkB,CAAA;IAC9B,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;CAC1E,UAsDA;AAiDD,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,cAAc,EAAE,CAmDlB;AAOD,MAAM,WAAW,yBAAyB;IACxC,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAA;IACd,mEAAmE;IACnE,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IACzB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,OAAO,GAAG,yBAAyB,EAAE,CA4B3F;AAED,MAAM,WAAW,yBAA0B,SAAQ,6BAA6B;IAC9E;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,EAAE,CAyBjG"}
1
+ {"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAEL,KAAK,kBAAkB,EAExB,MAAM,kCAAkC,CAAA;AAEzC,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,qHAAqH;IACrH,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC5C;AAED,eAAO,MAAM,mCAAmC,6CACJ,CAAA;AAE5C,eAAO,MAAM,6BAA6B,kEACuB,CAAA;AAEjE;;;GAGG;AACH,eAAO,MAAM,wCAAwC,YAAY,CAAA;AAUjE;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,kBAAkB,CAYnF;AAED,eAAO,MAAM,6BAA6B,iCAAiC,CAAA;AAE3E,eAAO,MAAM,8BAA8B,iCAAiC,CAAA;AAE5E,eAAO,MAAM,mBAAmB,qBACZ,CAAA;AAEpB,iFAAiF;AACjF,eAAO,MAAM,mCAAmC,wCAAwC,CAAA;AAExF,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,CAAA;CAC1B;AA0BD,MAAM,WAAW,uCAAuC;IACtD,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,6BAA6B;IAC5C,mDAAmD;IACnD,UAAU,EAAE,kBAAkB,CAAA;IAC9B,IAAI,CAAC,EAAE,qBAAqB,CAAA;IAC5B,wEAAwE;IACxE,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;IACzE,2FAA2F;IAC3F,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,wEAAwE;IACxE,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;CACzC;AAcD,wBAAgB,kCAAkC,CAAC,OAAO,EAAE;IAC1D,UAAU,EAAE,kBAAkB,CAAA;IAC9B,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;CAC1E,UAsDA;AAiDD,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,cAAc,EAAE,CAmDlB;AAOD,MAAM,WAAW,yBAAyB;IACxC,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAA;IACd,mEAAmE;IACnE,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IACzB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,OAAO,GAAG,yBAAyB,EAAE,CA4B3F;AAED,MAAM,WAAW,yBAA0B,SAAQ,6BAA6B;IAC9E;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,EAAE,CAyBjG"}
package/dist/headers.js CHANGED
@@ -54,8 +54,14 @@ export const BRANDLAB_HSTS_VALUE = 'max-age=31536000';
54
54
  /** Cache-Control for the declared static public folders on production builds. */
55
55
  export const BRANDLAB_STATIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
56
56
  const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
57
+ /**
58
+ * Headers a project's `extraHeaders` may not set. The CDN cache headers (3.13.0) sit beside `cache-control`: a
59
+ * hand-set `CDN-Cache-Control` or `Vercel-CDN-Cache-Control` would change what Vercel caches without review.
60
+ */
57
61
  const PROTECTED_HEADER_NAMES = new Set([
58
62
  'cache-control',
63
+ 'cdn-cache-control',
64
+ 'vercel-cdn-cache-control',
59
65
  'content-security-policy',
60
66
  'content-security-policy-report-only',
61
67
  'x-frame-options',