feat: 将发布预检移入内置浏览器
This commit is contained in:
@@ -27,6 +27,53 @@ const DEFAULT_CDP_TIMEOUT_MS = 10_000;
|
||||
const MAX_CDP_TIMEOUT_MS = 30_000;
|
||||
const OPEN_TIMEOUT_MS = 30_000;
|
||||
const RENDERER_PRIME_URL = 'about:blank';
|
||||
const PUBLISH_PREFLIGHT_TIMEOUT_MS = 30_000;
|
||||
const PUBLISH_PREFLIGHT_SETTLE_MS = 500;
|
||||
const PUBLISH_PREFLIGHT_VIEWPORTS = [
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 390, height: 844 },
|
||||
] as const;
|
||||
const PUBLISH_PREFLIGHT_INSPECTION = `(() => {
|
||||
const body = document.body;
|
||||
if (!body) return { readyState: document.readyState, visible: false };
|
||||
const candidates = Array.from(body.querySelectorAll('*')).slice(0, 2000);
|
||||
const visible = candidates.some((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 1 || rect.height <= 1) return false;
|
||||
if (element instanceof HTMLCanvasElement) {
|
||||
const scale = Math.min(rect.width / Math.max(element.width, 1), rect.height / Math.max(element.height, 1));
|
||||
return element.width > 1 && element.height > 1 && scale >= 0.15;
|
||||
}
|
||||
if (element instanceof HTMLImageElement) return element.complete && element.naturalWidth > 1;
|
||||
if (element instanceof HTMLVideoElement) return element.readyState >= 2;
|
||||
if (element instanceof SVGElement) return true;
|
||||
return Boolean(element.textContent?.trim())
|
||||
|| style.backgroundImage !== 'none';
|
||||
});
|
||||
return {
|
||||
readyState: document.readyState,
|
||||
visible: visible || Boolean(body.innerText?.trim()),
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
};
|
||||
})()`;
|
||||
const SAFE_PREFLIGHT_MESSAGES = {
|
||||
PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。',
|
||||
PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。',
|
||||
PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。',
|
||||
PUBLISH_PREFLIGHT_BLANK: '作品打开后没有可见内容。',
|
||||
PUBLISH_PREFLIGHT_TIMEOUT: '作品打开检查超时。',
|
||||
} as const;
|
||||
|
||||
class PublishPreflightFault extends Error {
|
||||
constructor(
|
||||
readonly code: keyof typeof SAFE_PREFLIGHT_MESSAGES,
|
||||
) {
|
||||
super(SAFE_PREFLIGHT_MESSAGES[code]);
|
||||
this.name = 'PublishPreflightFault';
|
||||
}
|
||||
}
|
||||
const ENABLED_DOMAINS = ['Runtime.enable', 'Log.enable', 'Network.enable', 'Page.enable'] as const;
|
||||
const STREAM_ISSUING_METHODS = new Set([
|
||||
'Fetch.takeResponseBodyAsStream',
|
||||
@@ -136,6 +183,25 @@ export class AgentBrowserModule {
|
||||
this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard();
|
||||
}
|
||||
|
||||
async preflightCurrentProject(projectPath: string): Promise<{ ok: true }> {
|
||||
this.assertAvailable();
|
||||
let record: BrowserRecord;
|
||||
try {
|
||||
record = this.requireRecord(projectPath);
|
||||
if (record.state !== 'attached' || !record.view.webContents.debugger.isAttached()) {
|
||||
throw new Error('preview-not-attached');
|
||||
}
|
||||
} catch {
|
||||
throw new PublishPreflightFault('PREVIEW_REQUIRED');
|
||||
}
|
||||
const targetUrl = normalizeLoopbackPreviewUrl(record.url);
|
||||
const deadline = Date.now() + PUBLISH_PREFLIGHT_TIMEOUT_MS;
|
||||
for (const viewport of PUBLISH_PREFLIGHT_VIEWPORTS) {
|
||||
await this.preflightStaticViewport(targetUrl, viewport, deadline);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async getSnapshot(projectPath?: string): Promise<AgentBrowserSnapshot> {
|
||||
if (!this.record) return this.closedSnapshot();
|
||||
if (projectPath) this.assertProject(this.record, projectPath);
|
||||
@@ -420,6 +486,135 @@ export class AgentBrowserModule {
|
||||
this.payloadStore.clear();
|
||||
}
|
||||
|
||||
private async preflightStaticViewport(
|
||||
targetUrl: string,
|
||||
viewport: { width: number; height: number },
|
||||
deadline: number,
|
||||
): Promise<void> {
|
||||
const partition = `niancode-publish-preflight:${randomUUID()}`;
|
||||
const previewOrigin = new URL(targetUrl).origin;
|
||||
let view: AgentBrowserViewPort | null = null;
|
||||
let releaseOriginRestriction: (() => void) | null = null;
|
||||
let runtimeError = false;
|
||||
const onDebuggerMessage: PortListener = (_event, methodValue, paramsValue) => {
|
||||
if (typeof methodValue !== 'string') return;
|
||||
const params = isRecord(paramsValue) ? paramsValue : {};
|
||||
if (methodValue === 'Runtime.exceptionThrown') {
|
||||
runtimeError = true;
|
||||
} else if (methodValue === 'Network.loadingFailed') {
|
||||
const errorText = typeof params.errorText === 'string' ? params.errorText : '';
|
||||
const canceled = params.canceled === true || errorText === 'net::ERR_ABORTED';
|
||||
if (!canceled) runtimeError = true;
|
||||
} else if (methodValue === 'Runtime.consoleAPICalled' && params.type === 'error') {
|
||||
runtimeError = true;
|
||||
} else if (methodValue === 'Log.entryAdded') {
|
||||
const entry = isRecord(params.entry) ? params.entry : {};
|
||||
if (entry.level === 'error') runtimeError = true;
|
||||
} else if (methodValue === 'Network.responseReceived') {
|
||||
const response = isRecord(params.response) ? params.response : {};
|
||||
if (typeof response.status === 'number' && response.status >= 400) runtimeError = true;
|
||||
} else if (methodValue === 'Network.requestWillBeSent') {
|
||||
const request = isRecord(params.request) ? params.request : {};
|
||||
if (typeof request.url !== 'string') return;
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
(requestUrl.protocol === 'http:' || requestUrl.protocol === 'https:')
|
||||
&& requestUrl.origin !== previewOrigin
|
||||
) runtimeError = true;
|
||||
} catch {
|
||||
runtimeError = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
const onDidFailLoad: PortListener = (
|
||||
_event, errorCodeValue, errorDescriptionValue, _url, isMainFrameValue,
|
||||
) => {
|
||||
if (
|
||||
isMainFrameValue !== false
|
||||
&& errorCodeValue !== -3
|
||||
&& errorDescriptionValue !== 'ERR_ABORTED'
|
||||
) runtimeError = true;
|
||||
};
|
||||
const onRendererFault: PortListener = () => {
|
||||
runtimeError = true;
|
||||
};
|
||||
|
||||
try {
|
||||
view = this.adapter.createView(partition);
|
||||
releaseOriginRestriction = this.adapter.restrictPartitionToOrigin(partition, previewOrigin);
|
||||
view.webContents.debugger.on('message', onDebuggerMessage);
|
||||
view.webContents.on('did-fail-load', onDidFailLoad);
|
||||
view.webContents.on('render-process-gone', onRendererFault);
|
||||
view.webContents.on('unresponsive', onRendererFault);
|
||||
view.webContents.on('destroyed', onRendererFault);
|
||||
view.webContents.denyWindowOpen();
|
||||
view.setBounds({ x: 0, y: 0, ...viewport });
|
||||
view.setVisible(false);
|
||||
await beforePublishPreflightDeadline(view.webContents.loadURL(RENDERER_PRIME_URL), deadline);
|
||||
view.webContents.debugger.attach(CDP_PROTOCOL_VERSION);
|
||||
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Runtime.enable'), deadline);
|
||||
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Log.enable'), deadline);
|
||||
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Network.enable'), deadline);
|
||||
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Page.enable'), deadline);
|
||||
await beforePublishPreflightDeadline(
|
||||
view.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: viewport.width < 600,
|
||||
screenWidth: viewport.width,
|
||||
screenHeight: viewport.height,
|
||||
scale: 1,
|
||||
}),
|
||||
deadline,
|
||||
);
|
||||
await beforePublishPreflightDeadline(view.webContents.loadURL(targetUrl), deadline);
|
||||
await beforePublishPreflightDeadline(delay(PUBLISH_PREFLIGHT_SETTLE_MS), deadline);
|
||||
const inspected = await beforePublishPreflightDeadline(
|
||||
view.webContents.executeJavaScript(PUBLISH_PREFLIGHT_INSPECTION),
|
||||
deadline,
|
||||
);
|
||||
if (new URL(view.webContents.getURL()).origin !== previewOrigin) {
|
||||
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_LOAD_FAILED');
|
||||
}
|
||||
if (runtimeError) {
|
||||
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_RUNTIME_ERROR');
|
||||
}
|
||||
const inspectedViewport = isRecord(inspected) && isRecord(inspected.viewport)
|
||||
? inspected.viewport
|
||||
: {};
|
||||
if (
|
||||
!isRecord(inspected)
|
||||
|| inspected.readyState !== 'complete'
|
||||
|| inspected.visible !== true
|
||||
|| !viewportMatches(inspectedViewport.width, viewport.width)
|
||||
|| !viewportMatches(inspectedViewport.height, viewport.height)
|
||||
) {
|
||||
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_BLANK');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PublishPreflightFault) throw error;
|
||||
throw new PublishPreflightFault(
|
||||
isTimeoutError(error) ? 'PUBLISH_PREFLIGHT_TIMEOUT' : 'PUBLISH_PREFLIGHT_LOAD_FAILED',
|
||||
);
|
||||
} finally {
|
||||
bestEffortCleanup(() => view?.webContents.debugger.removeListener('message', onDebuggerMessage));
|
||||
bestEffortCleanup(() => view?.webContents.removeListener('did-fail-load', onDidFailLoad));
|
||||
bestEffortCleanup(() => view?.webContents.removeListener('render-process-gone', onRendererFault));
|
||||
bestEffortCleanup(() => view?.webContents.removeListener('unresponsive', onRendererFault));
|
||||
bestEffortCleanup(() => view?.webContents.removeListener('destroyed', onRendererFault));
|
||||
bestEffortCleanup(() => {
|
||||
if (view?.webContents.debugger.isAttached()) view.webContents.debugger.detach();
|
||||
});
|
||||
bestEffortCleanup(() => {
|
||||
if (view) this.adapter.destroy(view);
|
||||
});
|
||||
bestEffortCleanup(() => releaseOriginRestriction?.());
|
||||
await bestEffortPartitionReset(this.adapter, partition);
|
||||
}
|
||||
}
|
||||
|
||||
private async attachDebugger(record: BrowserRecord, reattach: boolean): Promise<void> {
|
||||
if (record !== this.record || record.view.webContents.isDestroyed()) {
|
||||
throw new AgentBrowserFault(
|
||||
@@ -1199,6 +1394,89 @@ function normalizeUrl(value: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function beforePublishPreflightDeadline<T>(
|
||||
operation: Promise<T>,
|
||||
deadline: number,
|
||||
): Promise<T> {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error('PUBLISH_PREFLIGHT_TIMEOUT');
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('PUBLISH_PREFLIGHT_TIMEOUT')), remaining);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolvePromise) => {
|
||||
setTimeout(resolvePromise, milliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === 'PUBLISH_PREFLIGHT_TIMEOUT';
|
||||
}
|
||||
|
||||
function viewportMatches(actual: unknown, expected: number): boolean {
|
||||
return typeof actual === 'number'
|
||||
&& Number.isFinite(actual)
|
||||
&& Math.abs(actual - expected) <= Math.max(2, expected * 0.05);
|
||||
}
|
||||
|
||||
function bestEffortCleanup(cleanup: () => void): void {
|
||||
try {
|
||||
cleanup();
|
||||
} catch {
|
||||
// Every subsequent cleanup step must still run for the temporary renderer.
|
||||
}
|
||||
}
|
||||
|
||||
async function bestEffortPartitionReset(
|
||||
adapter: AgentBrowserAdapter,
|
||||
partition: string,
|
||||
): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
adapter.resetPartition(partition).catch(() => undefined),
|
||||
new Promise<void>((resolvePromise) => {
|
||||
timer = setTimeout(resolvePromise, 1_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLoopbackPreviewUrl(value: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new PublishPreflightFault('PREVIEW_REQUIRED');
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const loopback = hostname === 'localhost'
|
||||
|| hostname === '127.0.0.1'
|
||||
|| hostname === '[::1]';
|
||||
if (
|
||||
!loopback
|
||||
|| url.protocol !== 'http:'
|
||||
|| !url.port
|
||||
|| url.username
|
||||
|| url.password
|
||||
) {
|
||||
throw new PublishPreflightFault('PREVIEW_REQUIRED');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function normalizeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds {
|
||||
const normalized = {
|
||||
x: Math.trunc(bounds.x),
|
||||
|
||||
Reference in New Issue
Block a user