@salesforce/storefront-next-dev 1.4.0 → 1.5.0-alpha.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/README.md +13 -2
- package/dist/assets.js +25 -0
- package/dist/audit/catalog/categories/best-practices/CATEGORY.md +21 -0
- package/dist/audit/catalog/categories/best-practices/rules/commerce-api-client.md +20 -0
- package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.js +575 -0
- package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.md +20 -0
- package/dist/audit/catalog/categories/best-practices/rules/error-boundaries.md +19 -0
- package/dist/audit/catalog/categories/best-practices/rules/production-integrations.md +20 -0
- package/dist/audit/catalog/categories/best-practices/rules/route-revalidation.md +19 -0
- package/dist/audit/catalog/categories/best-practices/rules/server-data-loading.md +19 -0
- package/dist/audit/catalog/categories/performance/CATEGORY.md +18 -0
- package/dist/audit/catalog/categories/performance/rules/app-config-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/app-config-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/cache-control.js +59 -0
- package/dist/audit/catalog/categories/performance/rules/cache-control.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/cls-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/cls-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/content-encoding.js +35 -0
- package/dist/audit/catalog/categories/performance/rules/content-encoding.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/dom-size.js +18 -0
- package/dist/audit/catalog/categories/performance/rules/dom-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/font-display.js +248 -0
- package/dist/audit/catalog/categories/performance/rules/font-display.md +21 -0
- package/dist/audit/catalog/categories/performance/rules/html-document-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/html-document-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/image-dimensions.js +141 -0
- package/dist/audit/catalog/categories/performance/rules/image-dimensions.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/image-transformation.js +117 -0
- package/dist/audit/catalog/categories/performance/rules/image-transformation.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/javascript-size.js +126 -0
- package/dist/audit/catalog/categories/performance/rules/javascript-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/large-images.js +187 -0
- package/dist/audit/catalog/categories/performance/rules/large-images.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.js +227 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.md +27 -0
- package/dist/audit/catalog/categories/performance/rules/page-data-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/page-data-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.js +97 -0
- package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.md +73 -0
- package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.js +201 -0
- package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/server-request-waterfall.md +85 -0
- package/dist/audit/catalog/categories/performance/rules/tbt-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/tbt-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/third-party-fonts.js +265 -0
- package/dist/audit/catalog/categories/performance/rules/third-party-fonts.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/ttfb-budget.js +17 -0
- package/dist/audit/catalog/categories/performance/rules/ttfb-budget.md +20 -0
- package/dist/audit/catalog/categories/security/CATEGORY.md +17 -0
- package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.js +37 -0
- package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.md +20 -0
- package/dist/audit/catalog/categories/security/rules/content-security-policy.js +40 -0
- package/dist/audit/catalog/categories/security/rules/content-security-policy.md +20 -0
- package/dist/audit/catalog/categories/security/rules/csp-runtime-compatibility.md +24 -0
- package/dist/audit/catalog/categories/security/rules/dependency-vulnerabilities.md +19 -0
- package/dist/audit/catalog/categories/security/rules/dynamic-code-execution.md +19 -0
- package/dist/audit/catalog/categories/security/rules/hardcoded-credentials.md +19 -0
- package/dist/audit/catalog/categories/security/rules/html-config-secrets.js +46 -0
- package/dist/audit/catalog/categories/security/rules/html-config-secrets.md +22 -0
- package/dist/audit/catalog/categories/security/rules/protected-route-authorization.md +19 -0
- package/dist/audit/catalog/categories/security/rules/public-client-secrets.md +20 -0
- package/dist/audit/catalog/categories/security/rules/raw-html-sinks.md +20 -0
- package/dist/audit/catalog/categories/security/rules/strict-transport-security.js +183 -0
- package/dist/audit/catalog/categories/security/rules/strict-transport-security.md +24 -0
- package/dist/audit/catalog/categories/seo/CATEGORY.md +17 -0
- package/dist/audit/catalog/categories/seo/rules/canonical-url.md +20 -0
- package/dist/audit/catalog/categories/seo/rules/locale-alternates.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/meta-description.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/page-title.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/robots-indexing.md +20 -0
- package/dist/audit/catalog/categories/seo/rules/structured-data.md +19 -0
- package/dist/audit/instructions/evaluate-rule.md +42 -0
- package/dist/audit/schemas/audit-result.schema.json +1237 -0
- package/dist/audit/ui/assets/banner-brand-default-CDRB3_S0.png +0 -0
- package/dist/audit/ui/assets/banner-group-public-default-BGRJzVY8.png +0 -0
- package/dist/audit/ui/assets/banner-user-default-BW81Ba3z.png +0 -0
- package/dist/audit/ui/assets/bg-info@2x-CzpP0vaE.png +0 -0
- package/dist/audit/ui/assets/einstein-figure-B1xFOo8O.svg +1 -0
- package/dist/audit/ui/assets/group_avatar_160-CrxVHlYA.png +0 -0
- package/dist/audit/ui/assets/group_avatar_200-CSLS8I0r.png +0 -0
- package/dist/audit/ui/assets/index-CtE_JUcs.css +1 -0
- package/dist/audit/ui/assets/index-aSdbHIeq.js +44 -0
- package/dist/audit/ui/assets/profile_avatar_160-CtQTUBo0.png +0 -0
- package/dist/audit/ui/assets/profile_avatar_200-DAztxshF.png +0 -0
- package/dist/audit/ui/assets/symbols-BRmpfDbO.svg +1 -0
- package/dist/audit/ui/assets/symbols-D0Ne6690.svg +1 -0
- package/dist/audit/ui/einstein.png +0 -0
- package/dist/audit/ui/index.html +13 -0
- package/dist/audit.js +1898 -0
- package/dist/cartridge-services/index.d.ts +5 -0
- package/dist/cartridge-services/index.d.ts.map +1 -1
- package/dist/cartridge-services/index.js +94 -7
- package/dist/cartridge-services/index.js.map +1 -1
- package/dist/catalog.js +89 -0
- package/dist/commands/audit/catalog.js +27 -0
- package/dist/commands/audit/finalize.js +42 -0
- package/dist/commands/audit/prepare.js +84 -0
- package/dist/commands/audit/progress.js +51 -0
- package/dist/commands/audit/serve.js +63 -0
- package/dist/commands/config/aggregate-extensions.js +1 -0
- package/dist/commands/create-bundle.js +2 -1
- package/dist/commands/create-instructions.js +1 -0
- package/dist/commands/create-storefront.js +1 -0
- package/dist/commands/deploy-cartridge.js +35 -11
- package/dist/commands/dev.js +2 -0
- package/dist/commands/extensions/create.js +1 -0
- package/dist/commands/extensions/install.js +1 -0
- package/dist/commands/extensions/list.js +1 -0
- package/dist/commands/extensions/remove.js +1 -0
- package/dist/commands/generate-cartridge.js +49 -5
- package/dist/commands/locales/aggregate-extensions.js +1 -0
- package/dist/commands/prepare-local.js +1 -0
- package/dist/commands/preview.js +2 -0
- package/dist/commands/push.js +37 -7
- package/dist/commands/validate-cartridge.js +13 -4
- package/dist/config.js +13 -2
- package/dist/document-size.js +231 -0
- package/dist/events.js +688 -0
- package/dist/flags2.js +34 -0
- package/dist/generate-cartridge.js +41 -6
- package/dist/har.js +205 -0
- package/dist/images.js +146 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/live.js +277 -0
- package/dist/logger.js +1 -4
- package/dist/measured-run.js +91 -0
- package/dist/metric-budget.js +125 -0
- package/dist/mrt/ssr.mjs +50 -50
- package/dist/mrt/ssr.mjs.map +1 -1
- package/dist/mrt/streamingHandler.mjs +56 -56
- package/dist/mrt/streamingHandler.mjs.map +1 -1
- package/dist/package.js +5 -0
- package/dist/sanitize.js +185 -0
- package/dist/schema.js +121 -0
- package/dist/server.js +8 -60
- package/dist/server2.js +185 -0
- package/dist/ts-import.js +57 -0
- package/package.json +24 -6
package/dist/live.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { decode } from "@jridgewell/sourcemap-codec";
|
|
4
|
+
import { ImportType, init, parse } from "es-module-lexer";
|
|
5
|
+
|
|
6
|
+
//#region src/audit/bundle/parse.ts
|
|
7
|
+
const visualizerSchema = z.object({
|
|
8
|
+
version: z.number(),
|
|
9
|
+
nodeParts: z.record(z.string(), z.object({
|
|
10
|
+
renderedLength: z.number(),
|
|
11
|
+
metaUid: z.string()
|
|
12
|
+
}).passthrough()),
|
|
13
|
+
nodeMetas: z.record(z.string(), z.object({
|
|
14
|
+
id: z.string(),
|
|
15
|
+
moduleParts: z.record(z.string(), z.string()),
|
|
16
|
+
imported: z.array(z.object({
|
|
17
|
+
uid: z.string(),
|
|
18
|
+
dynamic: z.boolean().optional()
|
|
19
|
+
}).passthrough()),
|
|
20
|
+
isEntry: z.boolean().nullable().optional()
|
|
21
|
+
}).passthrough())
|
|
22
|
+
}).passthrough();
|
|
23
|
+
/** Data versions this parser has been checked against. */
|
|
24
|
+
const VISUALIZER_VERSIONS = new Set([2]);
|
|
25
|
+
/** Rollup's virtual modules (`\0vite/preload-helper`) have no file; they're bundler runtime. */
|
|
26
|
+
const isVirtual = (id) => id.startsWith("\0") && !id.slice(1).startsWith("/");
|
|
27
|
+
/**
|
|
28
|
+
* The visualizer writes ids relative to the project root when they're inside it (`/src/root.tsx`) and absolute
|
|
29
|
+
* otherwise (a hoisted `node_modules`). An id is relative when its first segment is an entry of the project root.
|
|
30
|
+
*/
|
|
31
|
+
function moduleIdResolver(projectRoot, rootEntries) {
|
|
32
|
+
return (raw) => {
|
|
33
|
+
const id = (raw.startsWith("\0") ? raw.slice(1) : raw).replace(/\?.*$/, "");
|
|
34
|
+
const first = id.split("/")[1] ?? "";
|
|
35
|
+
return id.startsWith("/") && rootEntries.has(first) ? path.join(projectRoot, id) : path.resolve(id);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function parseVisualizerHtml(html, resolveId) {
|
|
39
|
+
const match = html.match(/^\s*const data = (\{.*\});\s*$/m);
|
|
40
|
+
if (!match) throw new Error("The visualizer report has no embedded `const data = {…}`.");
|
|
41
|
+
const data = visualizerSchema.parse(JSON.parse(match[1]));
|
|
42
|
+
if (!VISUALIZER_VERSIONS.has(data.version)) throw new Error(`Unsupported visualizer data version ${data.version}.`);
|
|
43
|
+
const graph = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const meta of Object.values(data.nodeMetas)) {
|
|
45
|
+
if (isVirtual(meta.id)) continue;
|
|
46
|
+
const id = resolveId(meta.id);
|
|
47
|
+
const node = graph.get(id) ?? {
|
|
48
|
+
id,
|
|
49
|
+
static: /* @__PURE__ */ new Set(),
|
|
50
|
+
dynamic: /* @__PURE__ */ new Set(),
|
|
51
|
+
rendered: 0,
|
|
52
|
+
entry: false,
|
|
53
|
+
chunks: /* @__PURE__ */ new Set()
|
|
54
|
+
};
|
|
55
|
+
node.entry ||= meta.isEntry === true;
|
|
56
|
+
for (const [chunk, partUid] of Object.entries(meta.moduleParts)) {
|
|
57
|
+
const part = data.nodeParts[partUid];
|
|
58
|
+
if (!part) throw new Error(`Visualizer module ${meta.id} names an unknown part ${partUid}.`);
|
|
59
|
+
node.chunks.add(chunk);
|
|
60
|
+
node.rendered += part.renderedLength;
|
|
61
|
+
}
|
|
62
|
+
for (const edge of meta.imported) {
|
|
63
|
+
const target = data.nodeMetas[edge.uid];
|
|
64
|
+
if (!target || isVirtual(target.id)) continue;
|
|
65
|
+
const targetId = resolveId(target.id);
|
|
66
|
+
if (targetId !== id) (edge.dynamic ? node.dynamic : node.static).add(targetId);
|
|
67
|
+
}
|
|
68
|
+
graph.set(id, node);
|
|
69
|
+
}
|
|
70
|
+
for (const node of graph.values()) for (const target of node.dynamic) node.static.delete(target);
|
|
71
|
+
return graph;
|
|
72
|
+
}
|
|
73
|
+
const sourceMapSchema = z.object({
|
|
74
|
+
sources: z.array(z.string().nullable()),
|
|
75
|
+
sourceRoot: z.string().optional(),
|
|
76
|
+
mappings: z.string()
|
|
77
|
+
}).passthrough();
|
|
78
|
+
const PRELOAD_LIST_PREFIX = "const __vite__mapDeps=";
|
|
79
|
+
const ROUTER_MANIFEST_PREFIX = "window.__reactRouterManifest=";
|
|
80
|
+
const lexerReady = init;
|
|
81
|
+
/** `code` and `map` are the chunk and its `.map` (absent when the build wrote none); `assetsDir` resolves map sources. */
|
|
82
|
+
function parseChunk(file, code, map, assetsDir) {
|
|
83
|
+
const bytes = Buffer.byteLength(code);
|
|
84
|
+
const routerManifest = code.startsWith(ROUTER_MANIFEST_PREFIX);
|
|
85
|
+
const imports = /* @__PURE__ */ new Set();
|
|
86
|
+
const dynamicImports = /* @__PURE__ */ new Set();
|
|
87
|
+
if (!routerManifest) for (const item of parse(code)[0]) {
|
|
88
|
+
if (item.t === ImportType.ImportMeta || !item.n?.startsWith("./")) continue;
|
|
89
|
+
(item.t === ImportType.Dynamic ? dynamicImports : imports).add(item.n.slice(2));
|
|
90
|
+
}
|
|
91
|
+
const lines = code.split("\n");
|
|
92
|
+
const preloadList = lines[0].startsWith(PRELOAD_LIST_PREFIX) ? Buffer.byteLength(lines[0]) + (lines.length > 1 ? 1 : 0) : 0;
|
|
93
|
+
const chunk = {
|
|
94
|
+
file,
|
|
95
|
+
bytes,
|
|
96
|
+
modules: /* @__PURE__ */ new Map(),
|
|
97
|
+
preloadList,
|
|
98
|
+
unattributed: bytes - preloadList,
|
|
99
|
+
hasMap: false,
|
|
100
|
+
routerManifest,
|
|
101
|
+
imports: [...imports],
|
|
102
|
+
dynamicImports: [...dynamicImports].filter((item) => !imports.has(item))
|
|
103
|
+
};
|
|
104
|
+
if (routerManifest || map === void 0) return chunk;
|
|
105
|
+
const parsed = sourceMapSchema.parse(JSON.parse(map));
|
|
106
|
+
const sourceBase = path.resolve(assetsDir, parsed.sourceRoot ?? "");
|
|
107
|
+
const sources = parsed.sources.map((source) => source === null || source.includes("\0") ? null : path.resolve(sourceBase, source.replace(/\?.*$/, "")));
|
|
108
|
+
const decoded = decode(parsed.mappings);
|
|
109
|
+
let unattributed = 0;
|
|
110
|
+
const add = (source, length) => {
|
|
111
|
+
const id = source === void 0 ? null : sources[source];
|
|
112
|
+
if (id) chunk.modules.set(id, (chunk.modules.get(id) ?? 0) + length);
|
|
113
|
+
else unattributed += length;
|
|
114
|
+
};
|
|
115
|
+
lines.forEach((line, index) => {
|
|
116
|
+
const newline = index < lines.length - 1 ? 1 : 0;
|
|
117
|
+
if (index === 0 && preloadList) return;
|
|
118
|
+
unattributed += newline;
|
|
119
|
+
const segments = decoded[index] ?? [];
|
|
120
|
+
if (!segments.length) {
|
|
121
|
+
unattributed += Buffer.byteLength(line);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
unattributed += Buffer.byteLength(line.slice(0, segments[0][0]));
|
|
125
|
+
segments.forEach((segment, k) => {
|
|
126
|
+
const end = k + 1 < segments.length ? segments[k + 1][0] : line.length;
|
|
127
|
+
add(segment.length >= 4 ? segment[1] : void 0, Buffer.byteLength(line.slice(segment[0], end)));
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
chunk.hasMap = true;
|
|
131
|
+
chunk.unattributed = unattributed;
|
|
132
|
+
return chunk;
|
|
133
|
+
}
|
|
134
|
+
const manifestRouteSchema = z.object({
|
|
135
|
+
id: z.string(),
|
|
136
|
+
parentId: z.string().optional(),
|
|
137
|
+
path: z.string().optional(),
|
|
138
|
+
index: z.boolean().optional(),
|
|
139
|
+
module: z.string(),
|
|
140
|
+
imports: z.array(z.string()).optional()
|
|
141
|
+
}).passthrough();
|
|
142
|
+
const routerManifestSchema = z.object({
|
|
143
|
+
entry: z.object({
|
|
144
|
+
module: z.string(),
|
|
145
|
+
imports: z.array(z.string())
|
|
146
|
+
}).passthrough(),
|
|
147
|
+
routes: z.record(z.string(), manifestRouteSchema)
|
|
148
|
+
}).passthrough();
|
|
149
|
+
/** A chunk URL as the chunk's file name, the key every source here shares. */
|
|
150
|
+
const assetName = (url) => url.replace(/[?#].*$/, "").split("/").at(-1) ?? url;
|
|
151
|
+
/**
|
|
152
|
+
* Parses `window.__reactRouterManifest={…}`. React Router writes JSON; the storefront-next SDK replaces each URL
|
|
153
|
+
* with `(window._BUNDLE_PATH || "/") + "assets/x.js"`, which is folded back to a string. Module URLs become file names.
|
|
154
|
+
*/
|
|
155
|
+
function parseRouterManifest(code) {
|
|
156
|
+
if (!code.startsWith(ROUTER_MANIFEST_PREFIX)) throw new Error("Not a React Router manifest chunk.");
|
|
157
|
+
const json = code.slice(29).trim().replace(/;$/, "").replace(/\(\s*window\._BUNDLE_PATH\s*\|\|\s*"[^"]*"\s*\)\s*\+\s*"/g, "\"");
|
|
158
|
+
const manifest = routerManifestSchema.parse(JSON.parse(json));
|
|
159
|
+
manifest.entry.module = assetName(manifest.entry.module);
|
|
160
|
+
manifest.entry.imports = manifest.entry.imports.map(assetName);
|
|
161
|
+
for (const route of Object.values(manifest.routes)) {
|
|
162
|
+
route.module = assetName(route.module);
|
|
163
|
+
if (route.imports) route.imports = route.imports.map(assetName);
|
|
164
|
+
}
|
|
165
|
+
return manifest;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* React Router's `<Scripts>` renders an inline module that imports the matched routes and names them:
|
|
169
|
+
* `import * as route0 from "…/root-x.js"; … window.__reactRouterRouteModules = {"root":route0,…}; import("…/entry.client-y.js");`.
|
|
170
|
+
* That is the page's route branch, from the deployed site itself. Undefined when the script isn't this one.
|
|
171
|
+
*/
|
|
172
|
+
function parseRouteModulesScript(code) {
|
|
173
|
+
const assignment = code.match(/window\.__reactRouterRouteModules\s*=\s*\{([^}]*)\}/);
|
|
174
|
+
if (!assignment || assignment.index === void 0) return void 0;
|
|
175
|
+
const files = new Map([...code.matchAll(/import\s*\*\s*as\s+(\w+)\s+from\s*"([^"]+)"/g)].map((item) => [item[1], assetName(item[2])]));
|
|
176
|
+
const routes = [...assignment[1].matchAll(/"([^"]+)"\s*:\s*(\w+)/g)].flatMap((item) => {
|
|
177
|
+
const file = files.get(item[2]);
|
|
178
|
+
return file ? [{
|
|
179
|
+
id: item[1],
|
|
180
|
+
file
|
|
181
|
+
}] : [];
|
|
182
|
+
});
|
|
183
|
+
if (!routes.length) return void 0;
|
|
184
|
+
const entry = code.slice(assignment.index).match(/import\(\s*"([^"]+)"\s*\)/)?.[1];
|
|
185
|
+
return {
|
|
186
|
+
routes,
|
|
187
|
+
...entry ? { entry: assetName(entry) } : {}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/audit/bundle/live.ts
|
|
193
|
+
const CELL = {
|
|
194
|
+
formFactor: "mobile",
|
|
195
|
+
cacheState: "cold"
|
|
196
|
+
};
|
|
197
|
+
/** Storefront Next serves the app's build from this path on Managed Runtime. */
|
|
198
|
+
const BUNDLE_PATH = "/mobify/bundle/";
|
|
199
|
+
const lcpSummarySchema = z.object({ cells: z.array(z.object({
|
|
200
|
+
formFactor: z.string(),
|
|
201
|
+
cacheState: z.string(),
|
|
202
|
+
median: z.object({ runId: z.string() }).passthrough().nullable().optional()
|
|
203
|
+
}).passthrough()) }).passthrough();
|
|
204
|
+
const requestSchema = z.object({
|
|
205
|
+
url: z.string(),
|
|
206
|
+
resourceType: z.string().optional(),
|
|
207
|
+
statusCode: z.number().optional(),
|
|
208
|
+
resourceSize: z.number().optional(),
|
|
209
|
+
transferSize: z.number().optional(),
|
|
210
|
+
networkRequestTime: z.number().optional()
|
|
211
|
+
}).passthrough();
|
|
212
|
+
const lhrSchema = z.object({ audits: z.object({
|
|
213
|
+
"network-requests": z.object({ details: z.object({ items: z.array(requestSchema) }).passthrough().optional() }).passthrough().optional(),
|
|
214
|
+
metrics: z.object({ details: z.object({ items: z.array(z.object({ observedLoad: z.number().optional() }).passthrough()) }).passthrough().optional() }).passthrough().optional()
|
|
215
|
+
}).passthrough() }).passthrough();
|
|
216
|
+
const scriptsIndexSchema = z.object({ observations: z.array(z.object({
|
|
217
|
+
runId: z.string(),
|
|
218
|
+
url: z.string(),
|
|
219
|
+
body: z.object({ sha256: z.string() }).passthrough().nullable().optional()
|
|
220
|
+
}).passthrough()) }).passthrough();
|
|
221
|
+
/** A first-party script: served from the storefront's bundle path. */
|
|
222
|
+
function isFirstParty(url) {
|
|
223
|
+
try {
|
|
224
|
+
return new URL(url).pathname.includes(BUNDLE_PATH);
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** The page's median mobile cold-cache run's first-party scripts, or why they can't be read. */
|
|
230
|
+
async function readLivePage(playhouse, page) {
|
|
231
|
+
const runId = (await playhouse.readJson(page, "summary-lcp.json", lcpSummarySchema))?.cells.find((cell) => cell.formFactor === CELL.formFactor && cell.cacheState === CELL.cacheState)?.median?.runId;
|
|
232
|
+
if (!runId) return { reason: "summary-lcp.json has no mobile cold-cache median run." };
|
|
233
|
+
const lighthouseArtifact = `runs/${runId}/lhr.json`;
|
|
234
|
+
const lhr = await playhouse.readJson(page, lighthouseArtifact, lhrSchema);
|
|
235
|
+
const items = lhr?.audits["network-requests"]?.details?.items;
|
|
236
|
+
if (!items) return { reason: `${lighthouseArtifact} has no network-requests audit.` };
|
|
237
|
+
const load = lhr.audits.metrics?.details?.items[0]?.observedLoad;
|
|
238
|
+
const observedLoadMs = typeof load === "number" && load > 0 ? load : null;
|
|
239
|
+
const scripts = items.flatMap((request, item) => {
|
|
240
|
+
if (request.resourceType !== "Script" || (request.statusCode ?? 200) >= 400 || !isFirstParty(request.url)) return [];
|
|
241
|
+
const startMs = request.networkRequestTime ?? null;
|
|
242
|
+
return [{
|
|
243
|
+
url: request.url,
|
|
244
|
+
file: assetName(request.url),
|
|
245
|
+
item,
|
|
246
|
+
decodedBytes: request.resourceSize ?? 0,
|
|
247
|
+
transferBytes: request.transferSize ?? 0,
|
|
248
|
+
startMs,
|
|
249
|
+
beforeLoad: observedLoadMs === null || startMs !== null && startMs <= observedLoadMs
|
|
250
|
+
}];
|
|
251
|
+
});
|
|
252
|
+
const rendered = await readRenderedRoutes(playhouse, page, runId);
|
|
253
|
+
return {
|
|
254
|
+
page,
|
|
255
|
+
runId,
|
|
256
|
+
lighthouseArtifact,
|
|
257
|
+
observedLoadMs,
|
|
258
|
+
scripts,
|
|
259
|
+
...rendered ? { rendered } : {}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** The route branch from React Router's inline script in the run's HTML, when Playhouse archived it. */
|
|
263
|
+
async function readRenderedRoutes(playhouse, page, runId) {
|
|
264
|
+
const index = await playhouse.readJson(page, "scripts/index.json", scriptsIndexSchema);
|
|
265
|
+
const seen = /* @__PURE__ */ new Set();
|
|
266
|
+
for (const observation of index?.observations ?? []) {
|
|
267
|
+
const sha = observation.body?.sha256;
|
|
268
|
+
if (observation.runId !== runId || !sha || !/^[a-f0-9]{64}$/.test(sha) || seen.has(sha) || /\.m?js(?:[?#]|$)/.test(observation.url)) continue;
|
|
269
|
+
seen.add(sha);
|
|
270
|
+
const code = await playhouse.readText(page, `scripts/${sha}.js`);
|
|
271
|
+
const rendered = code === void 0 ? void 0 : parseRouteModulesScript(code);
|
|
272
|
+
if (rendered) return rendered;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
//#endregion
|
|
277
|
+
export { parseRouterManifest as a, parseChunk as i, lexerReady as n, parseVisualizerHtml as o, moduleIdResolver as r, readLivePage as t };
|
package/dist/logger.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
+
import { t as version } from "./package.js";
|
|
1
2
|
import os from "os";
|
|
2
3
|
import chalk from "chalk";
|
|
3
4
|
import { createRequire } from "module";
|
|
4
5
|
|
|
5
|
-
//#region package.json
|
|
6
|
-
var version = "1.4.0";
|
|
7
|
-
|
|
8
|
-
//#endregion
|
|
9
6
|
//#region src/utils/logger.ts
|
|
10
7
|
/**
|
|
11
8
|
* Get the local network IPv4 address
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
//#region src/audit/measured-run.ts
|
|
2
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
3
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
4
|
+
/** Playhouse writes 0 for a metric it could not measure. A rendered page always has a positive LCP, so a run without one measured nothing. */
|
|
5
|
+
function isMeasured(run) {
|
|
6
|
+
const lcp = object(run)?.lcp;
|
|
7
|
+
return typeof lcp === "number" && Number.isFinite(lcp) && lcp > 0;
|
|
8
|
+
}
|
|
9
|
+
/** A run row that has an LCP but not a positive one: Playhouse could not measure it. A row without an LCP can't say. */
|
|
10
|
+
const isFailedRow = (row) => typeof object(row)?.lcp === "number" && !isMeasured(row);
|
|
11
|
+
function median(values) {
|
|
12
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
13
|
+
const mid = Math.floor(sorted.length / 2);
|
|
14
|
+
return sorted.length % 2 === 0 ? ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2 : sorted[mid] ?? 0;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A summary.json cell's representative run, leaving out runs Playhouse could not measure.
|
|
18
|
+
* Playhouse picks the run whose LCP is closest to the median of every run, zeros included, so one failed run
|
|
19
|
+
* can move the pick. When a run failed, this repeats Playhouse's selection (closest to the median, ties to the
|
|
20
|
+
* earlier run) over the measured runs only; otherwise it returns Playhouse's own `medianRun`.
|
|
21
|
+
* Undefined when the cell is missing or no run was measured.
|
|
22
|
+
* Playhouse versions that retry and skip a run without an LCP never write these zeros, but archives from
|
|
23
|
+
* older versions still contain them.
|
|
24
|
+
*/
|
|
25
|
+
function measuredRun(summary, cellIndex, eligible = isMeasured) {
|
|
26
|
+
const root = object(summary);
|
|
27
|
+
const cell = object(list(root?.cells)[cellIndex]);
|
|
28
|
+
if (!cell) return void 0;
|
|
29
|
+
const rows = list(root?.runs).map((entry, index) => ({
|
|
30
|
+
entry: object(entry),
|
|
31
|
+
index
|
|
32
|
+
})).flatMap(({ entry, index }) => {
|
|
33
|
+
const row = object(entry?.row);
|
|
34
|
+
return row && typeof row.lcp === "number" && entry?.formFactor === cell.formFactor && entry?.cacheState === cell.cacheState ? [{
|
|
35
|
+
row,
|
|
36
|
+
index
|
|
37
|
+
}] : [];
|
|
38
|
+
});
|
|
39
|
+
const measured = rows.filter(({ row }) => eligible(row));
|
|
40
|
+
const medianRun = object(cell.medianRun);
|
|
41
|
+
if (medianRun && eligible(medianRun) && measured.length === rows.length) return {
|
|
42
|
+
run: medianRun,
|
|
43
|
+
pointer: `/cells/${cellIndex}/medianRun`,
|
|
44
|
+
unmeasuredRuns: 0
|
|
45
|
+
};
|
|
46
|
+
if (!measured.length) return void 0;
|
|
47
|
+
const lcp = (item) => item.row.lcp;
|
|
48
|
+
const target = median(measured.map(lcp));
|
|
49
|
+
const closest = measured.reduce((best, item) => Math.abs(lcp(item) - target) < Math.abs(lcp(best) - target) ? item : best);
|
|
50
|
+
return {
|
|
51
|
+
run: closest.row,
|
|
52
|
+
pointer: `/runs/${closest.index}/row`,
|
|
53
|
+
unmeasuredRuns: rows.length - measured.length
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** The mobile/cold (else first) cell's score and core metrics for the live progress screen. Returns no score
|
|
57
|
+
* when the artifact is absent or nothing was measured, so the dashboard never shows a fabricated number. */
|
|
58
|
+
function perfFromSummary(summary) {
|
|
59
|
+
const mobileCold = list(object(summary)?.cells).findIndex((entry) => object(entry)?.formFactor === "mobile" && object(entry)?.cacheState === "cold");
|
|
60
|
+
const run = measuredRun(summary, mobileCold >= 0 ? mobileCold : 0)?.run;
|
|
61
|
+
if (!run) return { metrics: [] };
|
|
62
|
+
const seconds = (value) => `${(value / 1e3).toFixed(1)}s`;
|
|
63
|
+
const metrics = [];
|
|
64
|
+
if (typeof run.lcp === "number") metrics.push({
|
|
65
|
+
key: "LCP",
|
|
66
|
+
value: seconds(run.lcp),
|
|
67
|
+
bad: run.lcp > 2500
|
|
68
|
+
});
|
|
69
|
+
if (typeof run.tbt === "number") metrics.push({
|
|
70
|
+
key: "TBT",
|
|
71
|
+
value: `${Math.round(run.tbt)}ms`,
|
|
72
|
+
bad: run.tbt > 200
|
|
73
|
+
});
|
|
74
|
+
if (typeof run.cls === "number") metrics.push({
|
|
75
|
+
key: "CLS",
|
|
76
|
+
value: run.cls.toFixed(2),
|
|
77
|
+
bad: run.cls > .1
|
|
78
|
+
});
|
|
79
|
+
if (typeof run.ttfb === "number") metrics.push({
|
|
80
|
+
key: "TTFB",
|
|
81
|
+
value: seconds(run.ttfb),
|
|
82
|
+
bad: run.ttfb > 800
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
score: typeof run.score === "number" ? run.score : void 0,
|
|
86
|
+
metrics
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
//#endregion
|
|
91
|
+
export { perfFromSummary as i, isMeasured as n, measuredRun as r, isFailedRow as t };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { n as isMeasured, r as measuredRun, t as isFailedRow } from "./measured-run.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
//#region src/audit/rule-helpers/metric-budget.ts
|
|
5
|
+
/** Budgets are checked against the mobile cold-cache median, the cell the dashboard leads with. */
|
|
6
|
+
const CELL = {
|
|
7
|
+
formFactor: "mobile",
|
|
8
|
+
cacheState: "cold"
|
|
9
|
+
};
|
|
10
|
+
const summarySchema = z.object({
|
|
11
|
+
cells: z.array(z.object({
|
|
12
|
+
formFactor: z.string(),
|
|
13
|
+
cacheState: z.string(),
|
|
14
|
+
medianRun: z.record(z.string(), z.unknown())
|
|
15
|
+
}).passthrough()),
|
|
16
|
+
runs: z.array(z.object({
|
|
17
|
+
formFactor: z.string(),
|
|
18
|
+
cacheState: z.string(),
|
|
19
|
+
row: z.record(z.string(), z.unknown()).optional()
|
|
20
|
+
}).passthrough()).optional().default([])
|
|
21
|
+
}).passthrough();
|
|
22
|
+
const dataSchema = z.object({
|
|
23
|
+
metric: z.string(),
|
|
24
|
+
threshold: z.number(),
|
|
25
|
+
cell: z.string(),
|
|
26
|
+
pages: z.array(z.object({
|
|
27
|
+
page: z.string(),
|
|
28
|
+
value: z.number(),
|
|
29
|
+
threshold: z.number(),
|
|
30
|
+
runs: z.array(z.number()),
|
|
31
|
+
unmeasuredRuns: z.number()
|
|
32
|
+
}).strict()),
|
|
33
|
+
skipped: z.array(z.object({
|
|
34
|
+
page: z.string(),
|
|
35
|
+
reason: z.string()
|
|
36
|
+
}).strict())
|
|
37
|
+
}).strict();
|
|
38
|
+
const isCell = (cell) => cell.formFactor === CELL.formFactor && cell.cacheState === CELL.cacheState;
|
|
39
|
+
const finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
40
|
+
/** A rule script that passes when every page's mobile/cold median is at or under the threshold. */
|
|
41
|
+
function metricBudget(budget) {
|
|
42
|
+
return async ({ pages, playhouse }) => {
|
|
43
|
+
const measured = [];
|
|
44
|
+
const skipped = [];
|
|
45
|
+
const evidence = [];
|
|
46
|
+
for (const page of pages) {
|
|
47
|
+
const summary = await playhouse.readJson(page.label, "summary.json", summarySchema);
|
|
48
|
+
const index = summary?.cells.findIndex(isCell) ?? -1;
|
|
49
|
+
if (index < 0) {
|
|
50
|
+
skipped.push({
|
|
51
|
+
page: page.label,
|
|
52
|
+
reason: summary ? `No ${CELL.formFactor}/${CELL.cacheState} median ${budget.label} in summary.json.` : "summary.json is missing."
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const eligible = (run) => {
|
|
57
|
+
const value$1 = run[budget.metric];
|
|
58
|
+
return isMeasured(run) && finite(value$1) && Boolean(budget.validValue?.(value$1));
|
|
59
|
+
};
|
|
60
|
+
const representative = budget.validValue ? measuredRun(summary, index, eligible) : measuredRun(summary, index);
|
|
61
|
+
if (!representative) {
|
|
62
|
+
const requirement = budget.validValue ? `a positive LCP and valid ${budget.label}` : "a positive LCP";
|
|
63
|
+
skipped.push({
|
|
64
|
+
page: page.label,
|
|
65
|
+
reason: `Not measured: no ${CELL.formFactor}/${CELL.cacheState} run in summary.json has ${requirement}.`
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const value = representative.run[budget.metric];
|
|
70
|
+
if (!finite(value)) {
|
|
71
|
+
skipped.push({
|
|
72
|
+
page: page.label,
|
|
73
|
+
reason: `No ${CELL.formFactor}/${CELL.cacheState} median ${budget.label} in summary.json.`
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const runs = (summary?.runs ?? []).filter((run) => isCell(run) && (budget.validValue ? Boolean(run.row && eligible(run.row)) : !isFailedRow(run.row))).map((run) => run.row?.[budget.metric]).filter(finite);
|
|
78
|
+
measured.push({
|
|
79
|
+
page: page.label,
|
|
80
|
+
value,
|
|
81
|
+
threshold: budget.threshold,
|
|
82
|
+
runs,
|
|
83
|
+
unmeasuredRuns: representative.unmeasuredRuns
|
|
84
|
+
});
|
|
85
|
+
const left = representative.unmeasuredRuns ? `; ${representative.unmeasuredRuns} run${representative.unmeasuredRuns === 1 ? "" : "s"} not measured, left out` : "";
|
|
86
|
+
const spread = budget.includeSpread && runs.length > 1 ? `; measured spread ${budget.format(Math.min(...runs))}–${budget.format(Math.max(...runs))}` : "";
|
|
87
|
+
evidence.push({
|
|
88
|
+
type: "playhouse",
|
|
89
|
+
page: page.label,
|
|
90
|
+
artifact: "summary.json",
|
|
91
|
+
pointer: `${representative.pointer}/${budget.metric}`,
|
|
92
|
+
value: `${budget.label} ${budget.format(value)} (budget ${budget.format(budget.threshold)}${spread}${left})`
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const data = {
|
|
96
|
+
metric: budget.metric,
|
|
97
|
+
threshold: budget.threshold,
|
|
98
|
+
cell: `${CELL.formFactor}/${CELL.cacheState}`,
|
|
99
|
+
pages: measured,
|
|
100
|
+
skipped
|
|
101
|
+
};
|
|
102
|
+
if (!measured.length) return { data };
|
|
103
|
+
const over = measured.filter((entry) => entry.value > budget.threshold);
|
|
104
|
+
const list = (entries) => entries.map((entry) => `${entry.page} ${budget.format(entry.value)}`).join(", ");
|
|
105
|
+
const severity = over.length && budget.severityForFailure ? budget.severityForFailure(Math.max(...over.map((entry) => entry.value))) : void 0;
|
|
106
|
+
const measurement = budget.measurementLabel ?? "median";
|
|
107
|
+
return {
|
|
108
|
+
data,
|
|
109
|
+
decision: over.length ? {
|
|
110
|
+
status: "fail",
|
|
111
|
+
...severity ? { severity } : {},
|
|
112
|
+
summary: `Mobile cold-cache ${measurement} ${budget.label} is over ${budget.format(budget.threshold)} on ${list(over)}.`,
|
|
113
|
+
evidence: evidence.filter((item) => over.some((entry) => entry.page === item.page))
|
|
114
|
+
} : {
|
|
115
|
+
status: "pass",
|
|
116
|
+
summary: `Mobile cold-cache ${measurement} ${budget.label} is within ${budget.format(budget.threshold)} on every measured page (${list(measured)}).`,
|
|
117
|
+
evidence
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const milliseconds = (value) => `${Math.round(value)} ms`;
|
|
123
|
+
|
|
124
|
+
//#endregion
|
|
125
|
+
export { metricBudget as n, milliseconds as r, dataSchema as t };
|