@fusion.dev/helix-diagnostics 0.138.0 → 0.140.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/dist/index.js CHANGED
@@ -1,82 +1,12 @@
1
1
  export { collect } from './collect.js';
2
2
  export { DEFAULTS, pagePattern } from './config.js';
3
3
  export { report } from './console.js';
4
+ export { diagnose } from './diagnose.js';
4
5
  export { fingerprintOf, normalize } from './identity.js';
5
- export { index, nearest } from './manifest.js';
6
+ export { INSTANCE, initDiagnostics } from './init.js';
7
+ export { index, lintOf, nearest } from './manifest.js';
6
8
  export { CATEGORIES, KINDS } from './report.js';
7
9
  export { SHIPPED_RULES } from './rules.js';
10
+ export { TIMING } from './schedule.js';
8
11
  export { watch } from './usage.js';
9
12
  export { VERSION } from './version.js';
10
- import { analyze, markup, registrations, skew, versionSkew } from './analyze.js';
11
- import { collect } from './collect.js';
12
- import { pagePattern, resolveConfig } from './config.js';
13
- import { identify } from './identity.js';
14
- import { index, versionsOf } from './manifest.js';
15
- import { applyRules, overridesOf } from './rules.js';
16
- import { listed, loadedVersions, styled, walk } from './usage.js';
17
- import { VERSION } from './version.js';
18
- const runId = () => globalThis.crypto?.randomUUID?.() ??
19
- `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
20
- export const diagnose = (options) => {
21
- const started = performance.now();
22
- const at = new Date().toISOString();
23
- const { duplicates, environment, file, from, page, tiers, watcher, ...call } = options;
24
- if (tiers.length === 0) {
25
- throw new Error('diagnose() needs at least one manifest. Without one every part name is unknown and every finding would be noise.');
26
- }
27
- const resolved = resolveConfig(file, call);
28
- const { detail } = resolved.configuration;
29
- const document_ = from ?? globalThis.document;
30
- const { answered, coverage, readers, rules: located, used } = collect(document_);
31
- const tags = index(tiers);
32
- const counted = watcher ? watcher.counts() : walk(document_);
33
- const seen = loadedVersions();
34
- const manifests = versionsOf(tiers);
35
- const byVersion = versionSkew(manifests, seen);
36
- const known = seen.length > 0 && Object.keys(manifests).length > 0;
37
- const skewed = applyRules((byVersion.length > 0 ? byVersion : known ? [] : skew(tags, document_)).map((one) => identify(one, tags)), file?.rules, call.rules);
38
- const judged = skewed.findings.length > 0
39
- ? { findings: [], silenced: [] }
40
- : applyRules([
41
- ...analyze(located, tags, document_, answered, used, readers),
42
- ...markup(tags, document_),
43
- ...registrations(document_, duplicates),
44
- ].map((one) => identify(one, tags)), file?.rules, call.rules);
45
- const path = page ?? globalThis.location?.pathname;
46
- const shownPage = path === undefined || detail.pages === 'none'
47
- ? undefined
48
- : detail.pages === 'patterns'
49
- ? pagePattern(path)
50
- : path;
51
- return {
52
- configuration: {
53
- ...resolved.configuration,
54
- overrides: overridesOf(file?.rules, call.rules),
55
- silenced: [...skewed.silenced, ...judged.silenced],
56
- },
57
- coverage,
58
- findings: [...skewed.findings, ...judged.findings],
59
- project: resolved.project,
60
- run: {
61
- at,
62
- duration: Math.round(performance.now() - started),
63
- id: runId(),
64
- source: 'page',
65
- ...(environment === undefined ? {} : { environment }),
66
- ...(shownPage === undefined ? {} : { page: shownPage }),
67
- },
68
- schema: 1,
69
- usage: {
70
- ...listed(counted, tags),
71
- ...styled(located, tags, answered, detail.hookValues),
72
- counted: watcher ? 'watched' : 'walked',
73
- },
74
- versions: { collector: VERSION, helix: seen, manifests, rules: VERSION },
75
- withheld: Object.entries(resolved.send)
76
- .filter(([, sent]) => !sent.on)
77
- .map(([category, sent]) => ({
78
- category: category,
79
- decidedBy: sent.decidedBy,
80
- })),
81
- };
82
- };
package/dist/init.d.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * One call, in any framework: `initDiagnostics()`.
3
+ *
4
+ * ```ts
5
+ * import { initDiagnostics } from '@fusion.dev/helix-diagnostics'
6
+ *
7
+ * initDiagnostics({ environment: import.meta.env.MODE })
8
+ * ```
9
+ *
10
+ * **Once, at the app's entry, before the app mounts**, in whatever builds it:
11
+ * `main.ts` in Vite, Vue, Svelte, or Solid, before `bootstrapApplication` in
12
+ * Angular, `instrumentation-client.ts` in Next.js, or a module script with no
13
+ * bundler. It needs nothing from a framework: it reads the DOM, hears
14
+ * elements render through core's listener, and hears navigation from the
15
+ * browser.
16
+ *
17
+ * **It never breaks the app.** It never throws; a setting that names nothing
18
+ * is printed with the closest setting, and the handle it returns does
19
+ * nothing. A run that throws is printed, and three in a row stop it. Each
20
+ * place a run goes is isolated from the others.
21
+ *
22
+ * **It never runs in production by accident.** With no environment declared
23
+ * it runs only on a local host, since each framework says development its
24
+ * own way. Production runs only with `environment: 'production'` and a sample
25
+ * rate.
26
+ */
27
+ import { type Config } from './config.js';
28
+ import type { Tier } from './manifest.js';
29
+ import type { Environment, Report } from './report.js';
30
+ /** What `initDiagnostics()` takes: the settings, and where the run comes from. */
31
+ export interface InitOptions extends Config {
32
+ /**
33
+ * Where lint data is loaded from, where `tiers` is not given: a base the
34
+ * package and version follow, as
35
+ * `https://cdn.jsdelivr.net/npm/@fusion.dev/helix@0.138.0/lint.json`.
36
+ * `false` loads nothing. jsDelivr by default.
37
+ */
38
+ cdn?: string | false | undefined;
39
+ /** `duplicateRegistrations` from `@fusion.dev/helix-core`, as `diagnose` takes it. */
40
+ duplicates?: (() => string[]) | undefined;
41
+ /**
42
+ * Where the page runs, as the project declares it. Undeclared, it runs only
43
+ * on a local host: `localhost`, `127.0.0.1`, `[::1]`, `*.localhost`, and
44
+ * `*.test`.
45
+ */
46
+ environment?: Environment | undefined;
47
+ /** The project's own file, `helix-diagnostics.json`, parsed. */
48
+ file?: Config | undefined;
49
+ /** Called with each run, after the console has it. */
50
+ onReport?: ((run: Report) => void) | undefined;
51
+ /**
52
+ * The page's path as the app's router names it, such as `/products/[id]`,
53
+ * asked at each run. The location, made a pattern, otherwise.
54
+ */
55
+ page?: (() => string | undefined) | undefined;
56
+ /**
57
+ * The lint data or manifests to check against. Loaded from `cdn`, for the
58
+ * Helix version core lists on the page, where not given.
59
+ */
60
+ tiers?: Tier[] | undefined;
61
+ }
62
+ /** What `initDiagnostics()` returns. */
63
+ export interface Diagnostics {
64
+ /** Whether it is running: false where it declined to start, and after `stop()`. */
65
+ readonly active: boolean;
66
+ /** The last run, once there is one. */
67
+ readonly latest: Report | undefined;
68
+ /** Runs now, outside the schedule, and returns the run, where it can run yet. */
69
+ run(): Report | undefined;
70
+ /** Calls `listener` with every run from now on, and returns its removal. */
71
+ subscribe(listener: (run: Report) => void): () => void;
72
+ /** Stops it: nothing more runs. A later `initDiagnostics()` starts afresh. */
73
+ stop(): void;
74
+ }
75
+ /**
76
+ * The key the running instance is kept under: a second call returns it, as
77
+ * React's strict mode, hot module replacement, and a component mounting twice
78
+ * each make, and an end-to-end test reads it to assert a page has no findings.
79
+ */
80
+ export declare const INSTANCE: unique symbol;
81
+ /**
82
+ * Starts diagnostics on this page, or returns the handle it already has.
83
+ *
84
+ * Never throws. On a server, with no `document`, it returns a handle that
85
+ * does nothing, so it is safe in code server-side rendering also runs.
86
+ */
87
+ export declare const initDiagnostics: (options?: InitOptions) => Diagnostics;
package/dist/init.js ADDED
@@ -0,0 +1,337 @@
1
+ import { checkConfig, LOCAL, resolveConfig } from './config.js';
2
+ import { report as print } from './console.js';
3
+ import { diagnose } from './diagnose.js';
4
+ import { overlayOn } from './overlay.js';
5
+ import { shownRuntime } from './runtime.js';
6
+ import { schedule } from './schedule.js';
7
+ import { loadedVersions, watch } from './usage.js';
8
+ export const INSTANCE = Symbol.for('fusion.dev/helix/diagnostics');
9
+ const CDN = 'https://cdn.jsdelivr.net/npm';
10
+ const RELAY = 'http://127.0.0.1:7417';
11
+ const KEEPALIVE = 60_000;
12
+ const FAILURES = 3;
13
+ const inert = () => ({
14
+ active: false,
15
+ latest: undefined,
16
+ run: () => undefined,
17
+ stop: () => { },
18
+ subscribe: () => () => { },
19
+ });
20
+ const said = new Set();
21
+ const once = (key, print) => {
22
+ if (said.has(key)) {
23
+ return;
24
+ }
25
+ said.add(key);
26
+ try {
27
+ print();
28
+ }
29
+ catch {
30
+ }
31
+ };
32
+ const messageOf = (error) => error instanceof Error ? error.message : String(error);
33
+ export const initDiagnostics = (options = {}) => {
34
+ const page = globalThis.document;
35
+ if (page === undefined) {
36
+ return inert();
37
+ }
38
+ const store = globalThis;
39
+ const existing = store[INSTANCE];
40
+ if (existing?.active) {
41
+ return existing;
42
+ }
43
+ try {
44
+ const started = start(options, page);
45
+ if (started.active) {
46
+ store[INSTANCE] = started;
47
+ }
48
+ return started;
49
+ }
50
+ catch (error) {
51
+ once(`start ${messageOf(error)}`, () => console.error('helix diagnostics: not started, and the page goes on as usual.', error));
52
+ return inert();
53
+ }
54
+ };
55
+ const start = (options, page) => {
56
+ const { cdn = CDN, duplicates, environment, file, onReport, page: route, tiers, ...call } = options;
57
+ let resolved;
58
+ try {
59
+ resolved = resolveConfig(file, call);
60
+ }
61
+ catch (error) {
62
+ once(`settings ${messageOf(error)}`, () => console.error(`helix diagnostics: not started. ${messageOf(error)}`));
63
+ return inert();
64
+ }
65
+ const { configuration } = resolved;
66
+ const host = globalThis.location?.hostname ?? '';
67
+ if (environment === undefined && !LOCAL.test(host)) {
68
+ once(`host ${host}`, () => console.info(`helix diagnostics: not running on ${host || 'this page'}, which is no local host, with no environment declared. Pass \`environment\` to run here.`));
69
+ return inert();
70
+ }
71
+ if (environment === 'production') {
72
+ const production = configuration.environments.production;
73
+ if (production === false || Math.random() >= production.sample) {
74
+ return inert();
75
+ }
76
+ }
77
+ return running({
78
+ call,
79
+ cdn,
80
+ duplicates,
81
+ environment,
82
+ file,
83
+ measure: resolved.send.performance.on || configuration.console.performance !== 'off',
84
+ onReport,
85
+ page,
86
+ route,
87
+ sinks: configuration.sinks,
88
+ tiers,
89
+ warnings: configuration.core.warnings,
90
+ });
91
+ };
92
+ const running = (given) => {
93
+ const { call, cdn, duplicates, environment, page, route, sinks } = given;
94
+ const production = environment === 'production';
95
+ const development = environment === undefined || environment === 'development';
96
+ let file = given.file;
97
+ let warnings = given.warnings;
98
+ let overlay;
99
+ const relay = production ? undefined : sinks.relay === true ? RELAY : sinks.relay || undefined;
100
+ let relayed = false;
101
+ const sent = new Map();
102
+ const listeners = new Set();
103
+ if (given.onReport) {
104
+ listeners.add(given.onReport);
105
+ }
106
+ const shown = new Map();
107
+ let lint = given.tiers;
108
+ let loading = false;
109
+ let latest;
110
+ let failures = 0;
111
+ let active = true;
112
+ const deliver = (where, send) => {
113
+ try {
114
+ send();
115
+ }
116
+ catch (error) {
117
+ once(`sink ${where}`, () => console.error(`helix diagnostics: ${where} threw on a run, and the others had it.`, error));
118
+ }
119
+ };
120
+ const runNow = () => {
121
+ const begun = globalThis.performance?.now() ?? Date.now();
122
+ if (!active) {
123
+ return 0;
124
+ }
125
+ if (!lint) {
126
+ load();
127
+ return 0;
128
+ }
129
+ let path;
130
+ try {
131
+ path = route?.();
132
+ }
133
+ catch {
134
+ path = undefined;
135
+ }
136
+ let run;
137
+ try {
138
+ run = diagnose({
139
+ ...call,
140
+ duplicates,
141
+ environment,
142
+ file,
143
+ from: page,
144
+ page: path,
145
+ tiers: lint,
146
+ watcher,
147
+ });
148
+ failures = 0;
149
+ }
150
+ catch (error) {
151
+ failures += 1;
152
+ once(`run ${messageOf(error)}`, () => console.error('helix diagnostics: a run failed.', error));
153
+ if (failures >= FAILURES) {
154
+ once('stopped', () => console.error(`helix diagnostics: stopped after ${FAILURES} failed runs in a row, and the page goes on as usual.`));
155
+ stop();
156
+ }
157
+ return (globalThis.performance?.now() ?? Date.now()) - begun;
158
+ }
159
+ latest = run;
160
+ const key = run.run.page ?? '';
161
+ const seen = shown.get(key) ?? new Set();
162
+ shown.set(key, seen);
163
+ if (!production) {
164
+ deliver('the console', () => print(run, console, { shown: seen }));
165
+ }
166
+ for (const listener of listeners) {
167
+ deliver('a subscriber', () => listener(run));
168
+ }
169
+ deliver('the helix-diagnostics event', () => page.dispatchEvent(new CustomEvent('helix-diagnostics', { detail: run })));
170
+ if (sinks.overlay && development) {
171
+ deliver('the overlay', () => {
172
+ if (overlay || run.findings.length > 0 || shownRuntime(run.runtime).length > 0) {
173
+ overlay ??= overlayOn(page);
174
+ overlay.show(run);
175
+ }
176
+ });
177
+ }
178
+ if (relay && relayed) {
179
+ deliver('the relay', () => {
180
+ const same = JSON.stringify([
181
+ run.findings.map((one) => [one.fingerprint, one.severity]),
182
+ run.coverage.read,
183
+ run.coverage.sheets,
184
+ run.usage.tags,
185
+ ]);
186
+ if (sent.get(key) === same) {
187
+ return;
188
+ }
189
+ sent.set(key, same);
190
+ const body = JSON.stringify(run);
191
+ fetch(`${relay}/runs`, {
192
+ body,
193
+ credentials: 'omit',
194
+ headers: { 'content-type': 'text/plain' },
195
+ keepalive: body.length < KEEPALIVE,
196
+ method: 'POST',
197
+ }).catch(() => {
198
+ relayed = false;
199
+ once('relay gone', () => console.warn('helix diagnostics: the relay stopped answering, so runs are no longer sent to it.'));
200
+ });
201
+ });
202
+ }
203
+ return run.run.duration;
204
+ };
205
+ const load = () => {
206
+ if (loading || cdn === false) {
207
+ if (cdn === false) {
208
+ once('no lint', () => console.warn('helix diagnostics: no lint data, since `cdn` is false and no `tiers` were given, so this page is not checked.'));
209
+ }
210
+ return;
211
+ }
212
+ const [version] = loadedVersions();
213
+ if (version === undefined) {
214
+ return;
215
+ }
216
+ const counts = watcher.counts();
217
+ const tags = [...Object.keys(counts.tags), ...Object.keys(counts.composed)];
218
+ const styled = tags.some((tag) => tag.startsWith('helix-') && !tag.startsWith('helix-headless-'));
219
+ const packages = styled
220
+ ? ['@fusion.dev/helix', '@fusion.dev/helix-headless']
221
+ : ['@fusion.dev/helix-headless'];
222
+ loading = true;
223
+ Promise.all(packages.map(async (name) => {
224
+ const url = `${cdn}/${name}@${version}/lint.json`;
225
+ const response = await fetch(url, { credentials: 'omit' });
226
+ if (!response.ok) {
227
+ throw new Error(`${url} answered ${response.status}`);
228
+ }
229
+ const data = (await response.json());
230
+ if (typeof data?.tags !== 'object' || data.tags === null) {
231
+ throw new Error(`${url} is not lint data`);
232
+ }
233
+ return { lint: data };
234
+ })).then((loaded) => {
235
+ lint = loaded;
236
+ plan.poke();
237
+ }, (error) => {
238
+ once('no lint', () => console.warn(`helix diagnostics: the lint data for Helix ${version} could not be loaded (${messageOf(error)}), so this page is not checked. Pass \`tiers\` to initDiagnostics() to check it without the network.`));
239
+ });
240
+ };
241
+ if (relay) {
242
+ const ask = (path) => fetch(`${relay}${path}`, {
243
+ credentials: 'omit',
244
+ ...(typeof AbortSignal.timeout === 'function' ? { signal: AbortSignal.timeout(1000) } : {}),
245
+ });
246
+ ask('/health')
247
+ .then(async (answer) => {
248
+ if (!answer.ok) {
249
+ throw new Error(`it answered ${answer.status}`);
250
+ }
251
+ relayed = true;
252
+ if (!given.file) {
253
+ const settings = await ask('/config');
254
+ if (settings.ok) {
255
+ const served = (await settings.json());
256
+ try {
257
+ checkConfig(served, 'file');
258
+ file = served;
259
+ warnings = resolveConfig(file, call).configuration.core.warnings;
260
+ }
261
+ catch (error) {
262
+ once(`relay settings ${messageOf(error)}`, () => console.error(`helix diagnostics: the settings the relay served were not used. ${messageOf(error)}`));
263
+ }
264
+ }
265
+ }
266
+ plan.poke();
267
+ })
268
+ .catch((error) => once('no relay', () => console.warn(`helix diagnostics: no relay at ${relay} (${messageOf(error)}). Start it with \`helix diagnostics\`, or turn \`sinks.relay\` off.`)));
269
+ }
270
+ const plan = schedule(runNow);
271
+ const watcher = watch(page, {
272
+ onRender: () => plan.poke(),
273
+ onRuntime: () => plan.poke(),
274
+ performance: given.measure,
275
+ quiet: () => !warnings,
276
+ });
277
+ const poke = () => plan.poke();
278
+ const observer = typeof MutationObserver === 'function'
279
+ ? new MutationObserver((records) => {
280
+ if (records.some((record) => [...record.addedNodes, ...record.removedNodes].some((node) => node !== overlay?.element))) {
281
+ poke();
282
+ }
283
+ })
284
+ : undefined;
285
+ observer?.observe(page.documentElement, { childList: true, subtree: true });
286
+ const navigation = globalThis.navigation;
287
+ if (navigation) {
288
+ navigation.addEventListener('navigatesuccess', poke);
289
+ }
290
+ else {
291
+ globalThis.addEventListener?.('popstate', poke);
292
+ globalThis.addEventListener?.('hashchange', poke);
293
+ }
294
+ if (page.readyState === 'complete') {
295
+ poke();
296
+ }
297
+ else {
298
+ globalThis.addEventListener?.('load', poke, { once: true });
299
+ }
300
+ const stop = () => {
301
+ if (!active) {
302
+ return;
303
+ }
304
+ active = false;
305
+ plan.stop();
306
+ observer?.disconnect();
307
+ watcher.stop();
308
+ navigation?.removeEventListener('navigatesuccess', poke);
309
+ globalThis.removeEventListener?.('popstate', poke);
310
+ globalThis.removeEventListener?.('hashchange', poke);
311
+ globalThis.removeEventListener?.('load', poke);
312
+ listeners.clear();
313
+ overlay?.remove();
314
+ const store = globalThis;
315
+ if (store[INSTANCE] === handle) {
316
+ delete store[INSTANCE];
317
+ }
318
+ };
319
+ const handle = {
320
+ get active() {
321
+ return active;
322
+ },
323
+ get latest() {
324
+ return latest;
325
+ },
326
+ run: () => {
327
+ runNow();
328
+ return latest;
329
+ },
330
+ stop,
331
+ subscribe: (listener) => {
332
+ listeners.add(listener);
333
+ return () => listeners.delete(listener);
334
+ },
335
+ };
336
+ return handle;
337
+ };
@@ -51,12 +51,50 @@ export interface ManifestSource {
51
51
  }>;
