Files
tech-log-frontend/src/adapters/cross-context-invalidation/browser-cross-context-host.ts
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +09:00

277 lines
8.5 KiB
TypeScript

import {
isCacheInvalidationOpaqueIdentifier,
type CacheInvalidationTopicDefinition,
} from "../../contracts/cache-invalidation.ts";
import { STORAGE_REGISTRY } from "../../contracts/storage-keys.ts";
import {
createBrowserCrossContextInvalidation,
type BroadcastChannelFacade,
type BroadcastMessageListener,
type BrowserCrossContextInvalidation,
type CrossContextInvalidationObservation,
type StorageEventTargetFacade,
type StoragePulseFacade,
type StoragePulseListener,
} from "./browser-cross-context-invalidation.ts";
export type BrowserCrossContextHostDependencies = Readonly<{
host?: Record<string, unknown>;
cacheEpoch: string;
topics: readonly CacheInvalidationTopicDefinition[];
observe?: (observation: CrossContextInvalidationObservation) => void;
}>;
type NativeBroadcastChannel = Readonly<{
postMessage(value: unknown): void;
addEventListener(type: string, listener: (event: unknown) => void): void;
removeEventListener(
type: string,
listener: (event: unknown) => void,
): void;
close(): void;
}>;
const CHANNEL_NAME = "ca-client-cache-invalidation-v1";
// N-09. The registry owns the physical-key policy for the pulse.
const STORAGE_PULSE_KEY =
STORAGE_REGISTRY.CACHE_INVALIDATION_PULSE.physicalKey;
/**
* Captures native capabilities without allowing a SecurityError getter or a
* missing random source to fail application boot.
*/
export function createBrowserCrossContextInvalidationFromHost(
dependencies: BrowserCrossContextHostDependencies,
): BrowserCrossContextInvalidation | undefined {
// A zero-feature build owns no cross-context invalidation runtime. Preserve
// that property strictly: do not even probe browser capability getters.
if (dependencies.topics.length === 0) return undefined;
const host =
dependencies.host ??
(globalThis as unknown as Record<string, unknown>);
if (
!isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch)
) {
return undefined;
}
const createOpaqueId = randomIdFactory(host);
if (!createOpaqueId) return undefined;
const sourceId = createOpaqueId("tab");
const sourceEpoch = createOpaqueId("page");
if (!sourceId || !sourceEpoch) return undefined;
const capturedLocalStorage = captureLocalStorage(host);
return createBrowserCrossContextInvalidation({
channelName: CHANNEL_NAME,
storagePulseKey: STORAGE_PULSE_KEY,
sourceId,
sourceEpoch,
cacheEpoch: dependencies.cacheEpoch,
topics: dependencies.topics,
createEventId: () => {
const eventId = createOpaqueId("event");
if (!eventId) throw new TypeError("Secure random is unavailable.");
return eventId;
},
createBroadcastChannel: broadcastFactory(host),
// N-09. One capture, one identity: the write facade and the event
// validator must agree about which Storage object they trust. Reading the
// getter twice would let a hostile host return a different object.
storage: capturedLocalStorage
? storageFacade(capturedLocalStorage)
: undefined,
storageEvents: storageEventTarget(host, capturedLocalStorage),
observe: dependencies.observe,
});
}
function safeGet(
target: Record<string, unknown>,
property: string,
): unknown {
try {
return Reflect.get(target, property);
} catch {
return undefined;
}
}
function randomIdFactory(
host: Record<string, unknown>,
): ((prefix: string) => string | null) | undefined {
const cryptoCandidate = safeGet(host, "crypto");
if (!cryptoCandidate || typeof cryptoCandidate !== "object") {
return undefined;
}
const randomUuid = safeGet(
cryptoCandidate as Record<string, unknown>,
"randomUUID",
);
if (typeof randomUuid !== "function") return undefined;
return (prefix) => {
try {
const value = Reflect.apply(randomUuid, cryptoCandidate, []);
if (typeof value !== "string") return null;
const candidate = `${prefix}.${value}`;
return isCacheInvalidationOpaqueIdentifier(candidate)
? candidate
: null;
} catch {
return null;
}
};
}
function broadcastFactory(
host: Record<string, unknown>,
):
| ((name: string) => BroadcastChannelFacade)
| undefined {
const Constructor = safeGet(host, "BroadcastChannel");
if (typeof Constructor !== "function") return undefined;
return (name) => {
const candidate = Reflect.construct(Constructor, [name]) as unknown;
if (!isNativeBroadcastChannel(candidate)) {
throw new TypeError("BroadcastChannel is incompatible.");
}
const listenerBindings = new Map<
BroadcastMessageListener,
(event: unknown) => void
>();
return Object.freeze({
postMessage(value: unknown) {
candidate.postMessage(value);
},
addEventListener(
_type: "message",
listener: BroadcastMessageListener,
) {
const bound = (event: unknown) => {
listener({
data:
event && typeof event === "object"
? safeGet(
event as Record<string, unknown>,
"data",
)
: undefined,
});
};
listenerBindings.set(listener, bound);
candidate.addEventListener("message", bound);
},
removeEventListener(
_type: "message",
listener: BroadcastMessageListener,
) {
const bound = listenerBindings.get(listener);
if (!bound) return;
listenerBindings.delete(listener);
candidate.removeEventListener("message", bound);
},
close() {
listenerBindings.clear();
candidate.close();
},
});
};
}
function isNativeBroadcastChannel(
value: unknown,
): value is NativeBroadcastChannel {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return ["postMessage", "addEventListener", "removeEventListener", "close"].every(
(method) => typeof safeGet(candidate, method) === "function",
);
}
function captureLocalStorage(
host: Record<string, unknown>,
): object | undefined {
const candidate = safeGet(host, "localStorage");
return candidate && typeof candidate === "object" ? candidate : undefined;
}
function storageFacade(
candidate: object,
): StoragePulseFacade | undefined {
const record = candidate as Record<string, unknown>;
const setItem = safeGet(record, "setItem");
const removeItem = safeGet(record, "removeItem");
if (typeof setItem !== "function" || typeof removeItem !== "function") {
return undefined;
}
return Object.freeze({
setItem(key, value) {
Reflect.apply(setItem, candidate, [key, value]);
},
removeItem(key) {
Reflect.apply(removeItem, candidate, [key]);
},
});
}
function storageEventTarget(
host: Record<string, unknown>,
expectedLocalStorage: object | undefined,
): StorageEventTargetFacade | undefined {
const addEventListener = safeGet(host, "addEventListener");
const removeEventListener = safeGet(host, "removeEventListener");
if (
typeof addEventListener !== "function" ||
typeof removeEventListener !== "function"
) {
return undefined;
}
const bindings = new Map<
StoragePulseListener,
(event: unknown) => void
>();
return Object.freeze({
addEventListener(_type: "storage", listener: StoragePulseListener) {
const bound = (event: unknown) => {
if (!event || typeof event !== "object") {
listener({
key: null,
newValue: null,
storageArea: "OTHER_OR_UNKNOWN",
});
return;
}
const record = event as Record<string, unknown>;
const key = safeGet(record, "key");
const newValue = safeGet(record, "newValue");
const storageArea = safeGet(record, "storageArea");
listener({
key: typeof key === "string" ? key : null,
newValue: typeof newValue === "string" ? newValue : null,
// Compared by object identity against the captured area, never by
// shape or by re-reading `host.localStorage`.
storageArea:
expectedLocalStorage !== undefined &&
storageArea === expectedLocalStorage
? "EXPECTED_LOCAL_STORAGE"
: "OTHER_OR_UNKNOWN",
});
};
bindings.set(listener, bound);
Reflect.apply(addEventListener, host, ["storage", bound]);
},
removeEventListener(
_type: "storage",
listener: StoragePulseListener,
) {
const bound = bindings.get(listener);
if (!bound) return;
bindings.delete(listener);
Reflect.apply(removeEventListener, host, ["storage", bound]);
},
});
}