@bygghammar/cms-platform 0.93.0 → 0.95.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 (40) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +9 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/admin/module-inventory.ts +67 -0
  6. package/src/admin/registry-metadata.ts +256 -0
  7. package/src/admin/semver.ts +111 -0
  8. package/src/admin/version-core.ts +233 -25
  9. package/src/admin/version-report.ts +295 -0
  10. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  11. package/src/backoffice/NavigationView.tsx +38 -0
  12. package/src/backoffice/PageBuilderView.tsx +149 -3
  13. package/src/backoffice/PagesListView.tsx +173 -57
  14. package/src/backoffice/SiteSettingsView.tsx +4 -0
  15. package/src/backoffice/StatusBadge.tsx +38 -2
  16. package/src/backoffice/VersionView.tsx +747 -66
  17. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  18. package/src/backoffice/routes/host.ts +24 -0
  19. package/src/backoffice/routes/index.tsx +148 -9
  20. package/src/backoffice/routes/pages.tsx +13 -0
  21. package/src/backoffice/shell/use-resolved-config.tsx +30 -5
  22. package/src/cms/booking-links.ts +176 -0
  23. package/src/cms/page-publication.ts +11 -6
  24. package/src/cms/page-visibility.ts +206 -0
  25. package/src/config/modules.ts +11 -0
  26. package/src/html/sanitize-html.ts +1 -1
  27. package/src/navigation/visibility.ts +235 -0
  28. package/src/popups/package-name.ts +8 -0
  29. package/src/popups/runtime.tsx +155 -4
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/published-cache.ts +23 -3
  33. package/src/server/schemas.ts +17 -0
  34. package/src/site/PageRenderer.tsx +34 -5
  35. package/src/site/SiteFooter.tsx +11 -2
  36. package/src/site/SiteHeader.tsx +41 -21
  37. package/src/site/link-visibility.tsx +28 -0
  38. package/src/site/route-aliases.ts +74 -0
  39. package/src/site/routes/preview-page.tsx +39 -3
  40. package/src/site/routes/public-page.tsx +81 -9
package/CHANGELOG.md CHANGED
@@ -8,6 +8,147 @@ matches `package.json` exactly. See
8
8
  Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
9
9
  from the published npm tarballs.
10
10
 
