@brandlab-digital/conformance 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 (94) hide show
  1. package/README.md +32 -1
  2. package/dist/current-release.js +6 -6
  3. package/dist/current-release.js.map +1 -1
  4. package/dist/edge-probe-cli.d.ts +57 -0
  5. package/dist/edge-probe-cli.d.ts.map +1 -0
  6. package/dist/edge-probe-cli.js +134 -0
  7. package/dist/edge-probe-cli.js.map +1 -0
  8. package/dist/engineering.d.ts +4 -1
  9. package/dist/engineering.d.ts.map +1 -1
  10. package/dist/engineering.js +19 -14
  11. package/dist/engineering.js.map +1 -1
  12. package/dist/estate-sample.d.ts +60 -0
  13. package/dist/estate-sample.d.ts.map +1 -0
  14. package/dist/estate-sample.js +144 -0
  15. package/dist/estate-sample.js.map +1 -0
  16. package/dist/estate.d.ts +17 -0
  17. package/dist/estate.d.ts.map +1 -1
  18. package/dist/estate.js +47 -10
  19. package/dist/estate.js.map +1 -1
  20. package/dist/origin-region.d.ts +14 -0
  21. package/dist/origin-region.d.ts.map +1 -0
  22. package/dist/origin-region.js +46 -0
  23. package/dist/origin-region.js.map +1 -0
  24. package/dist/prefetch-probe-cli.d.ts +36 -0
  25. package/dist/prefetch-probe-cli.d.ts.map +1 -0
  26. package/dist/prefetch-probe-cli.js +163 -0
  27. package/dist/prefetch-probe-cli.js.map +1 -0
  28. package/dist/site-system/checks.d.ts +36 -0
  29. package/dist/site-system/checks.d.ts.map +1 -1
  30. package/dist/site-system/checks.js +6 -0
  31. package/dist/site-system/checks.js.map +1 -1
  32. package/dist/site-system/config.d.ts +27 -0
  33. package/dist/site-system/config.d.ts.map +1 -1
  34. package/dist/site-system/config.js +50 -1
  35. package/dist/site-system/config.js.map +1 -1
  36. package/dist/site-system/index.d.ts +1 -1
  37. package/dist/site-system/index.d.ts.map +1 -1
  38. package/dist/site-system/index.js +1 -1
  39. package/dist/site-system/index.js.map +1 -1
  40. package/dist/site-system/journeys/plan.d.ts +9 -2
  41. package/dist/site-system/journeys/plan.d.ts.map +1 -1
  42. package/dist/site-system/journeys/plan.js +11 -1
  43. package/dist/site-system/journeys/plan.js.map +1 -1
  44. package/dist/site-system/journeys/prefetch-measure.d.ts +28 -0
  45. package/dist/site-system/journeys/prefetch-measure.d.ts.map +1 -0
  46. package/dist/site-system/journeys/prefetch-measure.js +94 -0
  47. package/dist/site-system/journeys/prefetch-measure.js.map +1 -0
  48. package/dist/site-system/journeys/prefetch-policy.d.ts +85 -0
  49. package/dist/site-system/journeys/prefetch-policy.d.ts.map +1 -0
  50. package/dist/site-system/journeys/prefetch-policy.js +152 -0
  51. package/dist/site-system/journeys/prefetch-policy.js.map +1 -0
  52. package/dist/site-system/journeys/prefetch.d.ts +3 -0
  53. package/dist/site-system/journeys/prefetch.d.ts.map +1 -0
  54. package/dist/site-system/journeys/prefetch.js +37 -0
  55. package/dist/site-system/journeys/prefetch.js.map +1 -0
  56. package/dist/site-system/journeys/variants.d.ts +34 -0
  57. package/dist/site-system/journeys/variants.d.ts.map +1 -0
  58. package/dist/site-system/journeys/variants.js +139 -0
  59. package/dist/site-system/journeys/variants.js.map +1 -0
  60. package/dist/site-system/rule-modules.d.ts.map +1 -1
  61. package/dist/site-system/rule-modules.js +5 -0
  62. package/dist/site-system/rule-modules.js.map +1 -1
  63. package/dist/site-system/rules/full/common.d.ts +3 -0
  64. package/dist/site-system/rules/full/common.d.ts.map +1 -1
  65. package/dist/site-system/rules/full/common.js.map +1 -1
  66. package/dist/site-system/rules/full/estate.d.ts +4 -0
  67. package/dist/site-system/rules/full/estate.d.ts.map +1 -1
  68. package/dist/site-system/rules/full/estate.js +11 -2
  69. package/dist/site-system/rules/full/estate.js.map +1 -1
  70. package/dist/site-system/rules/full/index.d.ts +2 -1
  71. package/dist/site-system/rules/full/index.d.ts.map +1 -1
  72. package/dist/site-system/rules/full/index.js +14 -7
  73. package/dist/site-system/rules/full/index.js.map +1 -1
  74. package/dist/site-system/rules/full/w3-refresh.d.ts +22 -0
  75. package/dist/site-system/rules/full/w3-refresh.d.ts.map +1 -0
  76. package/dist/site-system/rules/full/w3-refresh.js +70 -0
  77. package/dist/site-system/rules/full/w3-refresh.js.map +1 -0
  78. package/dist/site-system/rules/guards/index.d.ts +22 -0
  79. package/dist/site-system/rules/guards/index.d.ts.map +1 -0
  80. package/dist/site-system/rules/guards/index.js +109 -0
  81. package/dist/site-system/rules/guards/index.js.map +1 -0
  82. package/dist/site-system/rules/guards/w10-crawlers.d.ts +55 -0
  83. package/dist/site-system/rules/guards/w10-crawlers.d.ts.map +1 -0
  84. package/dist/site-system/rules/guards/w10-crawlers.js +182 -0
  85. package/dist/site-system/rules/guards/w10-crawlers.js.map +1 -0
  86. package/dist/site-system/rules/guards/w9-params.d.ts +19 -0
  87. package/dist/site-system/rules/guards/w9-params.d.ts.map +1 -0
  88. package/dist/site-system/rules/guards/w9-params.js +84 -0
  89. package/dist/site-system/rules/guards/w9-params.js.map +1 -0
  90. package/dist/site-system-e2e.d.ts +1 -0
  91. package/dist/site-system-e2e.d.ts.map +1 -1
  92. package/dist/site-system-e2e.js +8 -2
  93. package/dist/site-system-e2e.js.map +1 -1
  94. package/package.json +6 -4
package/README.md CHANGED
@@ -21,7 +21,9 @@ Proprietary software © Brandlab Digital Marketing. All rights reserved. Publish
21
21
  A site adopts Site System v2 by declaring `siteSystem: { tier: 'shell' | 'enquiry' | 'full' }` in
