@bygghammar/cms-platform 0.92.2 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +93 -0
- package/package.json +2 -1
- package/src/admin/module-inventory.ts +67 -0
- package/src/admin/registry-metadata.ts +256 -0
- package/src/admin/semver.ts +111 -0
- package/src/admin/version-core.ts +233 -25
- package/src/admin/version-report.ts +295 -0
- package/src/backoffice/VersionView.tsx +747 -66
- package/src/backoffice/media/ReuploadOverwrite.tsx +2 -2
- package/src/backoffice/media/ReviewImageUpload.tsx +8 -4
- package/src/backoffice/routes/index.tsx +94 -9
- package/src/backoffice/shell/use-resolved-config.tsx +30 -5
- package/src/config/modules.ts +11 -0
- package/src/media/FocalImage.tsx +12 -16
- package/src/media/optimize.ts +8 -6
- package/src/media/review-image.ts +22 -8
- package/src/media/srcset.ts +13 -6
- package/src/popups/package-name.ts +8 -0
- package/src/server/media-library-core.ts +5 -3
- package/src/server/review-image-validate.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,99 @@ 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.94.0
|
|
12
|
+
|
|
13
|
+
- **The version screen shows what is actually deployed and installed.**
|
|
14
|
+
`/admin/version`, now "Version & modules", no longer labels the site's
|
|
15
|
+
`package.json` version as the "CMS build". It shows:
|
|
16
|
+
- the release identity from the runtime `APP_VERSION` (falling back to
|
|
17
|
+
`VITE_PUBLIC_APP_VERSION`, the values the health check reports), short,
|
|
18
|
+
in full and with a copy control, or "Unknown";
|
|
19
|
+
- the build time, labelled as such, and a deployment time only when the
|
|
20
|
+
host supplies real evidence; otherwise "Not recorded";
|
|
21
|
+
- the installed CMS package and module packages: installed and declared
|
|
22
|
+
versions, publication dates, the newest version in their channel and an
|
|
23
|
+
advisory status;
|
|
24
|
+
- every module from the module table, with its enabled status read from
|
|
25
|
+
the same saved settings as Site settings. A loading or failed settings
|
|
26
|
+
read is shown as such, never as the compiled defaults;
|
|
27
|
+
- recent CMS releases, and the packaged schema figures labelled as package
|
|
28
|
+
facts rather than a check of the live database.
|
|
29
|
+
|
|
30
|
+
The screen never changes settings.
|
|
31
|
+
- **Update policy.** Versions are compared by semver precedence, not as
|
|
32
|
+
strings or by publication date.
|
|
33
|
+
- A release install follows stable releases.
|
|
34
|
+
- A prerelease install follows its prerelease line (`0.2.0-m7`) and is
|
|
35
|
+
reported as "Prerelease installed", not "Up to date".
|
|
36
|
+
- Unknown, malformed, unpublished or ahead-of-registry versions, and any
|
|
37
|
+
case without a registry answer, are never "Up to date".
|
|
38
|
+
- **Registry reads are bounded and server-side.** `admin/registry-metadata`
|
|
39
|
+
looks up only the allowlisted first-party packages (the CMS package plus
|
|
40
|
+
packages named by modules) in the public npm registry:
|
|
41
|
+
- anonymous, refusing redirects, with a 5 s timeout and an 8 MiB cap;
|
|
42
|
+
- a 15-minute cache, and at most one attempt per package per minute,
|
|
43
|
+
manual "Check again" included;
|
|
44
|
+
- shared in-flight requests.
|
|
45
|
+
|
|
46
|
+
`fetchPlatformReleases` now goes through the same client.
|
|
47
|
+
- **Failed checks are never shown as current.**
|
|
48
|
+
- When a later registry attempt fails, the last good answer stays on
|
|
49
|
+
screen marked "Not re-verified", with its success and failure times.
|
|
50
|
+
- A package that has never been checked successfully says so.
|
|
51
|
+
- A failed "Check again" or re-read shows an alert and keeps the last
|
|
52
|
+
answer, marked as not re-verified, without the raw error.
|
|
53
|
+
- The next successful answer clears these notices.
|
|
54
|
+
- **New contract pieces (additive):**
|
|
55
|
+
- in `admin/version-core`: `buildVersionReport`, `readReleaseIdentity`,
|
|
56
|
+
`readEnvironment`, `readInstalledPackages`, `releasesFromReport`,
|
|
57
|
+
`FIRST_PARTY_PACKAGES` and `platformRegistryClient`;
|
|
58
|
+
- the `./admin/version-report` export: report types,
|
|
59
|
+
`evaluatePackageUpdate` and `summarizeUpdates`;
|
|
60
|
+
- an optional `package` field on `CmsModuleDefinition` (Popups declares
|
|
61
|
+
`@bygghammar/lightweight-popup`);
|
|
62
|
+
- `status` from `useResolvedSiteConfig()`;
|
|
63
|
+
- optional `report`, `modules`, `latestCheckFailed` and `onRefresh` props
|
|
64
|
+
on `VersionView`.
|
|
65
|
+
|
|
66
|
+
A host whose `getSystemVersion` still returns only `{ build, releases }`
|
|
67
|
+
keeps rendering, with the release shown as unknown and no update check.
|
|
68
|
+
- **Host side:**
|
|
69
|
+
- protect `getSystemVersion` with the site's auth middleware and
|
|
70
|
+
`assertStaff`;
|
|
71
|
+
- accept `{ refresh?: boolean }` and pass it to `buildVersionReport`;
|
|
72
|
+
- inject the installed package list as the `packages` build constant
|
|
73
|
+
(`[{ name, installed, declared, requiredByCms }]`, read from
|
|
74
|
+
`node_modules` at build time).
|
|
75
|
+
|
|
76
|
+
Voyara's adoption does all three.
|
|
77
|
+
|
|
78
|
+
## 0.93.0
|
|
79
|
+
|
|
80
|
+
- **Breaking for callers of `responsiveImage`, and for transparent images in
|
|
81
|
+
browsers without WebP:** responsive images now display as WebP only.
|
|
82
|
+
`responsiveImage` used to derive a `-fallback.png` for every responsive WebP
|
|
83
|
+
and `FocalImage` rendered it as the `<img src>`; the optimizer writes that
|
|
84
|
+
PNG only for transparent uploads, so opaque images carried a missing object
|
|
85
|
+
that crawlers and browsers without WebP requested. `FocalImage` now renders
|
|
86
|
+
the stored URL as `src` with the WebP widths as `srcSet`, and uses
|
|
87
|
+
`<picture>` only for art direction. `ResponsiveImage.fallbackSrc` is
|
|
88
|
+
deprecated and never set. Transparent uploads still get their PNG copy, but
|
|
89
|
+
responsive page markup does not serve it, so a browser without WebP support
|
|
90
|
+
does not receive it, even when the upload generated one. **Host side:** drop
|
|
91
|
+
any branch that reads `fallbackSrc`; it is now always undefined (Voyara's
|
|
92
|
+
site hero has one).
|
|
93
|
+
- Eager responsive images are now preloaded by React's server renderer,
|
|
94
|
+
because they are no longer inside a `<picture>`.
|
|
95
|
+
- Fix review-image uploads. The browser prepared five WebP widths (including
|
|
96
|
+
2560) while the server accepts exactly four (480, 768, 1200, 1920), so every
|
|
97
|
+
review upload was rejected before storage. Review images are now prepared at
|
|
98
|
+
the four review widths. The upload help text now says what happens:
|
|
99
|
+
proportions are usually kept, a very tall image may be cropped at the top
|
|
100
|
+
and bottom to the 2400px height limit, and no PNG copy is made.
|
|
101
|
+
- Upload and re-upload help text no longer promises a PNG fallback; review
|
|
102
|
+
image uploads never made one.
|
|
103
|
+
|
|
11
104
|
## 0.92.2
|
|
12
105
|
|
|
13
106
|
- Fix rich-text attribute escaping. `sanitizeHtml` read attribute values as
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.94.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": [
|
|
@@ -186,6 +186,7 @@
|
|
|
186
186
|
"./site/public-origin": "./src/site/public-origin.ts",
|
|
187
187
|
"./site/font-bundle": "./src/site/font-bundle.ts",
|
|
188
188
|
"./admin/version-core": "./src/admin/version-core.ts",
|
|
189
|
+
"./admin/version-report": "./src/admin/version-report.ts",
|
|
189
190
|
"./config/package-owned-modules": "./src/config/package-owned-modules.ts",
|
|
190
191
|
"./modules": "./src/modules/index.ts",
|
|
191
192
|
"./popups/host": "./src/popups/host.ts",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic Versioning 2.0.0 parsing and precedence.
|
|
3
|
+
*
|
|
4
|
+
* Package versions are compared by semver precedence, never as strings and
|
|
5
|
+
* never by publication date: `0.10.0` is newer than `0.9.0`, and a prerelease
|
|
6
|
+
* published after a stable release is not thereby newer than it. Build metadata
|
|
7
|
+
* (`+…`) is ignored for precedence, as the specification requires.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately small and dependency-free: the version screen only needs to
|
|
10
|
+
* parse exact versions and order them. Ranges (`^1.2.0`, `>=0.2.0-m7.8`) are not
|
|
11
|
+
* evaluated here; they are shown to people as declared, not resolved.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type SemVer = {
|
|
15
|
+
readonly major: number;
|
|
16
|
+
readonly minor: number;
|
|
17
|
+
readonly patch: number;
|
|
18
|
+
/** Dot-separated prerelease identifiers; empty for a release version. */
|
|
19
|
+
readonly prerelease: readonly (string | number)[];
|
|
20
|
+
/** The normalized `major.minor.patch[-prerelease]`, without build metadata. */
|
|
21
|
+
readonly version: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const NUMERIC = /^(0|[1-9]\d*)$/;
|
|
25
|
+
const IDENTIFIER = /^[0-9A-Za-z-]+$/;
|
|
26
|
+
const PATTERN =
|
|
27
|
+
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
|
28
|
+
|
|
29
|
+
/** Parse an exact version. Returns `null` for anything that is not valid semver. */
|
|
30
|
+
export function parseSemver(input: unknown): SemVer | null {
|
|
31
|
+
if (typeof input !== "string") return null;
|
|
32
|
+
const value = input.trim();
|
|
33
|
+
if (value.length === 0 || value.length > 256) return null;
|
|
34
|
+
const match = PATTERN.exec(value);
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
const [, major, minor, patch, pre, build] = match;
|
|
37
|
+
const nums = [major, minor, patch].map(Number);
|
|
38
|
+
if (nums.some((n) => !Number.isSafeInteger(n))) return null;
|
|
39
|
+
|
|
40
|
+
const prerelease: (string | number)[] = [];
|
|
41
|
+
if (pre !== undefined) {
|
|
42
|
+
for (const part of pre.split(".")) {
|
|
43
|
+
if (!IDENTIFIER.test(part)) return null;
|
|
44
|
+
if (/^\d+$/.test(part)) {
|
|
45
|
+
// Numeric identifiers must not have leading zeroes.
|
|
46
|
+
if (!NUMERIC.test(part)) return null;
|
|
47
|
+
const n = Number(part);
|
|
48
|
+
if (!Number.isSafeInteger(n)) return null;
|
|
49
|
+
prerelease.push(n);
|
|
50
|
+
} else {
|
|
51
|
+
prerelease.push(part);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (build !== undefined && build.split(".").some((part) => !IDENTIFIER.test(part))) return null;
|
|
56
|
+
|
|
57
|
+
const [ma, mi, pa] = nums as [number, number, number];
|
|
58
|
+
return {
|
|
59
|
+
major: ma,
|
|
60
|
+
minor: mi,
|
|
61
|
+
patch: pa,
|
|
62
|
+
prerelease,
|
|
63
|
+
version: `${ma}.${mi}.${pa}${prerelease.length ? `-${prerelease.join(".")}` : ""}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function compareIdentifiers(a: string | number, b: string | number): number {
|
|
68
|
+
const an = typeof a === "number";
|
|
69
|
+
const bn = typeof b === "number";
|
|
70
|
+
if (an && bn) return a === b ? 0 : a < b ? -1 : 1;
|
|
71
|
+
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
|
72
|
+
if (an) return -1;
|
|
73
|
+
if (bn) return 1;
|
|
74
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Semver precedence: negative when `a` is older than `b`, positive when newer. */
|
|
78
|
+
export function compareSemver(a: SemVer, b: SemVer): number {
|
|
79
|
+
if (a.major !== b.major) return a.major < b.major ? -1 : 1;
|
|
80
|
+
if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
|
|
81
|
+
if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
|
|
82
|
+
const ap = a.prerelease;
|
|
83
|
+
const bp = b.prerelease;
|
|
84
|
+
// A release version has higher precedence than any of its prereleases.
|
|
85
|
+
if (ap.length === 0 || bp.length === 0) {
|
|
86
|
+
if (ap.length === bp.length) return 0;
|
|
87
|
+
return ap.length === 0 ? 1 : -1;
|
|
88
|
+
}
|
|
89
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i += 1) {
|
|
90
|
+
if (i >= ap.length) return -1;
|
|
91
|
+
if (i >= bp.length) return 1;
|
|
92
|
+
const c = compareIdentifiers(ap[i] as string | number, bp[i] as string | number);
|
|
93
|
+
if (c !== 0) return c;
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when the version carries prerelease identifiers (`1.0.0-rc.1`). */
|
|
99
|
+
export function isPrerelease(version: SemVer): boolean {
|
|
100
|
+
return version.prerelease.length > 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The prerelease line a version belongs to: its `major.minor.patch` and first
|
|
105
|
+
* prerelease identifier, for example `0.2.0-m7` for `0.2.0-m7.9`. `null` for a
|
|
106
|
+
* release version.
|
|
107
|
+
*/
|
|
108
|
+
export function prereleaseLine(version: SemVer): string | null {
|
|
109
|
+
if (!isPrerelease(version)) return null;
|
|
110
|
+
return `${version.major}.${version.minor}.${version.patch}-${String(version.prerelease[0])}`;
|
|
111
|
+
}
|