11
+ ## 0.95.0
12
+
13
+ - **Pages can be link-only, and unpublished from the page itself.** Listing is
14
+ a second property beside publication, and neither changes the other:
15
+ - **Make link-only / Show on website** (`setPageListingCore`). A link-only
16
+ page stays online at its address but is left out of menus, the footer and
17
+ the sitemap, and gets `noindex`. No publication, version, schedule or
18
+ status is written, so "Show on website" cannot publish an offline page,
19
+ and saving, publishing and scheduled replacements keep a page link-only.
20
+ Link-only is not access control.
21
+ - **Unpublish** (the existing `cms_set_page_status`) is also offered in the
22
+ builder header. Both actions are in the page builder and the pages list,
23
+ and ask for confirmation with the consequences spelled out in full.
24
+ - **Protected pages.** A host passes `protectedPageKeys` in the page deps
25
+ (Voyara: the start page and the contact page). The server refuses to
26
+ unpublish, end-date, trash or unlist them. This is an application
27
+ safeguard; direct database access is not blocked by it.
28
+ - Offline states read `Offline`, `Scheduled to go offline` and
29
+ `Offline after expiry` (previously "Hidden…"), so they cannot be confused
30
+ with link-only.
31
+ - A copy of a link-only page starts link-only. A same-key retry completes a
32
+ copy whose listing write failed, with the original decision, and never
33
+ changes a finished copy: the decision is applied only if the database
34
+ recorded it before the copy existed. Booking exceptions are never copied.
35
+ - **A site-wide booking switch, with exception pages.** One switch
36
+ (`setBookingEnabledCore`, Booking card: `./backoffice/BookingSettingsCard`)
37
+ hides every link to a host starting with `booking` (`isBookingHref`) in page
38
+ content, the menu, the footer and popups, plus a header CTA the host marks
39
+ `booking: true`. Pages marked as exceptions keep the booking links in their
40
+ own content. Stored content is never changed: hosts remove booking from data
41
+ before it leaves the server (`stripBookingDeep`, `withoutBookingPopups`),
42
+ and the page renderer can apply the same removal to whatever the browser
43
+ cache holds.
44
+ - **One public policy for publication, listing and booking**
45
+ (`loadPublicPolicyCore`, `./cms/page-visibility`). It is read in one
46
+ snapshot and cached with the published-page cache. A read begun before an
47
+ invalidation can no longer refill the cache with the old answer. A failed
48
+ read throws and is not cached, and hosts fail closed.
49
+ - Hosts send the chrome only the hidden addresses the menu and footer link
50
+ to (`routeAliasMap` from `./site/route-aliases`; `restrictToLinks` and
51
+ `savedLinkHrefs` from `./navigation/visibility`) and provide them through
52
+ `LinkVisibilityProvider` (`./site/link-visibility`). The saved menu is
53
+ never changed.
54
+ - The public page route takes a per-page `pagePolicy`: an offline page
55
+ answers 404 even when the browser has it cached, a cached "not found" is
56
+ re-read once the page is live, and a non-indexable page gets `noindex`.
57
+ `PageRenderer` accepts an optional `transform` and `variant`, so the
58
+ current policy decides over a warm page cache.
59
+ - **Popups follow the booking switch without losing anything else.** The
60
+ popup module is no longer remounted when the policy changes. While booking
61
+ is hidden, booking popups are kept paused, with their booking links and
62
+ actions taken out, so an open one stays readable and closes normally. The
63
+ restriction applies at once from what is already loaded, without waiting for
64
+ a refresh, and a slow, failed or late refresh cannot bring booking back.
65
+ An open newsletter form keeps its field, text and focus, its trigger timers,
66
+ counts and analytics.
67
+ - **Storage: no migration.** One `site_settings` row per decision
68
+ (`page.listing.<uuid>`, `booking`, `booking.exception.<uuid>`, and a
69
+ short-lived `duplicate.intent.<key>` during a duplication). Every write is a
70
+ compare-and-swap on `updated_at` (`VisibilityConflictError`). None of these
71
+ keys can be reached by the generic settings save or reset.
72
+ - **New exports:**
73
+ - `./cms/page-visibility`;
74
+ - `./cms/booking-links`;
75
+ - `./server/page-visibility-core`;
76
+ - `./navigation/visibility`;
77
+ - `./site/link-visibility`;
78
+ - `./site/route-aliases`;
79
+ - `./backoffice/BookingSettingsCard`.
80
+
81
+ The new host wiring is optional (`pages.fns.setPageListing`, `booking.fns`,
82
+ `useLinkVisibility`); without it the new actions and the Booking card are
83
+ not shown. See `docs/PAGES.md`.
84
+
85
+ ## 0.94.0
86
+
87
+ - **The version screen shows what is actually deployed and installed.**
88
+ `/admin/version`, now "Version & modules", no longer labels the site's
89
+ `package.json` version as the "CMS build". It shows:
90
+ - the release identity from the runtime `APP_VERSION` (falling back to
91
+ `VITE_PUBLIC_APP_VERSION`, the values the health check reports), short,
92
+ in full and with a copy control, or "Unknown";
93
+ - the build time, labelled as such, and a deployment time only when the
94
+ host supplies real evidence; otherwise "Not recorded";
95
+ - the installed CMS package and module packages: installed and declared
96
+ versions, publication dates, the newest version in their channel and an
97
+ advisory status;
98
+ - every module from the module table, with its enabled status read from
99
+ the same saved settings as Site settings. A loading or failed settings
100
+ read is shown as such, never as the compiled defaults;
101
+ - recent CMS releases, and the packaged schema figures labelled as package
102
+ facts rather than a check of the live database.
103
+
104
+ The screen never changes settings.
105
+ - **Update policy.** Versions are compared by semver precedence, not as
106
+ strings or by publication date.
107
+ - A release install follows stable releases.
108
+ - A prerelease install follows its prerelease line (`0.2.0-m7`) and is
109
+ reported as "Prerelease installed", not "Up to date".
110
+ - Unknown, malformed, unpublished or ahead-of-registry versions, and any
111
+ case without a registry answer, are never "Up to date".
112
+ - **Registry reads are bounded and server-side.** `admin/registry-metadata`
113
+ looks up only the allowlisted first-party packages (the CMS package plus
114
+ packages named by modules) in the public npm registry:
115
+ - anonymous, refusing redirects, with a 5 s timeout and an 8 MiB cap;
116
+ - a 15-minute cache, and at most one attempt per package per minute,
117
+ manual "Check again" included;
118
+ - shared in-flight requests.
119
+
120
+ `fetchPlatformReleases` now goes through the same client.
121
+ - **Failed checks are never shown as current.**
122
+ - When a later registry attempt fails, the last good answer stays on
123
+ screen marked "Not re-verified", with its success and failure times.
124
+ - A package that has never been checked successfully says so.
125
+ - A failed "Check again" or re-read shows an alert and keeps the last
126
+ answer, marked as not re-verified, without the raw error.
127
+ - The next successful answer clears these notices.
128
+ - **New contract pieces (additive):**
129
+ - in `admin/version-core`: `buildVersionReport`, `readReleaseIdentity`,
130
+ `readEnvironment`, `readInstalledPackages`, `releasesFromReport`,
131
+ `FIRST_PARTY_PACKAGES` and `platformRegistryClient`;
132
+ - the `./admin/version-report` export: report types,
133
+ `evaluatePackageUpdate` and `summarizeUpdates`;
134
+ - an optional `package` field on `CmsModuleDefinition` (Popups declares
135
+ `@bygghammar/lightweight-popup`);
136
+ - `status` from `useResolvedSiteConfig()`;
137
+ - optional `report`, `modules`, `latestCheckFailed` and `onRefresh` props
138
+ on `VersionView`.
139
+
140
+ A host whose `getSystemVersion` still returns only `{ build, releases }`
141
+ keeps rendering, with the release shown as unknown and no update check.
142
+ - **Host side:**
143
+ - protect `getSystemVersion` with the site's auth middleware and
144
+ `assertStaff`;
145
+ - accept `{ refresh?: boolean }` and pass it to `buildVersionReport`;
146
+ - inject the installed package list as the `packages` build constant
147
+ (`[{ name, installed, declared, requiredByCms }]`, read from
148
+ `node_modules` at build time).
149
+
150
+ Voyara's adoption does all three.
151
+
11
152
  ## 0.93.0