52
52
  }>;
53
53
  }
54
- /** One manifest, and which tier shipped it. */
55
- export interface Tier {
54
+ /**
55
+ * What diagnostics reads of one package's manifest, and nothing else: per
56
+ * tag, the parts, the hooks, the attributes with the values each allows, the
57
+ * attributes the element writes on itself, and the family.
58
+ *
59
+ * **About a seventieth of the manifest.** Measured 4 Oct 2026: 124 KB for the
60
+ * styled tier and 103 KB for the headless one, 18 KB and 14 KB compressed,
61
+ * against manifests of 8.6 MB and 7.0 MB. `analyze` writes it beside each
62
+ * `custom-elements.json` as `lint.json`, and every Helix package publishes
63
+ * it, so a page can load the data for exactly the version it runs.
64
+ */
65
+ export interface LintData {
66
+ /** The package the data describes. */
67
+ package: string;
68
+ tags: Record<string, LintTag>;
69
+ /** The package's version, where the manifest says. */
70
+ version?: string | undefined;
71
+ }
72
+ /** What one tag publishes, as lint data says it. */
73
+ export interface LintTag {
74
+ /** Each attribute, with the values it allows, or `null` where it takes any. */
75
+ attributes: Record<string, string[] | null>;
76
+ /** The family, from the module's path. */
77
+ family?: string | undefined;
78
+ hooks: string[];
79
+ parts: string[];
80
+ /** The attributes the element writes on itself: one named for a read-only member. */
81
+ written: string[];
82
+ }
83
+ /**
84
+ * One installed tier: its manifest, with the package name, or its lint data,
85
+ * which names the package itself.
86
+ */
87
+ export type Tier = {
56
88
  manifest: ManifestSource;
57
89
  /** Package name, used verbatim in a finding's message. */
58
90
  name: string;
59
- }
91
+ } | {
92
+ lint: LintData;
93
+ };
94
+ /** The lint data for a manifest: what `analyze` writes, and what `index` reads. */
95
+ export declare const lintOf: (manifest: ManifestSource, name: string) => LintData;
96
+ /** A tier's lint data, read from its lint data or derived from its manifest. */
97
+ export declare const lintFor: (tier: Tier) => LintData;
60
98
  /**
61
99
  * What one tag publishes, per tier.
62
100
  *