22
22
  `brandlab.config.ts` (`newBuild: true` for new builds and redesigns; `samples` adds pages to check; `labels`,
23
23
  `freeQuotes`, `thirdParties` and `phones` record the quote labels, free-quote offer, third-party hosts and extra
24
- `tel:` links the shell rules accept). Without it, nothing in the gate blocks.
24
+ `tel:` links the shell rules accept; `refreshExceptions` lists estate families allowed to refresh more often than
25
+ every 7 days, each `{ family, revalidateSeconds, reason, decisionId }`, for W3 from 3.13.0). Without it, nothing in
26
+ the gate blocks.
25
27
 
26
28
  - `brandlab-ready` (after `next build` and the site-system journeys) writes `docs/ready/ready-report.md` and
27
29
  `.json`: every check with its tier, whether it blocks for this site, its status (pass, fail, not run, excepted,
@@ -41,3 +43,32 @@ A site adopts Site System v2 by declaring `siteSystem: { tier: 'shell' | 'enquir
41
43
  `brandlab-ready --launch` also fails until every step is recorded.
42
44
  - Rule modules export a `SiteSystemRenderedRunner` from `@brandlab-digital/conformance/site-system` and are
43
45
  listed in `SITE_SYSTEM_RENDERED_RUNNERS`.
46
+ - W2 (shell tier, from 3.13.0) runs inside the site-system journeys on each sampled page at 390 x 844 and
47
+ 1440 x 900: at most 8 prefetched pages on landing and 12 after a full scroll; none from footer, header panel,
48
+ drawer, area (`coverage`), nearby (`nearby-areas`), map (`area-map`, `data-area-map`), finder or other-services
49
+ links (a site marks its own lists with `data-bl-links="area|nearby|map|finder|other-services"`), none through a
50
+ hidden link and never the current page. Compressed prefetch bytes above twice the page's own HTML add a warning.
51
+ - `brandlab-prefetch-probe <url> [--viewport 390x844 ...] [--json] [--strict]` measures one page locally (a local
52
+ build, a preview or a live page) in headless Chromium: the pages prefetched on landing and after a full scroll at
53
+ each viewport, their compressed and decoded bytes, the edge cache result and the W2 result. No request leaves for
54
+ another site, and it never crawls.
55
+
56
+ ## Cost guards (from 3.13.0)
57
+
58
+ - **W5** (static, shell): `vercel.json` `regions` equal `brandlab.config.ts` `hosting.originRegion` (Sydney,
59
+ `syd1`, when there is no hosting block). Any other region needs an `origin-region-v1` decision whose `reviewBy`
60
+ is at most six months after `decidedOn`; the check fails once `reviewBy` passes (Sydney time). The engineering
61
+ check `vercel-region` (`brandlab-verify --engineering`, inside every site's `npm run verify`) applies the same
62
+ rule except the date: a lapsed `reviewBy` never stops a build, a fix or a rollback.
63
+ - **W10** (rendered, shell): the built `robots.txt` (or the running site's, with `--base-url`; a Preview that
64
+ answers noindex, or a build made for a Preview whose `robots.txt` disallows everything, is not judged) lets every search and answer crawler (site-core `BRANDLAB_SEARCH_CRAWLERS`) fetch
65
+ home and the representative routes, and gives none of them a Crawl-delay. Firewall rules that match on the user
66
+ agent (`vercel.json` `routes` with `mitigate`, and `hosting.firewall`) must not deny or challenge them; a rate
67
+ limit on one is a warning.
68
+ - **W9** (journey, full): `defineBrandlabSiteSystemTests({ pages: { families: [...] } })` names one built page per
69
+ dynamic route family; an unknown slug answers the shared 404, the uppercase slug answers it too or redirects once
70
+ (301 or 308) to its exact lowercase form, and `?utm_source=x` serves the same cacheable page as the clean URL. **W9-params** (static, full): no page under a dynamic route reads `searchParams`.
71
+ - `brandlab-edge-probe [--json] <url>...` (run locally as `npm run probe:edge`, at most 25 URLs) logs
72
+ `x-vercel-cache`, `age`, `x-vercel-id` (edge and function region) and time to first byte for each page, its RSC
73
+ payload and its `_tree` prefetch, and a GET to each origin's `/api/contact` (expected 405). It is the measuring
74
+ stick of the origin-region benchmark (`operations/shared-platform/ORIGIN_REGION.md`).
@@ -23,23 +23,23 @@ export const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze({
23
23
  "kind": "brandlab-runtime-release-contract",
24
24
  "packages": {
25
25
  "@brandlab-digital/conformance": {
26
- "version": "3.12.0"
26
+ "version": "3.13.0"
27
27
  },
28
28
  "@brandlab-digital/eslint-plugin": {
29
- "version": "3.12.0"
29
+ "version": "3.13.0"
30
30
  },
31
31
  "@brandlab-digital/site-core": {
32
- "version": "3.12.0"
32
+ "version": "3.13.0"
33
33
  },
34
34
  "@brandlab-digital/site-next": {
35
- "version": "3.12.0"
35
+ "version": "3.13.0"
36
36
  }
37
37
  },
38
- "releaseVersion": "3.12.0",
38
+ "releaseVersion": "3.13.0",
39
39
  "runtime": {
40
40
  "bundler": "turbopack",
41
41
  "cacheComponents": false,
42
- "next": "16.3.8",
42
+ "next": "16.4.2",
43
43
  "nextVersionPolicy": "exact",
44
44
  "node": "24.x",
45
45
  "packageManager": "npm@11.14.1",
@@ -1 +1 @@
1
- {"version":3,"file":"current-release.js","sourceRoot":"","sources":["../src/current-release.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,wEAAwE;AACxE,OAAO,EAEL,sCAAsC,GACvC,MAAM,uBAAuB,CAAC;AAE/B,MAAM,CAAC,MAAM,gCAAgC,GAAG,MAAM,CAAC,MAAM,CAC7D;IACE,cAAc,EAAE;QACd,uCAAuC;QACvC,8BAA8B;QAC9B,yBAAyB;QACzB,6BAA6B;QAC7B,0BAA0B;QAC1B,4BAA4B;QAC5B,kCAAkC;QAClC,wCAAwC;QACxC,yBAAyB;KAC1B;IACD,WAAW,EAAE;QACX,WAAW,EAAE,cAAc;QAC3B,cAAc,EAAE,iBAAiB;QACjC,eAAe,EAAE,mBAAmB;QACpC,SAAS,EAAE,YAAY;QACvB,KAAK,EAAE,QAAQ;KAChB;IACD,MAAM,EAAE,mCAAmC;IAC3C,UAAU,EAAE;QACV,+BAA+B,EAAE;YAC/B,SAAS,EAAE,QAAQ;SACpB;QACD,iCAAiC,EAAE;YACjC,SAAS,EAAE,QAAQ;SACpB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,QAAQ;SACpB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,QAAQ;SACpB;KACF;IACD,gBAAgB,EAAE,QAAQ;IAC1B,SAAS,EAAE;QACT,SAAS,EAAE,WAAW;QACtB,iBAAiB,EAAE,KAAK;QACxB,MAAM,EAAE,QAAQ;QAChB,mBAAmB,EAAE,OAAO;QAC5B,MAAM,EAAE,MAAM;QACd,gBAAgB,EAAE,aAAa;QAC/B,OAAO,EAAE,QAAQ;QACjB,UAAU,EAAE,QAAQ;QACpB,QAAQ,EAAE,KAAK;QACf,OAAO,EAAE,QAAQ;QACjB,YAAY,EAAE,OAAO;KACtB;IACD,eAAe,EAAE,sCAAsC;CACxD,CAC6C,CAAC;AAE/C,sCAAsC,CAAC,gCAAgC,CAAC,CAAC","sourcesContent":["// Generated by standards/scripts/generate-conformance-current-release.mjs.\n// Each exact package release verifies its own current runtime contract.\nimport {\n type BrandlabRuntimeReleaseContract,\n validateBrandlabRuntimeReleaseContract,\n} from \"./release-manifest.js\";\n\nexport const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze(\n{\n \"capabilities\": [\n \"typed-project-business-and-route-data\",\n \"essential-search-foundations\",\n \"shared-contact-delivery\",\n \"privacy-safe-lead-analytics\",\n \"security-patched-runtime\",\n \"live-google-review-summary\",\n \"high-signal-project-verification\",\n \"packed-positive-and-negative-consumers\",\n \"changesets-oidc-release\"\n ],\n \"contracts\": {\n \"analytics\": \"analytics-v4\",\n \"architecture\": \"architecture-v7\",\n \"configuration\": \"project-config-v7\",\n \"contact\": \"contact-v4\",\n \"seo\": \"seo-v4\"\n },\n \"kind\": \"brandlab-runtime-release-contract\",\n \"packages\": {\n \"@brandlab-digital/conformance\": {\n \"version\": \"3.12.0\"\n },\n \"@brandlab-digital/eslint-plugin\": {\n \"version\": \"3.12.0\"\n },\n \"@brandlab-digital/site-core\": {\n \"version\": \"3.12.0\"\n },\n \"@brandlab-digital/site-next\": {\n \"version\": \"3.12.0\"\n }\n },\n \"releaseVersion\": \"3.12.0\",\n \"runtime\": {\n \"bundler\": \"turbopack\",\n \"cacheComponents\": false,\n \"next\": \"16.3.8\",\n \"nextVersionPolicy\": \"exact\",\n \"node\": \"24.x\",\n \"packageManager\": \"npm@11.14.1\",\n \"react\": \"19.2.8\",\n \"reactDom\": \"19.2.8\",\n \"router\": \"app\",\n \"sharp\": \"0.35.5\",\n \"typescript\": \"6.0.3\"\n },\n \"schemaVersion\": \"brandlab.runtime-release-contract.v1\"\n},\n) as unknown as BrandlabRuntimeReleaseContract;\n\nvalidateBrandlabRuntimeReleaseContract(BRANDLAB_CURRENT_RUNTIME_RELEASE);\n"]}
1
+ {"version":3,"file":"current-release.js","sourceRoot":"","sources":["../src/current-release.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,wEAAwE;AACxE,OAAO,EAEL,sCAAsC,GACvC,MAAM,uBAAuB,CAAC;AAE/B,MAAM,CAAC,MAAM,gCAAgC,GAAG,MAAM,CAAC,MAAM,CAC7D;IACE,cAAc,EAAE;QACd,uCAAuC;QACvC,8BAA8B;QAC9B,yBAAyB;QACzB,6BAA6B;QAC7B,0BAA0B;QAC1B,4BAA4B;QAC5B,kCAAkC;QAClC,wCAAwC;QACxC,yBAAyB;KAC1B;IACD,WAAW,EAAE;QACX,WAAW,EAAE,cAAc;QAC3B,cAAc,EAAE,iBAAiB;QACjC,eAAe,EAAE,mBAAmB;QACpC,SAAS,EAAE,YAAY;QACvB,KAAK,EAAE,QAAQ;KAChB;IACD,MAAM,EAAE,mCAAmC;IAC3C,UAAU,EAAE;QACV,+BAA+B,EAAE;YAC/B,SAAS,EAAE,QAAQ;SACpB;QACD,iCAAiC,EAAE;YACjC,SAAS,EAAE,QAAQ;SACpB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,QAAQ;SACpB;QACD,6BAA6B,EAAE;YAC7B,SAAS,EAAE,QAAQ;SACpB;KACF;IACD,gBAAgB,EAAE,QAAQ;IAC1B,SAAS,EAAE;QACT,SAAS,EAAE,WAAW;QACtB,iBAAiB,EAAE,KAAK;QACxB,MAAM,EAAE,QAAQ;QAChB,mBAAmB,EAAE,OAAO;QAC5B,MAAM,EAAE,MAAM;QACd,gBAAgB,EAAE,aAAa;QAC/B,OAAO,EAAE,QAAQ;QACjB,UAAU,EAAE,QAAQ;QACpB,QAAQ,EAAE,KAAK;QACf,OAAO,EAAE,QAAQ;QACjB,YAAY,EAAE,OAAO;KACtB;IACD,eAAe,EAAE,sCAAsC;CACxD,CAC6C,CAAC;AAE/C,sCAAsC,CAAC,gCAAgC,CAAC,CAAC","sourcesContent":["// Generated by standards/scripts/generate-conformance-current-release.mjs.\n// Each exact package release verifies its own current runtime contract.\nimport {\n type BrandlabRuntimeReleaseContract,\n validateBrandlabRuntimeReleaseContract,\n} from \"./release-manifest.js\";\n\nexport const BRANDLAB_CURRENT_RUNTIME_RELEASE = Object.freeze(\n{\n \"capabilities\": [\n \"typed-project-business-and-route-data\",\n \"essential-search-foundations\",\n \"shared-contact-delivery\",\n \"privacy-safe-lead-analytics\",\n \"security-patched-runtime\",\n \"live-google-review-summary\",\n \"high-signal-project-verification\",\n \"packed-positive-and-negative-consumers\",\n \"changesets-oidc-release\"\n ],\n \"contracts\": {\n \"analytics\": \"analytics-v4\",\n \"architecture\": \"architecture-v7\",\n \"configuration\": \"project-config-v7\",\n \"contact\": \"contact-v4\",\n \"seo\": \"seo-v4\"\n },\n \"kind\": \"brandlab-runtime-release-contract\",\n \"packages\": {\n \"@brandlab-digital/conformance\": {\n \"version\": \"3.13.0\"\n },\n \"@brandlab-digital/eslint-plugin\": {\n \"version\": \"3.13.0\"\n },\n \"@brandlab-digital/site-core\": {\n \"version\": \"3.13.0\"\n },\n \"@brandlab-digital/site-next\": {\n \"version\": \"3.13.0\"\n }\n },\n \"releaseVersion\": \"3.13.0\",\n \"runtime\": {\n \"bundler\": \"turbopack\",\n \"cacheComponents\": false,\n \"next\": \"16.4.2\",\n \"nextVersionPolicy\": \"exact\",\n \"node\": \"24.x\",\n \"packageManager\": \"npm@11.14.1\",\n \"react\": \"19.2.8\",\n \"reactDom\": \"19.2.8\",\n \"router\": \"app\",\n \"sharp\": \"0.35.5\",\n \"typescript\": \"6.0.3\"\n },\n \"schemaVersion\": \"brandlab.runtime-release-contract.v1\"\n},\n) as unknown as BrandlabRuntimeReleaseContract;\n\nvalidateBrandlabRuntimeReleaseContract(BRANDLAB_CURRENT_RUNTIME_RELEASE);\n"]}
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ export declare const EDGE_PROBE_MAX_URLS = 25;
3
+ export type EdgeProbeKind = "page" | "rsc" | "tree" | "contact";
4
+ export interface EdgeProbeRequest {
5
+ readonly kind: EdgeProbeKind;
6
+ readonly url: string;
7
+ readonly headers: Readonly<Record<string, string>>;
8
+ /** The status the request should answer; only the contact GET has one (405). */
9
+ readonly expectStatus?: number;
10
+ }
11
+ export interface EdgeProbeResult {
12
+ readonly kind: EdgeProbeKind;
13
+ readonly url: string;
14
+ readonly status: number | null;
15
+ readonly cache: string | null;
16
+ readonly age: string | null;
17
+ readonly vercelId: string | null;
18
+ /** The function region from x-vercel-id when the request reached a function. */
19
+ readonly functionRegion: string | null;
20
+ readonly edgeRegion: string | null;
21
+ readonly ttfbMs: number | null;
22
+ readonly problem?: string;
23
+ }
24
+ /** The requests for the given URLs: page, RSC and `_tree` for each, then one contact GET per origin. */
25
+ export declare function planEdgeProbe(urls: readonly string[]): EdgeProbeRequest[];
26
+ /** `syd1::iad1::abc-123` → edge syd1, function iad1; `syd1::abc-123` (served at the edge) → edge syd1, no function. */
27
+ export declare function vercelIdRegions(vercelId: string | null): {
28
+ edge: string | null;
29
+ function: string | null;
30
+ };
31
+ /** One result as a row of the plain-text table. */
32
+ export declare function formatEdgeProbeRow(result: EdgeProbeResult): string;
33
+ type Fetcher = (url: string, init: {
34
+ headers: Record<string, string>;
35
+ redirect: "manual";
36
+ signal?: AbortSignal;
37
+ }) => Promise<{
38
+ status: number;
39
+ headers: {
40
+ get(name: string): string | null;
41
+ };
42
+ body?: {
43
+ cancel(): Promise<void>;
44
+ } | null;
45
+ }>;
46
+ export declare function runEdgeProbe(requests: readonly EdgeProbeRequest[], options?: {
47
+ fetch?: Fetcher;
48
+ now?: () => number;
49
+ }): Promise<EdgeProbeResult[]>;
50
+ export declare function runEdgeProbeCli(argv: readonly string[], io: {
51
+ stdout: (text: string) => void;
52
+ stderr: (text: string) => void;
53
+ }, options?: {
54
+ fetch?: Fetcher;
55
+ }): Promise<number>;
56
+ export {};
57
+ //# sourceMappingURL=edge-probe-cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge-probe-cli.d.ts","sourceRoot":"","sources":["../src/edge-probe-cli.ts"],"names":[],"mappings":";AAgBA,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,SAAS,CAAC;AAEhE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,gFAAgF;IAChF,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,wGAAwG;AACxG,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,gBAAgB,EAAE,CAsBzE;AAED,uHAAuH;AACvH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG;IAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAKzG;AAED,mDAAmD;AACnD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,CAWlE;AAED,KAAK,OAAO,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,KAAK,OAAO,CAAC;IAC3H,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IAC9C,IAAI,CAAC,EAAE;QAAE,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC;CAC3C,CAAC,CAAC;AAEH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,EAAE,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CA8B3J;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,EAAE,EAAE;IAAE,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,EAAE,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAczL"}
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * brandlab-edge-probe (3.13.0): `npm run probe:edge`, run on a laptop, never in CI. For each fixed URL it requests the
4
+ * page, its RSC payload and its `_tree` segment prefetch the way the Next 16 router does, then makes one GET to
5
+ * `/api/contact` on each origin (which answers 405), and logs `x-vercel-cache`, `age`, `x-vercel-id` and the time to
6
+ * first byte. It is the measuring stick for the origin-region benchmark (operations/shared-platform/ORIGIN_REGION.md):
7
+ * `x-vercel-id` reads `<edge>::<function region>::<id>`, so `syd1::iad1::…` on `/api/contact` confirms a Washington
8
+ * function. A handful of requests, never a crawl: at most 25 URLs.
9
+ *
10
+ * npx brandlab-edge-probe https://www.example.com.au/ https://www.example.com.au/services/blocked-drains
11
+ * npx brandlab-edge-probe --json https://www.example.com.au/
12
+ */
13
+ import { realpathSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ export const EDGE_PROBE_MAX_URLS = 25;
17
+ /** The requests for the given URLs: page, RSC and `_tree` for each, then one contact GET per origin. */
18
+ export function planEdgeProbe(urls) {
19
+ if (urls.length === 0)
20
+ throw new Error("Name at least one URL to probe.");
21
+ if (urls.length > EDGE_PROBE_MAX_URLS)
22
+ throw new Error(`Probe at most ${EDGE_PROBE_MAX_URLS} URLs; this is a measurement, not a crawl.`);
23
+ const requests = [];
24
+ const origins = [];
25
+ for (const raw of urls) {
26
+ let url;
27
+ try {
28
+ url = new URL(raw);
29
+ }
30
+ catch {
31
+ throw new Error(`"${raw}" is not an absolute URL such as https://www.example.com.au/services.`);
32
+ }
33
+ if (url.protocol !== "https:" && url.protocol !== "http:")
34
+ throw new Error(`"${raw}" is not an http(s) URL.`);
35
+ url.hash = "";
36
+ const href = url.toString();
37
+ requests.push({ kind: "page", url: href, headers: { accept: "text/html" } });
38
+ requests.push({ kind: "rsc", url: href, headers: { rsc: "1" } });
39
+ requests.push({ kind: "tree", url: href, headers: { rsc: "1", "next-router-prefetch": "1", "next-router-segment-prefetch": "/_tree" } });
40
+ if (!origins.includes(url.origin))
41
+ origins.push(url.origin);
42
+ }
43
+ for (const origin of origins)
44
+ requests.push({ kind: "contact", url: `${origin}/api/contact`, headers: { accept: "application/json" }, expectStatus: 405 });
45
+ return requests;
46
+ }
47
+ /** `syd1::iad1::abc-123` → edge syd1, function iad1; `syd1::abc-123` (served at the edge) → edge syd1, no function. */
48
+ export function vercelIdRegions(vercelId) {
49
+ if (!vercelId)
50
+ return { edge: null, function: null };
51
+ const parts = vercelId.split("::");
52
+ const region = (part) => (part && /^[a-z]{3}\d$/.test(part) ? part : null);
53
+ return { edge: region(parts[0]), function: parts.length >= 3 ? region(parts[1]) : null };
54
+ }
55
+ /** One result as a row of the plain-text table. */
56
+ export function formatEdgeProbeRow(result) {
57
+ const cells = [
58
+ result.kind.padEnd(7),
59
+ String(result.status ?? "-").padEnd(4),
60
+ (result.cache ?? "-").padEnd(8),
61
+ `age ${result.age ?? "-"}`.padEnd(10),
62
+ `${result.ttfbMs === null ? "-" : `${result.ttfbMs} ms`}`.padStart(8),
63
+ `${result.edgeRegion ?? "-"}${result.functionRegion ? ` -> ${result.functionRegion}` : ""}`.padEnd(14),
64
+ result.url,
65
+ ];
66
+ return `${cells.join(" ")}${result.problem ? ` (${result.problem})` : ""}`;
67
+ }
68
+ export async function runEdgeProbe(requests, options = {}) {
69
+ const fetcher = options.fetch ?? globalThis.fetch;
70
+ const now = options.now ?? (() => performance.now());
71
+ const results = [];
72
+ for (const request of requests) {
73
+ const started = now();
74
+ try {
75
+ const response = await fetcher(request.url, { headers: { ...request.headers, "user-agent": "brandlab-edge-probe" }, redirect: "manual", signal: AbortSignal.timeout(30_000) });
76
+ const ttfbMs = Math.round(now() - started);
77
+ await response.body?.cancel().catch(() => undefined);
78
+ const vercelId = response.headers.get("x-vercel-id");
79
+ const regions = vercelIdRegions(vercelId);
80
+ const problem = request.expectStatus !== undefined && response.status !== request.expectStatus ? `expected ${request.expectStatus}` : undefined;
81
+ results.push({
82
+ kind: request.kind,
83
+ url: request.url,
84
+ status: response.status,
85
+ cache: response.headers.get("x-vercel-cache"),
86
+ age: response.headers.get("age"),
87
+ vercelId,
88
+ functionRegion: regions.function,
89
+ edgeRegion: regions.edge,
90
+ ttfbMs,
91
+ ...(problem ? { problem } : {}),
92
+ });
93
+ }
94
+ catch (error) {
95
+ results.push({ kind: request.kind, url: request.url, status: null, cache: null, age: null, vercelId: null, functionRegion: null, edgeRegion: null, ttfbMs: null, problem: error instanceof Error ? error.message : String(error) });
96
+ }
97
+ }
98
+ return results;
99
+ }
100
+ export async function runEdgeProbeCli(argv, io, options = {}) {
101
+ try {
102
+ const json = argv.includes("--json");
103
+ const urls = argv.filter((argument) => argument !== "--json");
104
+ const unknown = urls.find((argument) => argument.startsWith("--"));
105
+ if (unknown)
106
+ throw new Error(`Unknown option ${unknown}. Usage: brandlab-edge-probe [--json] <url>...`);
107
+ const results = await runEdgeProbe(planEdgeProbe(urls), options);
108
+ if (json)
109
+ io.stdout(`${JSON.stringify(results, null, 2)}\n`);
110
+ else
111
+ io.stdout(`${results.map(formatEdgeProbeRow).join("\n")}\n`);
112
+ return results.some((result) => result.problem) ? 1 : 0;
113
+ }
114
+ catch (error) {
115
+ io.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
116
+ return 2;
117
+ }
118
+ }
119
+ function isEntrypoint(moduleUrl, argvPath) {
120
+ if (!argvPath)
121
+ return false;
122
+ try {
123
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(resolve(argvPath));
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ if (isEntrypoint(import.meta.url, process.argv[1])) {
130
+ runEdgeProbeCli(process.argv.slice(2), { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value) }).then((code) => {
131
+ process.exitCode = code;
132
+ });
133
+ }
134
+ //# sourceMappingURL=edge-probe-cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edge-probe-cli.js","sourceRoot":"","sources":["../src/edge-probe-cli.ts"],"names":[],"mappings":";AACA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,MAAM,CAAC,MAAM,mBAAmB,GAAG,EAAE,CAAC;AA0BtC,wGAAwG;AACxG,MAAM,UAAU,aAAa,CAAC,IAAuB;IACnD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;IAC1E,IAAI,IAAI,CAAC,MAAM,GAAG,mBAAmB;QAAE,MAAM,IAAI,KAAK,CAAC,iBAAiB,mBAAmB,4CAA4C,CAAC,CAAC;IACzI,MAAM,QAAQ,GAAuB,EAAE,CAAC;IACxC,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,IAAI,GAAQ,CAAC;QACb,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,IAAI,KAAK,CAAC,IAAI,GAAG,uEAAuE,CAAC,CAAC;QAClG,CAAC;QACD,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,IAAI,GAAG,CAAC,QAAQ,KAAK,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,IAAI,GAAG,0BAA0B,CAAC,CAAC;QAC9G,GAAG,CAAC,IAAI,GAAG,EAAE,CAAC;QACd,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,EAAE,CAAC;QAC5B,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE,CAAC,CAAC;QAC7E,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;QACjE,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,sBAAsB,EAAE,GAAG,EAAE,8BAA8B,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;QACzI,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9D,CAAC;IACD,KAAK,MAAM,MAAM,IAAI,OAAO;QAAE,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,GAAG,MAAM,cAAc,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,YAAY,EAAE,GAAG,EAAE,CAAC,CAAC;IAC3J,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,uHAAuH;AACvH,MAAM,UAAU,eAAe,CAAC,QAAuB;IACrD,IAAI,CAAC,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IACrD,MAAM,KAAK,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACnC,MAAM,MAAM,GAAG,CAAC,IAAwB,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC/F,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;AAC3F,CAAC;AAED,mDAAmD;AACnD,MAAM,UAAU,kBAAkB,CAAC,MAAuB;IACxD,MAAM,KAAK,GAAG;QACZ,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;QACrB,MAAM,CAAC,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;QACtC,CAAC,MAAM,CAAC,KAAK,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;QAC/B,OAAO,MAAM,CAAC,GAAG,IAAI,GAAG,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC;QACrC,GAAG,MAAM,CAAC,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,KAAK,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;QACrE,GAAG,MAAM,CAAC,UAAU,IAAI,GAAG,GAAG,MAAM,CAAC,cAAc,CAAC,CAAC,CAAC,OAAO,MAAM,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC;QACtG,MAAM,CAAC,GAAG;KACX,CAAC;IACF,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,MAAM,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;AAC/E,CAAC;AAQD,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,QAAqC,EAAE,UAAmD,EAAE;IAC7H,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,IAAK,UAAU,CAAC,KAA4B,CAAC;IAC1E,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC,CAAC;IACrD,MAAM,OAAO,GAAsB,EAAE,CAAC;IACtC,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC;QACtB,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,YAAY,EAAE,qBAAqB,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YAC/K,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC;YAC3C,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YACrD,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;YACrD,MAAM,OAAO,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;YAC1C,MAAM,OAAO,GAAG,OAAO,CAAC,YAAY,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC,YAAY,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;YAChJ,OAAO,CAAC,IAAI,CAAC;gBACX,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,GAAG,EAAE,OAAO,CAAC,GAAG;gBAChB,MAAM,EAAE,QAAQ,CAAC,MAAM;gBACvB,KAAK,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC;gBAC7C,GAAG,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC;gBAChC,QAAQ;gBACR,cAAc,EAAE,OAAO,CAAC,QAAQ;gBAChC,UAAU,EAAE,OAAO,CAAC,IAAI;gBACxB,MAAM;gBACN,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChC,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QACtO,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAuB,EAAE,EAAsE,EAAE,UAA+B,EAAE;IACtK,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QACrC,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC;QAC9D,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC;QACnE,IAAI,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,kBAAkB,OAAO,gDAAgD,CAAC,CAAC;QACxG,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,CAAC;QACjE,IAAI,IAAI;YAAE,EAAE,CAAC,MAAM,CAAC,GAAG,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC;;YACxD,EAAE,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClE,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,EAAE,CAAC,MAAM,CAAC,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACzE,OAAO,CAAC,CAAC;IACX,CAAC;AACH,CAAC;AAED,SAAS,YAAY,CAAC,SAAiB,EAAE,QAA4B;IACnE,IAAI,CAAC,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5B,IAAI,CAAC;QACH,OAAO,YAAY,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,KAAK,YAAY,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IACpF,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,IAAI,YAAY,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IACnD,eAAe,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE;QACvJ,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAC1B,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["#!/usr/bin/env node\n/**\n * brandlab-edge-probe (3.13.0): `npm run probe:edge`, run on a laptop, never in CI. For each fixed URL it requests the\n * page, its RSC payload and its `_tree` segment prefetch the way the Next 16 router does, then makes one GET to\n * `/api/contact` on each origin (which answers 405), and logs `x-vercel-cache`, `age`, `x-vercel-id` and the time to\n * first byte. It is the measuring stick for the origin-region benchmark (operations/shared-platform/ORIGIN_REGION.md):\n * `x-vercel-id` reads `<edge>::<function region>::<id>`, so `syd1::iad1::…` on `/api/contact` confirms a Washington\n * function. A handful of requests, never a crawl: at most 25 URLs.\n *\n * npx brandlab-edge-probe https://www.example.com.au/ https://www.example.com.au/services/blocked-drains\n * npx brandlab-edge-probe --json https://www.example.com.au/\n */\nimport { realpathSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport const EDGE_PROBE_MAX_URLS = 25;\n\nexport type EdgeProbeKind = \"page\" | \"rsc\" | \"tree\" | \"contact\";\n\nexport interface EdgeProbeRequest {\n readonly kind: EdgeProbeKind;\n readonly url: string;\n readonly headers: Readonly<Record<string, string>>;\n /** The status the request should answer; only the contact GET has one (405). */\n readonly expectStatus?: number;\n}\n\nexport interface EdgeProbeResult {\n readonly kind: EdgeProbeKind;\n readonly url: string;\n readonly status: number | null;\n readonly cache: string | null;\n readonly age: string | null;\n readonly vercelId: string | null;\n /** The function region from x-vercel-id when the request reached a function. */\n readonly functionRegion: string | null;\n readonly edgeRegion: string | null;\n readonly ttfbMs: number | null;\n readonly problem?: string;\n}\n\n/** The requests for the given URLs: page, RSC and `_tree` for each, then one contact GET per origin. */\nexport function planEdgeProbe(urls: readonly string[]): EdgeProbeRequest[] {\n if (urls.length === 0) throw new Error(\"Name at least one URL to probe.\");\n if (urls.length > EDGE_PROBE_MAX_URLS) throw new Error(`Probe at most ${EDGE_PROBE_MAX_URLS} URLs; this is a measurement, not a crawl.`);\n const requests: EdgeProbeRequest[] = [];\n const origins: string[] = [];\n for (const raw of urls) {\n let url: URL;\n try {\n url = new URL(raw);\n } catch {\n throw new Error(`\"${raw}\" is not an absolute URL such as https://www.example.com.au/services.`);\n }\n if (url.protocol !== \"https:\" && url.protocol !== \"http:\") throw new Error(`\"${raw}\" is not an http(s) URL.`);\n url.hash = \"\";\n const href = url.toString();\n requests.push({ kind: \"page\", url: href, headers: { accept: \"text/html\" } });\n requests.push({ kind: \"rsc\", url: href, headers: { rsc: \"1\" } });\n requests.push({ kind: \"tree\", url: href, headers: { rsc: \"1\", \"next-router-prefetch\": \"1\", \"next-router-segment-prefetch\": \"/_tree\" } });\n if (!origins.includes(url.origin)) origins.push(url.origin);\n }\n for (const origin of origins) requests.push({ kind: \"contact\", url: `${origin}/api/contact`, headers: { accept: \"application/json\" }, expectStatus: 405 });\n return requests;\n}\n\n/** `syd1::iad1::abc-123` → edge syd1, function iad1; `syd1::abc-123` (served at the edge) → edge syd1, no function. */\nexport function vercelIdRegions(vercelId: string | null): { edge: string | null; function: string | null } {\n if (!vercelId) return { edge: null, function: null };\n const parts = vercelId.split(\"::\");\n const region = (part: string | undefined) => (part && /^[a-z]{3}\\d$/.test(part) ? part : null);\n return { edge: region(parts[0]), function: parts.length >= 3 ? region(parts[1]) : null };\n}\n\n/** One result as a row of the plain-text table. */\nexport function formatEdgeProbeRow(result: EdgeProbeResult): string {\n const cells = [\n result.kind.padEnd(7),\n String(result.status ?? \"-\").padEnd(4),\n (result.cache ?? \"-\").padEnd(8),\n `age ${result.age ?? \"-\"}`.padEnd(10),\n `${result.ttfbMs === null ? \"-\" : `${result.ttfbMs} ms`}`.padStart(8),\n `${result.edgeRegion ?? \"-\"}${result.functionRegion ? ` -> ${result.functionRegion}` : \"\"}`.padEnd(14),\n result.url,\n ];\n return `${cells.join(\" \")}${result.problem ? ` (${result.problem})` : \"\"}`;\n}\n\ntype Fetcher = (url: string, init: { headers: Record<string, string>; redirect: \"manual\"; signal?: AbortSignal }) => Promise<{\n status: number;\n headers: { get(name: string): string | null };\n body?: { cancel(): Promise<void> } | null;\n}>;\n\nexport async function runEdgeProbe(requests: readonly EdgeProbeRequest[], options: { fetch?: Fetcher; now?: () => number } = {}): Promise<EdgeProbeResult[]> {\n const fetcher = options.fetch ?? (globalThis.fetch as unknown as Fetcher);\n const now = options.now ?? (() => performance.now());\n const results: EdgeProbeResult[] = [];\n for (const request of requests) {\n const started = now();\n try {\n const response = await fetcher(request.url, { headers: { ...request.headers, \"user-agent\": \"brandlab-edge-probe\" }, redirect: \"manual\", signal: AbortSignal.timeout(30_000) });\n const ttfbMs = Math.round(now() - started);\n await response.body?.cancel().catch(() => undefined);\n const vercelId = response.headers.get(\"x-vercel-id\");\n const regions = vercelIdRegions(vercelId);\n const problem = request.expectStatus !== undefined && response.status !== request.expectStatus ? `expected ${request.expectStatus}` : undefined;\n results.push({\n kind: request.kind,\n url: request.url,\n status: response.status,\n cache: response.headers.get(\"x-vercel-cache\"),\n age: response.headers.get(\"age\"),\n vercelId,\n functionRegion: regions.function,\n edgeRegion: regions.edge,\n ttfbMs,\n ...(problem ? { problem } : {}),\n });\n } catch (error) {\n results.push({ kind: request.kind, url: request.url, status: null, cache: null, age: null, vercelId: null, functionRegion: null, edgeRegion: null, ttfbMs: null, problem: error instanceof Error ? error.message : String(error) });\n }\n }\n return results;\n}\n\nexport async function runEdgeProbeCli(argv: readonly string[], io: { stdout: (text: string) => void; stderr: (text: string) => void }, options: { fetch?: Fetcher } = {}): Promise<number> {\n try {\n const json = argv.includes(\"--json\");\n const urls = argv.filter((argument) => argument !== \"--json\");\n const unknown = urls.find((argument) => argument.startsWith(\"--\"));\n if (unknown) throw new Error(`Unknown option ${unknown}. Usage: brandlab-edge-probe [--json] <url>...`);\n const results = await runEdgeProbe(planEdgeProbe(urls), options);\n if (json) io.stdout(`${JSON.stringify(results, null, 2)}\\n`);\n else io.stdout(`${results.map(formatEdgeProbeRow).join(\"\\n\")}\\n`);\n return results.some((result) => result.problem) ? 1 : 0;\n } catch (error) {\n io.stderr(`${error instanceof Error ? error.message : String(error)}\\n`);\n return 2;\n }\n}\n\nfunction isEntrypoint(moduleUrl: string, argvPath: string | undefined): boolean {\n if (!argvPath) return false;\n try {\n return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(resolve(argvPath));\n } catch {\n return false;\n }\n}\n\nif (isEntrypoint(import.meta.url, process.argv[1])) {\n runEdgeProbeCli(process.argv.slice(2), { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value) }).then((code) => {\n process.exitCode = code;\n });\n}\n"]}
@@ -17,7 +17,10 @@ export interface EngineeringCheck {
17
17
  coverage: string;
18
18
  outstandingEvidence: readonly string[];
19
19
  }
20
- /** The Vercel function region of every Brandlab site (3.2.1): Sydney. */
20
+ /**
21
+ * The default Vercel function region (3.2.1): Sydney. From 3.13.0 a site may record another in brandlab.config.ts
22
+ * (`hosting.originRegion`) with an origin-region-v1 decision; `vercel.json` regions must equal the recorded value.
23
+ */
21
24
  export declare const BRANDLAB_FUNCTION_REGION = "syd1";
22
25
  /**
23
26
  * The Vercel ignored build step of every Brandlab site (3.3.3). Exit 0 skips a deployment, anything else builds it:
@@ -1 +1 @@
1
- {"version":3,"file":"engineering.d.ts","sourceRoot":"","sources":["../src/engineering.ts"],"names":[],"mappings":"AAmBA,eAAO,MAAM,yBAAyB,sGAAuG,CAAC;AAE9I,oFAAoF;AACpF,eAAO,MAAM,+BAA+B,4IAC+F,CAAC;AAE5I,iFAAiF;AACjF,eAAO,MAAM,yBAAyB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASrE,CAAC;AAEH,MAAM,MAAM,0BAA0B,GAAG,SAAS,GAAG,MAAM,CAAC;AAC5D,MAAM,WAAW,kBAAkB;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,0BAA0B,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE;AACxI,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAqBD,yEAAyE;AACzE,eAAO,MAAM,wBAAwB,SAAS,CAAC;AAC/C;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,iOAC6K,CAAC;AAqBzN,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAyBxE;AAwCD,uDAAuD;AACvD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAE3D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,EAAE,GAAG,IAAI,CA2B5G"}
1
+ {"version":3,"file":"engineering.d.ts","sourceRoot":"","sources":["../src/engineering.ts"],"names":[],"mappings":"AAqBA,eAAO,MAAM,yBAAyB,sGAAuG,CAAC;AAE9I,oFAAoF;AACpF,eAAO,MAAM,+BAA+B,4IAC+F,CAAC;AAE5I,iFAAiF;AACjF,eAAO,MAAM,yBAAyB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASrE,CAAC;AAEH,MAAM,MAAM,0BAA0B,GAAG,SAAS,GAAG,MAAM,CAAC;AAC5D,MAAM,WAAW,kBAAkB;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,0BAA0B,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE;AACxI,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,QAAQ,EAAE,kBAAkB,EAAE,CAAC;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;CACxC;AAqBD;;;GAGG;AACH,eAAO,MAAM,wBAAwB,SAAS,CAAC;AAC/C;;;;;GAKG;AACH,eAAO,MAAM,8BAA8B,iOAC6K,CAAC;AAqBzN,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAyBxE;AAwCD,uDAAuD;AACvD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAE3D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,EAAE,GAAG,IAAI,CA2B5G"}
@@ -4,6 +4,8 @@ import ts from 'typescript';
4
4
  import { globMatch } from './glob.js';
5
5
  import { collectBrandlabSource, createBrandlabModuleResolver, findImportedCalls, isDirectory, lineOfNode, listModuleReferences, objectPropertyValue, parseBrandlabSourceFile, walkProjectFiles, } from './source-files.js';
6
6
  import { describeCapabilities } from './static-rules.js';
7
+ import { originRegionProblems } from './origin-region.js';
8
+ import { parseProjectHosting } from './site-system/config.js';
7
9
  export const ENGINEERING_FAST_COMMANDS = ['typecheck', 'lint', 'test', 'check:unused', 'check:unused:production', 'check:standards'];
8
10
  /** The exact fast verification command from the Brandlab 3.0 target (section 6). */
9
11
  export const BRANDLAB_EXPECTED_VERIFY_SCRIPT = 'npm run check:standards && npm run typecheck && npm run lint && npm run test && npm run check:unused && npm run check:unused:production';
@@ -29,7 +31,10 @@ const CODE_OR_STYLE = /\.(?:[cm]?[jt]sx?|mdx|css)$/;
29
31
  const PASCAL_COMPONENT = /^[A-Z][A-Za-z0-9]*\.tsx$/;
30
32
  const CAMEL_MODULE = /^[a-z][A-Za-z0-9]*(?:\.d)?\.ts$/;
31
33
  const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
32
- /** The Vercel function region of every Brandlab site (3.2.1): Sydney. */
34
+ /**
35
+ * The default Vercel function region (3.2.1): Sydney. From 3.13.0 a site may record another in brandlab.config.ts
36
+ * (`hosting.originRegion`) with an origin-region-v1 decision; `vercel.json` regions must equal the recorded value.
37
+ */
33
38
  export const BRANDLAB_FUNCTION_REGION = 'syd1';
34
39
  /**
35
40
  * The Vercel ignored build step of every Brandlab site (3.3.3). Exit 0 skips a deployment, anything else builds it:
@@ -79,7 +84,7 @@ export function checkEngineeringStructure(root) {
79
84
  inspectServerBoundaries(root, capabilities, source, add);
80
85
  inspectTests(root, capabilities, add);
81
86
  inspectToolPresets(root, add);
82
- inspectDelivery(root, add);
87
+ inspectDelivery(root, add, config);
83
88
  return { status: findings.some(finding => finding.severity === 'blocker') ? 'blocked' : 'pass', findings,
84
89
  coverage: 'Static layout, naming, command, dependency-pin, tool-preset, CI, deployment and documentation structure only. This is not engineering-standard completion.',
85
90
  outstandingEvidence: ['Usage and deletion review including assets, configuration and dynamic references', 'Business data and component/style ownership review', 'Clean install, fast checks and production build', 'Full discovered technical URL estate', 'All distinct page families and important browser journeys', 'Exact-source protected Preview', 'Reporting/Ads provider cutover and authorised production verification'] };
@@ -637,12 +642,15 @@ function inspectToolPresets(root, add) {
637
642
  }
638
643
  }
639
644
  // --- CI, dependencies, Vercel and docs (section 6) ---------------------------
640
- function inspectDelivery(root, add) {
645
+ /** A day before any decision, so a recorded decision's reviewBy never counts as passed. */
646
+ const ORIGIN_DECISION_NEVER_LAPSED = '0000-01-01';
647
+ function inspectDelivery(root, add, config) {
641
648
  const workflowDirectory = join(root, '.github/workflows');
642
649
  const workflows = isDirectory(workflowDirectory) ? readdirSync(workflowDirectory).filter(name => /\.ya?ml$/.test(name)).sort() : [];
643
- const callers = workflows.filter(name => /uses\s*:\s*['"]?RomieDandan\/brandlab\/\.github\/workflows\/verify-next\.yml@/.test(readFileSync(join(workflowDirectory, name), 'utf8')));
650
+ // CI v2 (verify-next-v2.yml, 3.13.0) is the same verifier with draft, ready-for-review and merge depths.
651
+ const callers = workflows.filter(name => /uses\s*:\s*['"]?RomieDandan\/brandlab\/\.github\/workflows\/verify-next(?:-v2)?\.yml@/.test(readFileSync(join(workflowDirectory, name), 'utf8')));
644
652
  if (callers.length !== 1 || callers[0] !== 'verify.yml') {
645
- add('.github/workflows/verify.yml', 'ci-workflow', `Exactly one .github/workflows/verify.yml calls RomieDandan/brandlab/.github/workflows/verify-next.yml at the fleet SHA (found ${callers.length ? callers.join(', ') : 'none'}).`);
653
+ add('.github/workflows/verify.yml', 'ci-workflow', `Exactly one .github/workflows/verify.yml calls RomieDandan/brandlab/.github/workflows/verify-next.yml (or verify-next-v2.yml) at the fleet SHA (found ${callers.length ? callers.join(', ') : 'none'}).`);
646
654
  }
647
655
  for (const path of ['.github/dependabot.yml', '.github/dependabot.yaml', 'renovate.json', 'renovate.json5', '.github/renovate.json', '.renovaterc', '.renovaterc.json']) {
648
656
  if (existsSync(join(root, path)))
@@ -658,15 +666,12 @@ function inspectDelivery(root, add) {
658
666
  }
659
667
  if (installCommand !== 'npm ci')
660
668
  add('vercel.json', 'vercel-install', 'vercel.json sets installCommand to "npm ci".');
661
- let regions;
662
- try {
663
- regions = vercel === null ? undefined : JSON.parse(vercel).regions;
664
- }
665
- catch {
666
- regions = undefined;
667
- }
668
- if (!Array.isArray(regions) || regions.length !== 1 || regions[0] !== BRANDLAB_FUNCTION_REGION) {
669
- add('vercel.json', 'vercel-region', `vercel.json runs the server functions in Sydney: "regions": ["${BRANDLAB_FUNCTION_REGION}"] (found ${regions === undefined ? 'no regions' : JSON.stringify(regions)}). The visitors are in Australia; a Washington (iad1) function adds a Pacific round trip to every request it serves.`);
669
+ // W5 (3.13.0): the regions equal the recorded origin region, Sydney unless an origin-region-v1 decision says otherwise.
670
+ // This check runs inside every site's `npm run verify`, so it never judges the date: a lapsed reviewBy must not stop
671
+ // a build, a fix or a rollback. The lapse fails only the ready gate's W5 (as site-core's config validation does).
672
+ const hosting = config === null ? undefined : parseProjectHosting(config);
673
+ for (const problem of originRegionProblems({ hosting, vercelJson: vercel, today: ORIGIN_DECISION_NEVER_LAPSED })) {
674
+ add(problem.startsWith('vercel.json') ? 'vercel.json' : 'brandlab.config.ts', 'vercel-region', problem);
670
675
  }
671
676
  let ignoreCommand;
672
677
  try {