12
153
 
13
154
  - **Breaking for callers of `responsiveImage`, and for transparent images in
package/docs/PAGES.md CHANGED
@@ -92,11 +92,15 @@ show the state the rule produces — `publicationStateAt` over the page's own
92
92
 
93
93
  | State | Badge | Means |
94
94
  | --- | --- | --- |
95
- | `draft` | Hidden | Nothing open, nothing scheduled |
96
- | `scheduled` | Scheduled to publish | Hidden now; a window opens later |
95
+ | `draft` | Offline | Nothing open, nothing scheduled |
96
+ | `scheduled` | Scheduled to publish | Offline now; a window opens later |
97
97
  | `published` | Published | A visitor can open it right now |
98
- | `ending` | Scheduled to hide | Public, with an end date already set |
99
- | `expired` | Hidden after expiry | Was public; the window has closed |
98
+ | `ending` | Scheduled to go offline | Public, with an end date already set |
99
+ | `expired` | Offline after expiry | Was public; the window has closed |
100
+
101
+ (These read "Hidden", "Scheduled to hide" and "Hidden after expiry" until
102
+ listing was added: a link-only page is *online* but unlisted, and the two must
103
+ not share a word. See [Listing, link-only pages and booking](#listing-link-only-pages-and-booking).)
100
104
 
101
105
  `pages.status` is still shown, labelled **Editorial** so it cannot be mistaken
102
106
  for the other one, and the action offered follows the publication state rather
@@ -508,3 +512,67 @@ A host that has not registered them keeps working — the Pages list hides the t
508
512
  actions rather than offering a button that throws. Authorization is the existing
509
513
  one: both go through the same staff guard as every other page mutation, and
510
514
  `tests/page-publish-duplicate.test.ts` pins a non-staff caller being refused.
515
+
516
+ ## Listing, link-only pages and booking
517
+
518
+ Listing is a second property beside publication, and the two never touch:
519
+
520
+ | Publication | Listing | Shown as | A visitor gets |
521
+ | --- | --- | --- | --- |
522
+ | live | listed | `Published` | The page; it is in menus, footer and sitemap |
523
+ | live | link-only | `Published` + `Link only` | The page by its address only; left out of menus, footer and sitemap; `noindex` |
524
+ | not live | either | `Offline` (+ `Link only once published`) | 404 |
525
+
526
+ - **Make link-only / Show on website** change listing only (`setPageListingCore`).
527
+ No publication, version, schedule or `pages.status` is written, so "Show on
528
+ website" cannot publish an offline page, and saving, publishing and scheduled
529
+ replacements keep a page link-only. Link-only is not access control: the
530
+ address can be shared or found, and `noindex` asks crawlers, it does not
531
+ remove existing results.
532
+ - **Unpublish** is the existing `cms_set_page_status(draft)` with its
533
+ schedule-aware warnings, now also offered in the builder header.
534
+ - **Protected pages.** A host passes `protectedPageKeys` in the page deps (Voyara:
535
+ the start page and the contact page). The cores refuse to unpublish, end-date,
536
+ trash or unlist them. An application safeguard: direct database access is not
537
+ blocked by it.
538
+ - **Booking.** One switch hides every link whose address is `https:` (or
539
+ protocol-relative) with a host starting `booking` (`isBookingHref`), plus the
540
+ header CTA when the host marks it `booking: true`. Exception pages keep the
541
+ booking links in their own content. Removal (`stripBookingDeep`,
542
+ `withoutBookingPopups`) is for the host to apply to data before it leaves the
543
+ server; stored content is never changed.
544
+
545
+ ### Storage
546
+
547
+ No migration: one `site_settings` row per decision, keyed by page id —
548
+ `page.listing.<uuid>`, `booking`, `booking.exception.<uuid>`. Every write is a
549
+ compare-and-swap on `updated_at` (`VisibilityConflictError` when someone else
550
+ changed the row first). None is an `OVERRIDABLE_SETTING_KEYS` key, so the generic
551
+ settings save and reset cannot reach them.
552
+
553
+ ### The public policy
554
+
555
+ `loadPublicPolicyCore` reads publication, listing, booking and exceptions for
556
+ every page in one snapshot, cached with the published-page cache (cleared on
557
+ every page and visibility write, capped at the next publication boundary). A
558
+ failed read throws and is not cached; hosts fail closed (`known: false`: booking
559
+ hidden, nothing indexable, nothing reported offline).
560
+
561
+ Hosts turn the snapshot into what the chrome needs with `routeAliasMap`,
562
+ `restrictToLinks` and `savedLinkHrefs` — only the hidden addresses the menu and
563
+ footer actually link to are sent to the browser — and provide it through
564
+ `LinkVisibilityProvider`. The header and footer leave those links out; the
565
+ saved menu is never changed, and the navigation editor says why an item is not
566
+ shown. The public page route takes a per-page `pagePolicy` query and adds
567
+ `noindex` for a page that is not indexable.
568
+
569
+ ### Host wiring
570
+
571
+ ```ts
572
+ pages: { fns: { setPageStatus?, setPageListing?, ... } }
573
+ booking?: { fns: { getBookingSettings, setBookingEnabled, setBookingException } }
574
+ useLinkVisibility?: () => LinkVisibilityPolicy | undefined
575
+ ```
576
+
577
+ All optional: without them the actions, the Booking card and the editor's
578
+ explanation are simply not shown.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.93.0",
3
+ "version": "0.95.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -49,6 +49,7 @@
49
49
  "./trips": "./src/trips/index.ts",
50
50
  "./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
51
51
  "./navigation": "./src/navigation/index.ts",
52
+ "./navigation/visibility": "./src/navigation/visibility.ts",
52
53
  "./forms/FormRenderer": "./src/forms/FormRenderer.tsx",
53
54
  "./forms/success": "./src/forms/success-listeners.ts",
54
55
  "./forms/submission-result": "./src/forms/submission-result.ts",
@@ -124,6 +125,7 @@
124
125
  "./config/site-config": "./src/config/site-config.ts",
125
126
  "./server/site-settings-core": "./src/server/site-settings-core.ts",
126
127
  "./backoffice/SiteSettingsView": "./src/backoffice/SiteSettingsView.tsx",
128
+ "./backoffice/BookingSettingsCard": "./src/backoffice/BookingSettingsCard.tsx",
127
129
  "./backoffice/SitemapView": "./src/backoffice/SitemapView.tsx",
128
130
  "./backoffice/UsersView": "./src/backoffice/UsersView.tsx",
129
131
  "./backoffice/RolesView": "./src/backoffice/RolesView.tsx",
@@ -154,6 +156,7 @@
154
156
  "./server/media-library-core": "./src/server/media-library-core.ts",
155
157
  "./server/admin-core": "./src/server/admin-core.ts",
156
158
  "./server/pages-core": "./src/server/pages-core.ts",
159
+ "./server/page-visibility-core": "./src/server/page-visibility-core.ts",
157
160
  "./server/edit-locks-core": "./src/server/edit-locks-core.ts",
158
161
  "./server/edit-lock-adapter": "./src/server/edit-lock-adapter.ts",
159
162
  "./server/edit-lock-supabase-adapter": "./src/server/edit-lock-supabase-adapter.ts",
@@ -186,6 +189,7 @@
186
189
  "./site/public-origin": "./src/site/public-origin.ts",
187
190
  "./site/font-bundle": "./src/site/font-bundle.ts",
188
191
  "./admin/version-core": "./src/admin/version-core.ts",
192
+ "./admin/version-report": "./src/admin/version-report.ts",
189
193
  "./config/package-owned-modules": "./src/config/package-owned-modules.ts",
190
194
  "./modules": "./src/modules/index.ts",
191
195
  "./popups/host": "./src/popups/host.ts",
@@ -204,6 +208,8 @@
204
208
  "./media/use-media-upload": "./src/media/use-media-upload.ts",
205
209
  "./cms/page-draft-store": "./src/cms/page-draft-store.ts",
206
210
  "./cms/page-publication": "./src/cms/page-publication.ts",
211
+ "./cms/page-visibility": "./src/cms/page-visibility.ts",
212
+ "./cms/booking-links": "./src/cms/booking-links.ts",
207
213
  "./site/routes": "./src/site/routes.ts",
208
214
  "./site/PageRenderer": "./src/site/PageRenderer.tsx",
209
215
  "./server/env": "./src/server/env.ts",
@@ -231,6 +237,8 @@
231
237
  "./site/host": "./src/site/host.ts",
232
238
  "./site/locale-domains": "./src/site/locale-domains.ts",
233
239
  "./site/route-index": "./src/site/route-index.ts",
240
+ "./site/route-aliases": "./src/site/route-aliases.ts",
241
+ "./site/link-visibility": "./src/site/link-visibility.tsx",
234
242
  "./site/SiteHeader": "./src/site/SiteHeader.tsx",
235
243
  "./site/SiteFooter": "./src/site/SiteFooter.tsx",
236
244
  "./server/schemas": "./src/server/schemas.ts",
@@ -34,6 +34,9 @@ export const ACTION_LABELS: Record<string, string> = {
34
34
  "page.delete": "Deleted page",
35
35
  "page.publish": "Published page",
36
36
  "page.unpublish": "Unpublished page",
37
+ "page.listing": "Changed whether a page is listed",
38
+ "booking.settings": "Switched booking on or off",
39
+ "booking.exception": "Changed which pages keep booking links",
37
40
  "page.version.create": "Saved page version",
38
41
  "page.version.restore": "Restored page version",
39
42
  "template.create": "Created block template",
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The version screen's module list, derived from the module table.
3
+ *
4
+ * Every module is listed, enabled or not: switching a module off in Site
5
+ * settings hides it, it does not remove its code from the build. Whether it is
6
+ * on comes from the same saved settings the Settings screen and the sidebar
7
+ * read. Until that read has succeeded, the status is "checking" or
8
+ * "unavailable" — never the compiled default presented as the live answer.
9
+ */
10
+ import { CMS_MODULE_LIST, type CmsModuleKey } from "../config/modules";
11
+ import type { PackageReport } from "./version-report";
12
+
13
+ /** The state of the saved-settings read the enabled flags come from. */
14
+ export type ModuleSettingsRead =
15
+ | { readonly state: "loading" }
16
+ | { readonly state: "error" }
17
+ | {
18
+ readonly state: "ready";
19
+ readonly features: Readonly<Partial<Record<CmsModuleKey, boolean>>>;
20
+ /** True when a later re-read failed and these values are from an earlier one. */
21
+ readonly stale?: boolean;
22
+ };
23
+
24
+ export type ModuleEnabledState = "enabled" | "disabled" | "checking" | "unavailable";
25
+
26
+ export type ModuleInventoryRow = {
27
+ readonly key: CmsModuleKey;
28
+ readonly label: string;
29
+ readonly description: string | null;
30
+ readonly enabled: ModuleEnabledState;
31
+ /** True when `enabled` comes from an earlier successful read. */
32
+ readonly enabledStale: boolean;
33
+ /** Installed CMS package version the module ships inside, when known. */
34
+ readonly cmsVersion: string | null;
35
+ /** The separately published package the module is built on, if any. */
36
+ readonly packageName: string | null;
37
+ /** That package's report, when the host supplied one. */
38
+ readonly package: PackageReport | null;
39
+ };
40
+
41
+ /** One row per module, in module-table order. */
42
+ export function moduleInventory(
43
+ settings: ModuleSettingsRead,
44
+ packages: readonly PackageReport[] | null | undefined,
45
+ cmsVersion: string | null | undefined,
46
+ ): ModuleInventoryRow[] {
47
+ return CMS_MODULE_LIST.map((m) => {
48
+ let enabled: ModuleEnabledState;
49
+ if (settings.state === "loading") enabled = "checking";
50
+ else if (settings.state === "error") enabled = "unavailable";
51
+ else {
52
+ const value = settings.features[m.key];
53
+ enabled = value === true ? "enabled" : value === false ? "disabled" : "unavailable";
54
+ }
55
+ const packageName = m.package ?? null;
56
+ return {
57
+ key: m.key,
58
+ label: m.label,
59
+ description: m.description ?? null,
60
+ enabled,
61
+ enabledStale: settings.state === "ready" && settings.stale === true,
62
+ cmsVersion: cmsVersion ?? null,
63
+ packageName,
64
+ package: packageName ? (packages?.find((p) => p.name === packageName) ?? null) : null,
65
+ };
66
+ });
67
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Bounded, cached reads of public npm registry metadata — server side only.
3
+ *
4
+ * The version screen compares installed first-party packages with what has
5
+ * been published. That comparison must never be able to hurt the page or the
6
+ * server, so every read here is:
7
+ *
8
+ * - **allowlisted**: only package names the caller fixed at construction are
9
+ * looked up; the URL is built here from the fixed registry origin, never from
10
+ * request input. Anything else answers `not-allowed` without a request.
11
+ * - **anonymous**: no credentials, tokens or cookies are sent, and redirects are
12
+ * refused so the request cannot be bounced elsewhere.
13
+ * - **bounded**: a timeout covers the whole response, and bodies over a size
14
+ * cap are abandoned.
15
+ * - **cached**: a successful answer is reused for `freshForMs`; after any
16
+ * attempt, successful or not, the registry is not asked again for that
17
+ * package for `retryAfterMs`, even when a manual refresh is requested.
18
+ * Concurrent callers share one request.
19
+ * - **failure-tolerant**: a failed read keeps serving the last good answer as
20
+ * `stale` (cached, not re-verified) with the failure's fixed reason code —
21
+ * never the registry's own text — until a later attempt succeeds.
22
+ */
23
+ import { parseSemver } from "./semver";
24
+ import type {
25
+ RegistryCheck,
26
+ RegistryError,
27
+ RegistryMetadata,
28
+ RegistryVersion,
29
+ } from "./version-report";
30
+
31
+ /** The only registry this module talks to. */
32
+ export const NPM_REGISTRY_ORIGIN = "https://registry.npmjs.org";
33
+
34
+ /** Scoped npm package names only (`@scope/name`), lower case. */
35
+ const PACKAGE_NAME = /^@[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,127}$/;
36
+
37
+ export type RegistryLookup = {
38
+ readonly metadata: RegistryMetadata | null;
39
+ readonly check: RegistryCheck;
40
+ };
41
+
42
+ export type RegistryClientOptions = {
43
+ /** Exact package names that may be looked up. */
44
+ readonly allowlist: readonly string[];
45
+ /** Injected for tests; defaults to the global `fetch` at call time. */
46
+ readonly fetch?: (input: string, init: RequestInit) => Promise<Response>;
47
+ /** Injected for tests; milliseconds since the epoch. */
48
+ readonly now?: () => number;
49
+ /** How long a successful answer counts as fresh. Default 15 minutes. */
50
+ readonly freshForMs?: number;
51
+ /** Minimum time between two attempts for one package. Default 60 seconds. */
52
+ readonly retryAfterMs?: number;
53
+ /** Timeout for one request, body included. Default 5 seconds. */
54
+ readonly timeoutMs?: number;
55
+ /** Largest response body accepted. Default 8 MiB. */
56
+ readonly maxBytes?: number;
57
+ };
58
+
59
+ export type RegistryClient = {
60
+ lookup(name: string, options?: { refresh?: boolean }): Promise<RegistryLookup>;
61
+ /** When the next attempt for `name` may reach the registry (epoch ms), or null if now. */
62
+ nextAttemptAt(name: string): number | null;
63
+ };
64
+
65
+ /** The registry URL for a scoped package (`@scope%2Fname`). */
66
+ export function registryUrl(name: string): string {
67
+ return `${NPM_REGISTRY_ORIGIN}/${name.replace("/", "%2F")}`;
68
+ }
69
+
70
+ function iso(ms: number | null | undefined): string | null {
71
+ return typeof ms === "number" && Number.isFinite(ms) ? new Date(ms).toISOString() : null;
72
+ }
73
+
74
+ function isRecord(value: unknown): value is Record<string, unknown> {
75
+ return typeof value === "object" && value !== null && !Array.isArray(value);
76
+ }
77
+
78
+ /**
79
+ * Reduce a registry document to the fields the screen uses, or `null` when it
80
+ * does not have the expected shape. Versions that are not valid semver are
81
+ * dropped; a document with no usable version at all is malformed.
82
+ */
83
+ export function parseRegistryDocument(doc: unknown): RegistryMetadata | null {
84
+ if (!isRecord(doc) || !isRecord(doc["versions"])) return null;
85
+ const time = isRecord(doc["time"]) ? doc["time"] : {};
86
+ const rawTags = isRecord(doc["dist-tags"]) ? doc["dist-tags"] : {};
87
+
88
+ const versions: Record<string, RegistryVersion> = {};
89
+ for (const [key, entry] of Object.entries(doc["versions"])) {
90
+ const sv = parseSemver(key);
91
+ if (!sv) continue;
92
+ const stamp = time[key];
93
+ const ms = typeof stamp === "string" ? Date.parse(stamp) : Number.NaN;
94
+ const deprecated = isRecord(entry) && typeof entry["deprecated"] === "string";
95
+ versions[sv.version] = {
96
+ publishedAt: Number.isFinite(ms) ? new Date(ms).toISOString() : null,
97
+ deprecated: deprecated && (entry["deprecated"] as string).length > 0,
98
+ };
99
+ }
100
+ if (Object.keys(versions).length === 0) return null;
101
+
102
+ const distTags: Record<string, string> = {};
103
+ for (const [tag, value] of Object.entries(rawTags)) {
104
+ const sv = parseSemver(value);
105
+ if (sv && /^[A-Za-z0-9._-]{1,64}$/.test(tag)) distTags[tag] = sv.version;
106
+ }
107
+ return { distTags, versions };
108
+ }
109
+
110
+ async function readBounded(res: Response, maxBytes: number): Promise<string | null> {
111
+ const declared = Number(res.headers.get("content-length"));
112
+ if (Number.isFinite(declared) && declared > maxBytes) {
113
+ await res.body?.cancel().catch(() => {});
114
+ return null;
115
+ }
116
+ if (!res.body) {
117
+ const text = await res.text();
118
+ return new TextEncoder().encode(text).byteLength > maxBytes ? null : text;
119
+ }
120
+ const reader = res.body.getReader();
121
+ const chunks: Uint8Array[] = [];
122
+ let total = 0;
123
+ for (;;) {
124
+ const { done, value } = await reader.read();
125
+ if (done) break;
126
+ total += value.byteLength;
127
+ if (total > maxBytes) {
128
+ await reader.cancel().catch(() => {});
129
+ return null;
130
+ }
131
+ chunks.push(value);
132
+ }
133
+ const all = new Uint8Array(total);
134
+ let offset = 0;
135
+ for (const c of chunks) {
136
+ all.set(c, offset);
137
+ offset += c.byteLength;
138
+ }
139
+ return new TextDecoder().decode(all);
140
+ }
141
+
142
+ /** Create a registry client. One per process is the intended use. */
143
+ export function createRegistryClient(options: RegistryClientOptions): RegistryClient {
144
+ const allowed = new Set(options.allowlist.filter((n) => PACKAGE_NAME.test(n)));
145
+ const now = options.now ?? (() => Date.now());
146
+ const freshForMs = options.freshForMs ?? 15 * 60_000;
147
+ const retryAfterMs = options.retryAfterMs ?? 60_000;
148
+ const timeoutMs = options.timeoutMs ?? 5_000;
149
+ const maxBytes = options.maxBytes ?? 8 * 1024 * 1024;
150
+
151
+ const good = new Map<string, { metadata: RegistryMetadata; at: number }>();
152
+ const attempts = new Map<string, { at: number; error: RegistryError | null }>();
153
+ const inflight = new Map<string, Promise<void>>();
154
+
155
+ async function attempt(name: string): Promise<void> {
156
+ const startedAt = now();
157
+ const controller = new AbortController();
158
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
159
+ let error: RegistryError | null = null;
160
+ try {
161
+ const doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
162
+ const res = await doFetch(registryUrl(name), {
163
+ method: "GET",
164
+ headers: { accept: "application/json" },
165
+ redirect: "error",
166
+ credentials: "omit",
167
+ signal: controller.signal,
168
+ });
169
+ if (res.status === 404) {
170
+ error = "not-found";
171
+ await res.body?.cancel().catch(() => {});
172
+ } else if (!res.ok) {
173
+ error = "http";
174
+ await res.body?.cancel().catch(() => {});
175
+ } else {
176
+ const text = await readBounded(res, maxBytes);
177
+ if (text === null) {
178
+ error = "too-large";
179
+ } else {
180
+ let doc: unknown;
181
+ try {
182
+ doc = JSON.parse(text);
183
+ } catch {
184
+ doc = undefined;
185
+ }
186
+ const metadata = parseRegistryDocument(doc);
187
+ if (metadata) good.set(name, { metadata, at: startedAt });
188
+ else error = "malformed";
189
+ }
190
+ }
191
+ } catch {
192
+ error = controller.signal.aborted ? "timeout" : "network";
193
+ } finally {
194
+ clearTimeout(timer);
195
+ }
196
+ attempts.set(name, { at: startedAt, error });
197
+ }
198
+
199
+ function snapshot(name: string): RegistryLookup {
200
+ const hit = good.get(name);
201
+ const last = attempts.get(name);
202
+ // Fresh only when the most recent attempt is the successful one and it is
203
+ // within the lifetime. A newer failed attempt leaves the last good answer
204
+ // in place, but as cached and unverified, never as current.
205
+ const latestFailed = last !== undefined && last.error !== null;
206
+ const state = !hit
207
+ ? "unavailable"
208
+ : latestFailed || now() - hit.at >= freshForMs
209
+ ? "stale"
210
+ : "fresh";
211
+ return {
212
+ metadata: hit?.metadata ?? null,
213
+ check: {
214
+ state,
215
+ lastSuccessAt: iso(hit?.at),
216
+ lastAttemptAt: iso(last?.at),
217
+ error: last?.error ?? null,
218
+ },
219
+ };
220
+ }
221
+
222
+ function nextAttemptAt(name: string): number | null {
223
+ const last = attempts.get(name);
224
+ if (!last) return null;
225
+ const next = last.at + retryAfterMs;
226
+ return next > now() ? next : null;
227
+ }
228
+
229
+ return {
230
+ nextAttemptAt,
231
+ async lookup(name, { refresh = false } = {}) {
232
+ if (!allowed.has(name)) {
233
+ return {
234
+ metadata: null,
235
+ check: { state: "unavailable", lastSuccessAt: null, lastAttemptAt: null, error: "not-allowed" },
236
+ };
237
+ }
238
+ const hit = good.get(name);
239
+ const fresh = hit !== undefined && now() - hit.at < freshForMs;
240
+ const throttled = nextAttemptAt(name) !== null;
241
+ if (!throttled && (refresh || !fresh)) {
242
+ let pending = inflight.get(name);
243
+ if (!pending) {
244
+ pending = attempt(name).finally(() => inflight.delete(name));
245
+ inflight.set(name, pending);
246
+ }
247
+ await pending;
248
+ } else {
249
+ // Someone else may be mid-request; share its answer rather than a stale one.
250
+ const pending = inflight.get(name);
251
+ if (pending) await pending;
252
+ }
253
+ return snapshot(name);
254
+ },
255
+ };
256
+ }