@celestia-island/hikari 0.46.0 → 0.47.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/package.json +1 -1
- package/src/composables/useConnectionProbe.ts +19 -0
- package/src/composables/useProgressDialog.ts +24 -0
- package/src/errorReporting/state.ts +30 -0
- package/src/i18n/context.ts +32 -0
- package/src/index.ts +16 -0
- package/src/runtime/animationBus.ts +42 -1
- package/src/runtime/cronBus.ts +18 -0
- package/src/runtime/historySafetyNet.ts +39 -0
- package/src/runtime/imageFallback.ts +25 -1
- package/src/runtime/index.ts +18 -0
- package/src/runtime/intervalBus.ts +18 -0
- package/src/runtime/navigationGuard.ts +20 -0
- package/src/runtime/pageLifecycle.ts +21 -1
- package/src/runtime/registry.integration.test.ts +309 -0
- package/src/runtime/registry.test.ts +279 -0
- package/src/runtime/registry.ts +315 -0
- package/src/runtime/tooltipBridge.ts +23 -1
- package/src/runtime/useBlockingToast.ts +16 -0
- package/src/runtime/useOverlay.ts +27 -1
- package/src/runtime/usePopupManager.ts +32 -0
- package/src/runtime/useToast.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { onMounted, onUnmounted, ref, type Ref } from "vue";
|
|
2
2
|
|
|
3
3
|
import { scheduleInterval, scheduleIntervalAfter, type IntervalHandle } from "../runtime/intervalBus";
|
|
4
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
|
|
4
5
|
|
|
5
6
|
/** Fetch-based connectivity probe — the hikari-local successor of the
|
|
6
7
|
* plana-ui probe. Polls `/api/health` (or a custom endpoint set via
|
|
@@ -28,6 +29,20 @@ const DEFAULT_RETRY_TOTAL = 3;
|
|
|
28
29
|
|
|
29
30
|
let probeEndpoint: string | (() => string) = "";
|
|
30
31
|
|
|
32
|
+
// Runtime-registry reporting (the "context of contexts"): the probe is a
|
|
33
|
+
// per-component composable, so the MODULE is the hook — one registry
|
|
34
|
+
// entry counting live probe instances, with the configured endpoint in
|
|
35
|
+
// the read facet. Registration API unchanged.
|
|
36
|
+
let liveProbes = 0;
|
|
37
|
+
let probeRuntime: HkRuntimeHandle | null = null;
|
|
38
|
+
function ensureProbeRuntime(): HkRuntimeHandle {
|
|
39
|
+
return (probeRuntime ??= reportHkRuntime("connectionProbe", {
|
|
40
|
+
kind: "hook",
|
|
41
|
+
description: "The fetch-based connectivity probe: per-component /api/health polling with retry and countdown.",
|
|
42
|
+
read: () => ({ liveProbes, endpoint: endpointUrl() }),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
/** Override the probed endpoint (defaults to same-origin `/api/health`). */
|
|
32
47
|
export function setProbeEndpoint(endpoint: string | (() => string)): void {
|
|
33
48
|
probeEndpoint = endpoint;
|
|
@@ -127,11 +142,15 @@ export function useConnectionProbe(): {
|
|
|
127
142
|
}
|
|
128
143
|
|
|
129
144
|
onMounted(() => {
|
|
145
|
+
liveProbes += 1;
|
|
146
|
+
ensureProbeRuntime().pulse({ liveProbes });
|
|
130
147
|
void probeOnce();
|
|
131
148
|
tickHandle = scheduleInterval(applyCountdown, 1000);
|
|
132
149
|
});
|
|
133
150
|
|
|
134
151
|
onUnmounted(() => {
|
|
152
|
+
liveProbes = Math.max(0, liveProbes - 1);
|
|
153
|
+
probeRuntime?.pulse({ liveProbes });
|
|
135
154
|
pollHandle?.disconnect();
|
|
136
155
|
pollHandle = null;
|
|
137
156
|
tickHandle?.disconnect();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { reactive } from "vue";
|
|
2
2
|
|
|
3
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
|
|
4
|
+
|
|
3
5
|
export interface ProgressDialogState {
|
|
4
6
|
open: boolean;
|
|
5
7
|
title: string;
|
|
@@ -24,7 +26,27 @@ const state = reactive<ProgressDialogState>({
|
|
|
24
26
|
max: 100,
|
|
25
27
|
});
|
|
26
28
|
|
|
29
|
+
// Runtime-registry reporting (the "context of contexts"). Lazy: the
|
|
30
|
+
// progress-dialog context reports itself on first use and pulses on
|
|
31
|
+
// open/close, so the live dialog tree is answerable from
|
|
32
|
+
// readHkRuntime("progressDialog") without importing this module.
|
|
33
|
+
let runtimeReport: HkRuntimeHandle | null = null;
|
|
34
|
+
function ensureRuntimeReport(): HkRuntimeHandle {
|
|
35
|
+
return (runtimeReport ??= reportHkRuntime("progressDialog", {
|
|
36
|
+
kind: "context",
|
|
37
|
+
description: "The progress dialog context: the single global task-progress surface (title, log tail, value/max).",
|
|
38
|
+
read: () => ({
|
|
39
|
+
open: state.open,
|
|
40
|
+
title: state.title,
|
|
41
|
+
logLines: state.logs.length,
|
|
42
|
+
value: state.value,
|
|
43
|
+
max: state.max,
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
27
48
|
export function showProgressDialog(opts: { title: string }): ProgressDialogHandle {
|
|
49
|
+
ensureRuntimeReport().pulse({ open: true, title: opts.title });
|
|
28
50
|
state.title = opts.title;
|
|
29
51
|
state.logs = [];
|
|
30
52
|
state.value = null;
|
|
@@ -43,10 +65,12 @@ export function showProgressDialog(opts: { title: string }): ProgressDialogHandl
|
|
|
43
65
|
},
|
|
44
66
|
close() {
|
|
45
67
|
state.open = false;
|
|
68
|
+
runtimeReport?.pulse({ open: false });
|
|
46
69
|
},
|
|
47
70
|
};
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
export function useProgressDialog(): ProgressDialogState {
|
|
74
|
+
ensureRuntimeReport();
|
|
51
75
|
return state;
|
|
52
76
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ref, type Ref } from "vue";
|
|
2
2
|
|
|
3
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "../runtime/registry";
|
|
4
|
+
|
|
3
5
|
/** Where a reported error came from. */
|
|
4
6
|
export type HkErrorSource = "vue" | "window" | "rejection" | "manual";
|
|
5
7
|
|
|
@@ -66,6 +68,10 @@ export function getErrorReportingOptions(): HkErrorReportingOptions {
|
|
|
66
68
|
|
|
67
69
|
export function setErrorReportingOptions(options: HkErrorReportingOptions): void {
|
|
68
70
|
activeOptions = options;
|
|
71
|
+
runtimeReport?.setMeta({
|
|
72
|
+
captureWindow: options.captureWindow !== false,
|
|
73
|
+
captureRejection: options.captureRejection !== false,
|
|
74
|
+
});
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
export function useErrorReportingState(): Ref<HkReportedError | null> {
|
|
@@ -86,6 +92,11 @@ export function reportError(err: unknown, source: HkErrorSource, info?: string):
|
|
|
86
92
|
|
|
87
93
|
if (currentError.value !== null) return null;
|
|
88
94
|
|
|
95
|
+
// Accepted onto the landing (first-error-wins): later errors while the
|
|
96
|
+
// landing is up still log/fire onError above but do not count.
|
|
97
|
+
acceptedReports += 1;
|
|
98
|
+
runtimeReport?.pulse({ reports: acceptedReports });
|
|
99
|
+
|
|
89
100
|
const normalized = normalizeError(err);
|
|
90
101
|
const record: HkReportedError = {
|
|
91
102
|
source,
|
|
@@ -102,3 +113,22 @@ export function reportError(err: unknown, source: HkErrorSource, info?: string):
|
|
|
102
113
|
export function clearErrorReportingState(): void {
|
|
103
114
|
currentError.value = null;
|
|
104
115
|
}
|
|
116
|
+
|
|
117
|
+
// Runtime-registry reporting (the "context of contexts"): the error
|
|
118
|
+
// reporting module is a global singleton, so it reports itself at module
|
|
119
|
+
// init and pulses on every accepted error. The read facet keeps the
|
|
120
|
+
// current landing card observable (name/message/source only — never the
|
|
121
|
+
// whole error object) from readHkRuntime("errorReporting").
|
|
122
|
+
let acceptedReports = 0;
|
|
123
|
+
const runtimeReport: HkRuntimeHandle = reportHkRuntime("errorReporting", {
|
|
124
|
+
kind: "plugin",
|
|
125
|
+
description: "The global error reporting landing: app.config.errorHandler + window/rejection hooks with the full-screen error card.",
|
|
126
|
+
meta: { captureWindow: activeOptions.captureWindow !== false, captureRejection: activeOptions.captureRejection !== false },
|
|
127
|
+
read: () => ({
|
|
128
|
+
reports: acceptedReports,
|
|
129
|
+
landingUp: currentError.value !== null,
|
|
130
|
+
current: currentError.value
|
|
131
|
+
? { name: currentError.value.name, message: currentError.value.message, source: currentError.value.source }
|
|
132
|
+
: null,
|
|
133
|
+
}),
|
|
134
|
+
});
|
package/src/i18n/context.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { reactive } from "vue";
|
|
2
2
|
|
|
3
|
+
import { reportHkRuntime } from "../runtime/registry";
|
|
4
|
+
|
|
3
5
|
type Messages = Record<string, string>;
|
|
4
6
|
|
|
5
7
|
interface LocaleModule {
|
|
@@ -63,6 +65,7 @@ export async function setLocale(locale: string): Promise<void> {
|
|
|
63
65
|
...(mergedMessages["en"] ?? {}),
|
|
64
66
|
...(mergedMessages[locale] ?? {}),
|
|
65
67
|
};
|
|
68
|
+
runtime.pulse({ locale });
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export function mergeMessages(userMessages: Messages, locale?: string): void {
|
|
@@ -82,6 +85,7 @@ export function mergeMessages(userMessages: Messages, locale?: string): void {
|
|
|
82
85
|
...(mergedMessages["en"] ?? {}),
|
|
83
86
|
...(mergedMessages[state.locale] ?? {}),
|
|
84
87
|
};
|
|
88
|
+
runtime.pulse({ locale: state.locale });
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
export function useI18n() {
|
|
@@ -96,3 +100,31 @@ export function useI18n() {
|
|
|
96
100
|
|
|
97
101
|
return { t, locale: state.locale };
|
|
98
102
|
}
|
|
103
|
+
|
|
104
|
+
// Runtime-registry reporting (the "context of contexts"): the i18n
|
|
105
|
+
// context is a module singleton, so it reports itself at module init and
|
|
106
|
+
// pulses on every locale switch / message merge. The facets make the
|
|
107
|
+
// locale tree reachable and steerable from anywhere via
|
|
108
|
+
// readHkRuntime("i18n") / writeHkRuntime("i18n", …) without importing
|
|
109
|
+
// this module.
|
|
110
|
+
const runtime = reportHkRuntime("i18n", {
|
|
111
|
+
kind: "context",
|
|
112
|
+
description: "The i18n context: active locale, message tables and per-locale merged overrides.",
|
|
113
|
+
meta: { locale: state.locale },
|
|
114
|
+
read: () => ({
|
|
115
|
+
locale: state.locale,
|
|
116
|
+
keys: Object.keys(state.messages).length,
|
|
117
|
+
mergedLocales: Object.keys(mergedMessages),
|
|
118
|
+
}),
|
|
119
|
+
write: (op) => {
|
|
120
|
+
if (op.type === "setLocale" && typeof op.locale === "string") {
|
|
121
|
+
void setLocale(op.locale);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (op.type === "mergeMessages" && typeof op.messages === "object" && op.messages !== null) {
|
|
125
|
+
mergeMessages(op.messages as Messages, typeof op.locale === "string" ? op.locale : undefined);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`unknown i18n write op "${op.type}"`);
|
|
129
|
+
},
|
|
130
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -244,6 +244,22 @@ export {
|
|
|
244
244
|
type HkTooltipBridgeOptions,
|
|
245
245
|
installHkImageFallback,
|
|
246
246
|
type HkImageFallbackOptions,
|
|
247
|
+
reportHkRuntime,
|
|
248
|
+
getHkRuntimeEntry,
|
|
249
|
+
listHkRuntime,
|
|
250
|
+
readHkRuntime,
|
|
251
|
+
writeHkRuntime,
|
|
252
|
+
hkRuntimeSnapshot,
|
|
253
|
+
useHkRegistry,
|
|
254
|
+
type HkRuntimeKind,
|
|
255
|
+
type HkRuntimeStatus,
|
|
256
|
+
type HkRuntimeMeta,
|
|
257
|
+
type HkRuntimeEntryState,
|
|
258
|
+
type HkRuntimeReport,
|
|
259
|
+
type HkRuntimeHandle,
|
|
260
|
+
type HkRuntimeWriteOp,
|
|
261
|
+
type HkRuntimeSnapshot,
|
|
262
|
+
type HkRuntimeSnapshotEntry,
|
|
247
263
|
} from "./runtime";
|
|
248
264
|
|
|
249
265
|
// i18n
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { setCssAnimationsEnabled } from "../animation/registerAnimations";
|
|
2
2
|
|
|
3
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "./registry";
|
|
4
|
+
|
|
3
5
|
export interface FrameContext {
|
|
4
6
|
delta: number;
|
|
5
7
|
elapsed: number;
|
|
@@ -81,6 +83,35 @@ function busyCount(): number {
|
|
|
81
83
|
return syncEntries.size + normalEntries.size + idleEntries.size + intervalEntries.size + activeTransitions.size;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Runtime-registry reporting (the "context of contexts"). Lazy: the bus
|
|
87
|
+
// reports itself the first time anything schedules onto it and pulses on
|
|
88
|
+
// every scheduling call / reduced-motion flip. The read facet exposes
|
|
89
|
+
// live load (entries per tier, paused) and the write facet lets another
|
|
90
|
+
// context or a host plugin flip reduced motion plugin-style via
|
|
91
|
+
// writeHkRuntime("animationBus", { type: "setReducedMotion", value }).
|
|
92
|
+
let runtimeReport: HkRuntimeHandle | null = null;
|
|
93
|
+
function ensureRuntimeReport(): HkRuntimeHandle {
|
|
94
|
+
return (runtimeReport ??= reportHkRuntime("animationBus", {
|
|
95
|
+
kind: "bus",
|
|
96
|
+
description: "The shared rAF frame bus: sync/normal/idle tiers, one-shots, transitions and bus-ridden intervals.",
|
|
97
|
+
read: () => ({
|
|
98
|
+
sync: syncEntries.size,
|
|
99
|
+
normal: normalEntries.size,
|
|
100
|
+
idle: idleEntries.size,
|
|
101
|
+
intervals: intervalEntries.size,
|
|
102
|
+
transitions: activeTransitions.size,
|
|
103
|
+
paused,
|
|
104
|
+
}),
|
|
105
|
+
write: (op) => {
|
|
106
|
+
if (op.type === "setReducedMotion" && typeof op.value === "boolean") {
|
|
107
|
+
setReducedMotion(op.value);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(`unknown animationBus write op "${op.type}"`);
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
function pickRegistry(priority: Priority): Map<string, Entry> {
|
|
85
116
|
switch (priority) {
|
|
86
117
|
case "sync": return syncEntries;
|
|
@@ -195,7 +226,10 @@ export function setReducedMotion(flag: boolean) {
|
|
|
195
226
|
// the switch on every call so a direct setCssAnimationsEnabled(false)
|
|
196
227
|
// from a performance-context caller is undone when motion resumes.
|
|
197
228
|
setCssAnimationsEnabled(!flag);
|
|
198
|
-
if (paused === flag)
|
|
229
|
+
if (paused === flag) {
|
|
230
|
+
ensureRuntimeReport().pulse({ paused });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
199
233
|
paused = flag;
|
|
200
234
|
if (flag) {
|
|
201
235
|
if (raf !== 0) {
|
|
@@ -209,6 +243,7 @@ export function setReducedMotion(flag: boolean) {
|
|
|
209
243
|
} else {
|
|
210
244
|
ensure();
|
|
211
245
|
}
|
|
246
|
+
ensureRuntimeReport().pulse({ paused });
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
export function notifyScrollStart() {
|
|
@@ -225,6 +260,7 @@ export function onFrame(cb: Callback, priority: Priority = "sync"): AnimationHan
|
|
|
225
260
|
const registry = pickRegistry(priority);
|
|
226
261
|
registry.set(id, { cb, priority, lastRun: 0 });
|
|
227
262
|
ensure();
|
|
263
|
+
ensureRuntimeReport().pulse();
|
|
228
264
|
return {
|
|
229
265
|
disconnect() {
|
|
230
266
|
registry.delete(id);
|
|
@@ -252,6 +288,7 @@ function pumpOneShots(now: number): void {
|
|
|
252
288
|
export function onceFrame(cb: Callback): void {
|
|
253
289
|
oneShotEntries.set(uid(), { cb, cancelled: false });
|
|
254
290
|
ensureOneShotDrain();
|
|
291
|
+
ensureRuntimeReport().pulse();
|
|
255
292
|
}
|
|
256
293
|
|
|
257
294
|
export function scheduleFrame(cb: Callback): AnimationHandle {
|
|
@@ -259,6 +296,7 @@ export function scheduleFrame(cb: Callback): AnimationHandle {
|
|
|
259
296
|
const id = uid();
|
|
260
297
|
oneShotEntries.set(id, entry);
|
|
261
298
|
ensureOneShotDrain();
|
|
299
|
+
ensureRuntimeReport().pulse();
|
|
262
300
|
return {
|
|
263
301
|
disconnect() {
|
|
264
302
|
entry.cancelled = true;
|
|
@@ -271,6 +309,7 @@ export function reportTransition(durationMs: number): AnimationHandle {
|
|
|
271
309
|
const id = uid();
|
|
272
310
|
activeTransitions.add(id);
|
|
273
311
|
ensure();
|
|
312
|
+
ensureRuntimeReport().pulse();
|
|
274
313
|
const timer = setTimeout(() => {
|
|
275
314
|
activeTransitions.delete(id);
|
|
276
315
|
halt();
|
|
@@ -288,6 +327,7 @@ export function scheduleEvery(cb: () => void, intervalMs: number): AnimationHand
|
|
|
288
327
|
const id = uid();
|
|
289
328
|
intervalEntries.set(id, { cb, interval: intervalMs, lastRun: 0, once: false });
|
|
290
329
|
ensure();
|
|
330
|
+
ensureRuntimeReport().pulse();
|
|
291
331
|
return {
|
|
292
332
|
disconnect() {
|
|
293
333
|
intervalEntries.delete(id);
|
|
@@ -300,6 +340,7 @@ export function scheduleAfter(cb: () => void, delayMs: number): AnimationHandle
|
|
|
300
340
|
const id = uid();
|
|
301
341
|
intervalEntries.set(id, { cb, interval: delayMs, lastRun: performance.now(), once: true });
|
|
302
342
|
ensure();
|
|
343
|
+
ensureRuntimeReport().pulse();
|
|
303
344
|
return {
|
|
304
345
|
disconnect() {
|
|
305
346
|
intervalEntries.delete(id);
|
package/src/runtime/cronBus.ts
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "./registry";
|
|
2
|
+
|
|
1
3
|
export interface CronHandle {
|
|
2
4
|
disconnect(): void;
|
|
3
5
|
}
|
|
4
6
|
|
|
7
|
+
// Runtime-registry reporting (the "context of contexts"). Lazy: the bus
|
|
8
|
+
// reports itself on first schedule and pulses on every schedule/
|
|
9
|
+
// disconnect. Bare-timer cron has no inspectable state beyond its
|
|
10
|
+
// liveness, which is exactly what the registry is for.
|
|
11
|
+
let runtimeReport: HkRuntimeHandle | null = null;
|
|
12
|
+
function ensureRuntimeReport(): HkRuntimeHandle {
|
|
13
|
+
return (runtimeReport ??= reportHkRuntime("cronBus", {
|
|
14
|
+
kind: "bus",
|
|
15
|
+
description: "The bare timer bus (setInterval/setTimeout passthrough) used by cron-grade callers like toast auto-dismiss.",
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
export function scheduleCron(cb: () => void, intervalMs: number): CronHandle {
|
|
6
20
|
const id = setInterval(cb, intervalMs);
|
|
21
|
+
ensureRuntimeReport().pulse();
|
|
7
22
|
return {
|
|
8
23
|
disconnect() {
|
|
9
24
|
clearInterval(id);
|
|
25
|
+
runtimeReport?.pulse();
|
|
10
26
|
},
|
|
11
27
|
};
|
|
12
28
|
}
|
|
@@ -17,9 +33,11 @@ export function scheduleCronAfter(cb: () => void, delayMs: number): CronHandle {
|
|
|
17
33
|
fired = true;
|
|
18
34
|
cb();
|
|
19
35
|
}, delayMs);
|
|
36
|
+
ensureRuntimeReport().pulse();
|
|
20
37
|
return {
|
|
21
38
|
disconnect() {
|
|
22
39
|
if (!fired) clearTimeout(id);
|
|
40
|
+
runtimeReport?.pulse();
|
|
23
41
|
},
|
|
24
42
|
};
|
|
25
43
|
}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* next field occurrence can be attributed.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { reportHkRuntime } from "./registry";
|
|
26
|
+
|
|
25
27
|
/** Bare literals that read as a producer bug, never a real target. */
|
|
26
28
|
const POISONED_LITERALS = new Set(["undefined", "null", "nan"]);
|
|
27
29
|
|
|
@@ -80,12 +82,47 @@ export function sanitizeHistoryUrl(
|
|
|
80
82
|
* harness) is detected and re-patched. */
|
|
81
83
|
const PATCH_MARKER = "__hikariHistoryNet";
|
|
82
84
|
|
|
85
|
+
// Runtime-registry reporting (the "context of contexts"): the net is
|
|
86
|
+
// install-and-forget, so it reports itself on INSTALL (not at module
|
|
87
|
+
// init — importing the module must not claim an installed hook) and
|
|
88
|
+
// pulses on every (re)install and every coercion — the install
|
|
89
|
+
// signature is unchanged. Coercions bump a running counter in the read
|
|
90
|
+
// facet so field occurrences are countable from
|
|
91
|
+
// readHkRuntime("historySafetyNet").
|
|
92
|
+
let coercions = 0;
|
|
93
|
+
let historyNetRuntime: ReturnType<typeof reportHkRuntime> | null = null;
|
|
94
|
+
/** Options captured by the CURRENTLY-PATCHED closure. A re-install while
|
|
95
|
+
* our patch is still on the prototype skips re-patching (the `continue`
|
|
96
|
+
* below), so the first install's fallback/evidence stay in effect — the
|
|
97
|
+
* registry meta must report THOSE, not the newest call's options, or it
|
|
98
|
+
* would misreport the active fold target. */
|
|
99
|
+
let activeFallback = "/";
|
|
100
|
+
let activeEvidenceKey: string | false = DEFAULT_EVIDENCE_KEY;
|
|
101
|
+
function ensureHistoryNetRuntime() {
|
|
102
|
+
return (historyNetRuntime ??= reportHkRuntime("historySafetyNet", {
|
|
103
|
+
kind: "hook",
|
|
104
|
+
description: "History.prototype pushState/replaceState net: off-origin or poisoned targets fold onto the app landing before the native call.",
|
|
105
|
+
read: () => ({ coercions }),
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
83
109
|
/** Patch History.prototype. Safe to call multiple times: already-
|
|
84
110
|
* patched methods are left alone, but a method overwritten back to a
|
|
85
111
|
* foreign implementation is patched again (self-healing). */
|
|
86
112
|
export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}): void {
|
|
87
113
|
const fallback = options.fallback ?? "/";
|
|
88
114
|
const evidenceKey = options.evidenceKey === undefined ? DEFAULT_EVIDENCE_KEY : options.evidenceKey;
|
|
115
|
+
const alreadyPatched =
|
|
116
|
+
typeof (History.prototype as unknown as Record<string, unknown>).pushState === "function" &&
|
|
117
|
+
((History.prototype as unknown as Record<string, unknown>).pushState as { [PATCH_MARKER]?: boolean })[PATCH_MARKER] === true;
|
|
118
|
+
if (!alreadyPatched) {
|
|
119
|
+
// Only an install that actually patches may claim its options as
|
|
120
|
+
// active; a no-op re-install keeps the first install's capture.
|
|
121
|
+
activeFallback = fallback;
|
|
122
|
+
activeEvidenceKey = evidenceKey;
|
|
123
|
+
}
|
|
124
|
+
ensureHistoryNetRuntime().setMeta({ fallback: activeFallback, evidenceKey: activeEvidenceKey === false ? "off" : activeEvidenceKey });
|
|
125
|
+
ensureHistoryNetRuntime().pulse();
|
|
89
126
|
for (const method of ["pushState", "replaceState"] as const) {
|
|
90
127
|
const current: unknown = (History.prototype as unknown as Record<string, unknown>)[method];
|
|
91
128
|
if (
|
|
@@ -100,6 +137,8 @@ export function installHistorySafetyNet(options: HistorySafetyNetOptions = {}):
|
|
|
100
137
|
const safe = sanitizeHistoryUrl(url, fallback);
|
|
101
138
|
if (safe !== url) {
|
|
102
139
|
const info: HistoryCoercion = { method, raw: String(url ?? ""), foldedTo: fallback };
|
|
140
|
+
coercions += 1;
|
|
141
|
+
historyNetRuntime?.pulse({ coercions });
|
|
103
142
|
reportCoercion(info, evidenceKey, options);
|
|
104
143
|
}
|
|
105
144
|
return native.call(this, state as never, title, safe as never);
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
// no src swap — for hosts that style the class themselves)
|
|
28
28
|
import "./imageFallback.scss";
|
|
29
29
|
|
|
30
|
+
import { reportHkRuntime } from "./registry";
|
|
31
|
+
|
|
30
32
|
export interface HkImageFallbackOptions {
|
|
31
33
|
/** Which images are covered. Default: `img`. */
|
|
32
34
|
selector?: string;
|
|
@@ -57,6 +59,10 @@ interface FallbackState {
|
|
|
57
59
|
selector: string;
|
|
58
60
|
exclude: string;
|
|
59
61
|
fallbackSrc: string;
|
|
62
|
+
/** How many images this install has re-rendered (registry meta). */
|
|
63
|
+
marked: number;
|
|
64
|
+
/** Runtime-registry handle for this install (the "context of contexts"). */
|
|
65
|
+
runtime?: ReturnType<typeof reportHkRuntime>;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
const INSTALLS = new WeakMap<Document, FallbackState>();
|
|
@@ -72,6 +78,8 @@ function onError(state: FallbackState, e: Event) {
|
|
|
72
78
|
if (state.fallbackSrc && target.getAttribute("src") !== state.fallbackSrc) {
|
|
73
79
|
target.src = state.fallbackSrc;
|
|
74
80
|
}
|
|
81
|
+
state.marked += 1;
|
|
82
|
+
state.runtime?.pulse({ marked: state.marked });
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
function onLoad(state: FallbackState, e: Event) {
|
|
@@ -91,12 +99,16 @@ function uninstall(state: FallbackState) {
|
|
|
91
99
|
for (const [type, fn] of state.listeners.splice(0)) {
|
|
92
100
|
state.root.removeEventListener(type, fn, true);
|
|
93
101
|
}
|
|
102
|
+
state.runtime?.dispose();
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/**
|
|
97
106
|
* Install the document-level broken-image re-render. Returns the
|
|
98
107
|
* uninstaller (removes listeners; already-marked images keep their
|
|
99
|
-
* placeholder). Idempotent per document.
|
|
108
|
+
* placeholder). Idempotent per document. The install reports itself to
|
|
109
|
+
* the runtime registry (`imageFallback`, kind "hook") and pulses with a
|
|
110
|
+
* running `marked` count as images are re-rendered — the signature and
|
|
111
|
+
* options are unchanged.
|
|
100
112
|
*/
|
|
101
113
|
export function installHkImageFallback(options: HkImageFallbackOptions = {}): () => void {
|
|
102
114
|
if (typeof document === "undefined") return () => undefined;
|
|
@@ -109,7 +121,19 @@ export function installHkImageFallback(options: HkImageFallbackOptions = {}): ()
|
|
|
109
121
|
selector: options.selector ?? "img",
|
|
110
122
|
exclude: options.exclude ?? "[data-hk-img-native]",
|
|
111
123
|
fallbackSrc: options.fallbackSrc !== undefined ? options.fallbackSrc : DEFAULT_FALLBACK_SRC,
|
|
124
|
+
marked: 0,
|
|
112
125
|
};
|
|
126
|
+
state.runtime = reportHkRuntime("imageFallback", {
|
|
127
|
+
kind: "hook",
|
|
128
|
+
description: "Document-level broken-image re-render: swaps failed <img> loads for the muted placeholder tile.",
|
|
129
|
+
meta: {
|
|
130
|
+
selector: state.selector,
|
|
131
|
+
exclude: state.exclude,
|
|
132
|
+
fallback: state.fallbackSrc === "" ? "class-only" : state.fallbackSrc === DEFAULT_FALLBACK_SRC ? "bundled" : "custom",
|
|
133
|
+
marked: 0,
|
|
134
|
+
},
|
|
135
|
+
read: () => ({ installed: true, marked: state.marked }),
|
|
136
|
+
});
|
|
113
137
|
|
|
114
138
|
// Resource errors never bubble — capture phase is the only delegation
|
|
115
139
|
// point that sees them from the document.
|
package/src/runtime/index.ts
CHANGED
|
@@ -49,3 +49,21 @@ export { provideScrollWindow, useScrollWindow, type ScrollWindowContext } from "
|
|
|
49
49
|
export { useReportedTransition, type ReportedTransition, type ReportedTransitionTrack } from "../composables/useReportedTransition";
|
|
50
50
|
export { showProgressDialog, useProgressDialog, type ProgressDialogHandle, type ProgressDialogState } from "../composables/useProgressDialog";
|
|
51
51
|
export { useSafeArea, type SafeAreaInsets } from "../composables/useSafeArea";
|
|
52
|
+
export {
|
|
53
|
+
reportHkRuntime,
|
|
54
|
+
getHkRuntimeEntry,
|
|
55
|
+
listHkRuntime,
|
|
56
|
+
readHkRuntime,
|
|
57
|
+
writeHkRuntime,
|
|
58
|
+
hkRuntimeSnapshot,
|
|
59
|
+
useHkRegistry,
|
|
60
|
+
type HkRuntimeKind,
|
|
61
|
+
type HkRuntimeStatus,
|
|
62
|
+
type HkRuntimeMeta,
|
|
63
|
+
type HkRuntimeEntryState,
|
|
64
|
+
type HkRuntimeReport,
|
|
65
|
+
type HkRuntimeHandle,
|
|
66
|
+
type HkRuntimeWriteOp,
|
|
67
|
+
type HkRuntimeSnapshot,
|
|
68
|
+
type HkRuntimeSnapshotEntry,
|
|
69
|
+
} from "./registry";
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* burning down on a throttled timer.
|
|
30
30
|
*/
|
|
31
31
|
import { onPageLifecycle } from "./pageLifecycle";
|
|
32
|
+
import { reportHkRuntime, type HkRuntimeHandle } from "./registry";
|
|
32
33
|
|
|
33
34
|
export interface IntervalHandle {
|
|
34
35
|
disconnect(): void;
|
|
@@ -46,6 +47,19 @@ interface Slot {
|
|
|
46
47
|
const slots = new Set<Slot>();
|
|
47
48
|
let lifecycleUnsub: (() => void) | null = null;
|
|
48
49
|
|
|
50
|
+
// Runtime-registry reporting (the "context of contexts"). Lazy: the bus
|
|
51
|
+
// reports itself on first schedule and pulses on every schedule/
|
|
52
|
+
// disconnect, so polling liveness is answerable from
|
|
53
|
+
// readHkRuntime("intervalBus") without importing this module.
|
|
54
|
+
let runtimeReport: HkRuntimeHandle | null = null;
|
|
55
|
+
function ensureRuntimeReport(): HkRuntimeHandle {
|
|
56
|
+
return (runtimeReport ??= reportHkRuntime("intervalBus", {
|
|
57
|
+
kind: "bus",
|
|
58
|
+
description: "The visibility-aware interval bus for data polling (parks while hidden, catches up on return).",
|
|
59
|
+
read: () => ({ slots: slots.size, once: Array.from(slots).filter((s) => s.once).length }),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
/** Run a slot callback with bus isolation: a throwing consumer is
|
|
50
64
|
* reported but never takes the shared timer/lifecycle machinery down
|
|
51
65
|
* (mirrors pageLifecycle's per-listener isolation). A repeat slot whose
|
|
@@ -149,12 +163,14 @@ export function scheduleInterval(cb: () => void, intervalMs: number): IntervalHa
|
|
|
149
163
|
if (typeof document === "undefined" || !document.hidden) {
|
|
150
164
|
arm(slot);
|
|
151
165
|
}
|
|
166
|
+
ensureRuntimeReport().pulse();
|
|
152
167
|
return {
|
|
153
168
|
disconnect() {
|
|
154
169
|
slot.disconnected = true;
|
|
155
170
|
park(slot);
|
|
156
171
|
slots.delete(slot);
|
|
157
172
|
maybePark();
|
|
173
|
+
runtimeReport?.pulse();
|
|
158
174
|
},
|
|
159
175
|
};
|
|
160
176
|
}
|
|
@@ -178,12 +194,14 @@ export function scheduleIntervalAfter(cb: () => void, delayMs: number): Interval
|
|
|
178
194
|
if (typeof document === "undefined" || !document.hidden) {
|
|
179
195
|
arm(slot);
|
|
180
196
|
}
|
|
197
|
+
ensureRuntimeReport().pulse();
|
|
181
198
|
return {
|
|
182
199
|
disconnect() {
|
|
183
200
|
slot.disconnected = true;
|
|
184
201
|
park(slot);
|
|
185
202
|
slots.delete(slot);
|
|
186
203
|
maybePark();
|
|
204
|
+
runtimeReport?.pulse();
|
|
187
205
|
},
|
|
188
206
|
};
|
|
189
207
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { installHistorySafetyNet, type HistorySafetyNetOptions } from "./historySafetyNet";
|
|
16
|
+
import { reportHkRuntime } from "./registry";
|
|
16
17
|
|
|
17
18
|
/** Bare literals that read as a producer bug, never a real target. */
|
|
18
19
|
const POISONED_LITERALS = new Set(["undefined", "null", "nan"]);
|
|
@@ -41,17 +42,34 @@ export interface NavigationSafetyNetOptions extends HistorySafetyNetOptions {
|
|
|
41
42
|
router?: GuardRouter;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
// Runtime-registry reporting (the "context of contexts"): the guard
|
|
46
|
+
// reports itself on INSTALL (not at module init — importing the module
|
|
47
|
+
// must not claim an installed guard) and counts blocked navigations —
|
|
48
|
+
// the install signature is unchanged.
|
|
49
|
+
let blocked = 0;
|
|
50
|
+
let navGuardRuntime: ReturnType<typeof reportHkRuntime> | null = null;
|
|
51
|
+
function ensureNavGuardRuntime() {
|
|
52
|
+
return (navGuardRuntime ??= reportHkRuntime("navigationGuard", {
|
|
53
|
+
kind: "hook",
|
|
54
|
+
description: "Router-level poisoned-target guard: bad navigation targets die as in-app redirects to the landing route.",
|
|
55
|
+
read: () => ({ blocked }),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
/** Register the beforeEach poisoned-target fold on a router. */
|
|
45
60
|
export function createPoisonedLocationGuard(
|
|
46
61
|
router: GuardRouter,
|
|
47
62
|
fallback = "/",
|
|
48
63
|
): void {
|
|
64
|
+
ensureNavGuardRuntime();
|
|
49
65
|
router.beforeEach((to: GuardRoute) => {
|
|
50
66
|
const path = to.path ?? "";
|
|
51
67
|
if (!path.startsWith("/") || POISONED_LITERALS.has(path)) {
|
|
52
68
|
console.warn(
|
|
53
69
|
`[Router] Blocked poisoned navigation target "${path}" (fullPath "${to.fullPath}") — redirecting to "${fallback}"`,
|
|
54
70
|
);
|
|
71
|
+
blocked += 1;
|
|
72
|
+
navGuardRuntime?.pulse({ blocked });
|
|
55
73
|
return fallback;
|
|
56
74
|
}
|
|
57
75
|
return true;
|
|
@@ -63,5 +81,7 @@ export function createPoisonedLocationGuard(
|
|
|
63
81
|
* navigation (module scope of the app's router setup is ideal). */
|
|
64
82
|
export function installNavigationSafetyNet(options: NavigationSafetyNetOptions = {}): void {
|
|
65
83
|
installHistorySafetyNet(options);
|
|
84
|
+
ensureNavGuardRuntime().setMeta({ routerGuard: Boolean(options.router), fallback: options.fallback ?? "/" });
|
|
85
|
+
ensureNavGuardRuntime().pulse();
|
|
66
86
|
if (options.router) createPoisonedLocationGuard(options.router, options.fallback ?? "/");
|
|
67
87
|
}
|