@hraness/direct 0.7.5
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/LICENSE +21 -0
- package/README.md +436 -0
- package/dist/core/index.js +162 -0
- package/dist/index-1csg00w4.js +1167 -0
- package/dist/index-6mdfd2ey.js +464 -0
- package/dist/index-7n1h75n6.js +616 -0
- package/dist/index.js +232 -0
- package/dist/react.js +32 -0
- package/dist/testing/index.js +1069 -0
- package/dist/tooling/bombadil.js +2117 -0
- package/dist/tooling/browser-verification-entry.js +1499 -0
- package/dist/tooling/bundle-boundary.js +119 -0
- package/dist/web.js +605 -0
- package/package.json +179 -0
- package/skills/direct/AGENTS.md +13 -0
- package/skills/direct/SKILL.md +49 -0
- package/skills/direct/agents/openai.yaml +4 -0
- package/skills/direct/references/adoption.md +131 -0
- package/skills/direct/references/install.md +91 -0
- package/skills/direct/references/verification.md +247 -0
- package/src/core/coverage.ts +336 -0
- package/src/core/definition.ts +378 -0
- package/src/core/effects.ts +88 -0
- package/src/core/fixture.ts +185 -0
- package/src/core/ids.ts +77 -0
- package/src/core/index.ts +13 -0
- package/src/core/json-value.ts +7 -0
- package/src/core/json.ts +593 -0
- package/src/core/query.ts +230 -0
- package/src/core/reason.ts +16 -0
- package/src/core/resource.ts +10 -0
- package/src/core/result.ts +19 -0
- package/src/core/runtime.ts +229 -0
- package/src/core/scenario.ts +149 -0
- package/src/core/store.ts +784 -0
- package/src/index.ts +51 -0
- package/src/react.ts +54 -0
- package/src/testing/activity.ts +228 -0
- package/src/testing/coverage-binding.ts +99 -0
- package/src/testing/evidence.ts +59 -0
- package/src/testing/index.ts +22 -0
- package/src/testing/manifest.ts +559 -0
- package/src/testing/probe.ts +446 -0
- package/src/testing/scripted-transport.ts +775 -0
- package/src/testing/session.ts +525 -0
- package/src/tooling/bombadil-campaign.ts +288 -0
- package/src/tooling/bombadil-internal.d.ts +46 -0
- package/src/tooling/bombadil-runner.ts +1424 -0
- package/src/tooling/bombadil.ts +27 -0
- package/src/tooling/browser-verification-entry.ts +32 -0
- package/src/tooling/browser-verification.ts +916 -0
- package/src/tooling/bundle-boundary.ts +159 -0
- package/src/web/browser-bridge.ts +296 -0
- package/src/web/browser.ts +277 -0
- package/src/web/fetch-firewall.ts +251 -0
- package/src/web.ts +27 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const DIRECT_WIRE_MARKERS = Object.freeze([
|
|
4
|
+
"direct.browser-bridge/",
|
|
5
|
+
"direct.coverage/",
|
|
6
|
+
"direct.fixture/",
|
|
7
|
+
"direct.probe/",
|
|
8
|
+
"direct.runtime/",
|
|
9
|
+
"direct.session-manifest/",
|
|
10
|
+
] as const);
|
|
11
|
+
|
|
12
|
+
export interface BundleBoundaryViolation {
|
|
13
|
+
readonly file: string;
|
|
14
|
+
readonly markers: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BundleBoundaryResult {
|
|
18
|
+
readonly scanned: readonly string[];
|
|
19
|
+
readonly violations: readonly BundleBoundaryViolation[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BundleBoundaryOptions {
|
|
23
|
+
readonly directory: string;
|
|
24
|
+
readonly excludePatterns?: readonly string[];
|
|
25
|
+
readonly markers: readonly string[];
|
|
26
|
+
readonly patterns: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ExactVersionedMarkerEvidence {
|
|
30
|
+
readonly missing: readonly string[];
|
|
31
|
+
readonly observed: readonly string[];
|
|
32
|
+
readonly unexpected: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validatedMarkers(markers: readonly string[]): readonly string[] {
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
const output: string[] = [];
|
|
38
|
+
for (const marker of markers) {
|
|
39
|
+
if (marker.length === 0) throw new Error("Bundle-boundary markers cannot be empty.");
|
|
40
|
+
if (seen.has(marker)) throw new Error(`Bundle-boundary marker is duplicated: ${marker}`);
|
|
41
|
+
seen.add(marker);
|
|
42
|
+
output.push(marker);
|
|
43
|
+
}
|
|
44
|
+
if (output.length === 0) throw new Error("A bundle boundary needs at least one forbidden marker.");
|
|
45
|
+
return Object.freeze(output);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function validatedPatterns(patterns: readonly string[]): readonly string[] {
|
|
49
|
+
if (patterns.length === 0) throw new Error("A bundle boundary needs at least one file pattern.");
|
|
50
|
+
return Object.freeze(patterns.map((pattern) => {
|
|
51
|
+
if (pattern.length === 0) throw new Error("Bundle-boundary file patterns cannot be empty.");
|
|
52
|
+
return pattern;
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validatedExcludePatterns(patterns: readonly string[] | undefined): readonly string[] {
|
|
57
|
+
return Object.freeze((patterns ?? []).map((pattern) => {
|
|
58
|
+
if (pattern.length === 0) throw new Error("Bundle-boundary exclusion patterns cannot be empty.");
|
|
59
|
+
return pattern;
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function versionedMarkerFamilies(
|
|
64
|
+
expectedMarkers: readonly string[],
|
|
65
|
+
): readonly { readonly expected: string; readonly family: string }[] {
|
|
66
|
+
if (expectedMarkers.length === 0) {
|
|
67
|
+
throw new Error("An exact versioned-marker policy needs at least one expected marker.");
|
|
68
|
+
}
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
return Object.freeze(expectedMarkers.map((expected) => {
|
|
71
|
+
const match = /^(?<family>[A-Za-z0-9][A-Za-z0-9._/-]*\/v)(?<version>0|[1-9][0-9]*)$/u
|
|
72
|
+
.exec(expected);
|
|
73
|
+
const family = match?.groups?.["family"];
|
|
74
|
+
if (family === undefined) {
|
|
75
|
+
throw new Error(`Exact versioned marker must end in a canonical numeric version: ${expected}`);
|
|
76
|
+
}
|
|
77
|
+
if (seen.has(family)) {
|
|
78
|
+
throw new Error(`Exact versioned-marker family is duplicated: ${family}`);
|
|
79
|
+
}
|
|
80
|
+
seen.add(family);
|
|
81
|
+
return Object.freeze({ expected, family });
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function escapedRegExp(value: string): string {
|
|
86
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function inspectExactVersionedMarkers(
|
|
90
|
+
byteSequences: Iterable<Uint8Array>,
|
|
91
|
+
expectedMarkers: readonly string[],
|
|
92
|
+
): ExactVersionedMarkerEvidence {
|
|
93
|
+
const families = versionedMarkerFamilies(expectedMarkers);
|
|
94
|
+
const observed = new Set<string>();
|
|
95
|
+
const contents = [...byteSequences].map((bytes) => (
|
|
96
|
+
Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("latin1")
|
|
97
|
+
));
|
|
98
|
+
for (const { family } of families) {
|
|
99
|
+
const pattern = new RegExp(
|
|
100
|
+
`(?<![A-Za-z0-9._/-])${escapedRegExp(family)}[0-9]+(?![A-Za-z0-9._/-])`,
|
|
101
|
+
"gu",
|
|
102
|
+
);
|
|
103
|
+
for (const content of contents) {
|
|
104
|
+
for (const match of content.matchAll(pattern)) observed.add(match[0]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const expected = new Set(expectedMarkers);
|
|
108
|
+
const matching = expectedMarkers.filter((marker) => observed.has(marker));
|
|
109
|
+
const unexpected = [...observed]
|
|
110
|
+
.filter((marker) => !expected.has(marker))
|
|
111
|
+
.sort((left, right) => left.localeCompare(right));
|
|
112
|
+
return Object.freeze({
|
|
113
|
+
missing: Object.freeze(expectedMarkers.filter((marker) => !observed.has(marker))),
|
|
114
|
+
observed: Object.freeze([...matching, ...unexpected]),
|
|
115
|
+
unexpected: Object.freeze(unexpected),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function findForbiddenMarkers(
|
|
120
|
+
bytes: Uint8Array,
|
|
121
|
+
markers: readonly string[],
|
|
122
|
+
): readonly string[] {
|
|
123
|
+
const contents = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
124
|
+
return validatedMarkers(markers).filter((marker) => contents.includes(Buffer.from(marker)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function checkBundleBoundary(
|
|
128
|
+
options: BundleBoundaryOptions,
|
|
129
|
+
): Promise<BundleBoundaryResult> {
|
|
130
|
+
const root = path.resolve(options.directory);
|
|
131
|
+
const markers = validatedMarkers(options.markers);
|
|
132
|
+
const patterns = validatedPatterns(options.patterns);
|
|
133
|
+
const excludePatterns = validatedExcludePatterns(options.excludePatterns)
|
|
134
|
+
.map((pattern) => new Bun.Glob(pattern));
|
|
135
|
+
const scanned = new Set<string>();
|
|
136
|
+
const violations: BundleBoundaryViolation[] = [];
|
|
137
|
+
|
|
138
|
+
for (const pattern of patterns) {
|
|
139
|
+
const glob = new Bun.Glob(pattern);
|
|
140
|
+
for await (const relative of glob.scan({ cwd: root, dot: true, onlyFiles: true })) {
|
|
141
|
+
if (excludePatterns.some((excludePattern) => excludePattern.match(relative))) continue;
|
|
142
|
+
const file = path.join(root, relative);
|
|
143
|
+
if (scanned.has(file)) continue;
|
|
144
|
+
scanned.add(file);
|
|
145
|
+
const found = findForbiddenMarkers(
|
|
146
|
+
new Uint8Array(await Bun.file(file).arrayBuffer()),
|
|
147
|
+
markers,
|
|
148
|
+
);
|
|
149
|
+
if (found.length > 0) violations.push({ file, markers: found });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
scanned: Object.freeze([...scanned].sort()),
|
|
155
|
+
violations: Object.freeze(
|
|
156
|
+
[...violations].sort((left, right) => left.file.localeCompare(right.file)),
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { renderUnknownReason } from "../core/reason.js";
|
|
2
|
+
import { err, ok, type Result } from "../core/result.js";
|
|
3
|
+
import {
|
|
4
|
+
parseDirectProbeSnapshot,
|
|
5
|
+
type DirectProbe,
|
|
6
|
+
type DirectProbeSnapshot,
|
|
7
|
+
} from "../testing/probe.js";
|
|
8
|
+
import {
|
|
9
|
+
parseDirectSessionManifest,
|
|
10
|
+
type DirectSessionManifest,
|
|
11
|
+
} from "../testing/manifest.js";
|
|
12
|
+
|
|
13
|
+
export const DIRECT_BROWSER_BRIDGE_SCHEMA = "direct.browser-bridge/v2" as const;
|
|
14
|
+
|
|
15
|
+
export interface DirectBrowserBridge {
|
|
16
|
+
readonly schema: typeof DIRECT_BROWSER_BRIDGE_SCHEMA;
|
|
17
|
+
readonly manifest: DirectSessionManifest;
|
|
18
|
+
readonly snapshot: () => DirectProbeSnapshot;
|
|
19
|
+
readonly reset: () => undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DirectBrowserBridgeOptions {
|
|
23
|
+
readonly probe: Pick<DirectProbe, "snapshot">;
|
|
24
|
+
readonly manifest: unknown;
|
|
25
|
+
readonly reset?: () => undefined;
|
|
26
|
+
readonly target?: object;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type DirectBrowserBridgeErrorCode = "install-failed" | "invalid-manifest";
|
|
30
|
+
|
|
31
|
+
export interface DirectBrowserBridgeError {
|
|
32
|
+
readonly code: DirectBrowserBridgeErrorCode;
|
|
33
|
+
readonly message: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type DirectBrowserBridgeUninstall = () => undefined;
|
|
37
|
+
|
|
38
|
+
export interface PreparedDirectBrowserBridgeInstallation {
|
|
39
|
+
/** Make this provisional replacement the process owner. Cannot fail. */
|
|
40
|
+
readonly commit: () => undefined;
|
|
41
|
+
/** Restore the exact owner observed before preparation. */
|
|
42
|
+
readonly rollback: () => undefined;
|
|
43
|
+
/** Remove a committed replacement and restore its underlying owner. */
|
|
44
|
+
readonly uninstall: DirectBrowserBridgeUninstall;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const BRIDGE_KEYS = ["__direct"] as const;
|
|
48
|
+
|
|
49
|
+
interface ActiveBridgeInstallation {
|
|
50
|
+
readonly target: object;
|
|
51
|
+
readonly installed: ReadonlyMap<string, unknown>;
|
|
52
|
+
readonly restore: ReadonlyMap<string, PropertyDescriptor | undefined>;
|
|
53
|
+
readonly deactivate: () => undefined;
|
|
54
|
+
readonly uninstall: DirectBrowserBridgeUninstall;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let activeBridgeInstallation: ActiveBridgeInstallation | null = null;
|
|
58
|
+
|
|
59
|
+
function bridgeError(
|
|
60
|
+
code: DirectBrowserBridgeErrorCode,
|
|
61
|
+
message: string,
|
|
62
|
+
): DirectBrowserBridgeError {
|
|
63
|
+
return Object.freeze({ code, message });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Consume a foreign thenable so a callback that lied about being synchronous cannot reject globally. */
|
|
67
|
+
function containPromiseLike(value: unknown): boolean {
|
|
68
|
+
if ((typeof value !== "object" || value === null) && typeof value !== "function") return false;
|
|
69
|
+
let then: unknown;
|
|
70
|
+
try {
|
|
71
|
+
then = Reflect.get(value, "then");
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (typeof then !== "function") return false;
|
|
76
|
+
try {
|
|
77
|
+
void Promise.resolve(value).catch(() => undefined);
|
|
78
|
+
} catch {
|
|
79
|
+
// Promise assimilation is a foreign boundary too. The callback remains contained.
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requireSynchronousResetResult(value: unknown): undefined {
|
|
85
|
+
containPromiseLike(value);
|
|
86
|
+
if (value !== undefined) {
|
|
87
|
+
throw new Error("Direct reset must complete synchronously and return undefined");
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function defaultReset(): undefined {
|
|
93
|
+
const target = globalThis as typeof globalThis & {
|
|
94
|
+
readonly location?: { readonly reload?: () => unknown };
|
|
95
|
+
};
|
|
96
|
+
return requireSynchronousResetResult(target.location?.reload?.());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function restoreDescriptor(target: object, key: string, descriptor: PropertyDescriptor | undefined): void {
|
|
100
|
+
if (descriptor === undefined) {
|
|
101
|
+
Reflect.deleteProperty(target, key);
|
|
102
|
+
} else {
|
|
103
|
+
Object.defineProperty(target, key, descriptor);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function restoreInstalledValue(
|
|
108
|
+
target: object,
|
|
109
|
+
key: string,
|
|
110
|
+
installedValue: unknown,
|
|
111
|
+
previous: PropertyDescriptor | undefined,
|
|
112
|
+
): void {
|
|
113
|
+
try {
|
|
114
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, key);
|
|
115
|
+
if (descriptor?.value === installedValue) restoreDescriptor(target, key, previous);
|
|
116
|
+
} catch {
|
|
117
|
+
// Uninstall and failed-install cleanup are best effort on a hostile target.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Prepare a reversible bridge replacement without deactivating the current owner. */
|
|
122
|
+
export function prepareDirectBrowserBridgeInstallation(
|
|
123
|
+
options: DirectBrowserBridgeOptions,
|
|
124
|
+
): Result<PreparedDirectBrowserBridgeInstallation, DirectBrowserBridgeError> {
|
|
125
|
+
let manifestInput: unknown;
|
|
126
|
+
try {
|
|
127
|
+
manifestInput = options.manifest;
|
|
128
|
+
} catch (reason) {
|
|
129
|
+
return err(bridgeError(
|
|
130
|
+
"invalid-manifest",
|
|
131
|
+
renderUnknownReason(reason, "Failed to read the Direct session manifest"),
|
|
132
|
+
));
|
|
133
|
+
}
|
|
134
|
+
const parsedManifest = parseDirectSessionManifest(manifestInput);
|
|
135
|
+
if (!parsedManifest.ok) {
|
|
136
|
+
return err(bridgeError("invalid-manifest", parsedManifest.error.message));
|
|
137
|
+
}
|
|
138
|
+
const manifest = parsedManifest.value;
|
|
139
|
+
|
|
140
|
+
let target: object;
|
|
141
|
+
let reset: () => undefined;
|
|
142
|
+
let probe: Pick<DirectProbe, "snapshot">;
|
|
143
|
+
try {
|
|
144
|
+
target = options.target ?? globalThis;
|
|
145
|
+
reset = options.reset ?? defaultReset;
|
|
146
|
+
probe = options.probe;
|
|
147
|
+
} catch (reason) {
|
|
148
|
+
return err(bridgeError(
|
|
149
|
+
"install-failed",
|
|
150
|
+
renderUnknownReason(reason, "Failed to read Direct browser bridge options"),
|
|
151
|
+
));
|
|
152
|
+
}
|
|
153
|
+
const previousInstallation = activeBridgeInstallation;
|
|
154
|
+
const rollbackDescriptors = new Map<string, PropertyDescriptor | undefined>();
|
|
155
|
+
const restore = new Map<string, PropertyDescriptor | undefined>();
|
|
156
|
+
try {
|
|
157
|
+
for (const key of BRIDGE_KEYS) {
|
|
158
|
+
const descriptor = Object.getOwnPropertyDescriptor(target, key);
|
|
159
|
+
rollbackDescriptors.set(key, descriptor);
|
|
160
|
+
const previousOwnsValue = previousInstallation?.target === target
|
|
161
|
+
&& descriptor?.value === previousInstallation.installed.get(key);
|
|
162
|
+
restore.set(
|
|
163
|
+
key,
|
|
164
|
+
previousOwnsValue ? previousInstallation.restore.get(key) : descriptor,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
} catch (reason) {
|
|
168
|
+
return err(bridgeError(
|
|
169
|
+
"install-failed",
|
|
170
|
+
renderUnknownReason(reason, "Failed to inspect the Direct browser bridge target"),
|
|
171
|
+
));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const readSnapshot = (): DirectProbeSnapshot => {
|
|
175
|
+
try {
|
|
176
|
+
const snapshot: unknown = probe.snapshot();
|
|
177
|
+
if (containPromiseLike(snapshot)) {
|
|
178
|
+
throw new Error("Direct probe snapshots must complete synchronously");
|
|
179
|
+
}
|
|
180
|
+
if ((typeof snapshot !== "object" || snapshot === null) && typeof snapshot !== "function") {
|
|
181
|
+
throw new Error("Direct probe returned an invalid result");
|
|
182
|
+
}
|
|
183
|
+
const succeeded: unknown = Reflect.get(snapshot, "ok");
|
|
184
|
+
if (succeeded !== true) {
|
|
185
|
+
if (succeeded === false) throw new Error(renderUnknownReason(Reflect.get(snapshot, "error")));
|
|
186
|
+
throw new Error("Direct probe returned an invalid result");
|
|
187
|
+
}
|
|
188
|
+
const parsed = parseDirectProbeSnapshot(Reflect.get(snapshot, "value"));
|
|
189
|
+
if (!parsed.ok) throw new Error(parsed.error.message);
|
|
190
|
+
if (parsed.value.activationHash !== manifest.active.activationHash) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
"Direct probe activation hash does not match the installed session manifest",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return parsed.value;
|
|
196
|
+
} catch (reason) {
|
|
197
|
+
throw new Error(`Direct probe failed: ${renderUnknownReason(reason)}`);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
const runReset = (): undefined => {
|
|
201
|
+
try {
|
|
202
|
+
const returned: unknown = reset();
|
|
203
|
+
return requireSynchronousResetResult(returned);
|
|
204
|
+
} catch (reason) {
|
|
205
|
+
throw new Error(`Direct reset failed: ${renderUnknownReason(reason)}`);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const bridge: DirectBrowserBridge = Object.freeze({
|
|
209
|
+
schema: DIRECT_BROWSER_BRIDGE_SCHEMA,
|
|
210
|
+
manifest,
|
|
211
|
+
snapshot: readSnapshot,
|
|
212
|
+
reset: runReset,
|
|
213
|
+
});
|
|
214
|
+
const installed = new Map<string, unknown>([["__direct", bridge]]);
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
for (const [key, value] of installed) {
|
|
218
|
+
Object.defineProperty(target, key, {
|
|
219
|
+
configurable: true,
|
|
220
|
+
enumerable: false,
|
|
221
|
+
writable: true,
|
|
222
|
+
value,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
} catch (reason) {
|
|
226
|
+
for (const key of BRIDGE_KEYS) {
|
|
227
|
+
restoreInstalledValue(target, key, installed.get(key), rollbackDescriptors.get(key));
|
|
228
|
+
}
|
|
229
|
+
return err(bridgeError(
|
|
230
|
+
"install-failed",
|
|
231
|
+
renderUnknownReason(reason, "Direct browser bridge installation failed"),
|
|
232
|
+
));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let state: "prepared" | "committed" | "closed" = "prepared";
|
|
236
|
+
const rollback = (): undefined => {
|
|
237
|
+
if (state !== "prepared") return undefined;
|
|
238
|
+
state = "closed";
|
|
239
|
+
for (const key of BRIDGE_KEYS) {
|
|
240
|
+
restoreInstalledValue(target, key, installed.get(key), rollbackDescriptors.get(key));
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
};
|
|
244
|
+
const deactivate = (): undefined => {
|
|
245
|
+
if (state !== "committed") return undefined;
|
|
246
|
+
state = "closed";
|
|
247
|
+
if (activeBridgeInstallation === installation) activeBridgeInstallation = null;
|
|
248
|
+
return undefined;
|
|
249
|
+
};
|
|
250
|
+
const uninstall = (): undefined => {
|
|
251
|
+
if (state === "prepared") return rollback();
|
|
252
|
+
if (state !== "committed") return undefined;
|
|
253
|
+
state = "closed";
|
|
254
|
+
for (const key of BRIDGE_KEYS) {
|
|
255
|
+
restoreInstalledValue(target, key, installed.get(key), restore.get(key));
|
|
256
|
+
}
|
|
257
|
+
if (activeBridgeInstallation === installation) activeBridgeInstallation = null;
|
|
258
|
+
return undefined;
|
|
259
|
+
};
|
|
260
|
+
const installation: ActiveBridgeInstallation = Object.freeze({
|
|
261
|
+
target,
|
|
262
|
+
installed,
|
|
263
|
+
restore,
|
|
264
|
+
deactivate,
|
|
265
|
+
uninstall,
|
|
266
|
+
});
|
|
267
|
+
const commit = (): undefined => {
|
|
268
|
+
if (state !== "prepared") return undefined;
|
|
269
|
+
state = "committed";
|
|
270
|
+
if (previousInstallation !== null) {
|
|
271
|
+
if (previousInstallation.target === target) previousInstallation.deactivate();
|
|
272
|
+
else previousInstallation.uninstall();
|
|
273
|
+
}
|
|
274
|
+
activeBridgeInstallation = installation;
|
|
275
|
+
return undefined;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return ok(Object.freeze({
|
|
279
|
+
commit,
|
|
280
|
+
rollback,
|
|
281
|
+
uninstall,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Install one process-local browser automation bridge. A later installation
|
|
287
|
+
* restores and replaces the earlier one; stale uninstall handles are harmless.
|
|
288
|
+
*/
|
|
289
|
+
export function installDirectBrowserBridge(
|
|
290
|
+
options: DirectBrowserBridgeOptions,
|
|
291
|
+
): Result<DirectBrowserBridgeUninstall, DirectBrowserBridgeError> {
|
|
292
|
+
const prepared = prepareDirectBrowserBridgeInstallation(options);
|
|
293
|
+
if (!prepared.ok) return prepared;
|
|
294
|
+
prepared.value.commit();
|
|
295
|
+
return ok(prepared.value.uninstall);
|
|
296
|
+
}
|