chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* §20.4. The single owner of window lifecycle listeners.
|
||||
*
|
||||
* No capability adds its own `visibilitychange`, `online`, `offline`, `focus`,
|
||||
* `pagehide` or `pageshow` listener. They subscribe here instead, so the
|
||||
* listener count stays constant and leak inspection (§23.14) is meaningful.
|
||||
*
|
||||
* §20.6: nothing in this module is a correctness boundary. `beforeunload` is a
|
||||
* user prompt, never a place to complete a command, write a checkpoint or
|
||||
* guarantee a lease release.
|
||||
*/
|
||||
|
||||
export type BrowserLifecycleSnapshot = Readonly<{
|
||||
visibility: "VISIBLE" | "HIDDEN";
|
||||
connectivityHint: "ONLINE" | "OFFLINE";
|
||||
pageState: "ACTIVE" | "PAGEHIDE" | "BFCACHE_RESTORED";
|
||||
generation: number;
|
||||
}>;
|
||||
|
||||
export type BrowserLifecycleEvent =
|
||||
| Readonly<{ kind: "VISIBILITY_CHANGED"; snapshot: BrowserLifecycleSnapshot }>
|
||||
| Readonly<{ kind: "ONLINE"; snapshot: BrowserLifecycleSnapshot }>
|
||||
| Readonly<{ kind: "OFFLINE"; snapshot: BrowserLifecycleSnapshot }>
|
||||
| Readonly<{ kind: "FOCUS"; snapshot: BrowserLifecycleSnapshot }>
|
||||
| Readonly<{
|
||||
kind: "PAGEHIDE";
|
||||
persisted: boolean;
|
||||
snapshot: BrowserLifecycleSnapshot;
|
||||
}>
|
||||
| Readonly<{
|
||||
kind: "PAGESHOW";
|
||||
persisted: boolean;
|
||||
snapshot: BrowserLifecycleSnapshot;
|
||||
}>;
|
||||
|
||||
export type BrowserLifecycleRuntime = Readonly<{
|
||||
getSnapshot(): BrowserLifecycleSnapshot;
|
||||
subscribe(listener: (event: BrowserLifecycleEvent) => void): () => void;
|
||||
/**
|
||||
* Registers a dirty-state source. `beforeunload` is attached only while at
|
||||
* least one source reports dirty, and it uses the browser's standard prompt.
|
||||
*/
|
||||
registerDirtySource(isDirty: () => boolean): () => void;
|
||||
dispose(): void;
|
||||
}>;
|
||||
|
||||
type LifecycleHost = Readonly<{
|
||||
addEventListener: Window["addEventListener"];
|
||||
removeEventListener: Window["removeEventListener"];
|
||||
document?: Pick<Document, "visibilityState"> & {
|
||||
addEventListener: Document["addEventListener"];
|
||||
removeEventListener: Document["removeEventListener"];
|
||||
};
|
||||
navigator?: Pick<Navigator, "onLine">;
|
||||
}>;
|
||||
|
||||
export function createBrowserLifecycleRuntime(
|
||||
host: LifecycleHost = globalThis as unknown as LifecycleHost,
|
||||
): BrowserLifecycleRuntime {
|
||||
const listeners = new Set<(event: BrowserLifecycleEvent) => void>();
|
||||
const dirtySources = new Set<() => boolean>();
|
||||
const document = host.document;
|
||||
|
||||
let generation = 1;
|
||||
let visibility: BrowserLifecycleSnapshot["visibility"] =
|
||||
document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE";
|
||||
let connectivityHint: BrowserLifecycleSnapshot["connectivityHint"] =
|
||||
host.navigator?.onLine === false ? "OFFLINE" : "ONLINE";
|
||||
let pageState: BrowserLifecycleSnapshot["pageState"] = "ACTIVE";
|
||||
let disposed = false;
|
||||
let beforeUnloadAttached = false;
|
||||
|
||||
function snapshot(): BrowserLifecycleSnapshot {
|
||||
return Object.freeze({
|
||||
visibility,
|
||||
connectivityHint,
|
||||
pageState,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
|
||||
function publish(event: BrowserLifecycleEvent): void {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// One subscriber defect cannot suppress the signal for the others.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onVisibility = () => {
|
||||
visibility = document?.visibilityState === "hidden" ? "HIDDEN" : "VISIBLE";
|
||||
publish({ kind: "VISIBILITY_CHANGED", snapshot: snapshot() });
|
||||
};
|
||||
const onOnline = () => {
|
||||
connectivityHint = "ONLINE";
|
||||
publish({ kind: "ONLINE", snapshot: snapshot() });
|
||||
};
|
||||
const onOffline = () => {
|
||||
connectivityHint = "OFFLINE";
|
||||
publish({ kind: "OFFLINE", snapshot: snapshot() });
|
||||
};
|
||||
const onFocus = () => {
|
||||
publish({ kind: "FOCUS", snapshot: snapshot() });
|
||||
};
|
||||
const onPageHide = (event: Event) => {
|
||||
const persisted = (event as PageTransitionEvent).persisted === true;
|
||||
pageState = "PAGEHIDE";
|
||||
publish({ kind: "PAGEHIDE", persisted, snapshot: snapshot() });
|
||||
};
|
||||
const onPageShow = (event: Event) => {
|
||||
const persisted = (event as PageTransitionEvent).persisted === true;
|
||||
// §20.5. A bfcache restore is a new lifecycle generation, not a fresh boot.
|
||||
if (persisted) generation += 1;
|
||||
pageState = persisted ? "BFCACHE_RESTORED" : "ACTIVE";
|
||||
publish({ kind: "PAGESHOW", persisted, snapshot: snapshot() });
|
||||
};
|
||||
const onBeforeUnload = (event: Event) => {
|
||||
if (!hasDirtyState()) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
function hasDirtyState(): boolean {
|
||||
for (const isDirty of dirtySources) {
|
||||
try {
|
||||
if (isDirty()) return true;
|
||||
} catch {
|
||||
// A defective reporter is treated as clean rather than trapping the user.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function syncBeforeUnload(): void {
|
||||
const shouldAttach = dirtySources.size > 0;
|
||||
if (shouldAttach && !beforeUnloadAttached) {
|
||||
host.addEventListener("beforeunload", onBeforeUnload);
|
||||
beforeUnloadAttached = true;
|
||||
} else if (!shouldAttach && beforeUnloadAttached) {
|
||||
host.removeEventListener("beforeunload", onBeforeUnload);
|
||||
beforeUnloadAttached = false;
|
||||
}
|
||||
}
|
||||
|
||||
document?.addEventListener("visibilitychange", onVisibility);
|
||||
host.addEventListener("online", onOnline);
|
||||
host.addEventListener("offline", onOffline);
|
||||
host.addEventListener("focus", onFocus);
|
||||
host.addEventListener("pagehide", onPageHide);
|
||||
host.addEventListener("pageshow", onPageShow);
|
||||
|
||||
return Object.freeze({
|
||||
getSnapshot: snapshot,
|
||||
subscribe(listener) {
|
||||
if (disposed) return () => {};
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
registerDirtySource(isDirty) {
|
||||
if (disposed) return () => {};
|
||||
dirtySources.add(isDirty);
|
||||
syncBeforeUnload();
|
||||
return () => {
|
||||
dirtySources.delete(isDirty);
|
||||
syncBeforeUnload();
|
||||
};
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
document?.removeEventListener("visibilitychange", onVisibility);
|
||||
host.removeEventListener("online", onOnline);
|
||||
host.removeEventListener("offline", onOffline);
|
||||
host.removeEventListener("focus", onFocus);
|
||||
host.removeEventListener("pagehide", onPageHide);
|
||||
host.removeEventListener("pageshow", onPageShow);
|
||||
if (beforeUnloadAttached) {
|
||||
host.removeEventListener("beforeunload", onBeforeUnload);
|
||||
beforeUnloadAttached = false;
|
||||
}
|
||||
listeners.clear();
|
||||
dirtySources.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { MutationIntentFactory } from "../../application/ports/mutation-intent-factory.ts";
|
||||
import { defineMutationIntent } from "../../contracts/mutation-intent.ts";
|
||||
|
||||
export type BrowserMutationIntentFactoryDependencies = Readonly<{
|
||||
randomUUID?: () => string;
|
||||
monotonicNow?: () => number;
|
||||
}>;
|
||||
|
||||
export function createBrowserMutationIntentFactory(
|
||||
dependencies: BrowserMutationIntentFactoryDependencies = {},
|
||||
): MutationIntentFactory {
|
||||
const randomUUID =
|
||||
dependencies.randomUUID ??
|
||||
(() => {
|
||||
if (
|
||||
typeof globalThis.crypto === "undefined" ||
|
||||
typeof globalThis.crypto.randomUUID !== "function"
|
||||
) {
|
||||
throw new TypeError("Secure mutation identity generation is unavailable.");
|
||||
}
|
||||
return globalThis.crypto.randomUUID();
|
||||
});
|
||||
const monotonicNow =
|
||||
dependencies.monotonicNow ??
|
||||
(() => {
|
||||
if (
|
||||
typeof globalThis.performance === "undefined" ||
|
||||
typeof globalThis.performance.now !== "function"
|
||||
) {
|
||||
throw new TypeError("Monotonic time is unavailable.");
|
||||
}
|
||||
return globalThis.performance.now();
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
create(input) {
|
||||
const intentId = randomUUID();
|
||||
const idempotencyKey = input.requiresIdempotencyKey
|
||||
? randomUUID()
|
||||
: undefined;
|
||||
return defineMutationIntent({
|
||||
intentId,
|
||||
operationId: input.operationId,
|
||||
canonicalInputIdentity: input.canonicalInputIdentity,
|
||||
...(idempotencyKey === undefined ? {} : { idempotencyKey }),
|
||||
createdAtMonotonicMs: monotonicNow(),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ClockPort } from "../../application/ports/clock-port.ts";
|
||||
|
||||
export const systemClock: ClockPort = Object.freeze({
|
||||
now: () => Date.now(),
|
||||
sleep(milliseconds, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(signal?.reason);
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user