mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
fix(browser): retain module resolution through finalization
This commit is contained in:
1 parent
52b73ee857
commit
94bc7ed322
9 files changed
+923
-222
No files matched your search
@@ -1,11 +1,14 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Browser, LaunchOptions } from "playwright-core";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import type {
|
||||
WebModuleResolutionPort,
|
||||
WebModuleResolutionBatch,
|
||||
} from "../../application/WebModulePorts.js";
|
||||
Browser,
|
||||
BrowserContext,
|
||||
Page,
|
||||
ConsoleMessage,
|
||||
LaunchOptions,
|
||||
} from "playwright-core";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import type { WebModuleResolutionPort } from "../../application/WebModulePorts.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import {
|
||||
@@ -19,6 +22,14 @@ import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
} from "../../artifacts/AnalysisCleanupLease.js";
|
||||
import type {
|
||||
WebModuleResolutionBatch,
|
||||
WebModuleResolutionObservation,
|
||||
} from "../../domain/webModuleTrace.js";
|
||||
import { webModuleResolutionSchema } from "../../domain/webModuleTrace.js";
|
||||
import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js";
|
||||
|
||||
@@ -34,7 +45,25 @@ const reportSchema = z.strictObject({
|
||||
),
|
||||
});
|
||||
|
||||
type ModuleResolverBrowser = Pick<Browser, "newContext" | "version" | "close">;
|
||||
type ObserveResolverCall = <T>(call: () => Promise<T>) => Promise<T>;
|
||||
|
||||
interface ModuleResolverPage {
|
||||
on(event: "console", listener: (message: ConsoleMessage) => void): unknown;
|
||||
goto: Page["goto"];
|
||||
evaluate(expression: string | (() => unknown)): Promise<unknown>;
|
||||
waitForFunction(
|
||||
expression: () => unknown,
|
||||
argument: undefined,
|
||||
options: { timeout: number },
|
||||
): Promise<unknown>;
|
||||
}
|
||||
interface ModuleResolverBrowser extends Pick<Browser, "version" | "close"> {
|
||||
newContext(
|
||||
options: Parameters<Browser["newContext"]>[0],
|
||||
): Promise<
|
||||
Pick<BrowserContext, "route"> & { newPage(): Promise<ModuleResolverPage> }
|
||||
>;
|
||||
}
|
||||
/** Production launch seam: only the adapter knows the browser API. */
|
||||
export type ModuleResolverLauncher = (
|
||||
options: LaunchOptions,
|
||||
@@ -47,7 +76,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#pendingBrowser: ModuleResolverBrowser | undefined;
|
||||
#pendingCleanup: AnalysisCleanupLease | undefined;
|
||||
|
||||
constructor(
|
||||
readonly environment: Readonly<Record<string, string | undefined>>,
|
||||
@@ -74,7 +103,9 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
options?: ExecutionOptions,
|
||||
): ReturnType<WebModuleResolutionPort["resolve"]> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
const pending = await this.#retryCleanup();
|
||||
if (pending !== undefined) return err(pending);
|
||||
const executable = this.environment.REA_BROWSER_EXECUTABLE;
|
||||
@@ -91,86 +122,153 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
),
|
||||
);
|
||||
const deadline = new ProviderStartupDeadline(TIMEOUT_MS, options?.signal);
|
||||
let browser: ModuleResolverBrowser | undefined;
|
||||
let activeCall:
|
||||
| { settled: boolean; interruption?: AnalysisError }
|
||||
| undefined;
|
||||
let interruption: AnalysisError | undefined;
|
||||
const interrupted = (): AnalysisError =>
|
||||
deadline.interruption === "cancelled"
|
||||
? new AnalysisCancelledError(OPERATION, {
|
||||
cause: deadline.signal.reason,
|
||||
})
|
||||
: new AnalysisTimeoutError(OPERATION, TIMEOUT_MS);
|
||||
const assertActive = (): void => {
|
||||
if (!deadline.signal.aborted) return;
|
||||
interruption = interrupted();
|
||||
throw interruption;
|
||||
};
|
||||
const observe: ObserveResolverCall = (call) => {
|
||||
assertActive();
|
||||
const observed: NonNullable<typeof activeCall> = { settled: false };
|
||||
activeCall = observed;
|
||||
return call().then(
|
||||
(value) => {
|
||||
observed.settled = true;
|
||||
return value;
|
||||
},
|
||||
(cause: unknown) => {
|
||||
observed.settled = true;
|
||||
throw observed.interruption === undefined
|
||||
? cause
|
||||
: observed.interruption;
|
||||
},
|
||||
);
|
||||
};
|
||||
let lease: AnalysisCleanupLease | undefined;
|
||||
let cleanup: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
if (cleanup === undefined && browser !== undefined) {
|
||||
const owned = browser;
|
||||
if (cleanup === undefined && lease !== undefined) {
|
||||
const owned = lease;
|
||||
cleanup = Promise.resolve().then(async () => {
|
||||
await owned.close();
|
||||
this.#pendingBrowser = undefined;
|
||||
await owned.release();
|
||||
this.#pendingCleanup = undefined;
|
||||
});
|
||||
}
|
||||
return cleanup ?? Promise.resolve();
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
if (browser !== undefined) {
|
||||
if (lease !== undefined) {
|
||||
const pending = close();
|
||||
void pending.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
deadline.signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
let result: Result<WebModuleResolutionBatch, AnalysisError>;
|
||||
try {
|
||||
deadline.signal.throwIfAborted();
|
||||
browser = await this.launcher({
|
||||
executablePath: executable,
|
||||
headless: true,
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
args: [
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=128",
|
||||
...(this.environment.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
this.#pendingBrowser = browser;
|
||||
deadline.signal.throwIfAborted();
|
||||
result = ok(await resolveInBrowser(browser, input, deadline, executable));
|
||||
assertActive();
|
||||
const browser = await observe(() =>
|
||||
this.launcher({
|
||||
executablePath: executable,
|
||||
headless: true,
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
args: [
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=128",
|
||||
...(this.environment.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
lease = new AnalysisCleanupLease(async () => {
|
||||
if (
|
||||
deadline.signal.aborted &&
|
||||
activeCall !== undefined &&
|
||||
!activeCall.settled
|
||||
) {
|
||||
interruption = interrupted();
|
||||
activeCall.interruption = interruption;
|
||||
}
|
||||
await browser.close();
|
||||
}, ["chromium-browser"]);
|
||||
this.#pendingCleanup = lease;
|
||||
assertActive();
|
||||
result = ok(
|
||||
await resolveInBrowser(
|
||||
browser,
|
||||
input,
|
||||
deadline,
|
||||
executable,
|
||||
assertActive,
|
||||
observe,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
result = err(
|
||||
deadline.interruption === "cancelled"
|
||||
? new AnalysisCancelledError(OPERATION)
|
||||
: deadline.interruption === "timeout"
|
||||
? new AnalysisTimeoutError(OPERATION, TIMEOUT_MS)
|
||||
: cause instanceof AnalysisOutputError ||
|
||||
cause instanceof AnalysisCapabilityUnavailableError ||
|
||||
cause instanceof BrowserObservationError
|
||||
? cause
|
||||
: new ProviderAdapterError("native-module-resolver", OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
executable,
|
||||
error_message:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
}),
|
||||
cause === interruption && interruption !== undefined
|
||||
? interruption
|
||||
: cause instanceof AnalysisOutputError ||
|
||||
cause instanceof AnalysisCapabilityUnavailableError ||
|
||||
cause instanceof BrowserObservationError
|
||||
? cause
|
||||
: new ProviderAdapterError("native-module-resolver", OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
executable,
|
||||
error_message:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
deadline.signal.removeEventListener("abort", onAbort);
|
||||
deadline.dispose();
|
||||
}
|
||||
let cleanupError: AnalysisError | undefined;
|
||||
try {
|
||||
await close();
|
||||
} catch (cause: unknown) {
|
||||
const failure = cleanupFailure(
|
||||
cleanupError = cleanupFailure(
|
||||
cause,
|
||||
result.ok ? result.value.rawResult : undefined,
|
||||
);
|
||||
return err(
|
||||
result.ok
|
||||
? failure
|
||||
: analysisErrorWithCleanupFailure(result.error, failure, OPERATION),
|
||||
result.ok ? completedObservation(input, result.value) : undefined,
|
||||
);
|
||||
if (lease !== undefined)
|
||||
new AnalysisCleanupLeaseGroup(cleanupError, [lease]);
|
||||
}
|
||||
if (result.ok && options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
result = err(
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
partialObservation: completedObservation(input, result.value),
|
||||
}),
|
||||
);
|
||||
if (cleanupError !== undefined)
|
||||
return err(
|
||||
result.ok
|
||||
? cleanupError
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
cleanupError,
|
||||
OPERATION,
|
||||
).bindCleanupDependencies([result.error, cleanupError]),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -195,14 +293,16 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
}
|
||||
|
||||
async #retryCleanup(): Promise<AnalysisError | undefined> {
|
||||
const browser = this.#pendingBrowser;
|
||||
if (browser === undefined) return undefined;
|
||||
const lease = this.#pendingCleanup;
|
||||
if (lease === undefined) return undefined;
|
||||
try {
|
||||
await browser.close();
|
||||
this.#pendingBrowser = undefined;
|
||||
await lease.release();
|
||||
this.#pendingCleanup = undefined;
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
return cleanupFailure(cause);
|
||||
const failure = cleanupFailure(cause);
|
||||
new AnalysisCleanupLeaseGroup(failure, [lease]);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,12 +312,16 @@ const resolveInBrowser = async (
|
||||
input: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
deadline: ProviderStartupDeadline,
|
||||
executable: string,
|
||||
assertActive: () => void,
|
||||
observe: ObserveResolverCall,
|
||||
): Promise<WebModuleResolutionBatch> => {
|
||||
const context = await browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
acceptDownloads: false,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const context = await observe(() =>
|
||||
browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
acceptDownloads: false,
|
||||
}),
|
||||
);
|
||||
const page = await observe(() => context.newPage());
|
||||
const diagnostics: string[] = [];
|
||||
let diagnosticBytes = 0;
|
||||
page.on("console", (message) => {
|
||||
@@ -237,64 +341,74 @@ const resolveInBrowser = async (
|
||||
const transportUrl = new URL(importerUrl);
|
||||
transportUrl.hash = "";
|
||||
let stubSent = false;
|
||||
await context.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.resourceType() === "document" &&
|
||||
request.url() === documentUrl.split("#")[0]
|
||||
) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "document",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: `<!doctype html>${input.importMap === null ? "" : `<base href="${htmlAttribute(new URL(input.importMap.baseUrl).href)}"><script type="importmap">${scriptJson(input.importMap.value)}</script>`}`,
|
||||
});
|
||||
} else if (
|
||||
!stubSent &&
|
||||
request.resourceType() === "script" &&
|
||||
request.url() === transportUrl.href
|
||||
) {
|
||||
stubSent = true;
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "script",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/javascript",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: `if (typeof import.meta.resolve !== "function") {globalThis.__reaModuleUnsupported = true;} else {globalThis.__reaModuleResult = {importer_url: import.meta.url, entries: ${scriptJson(input.specifiers)}.map(specifier => {try {return {specifier, resolution: {state: "resolved", url: import.meta.resolve(specifier)}}} catch (error) {return {specifier, resolution: {state: "rejected", message: String(error)}}}})};}`,
|
||||
});
|
||||
} else {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: request.resourceType(),
|
||||
effect: "blocked",
|
||||
});
|
||||
await route.abort();
|
||||
}
|
||||
});
|
||||
await page.goto(documentUrl, {
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
});
|
||||
await page.evaluate(
|
||||
`{const script = document.createElement("script"); script.type = "module"; script.src = ${scriptJson(importerUrl)}; script.onerror = () => {globalThis.__reaModuleFailure = "Trusted resolver stub could not be loaded in the selected context.";}; document.head.append(script);}`,
|
||||
await observe(() =>
|
||||
context.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.resourceType() === "document" &&
|
||||
request.url() === documentUrl.split("#")[0]
|
||||
) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "document",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: `<!doctype html>${input.importMap === null ? "" : `<base href="${htmlAttribute(new URL(input.importMap.baseUrl).href)}"><script type="importmap">${scriptJson(input.importMap.value)}</script>`}`,
|
||||
});
|
||||
} else if (
|
||||
!stubSent &&
|
||||
request.resourceType() === "script" &&
|
||||
request.url() === transportUrl.href
|
||||
) {
|
||||
stubSent = true;
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "script",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/javascript",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: `if (typeof import.meta.resolve !== "function") {globalThis.__reaModuleUnsupported = true;} else {globalThis.__reaModuleResult = {importer_url: import.meta.url, entries: ${scriptJson(input.specifiers)}.map(specifier => {try {return {specifier, resolution: {state: "resolved", url: import.meta.resolve(specifier)}}} catch (error) {return {specifier, resolution: {state: "rejected", message: String(error)}}}})};}`,
|
||||
});
|
||||
} else {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: request.resourceType(),
|
||||
effect: "blocked",
|
||||
});
|
||||
await route.abort();
|
||||
}
|
||||
}),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Reflect.has(globalThis, "__reaModuleResult") ||
|
||||
Reflect.has(globalThis, "__reaModuleUnsupported") ||
|
||||
Reflect.has(globalThis, "__reaModuleFailure"),
|
||||
undefined,
|
||||
{ timeout: Math.max(1, deadline.remainingMs()) },
|
||||
await observe(() =>
|
||||
page.goto(documentUrl, {
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
}),
|
||||
);
|
||||
const unsupported: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown,
|
||||
await observe(() =>
|
||||
page.evaluate(
|
||||
`{const script = document.createElement("script"); script.type = "module"; script.src = ${scriptJson(importerUrl)}; script.onerror = () => {globalThis.__reaModuleFailure = "Trusted resolver stub could not be loaded in the selected context.";}; document.head.append(script);}`,
|
||||
),
|
||||
);
|
||||
await observe(() =>
|
||||
page.waitForFunction(
|
||||
() =>
|
||||
Reflect.has(globalThis, "__reaModuleResult") ||
|
||||
Reflect.has(globalThis, "__reaModuleUnsupported") ||
|
||||
Reflect.has(globalThis, "__reaModuleFailure"),
|
||||
undefined,
|
||||
{ timeout: Math.max(1, deadline.remainingMs()) },
|
||||
),
|
||||
);
|
||||
const unsupported: unknown = await observe(() =>
|
||||
page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown,
|
||||
),
|
||||
);
|
||||
if (unsupported === true)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
@@ -305,11 +419,15 @@ const resolveInBrowser = async (
|
||||
userMessage: `Configured Chromium ${browser.version()} does not supply import.meta.resolve; use a browser supporting native module resolution.`,
|
||||
},
|
||||
);
|
||||
const raw: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleResult") as unknown,
|
||||
const raw: unknown = await observe(() =>
|
||||
page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleResult") as unknown,
|
||||
),
|
||||
);
|
||||
const failure: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleFailure") as unknown,
|
||||
const failure: unknown = await observe(() =>
|
||||
page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleFailure") as unknown,
|
||||
),
|
||||
);
|
||||
if (typeof failure === "string")
|
||||
throw new AnalysisOutputError(
|
||||
@@ -331,7 +449,7 @@ const resolveInBrowser = async (
|
||||
);
|
||||
if (diagnosticBytes > 1024 * 1024)
|
||||
throw new BrowserObservationError(OPERATION, "payload_limit");
|
||||
deadline.signal.throwIfAborted();
|
||||
assertActive();
|
||||
const engine = {
|
||||
id: "chromium-native-module-resolver",
|
||||
version: browser.version(),
|
||||
@@ -350,20 +468,30 @@ const resolveInBrowser = async (
|
||||
};
|
||||
};
|
||||
|
||||
const completedObservation = (
|
||||
input: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
batch: WebModuleResolutionBatch,
|
||||
): WebModuleResolutionObservation => ({
|
||||
kind: "web-module-resolution",
|
||||
request: input,
|
||||
batch,
|
||||
});
|
||||
|
||||
const cleanupFailure = (
|
||||
cause: unknown,
|
||||
observation?: WebModuleResolutionBatch["rawResult"],
|
||||
observation?: WebModuleResolutionObservation,
|
||||
): ProviderCleanupError =>
|
||||
new ProviderCleanupError(
|
||||
"native-module-resolver",
|
||||
["chromium-browser"],
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
...(observation === undefined
|
||||
? {}
|
||||
: { partial_observation: observation }),
|
||||
},
|
||||
{ cause, operation: OPERATION },
|
||||
{
|
||||
cause,
|
||||
operation: OPERATION,
|
||||
...(observation === undefined ? {} : { partialObservation: observation }),
|
||||
},
|
||||
);
|
||||
|
||||
const scriptJson = (value: unknown): string =>
|
||||
|
||||
Reference in new issue
Block a user