feat(coding): add preview data runtime session
This commit is contained in:
@@ -14,6 +14,8 @@ export {
|
||||
agentBrowserPartition,
|
||||
} from './module';
|
||||
export type {
|
||||
AgentBrowserLifecycleEvent,
|
||||
AgentBrowserLifecycleListener,
|
||||
AgentBrowserModuleOptions,
|
||||
AgentBrowserNavigateInput,
|
||||
AgentBrowserOpenInput,
|
||||
|
||||
@@ -159,8 +159,21 @@ export interface AgentBrowserReadPayloadInput {
|
||||
export interface AgentBrowserModuleOptions {
|
||||
payloadStore?: AgentBrowserPayloadStore;
|
||||
cdpGuard?: AgentBrowserCdpGuard;
|
||||
onLifecycle?(event: AgentBrowserLifecycleEvent): void;
|
||||
}
|
||||
|
||||
export type AgentBrowserLifecycleEvent = Readonly<{
|
||||
type: 'closed' | 'detached' | 'crashed' | 'cross-origin-navigation' | 'generation-replaced';
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
generation: number;
|
||||
url: string;
|
||||
previousUrl?: string;
|
||||
nextUrl?: string;
|
||||
}>;
|
||||
|
||||
export type AgentBrowserLifecycleListener = (event: AgentBrowserLifecycleEvent) => void;
|
||||
|
||||
export function agentBrowserPartition(projectPath: string): string {
|
||||
const normalized = normalizePath(projectPath);
|
||||
const key = createHash('sha256').update(normalized).digest('hex').slice(0, 32);
|
||||
@@ -172,6 +185,7 @@ export class AgentBrowserModule {
|
||||
private readonly cdpGuard: AgentBrowserCdpGuard;
|
||||
private readonly eventWaiters = new Set<() => void>();
|
||||
private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>();
|
||||
private readonly lifecycleListeners = new Set<AgentBrowserLifecycleListener>();
|
||||
private record: BrowserRecord | null = null;
|
||||
private commandTail: Promise<void> = Promise.resolve();
|
||||
private lifecycleBarrier: Promise<void> = Promise.resolve();
|
||||
@@ -185,6 +199,15 @@ export class AgentBrowserModule {
|
||||
) {
|
||||
this.payloadStore = options.payloadStore ?? new AgentBrowserPayloadStore();
|
||||
this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard();
|
||||
if (options.onLifecycle) this.lifecycleListeners.add(options.onLifecycle);
|
||||
}
|
||||
|
||||
subscribeLifecycle(listener: AgentBrowserLifecycleListener): () => void {
|
||||
if (this.disposed) return () => undefined;
|
||||
this.lifecycleListeners.add(listener);
|
||||
return () => {
|
||||
this.lifecycleListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async preflightCurrentProject(projectPath: string): Promise<{ ok: true }> {
|
||||
@@ -671,6 +694,13 @@ export class AgentBrowserModule {
|
||||
record.generation = ++this.generation;
|
||||
record.childSessions.clear();
|
||||
record.ioHandles.clear();
|
||||
this.notifyLifecycle({
|
||||
type: 'generation-replaced',
|
||||
projectId: record.projectId,
|
||||
projectPath: record.projectPath,
|
||||
generation: record.generation,
|
||||
url: record.url,
|
||||
});
|
||||
}
|
||||
record.state = 'attaching';
|
||||
record.error = undefined;
|
||||
@@ -728,17 +758,52 @@ export class AgentBrowserModule {
|
||||
? '原生 DevTools 正在使用当前页面调试器。'
|
||||
: `开发浏览器调试器已断开:${String(reasonValue ?? 'unknown')}`,
|
||||
};
|
||||
this.notifyLifecycle({
|
||||
type: 'detached',
|
||||
projectId: record.projectId,
|
||||
projectPath: record.projectPath,
|
||||
generation: record.generation,
|
||||
url: record.url,
|
||||
});
|
||||
this.notifyEventWaiters();
|
||||
};
|
||||
this.addDebuggerListener(record, 'message', onDebuggerMessage);
|
||||
this.addDebuggerListener(record, 'detach', onDebuggerDetach);
|
||||
|
||||
this.addWebContentsListener(record, 'did-navigate', (_event, urlValue) => {
|
||||
if (typeof urlValue === 'string') record.url = urlValue;
|
||||
const previousUrl = record.url;
|
||||
if (typeof urlValue === 'string') {
|
||||
record.url = urlValue;
|
||||
if (crossOriginNavigation(previousUrl, urlValue)) {
|
||||
this.notifyLifecycle({
|
||||
type: 'cross-origin-navigation',
|
||||
projectId: record.projectId,
|
||||
projectPath: record.projectPath,
|
||||
generation: record.generation,
|
||||
url: urlValue,
|
||||
previousUrl,
|
||||
nextUrl: urlValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.refreshMetadata(record);
|
||||
});
|
||||
this.addWebContentsListener(record, 'did-navigate-in-page', (_event, urlValue) => {
|
||||
if (typeof urlValue === 'string') record.url = urlValue;
|
||||
this.addWebContentsListener(record, 'did-navigate-in-page', (_event, urlValue, isMainFrameValue) => {
|
||||
const previousUrl = record.url;
|
||||
if (typeof urlValue === 'string' && isMainFrameValue !== false) {
|
||||
record.url = urlValue;
|
||||
if (crossOriginNavigation(previousUrl, urlValue)) {
|
||||
this.notifyLifecycle({
|
||||
type: 'cross-origin-navigation',
|
||||
projectId: record.projectId,
|
||||
projectPath: record.projectPath,
|
||||
generation: record.generation,
|
||||
url: urlValue,
|
||||
previousUrl,
|
||||
nextUrl: urlValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.refreshMetadata(record);
|
||||
});
|
||||
this.addWebContentsListener(record, 'page-title-updated', (_event, titleValue) => {
|
||||
@@ -785,6 +850,13 @@ export class AgentBrowserModule {
|
||||
code: 'RENDERER_CRASHED',
|
||||
message: '开发浏览器页面进程已退出。',
|
||||
};
|
||||
this.notifyLifecycle({
|
||||
type: 'crashed',
|
||||
projectId: record.projectId,
|
||||
projectPath: record.projectPath,
|
||||
generation: record.generation,
|
||||
url: record.url,
|
||||
});
|
||||
record.eventBuffer.markGap('view-recreated');
|
||||
record.childSessions.clear();
|
||||
record.ioHandles.clear();
|
||||
@@ -797,6 +869,13 @@ export class AgentBrowserModule {
|
||||
code: 'TARGET_GONE',
|
||||
message: '开发浏览器页面已关闭。',
|
||||
};
|
||||
this.notifyLifecycle({
|
||||
type: 'crashed',
|
||||
projectId: record.projectId,
|
||||
projectPath: record.projectPath,
|
||||
generation: record.generation,
|
||||
url: record.url,
|
||||
});
|
||||
record.eventBuffer.markGap('view-recreated');
|
||||
this.notifyEventWaiters();
|
||||
});
|
||||
@@ -1137,6 +1216,13 @@ export class AgentBrowserModule {
|
||||
}
|
||||
if (expected && this.record !== expected) return;
|
||||
record.state = 'closing';
|
||||
this.notifyLifecycle({
|
||||
type: 'closed',
|
||||
projectId: record.projectId,
|
||||
projectPath: record.projectPath,
|
||||
generation: record.generation,
|
||||
url: record.url,
|
||||
});
|
||||
this.removeListeners(record);
|
||||
this.record = null;
|
||||
const interrupted = new AgentBrowserFault(
|
||||
@@ -1435,6 +1521,16 @@ export class AgentBrowserModule {
|
||||
this.eventWaiters.clear();
|
||||
for (const wake of waiters) wake();
|
||||
}
|
||||
|
||||
private notifyLifecycle(event: AgentBrowserLifecycleEvent): void {
|
||||
for (const listener of this.lifecycleListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// Lifecycle observers must not interrupt browser teardown or navigation.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRequiredPath(projectPath: string): string {
|
||||
@@ -1470,6 +1566,15 @@ function normalizeUrl(value: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function crossOriginNavigation(previousUrl: string, nextUrl: string): boolean {
|
||||
if (previousUrl === 'about:blank' || nextUrl === 'about:blank') return false;
|
||||
try {
|
||||
return new URL(previousUrl).origin !== new URL(nextUrl).origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function beforePublishPreflightDeadline<T>(
|
||||
operation: Promise<T>,
|
||||
deadline: number,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from './coding-provider-auth';
|
||||
import { createCodingProductHost, type CodingProductComposition } from './coding-product-services';
|
||||
import { createDataServiceOperations } from '../services/data-service-client';
|
||||
import { createPreviewDataSessionManager, type PreviewDataSessionManager } from '../services/preview-data-session';
|
||||
import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader';
|
||||
import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1';
|
||||
|
||||
@@ -164,6 +165,7 @@ export function createCodingComposition(
|
||||
? { acquireBackgroundLease: options.acquireBackgroundLease }
|
||||
: {}),
|
||||
});
|
||||
let previewDataSession: PreviewDataSessionManager | undefined;
|
||||
const projects = new CodingProjectService(projectStore, {
|
||||
migration: {
|
||||
resolveLegacyModel: async ({ legacyModel }) => resolveLegacyProjectModel(
|
||||
@@ -180,9 +182,11 @@ export function createCodingComposition(
|
||||
for (const conversation of conversations) registry.forget(conversation.id);
|
||||
},
|
||||
onProjectIdentityChanging: async (project) => {
|
||||
previewDataSession?.invalidate('project_identity_changed');
|
||||
await options.browser.close(project.path);
|
||||
},
|
||||
onProjectDeactivated: async (project, reason) => {
|
||||
previewDataSession?.invalidate('project_deactivated');
|
||||
const conversations = await conversationStoreForProject(project.path).read()
|
||||
.then((file) => file.conversations)
|
||||
.catch(() => []);
|
||||
@@ -208,9 +212,16 @@ export function createCodingComposition(
|
||||
});
|
||||
const dataService = createDataServiceOperations({ projects });
|
||||
productTools.configureDataService(dataService);
|
||||
previewDataSession = createPreviewDataSessionManager({ projects });
|
||||
const unsubscribeBrowserLifecycle = typeof options.browser.subscribeLifecycle === 'function'
|
||||
? options.browser.subscribeLifecycle((event) => {
|
||||
previewDataSession?.handleAgentBrowserLifecycle(event);
|
||||
})
|
||||
: () => undefined;
|
||||
return {
|
||||
attachments,
|
||||
dataService,
|
||||
previewDataSession,
|
||||
productTools,
|
||||
projects,
|
||||
conversations,
|
||||
@@ -224,6 +235,8 @@ export function createCodingComposition(
|
||||
)));
|
||||
},
|
||||
async shutdown() {
|
||||
previewDataSession?.dispose();
|
||||
unsubscribeBrowserLifecycle();
|
||||
await subagents.close();
|
||||
await runtime.shutdown();
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { CodingConversationService } from '../coding-runtime/conversation-s
|
||||
import type { CodingConversationRuntime } from '../coding-runtime/contracts';
|
||||
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
import type { DataServiceOperations } from '../services/data-service-client';
|
||||
import type { PreviewDataSessionManager } from '../services/preview-data-session';
|
||||
|
||||
export interface ActiveCodingProject {
|
||||
id: string;
|
||||
@@ -37,6 +38,7 @@ export interface CodingProductHost {
|
||||
export interface CodingProductComposition {
|
||||
attachments: CodingAttachmentStore;
|
||||
dataService: DataServiceOperations;
|
||||
previewDataSession?: PreviewDataSessionManager;
|
||||
productTools: PiProductTools;
|
||||
projects: CodingProjectService;
|
||||
conversations: CodingConversationService;
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { StaticArtifactSnapshot } from '../services/static-release-server';
|
||||
import type { BackgroundLifecycleController } from '../main/background-lifecycle';
|
||||
import type { ReleaseJobManager } from '../services/release-job';
|
||||
import type { CodingProductComposition } from './coding-product-services';
|
||||
import type { PreviewDataSessionManager } from '../services/preview-data-session';
|
||||
|
||||
export type WorksSubmissionBindingStore = ReturnType<typeof createWorksSubmissionBindingStore>;
|
||||
|
||||
@@ -77,4 +78,5 @@ export interface HostApiContext {
|
||||
lifecycle?: BackgroundLifecycleController;
|
||||
releaseJobs?: ReleaseJobManager;
|
||||
codingProducts?: CodingProductComposition;
|
||||
previewDataSession?: PreviewDataSessionManager;
|
||||
}
|
||||
|
||||
391
electron/api/routes/runtime-data.ts
Normal file
391
electron/api/routes/runtime-data.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { PREVIEW_DATA_MAX_REQUEST_BYTES, PREVIEW_DATA_MAX_RESPONSE_BYTES, PREVIEW_DATA_ROUTE_ROOT, type PreviewDataSessionManager } from '../../services/preview-data-session';
|
||||
import { sendNoContent } from '../route-utils';
|
||||
import type {
|
||||
DataServiceErrorContext,
|
||||
DataServiceHostResult,
|
||||
} from '../../../shared/data-service';
|
||||
|
||||
const MAX_CURSOR_LENGTH = 1_024;
|
||||
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
||||
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
||||
const CORS_METHODS = 'GET, PUT, DELETE, OPTIONS';
|
||||
const CORS_HEADERS = 'Authorization, Content-Type, If-Match';
|
||||
|
||||
class PreviewDataRouteError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly retryable = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PreviewDataRouteError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedString(value: unknown, maximum: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
return value && value.length <= maximum ? value : null;
|
||||
}
|
||||
|
||||
function routeFailure<T>(
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
retryable = false,
|
||||
extra: { retry_after_seconds?: number; context?: DataServiceErrorContext } = {},
|
||||
): DataServiceHostResult<T> {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
error: message,
|
||||
retryable,
|
||||
...(extra.retry_after_seconds === undefined ? {} : { retry_after_seconds: extra.retry_after_seconds }),
|
||||
...(extra.context === undefined ? {} : { context: extra.context }),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailable<T>(): DataServiceHostResult<T> {
|
||||
return routeFailure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true);
|
||||
}
|
||||
|
||||
function invalidResponse<T>(): DataServiceHostResult<T> {
|
||||
return routeFailure(502, 'upstream_invalid_response', 'Data Service returned an invalid response');
|
||||
}
|
||||
|
||||
function isPreviewDataPath(pathname: string): boolean {
|
||||
return pathname === PREVIEW_DATA_ROUTE_ROOT || pathname.startsWith(`${PREVIEW_DATA_ROUTE_ROOT}/`);
|
||||
}
|
||||
|
||||
function managerFor(ctx: HostApiContext): PreviewDataSessionManager | undefined {
|
||||
return ctx.previewDataSession ?? ctx.codingProducts?.previewDataSession;
|
||||
}
|
||||
|
||||
function applyCors(res: ServerResponse, manager: PreviewDataSessionManager | undefined, req: IncomingMessage): void {
|
||||
const origin = manager?.corsOrigin(req);
|
||||
if (origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', CORS_METHODS);
|
||||
res.setHeader('Access-Control-Allow-Headers', CORS_HEADERS);
|
||||
}
|
||||
|
||||
function serializeBounded(value: unknown): string | null {
|
||||
let encoded: string;
|
||||
try {
|
||||
encoded = JSON.stringify(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof encoded !== 'string') return null;
|
||||
return Buffer.byteLength(encoded, 'utf8') <= PREVIEW_DATA_MAX_RESPONSE_BYTES
|
||||
? encoded
|
||||
: null;
|
||||
}
|
||||
|
||||
function sendBoundedJson(res: ServerResponse, status: number, payload: unknown): boolean {
|
||||
const encoded = serializeBounded(payload);
|
||||
if (encoded === null) return false;
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(encoded);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, result: DataServiceHostResult<unknown>): void {
|
||||
const detail = {
|
||||
code: result.code ?? 'runtime_unavailable',
|
||||
message: result.error ?? 'Preview data runtime is unavailable',
|
||||
retryable: result.retryable,
|
||||
...(result.context && Object.keys(result.context).length > 0 ? { context: result.context } : {}),
|
||||
};
|
||||
if (result.retry_after_seconds !== undefined) {
|
||||
res.setHeader('Retry-After', String(result.retry_after_seconds));
|
||||
}
|
||||
if (!sendBoundedJson(res, result.status >= 400 && result.status <= 599 ? result.status : 503, { detail })) {
|
||||
sendBoundedJson(res, 503, {
|
||||
detail: {
|
||||
code: 'runtime_unavailable',
|
||||
message: 'Preview data runtime is unavailable',
|
||||
retryable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendResult<T>(res: ServerResponse, result: DataServiceHostResult<T>): void {
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
if (!result.success) {
|
||||
sendError(res, result as DataServiceHostResult<unknown>);
|
||||
return;
|
||||
}
|
||||
if (result.status === 204) {
|
||||
sendNoContent(res);
|
||||
return;
|
||||
}
|
||||
if (result.data === null) {
|
||||
sendError(res, invalidResponse());
|
||||
return;
|
||||
}
|
||||
if (isRecord(result.data) && Number.isSafeInteger(result.data.revision) && result.data.revision > 0) {
|
||||
res.setHeader('ETag', `"${result.data.revision}"`);
|
||||
}
|
||||
if (!sendBoundedJson(res, result.status, result.data)) {
|
||||
sendError(res, invalidResponse());
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
||||
const declared = req.headers['content-length'];
|
||||
const declaredValue = Array.isArray(declared) ? declared[0] : declared;
|
||||
if (declaredValue && /^\d+$/.test(declaredValue) && Number(declaredValue) > PREVIEW_DATA_MAX_REQUEST_BYTES) {
|
||||
throw new PreviewDataRouteError(413, 'request_too_large', 'Preview data request is too large');
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
size += buffer.byteLength;
|
||||
if (size > PREVIEW_DATA_MAX_REQUEST_BYTES) {
|
||||
throw new PreviewDataRouteError(413, 'request_too_large', 'Preview data request is too large');
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
||||
if (!isRecord(value)) throw new Error('object required');
|
||||
return value;
|
||||
} catch {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function requireExactKeys(value: Record<string, unknown>, keys: readonly string[]): void {
|
||||
const expected = new Set(keys);
|
||||
if (Object.keys(value).some((key) => !expected.has(key))
|
||||
|| keys.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSegment(value: string, maximum: number, pattern: RegExp): string {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(value);
|
||||
} catch {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
if (decoded.length > maximum || !pattern.test(decoded)) {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function parseCollection(value: string): string {
|
||||
return decodeSegment(value, 48, COLLECTION_PATTERN);
|
||||
}
|
||||
|
||||
function parseDocumentId(value: string): string {
|
||||
const documentId = decodeSegment(value, 128, DOCUMENT_ID_PATTERN);
|
||||
if (documentId === '.' || documentId === '..') {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
return documentId;
|
||||
}
|
||||
|
||||
function requireQueryKeys(url: URL, allowed: readonly string[]): void {
|
||||
const accepted = new Set(allowed);
|
||||
if ([...url.searchParams.keys()].some((key) => !accepted.has(key))) {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function parseLimit(url: URL): number | undefined {
|
||||
const values = url.searchParams.getAll('limit');
|
||||
if (values.length === 0) return undefined;
|
||||
if (values.length !== 1 || !/^[1-9]\d*$/.test(values[0])) {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
const limit = Number(values[0]);
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
function parseCursor(url: URL): string | undefined {
|
||||
const values = url.searchParams.getAll('cursor');
|
||||
if (values.length === 0) return undefined;
|
||||
const cursor = values.length === 1 ? boundedString(values[0], MAX_CURSOR_LENGTH) : null;
|
||||
if (!cursor) throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function parseIfMatch(req: IncomingMessage): number | undefined {
|
||||
const value = req.headers['if-match'];
|
||||
if (value === undefined) return undefined;
|
||||
if (Array.isArray(value) || !/^"[1-9]\d*"$/.test(value)) {
|
||||
throw new PreviewDataRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
|
||||
}
|
||||
const revision = Number(value.slice(1, -1));
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new PreviewDataRouteError(422, 'invalid_revision', 'Data Service document revision is invalid');
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
function parseData(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) {
|
||||
throw new PreviewDataRouteError(422, 'invalid_request', 'Preview data request is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireJsonContentType(req: IncomingMessage): void {
|
||||
const value = req.headers['content-type'];
|
||||
if (typeof value !== 'string' || value.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
||||
throw new PreviewDataRouteError(415, 'invalid_content_type', 'Preview data requests require application/json');
|
||||
}
|
||||
}
|
||||
|
||||
function sendOptions(res: ServerResponse): void {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
}
|
||||
|
||||
function operationsFor(ctx: HostApiContext) {
|
||||
return ctx.codingProducts?.dataService;
|
||||
}
|
||||
|
||||
export async function handlePreviewDataRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (!isPreviewDataPath(url.pathname)) return false;
|
||||
|
||||
const manager = managerFor(ctx);
|
||||
applyCors(res, manager, req);
|
||||
res.setHeader('Cache-Control', 'private, no-store');
|
||||
if (!manager) {
|
||||
sendError(res, unavailable());
|
||||
return true;
|
||||
}
|
||||
|
||||
const authorization = manager.authorizeRequest(req);
|
||||
if (!authorization.ok) {
|
||||
sendError(res, routeFailure(
|
||||
authorization.status,
|
||||
authorization.code,
|
||||
authorization.message,
|
||||
authorization.retryable,
|
||||
authorization.retryAfterSeconds === undefined
|
||||
? {}
|
||||
: { retry_after_seconds: authorization.retryAfterSeconds },
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
const operations = operationsFor(ctx);
|
||||
if (!operations && authorization.method !== 'OPTIONS') {
|
||||
sendError(res, unavailable());
|
||||
return true;
|
||||
}
|
||||
|
||||
const runOperation = async <T>(operation: () => Promise<DataServiceHostResult<T>>): Promise<void> => {
|
||||
if (!manager.isCurrentSession(authorization.session)) {
|
||||
sendError(res, unavailable());
|
||||
return;
|
||||
}
|
||||
const result = await operation();
|
||||
if (!manager.isCurrentSession(authorization.session)) {
|
||||
sendError(res, unavailable());
|
||||
return;
|
||||
}
|
||||
sendResult(res, result);
|
||||
};
|
||||
|
||||
try {
|
||||
const method = authorization.method;
|
||||
const collectionPath = url.pathname.match(
|
||||
new RegExp(`^${PREVIEW_DATA_ROUTE_ROOT}/collections/([^/]+)/documents$`),
|
||||
);
|
||||
if (collectionPath) {
|
||||
const collection = parseCollection(collectionPath[1]);
|
||||
requireQueryKeys(url, ['limit', 'cursor']);
|
||||
if (method === 'OPTIONS') {
|
||||
sendOptions(res);
|
||||
} else if (method === 'GET') {
|
||||
await runOperation(() => operations!.listDocuments({
|
||||
collection,
|
||||
limit: parseLimit(url),
|
||||
cursor: parseCursor(url),
|
||||
}, authorization.session.projectPath));
|
||||
} else {
|
||||
sendError(res, routeFailure(405, 'method_not_allowed', 'Preview data method is not allowed'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const documentPath = url.pathname.match(
|
||||
new RegExp(`^${PREVIEW_DATA_ROUTE_ROOT}/collections/([^/]+)/documents/([^/]+)$`),
|
||||
);
|
||||
if (documentPath) {
|
||||
const collection = parseCollection(documentPath[1]);
|
||||
const documentId = parseDocumentId(documentPath[2]);
|
||||
requireQueryKeys(url, []);
|
||||
if (method === 'OPTIONS') {
|
||||
sendOptions(res);
|
||||
} else if (method === 'GET') {
|
||||
await runOperation(() => operations!.getDocument(
|
||||
{ collection, document_id: documentId },
|
||||
authorization.session.projectPath,
|
||||
));
|
||||
} else if (method === 'PUT') {
|
||||
requireJsonContentType(req);
|
||||
const body = await readBoundedJson(req);
|
||||
requireExactKeys(body, ['data']);
|
||||
const ifRevision = parseIfMatch(req);
|
||||
await runOperation(() => operations!.putDocument({
|
||||
collection,
|
||||
document_id: documentId,
|
||||
data: parseData(body.data),
|
||||
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
|
||||
}, authorization.session.projectPath));
|
||||
} else if (method === 'DELETE') {
|
||||
const ifRevision = parseIfMatch(req);
|
||||
await runOperation(() => operations!.deleteDocument({
|
||||
collection,
|
||||
document_id: documentId,
|
||||
...(ifRevision === undefined ? {} : { if_revision: ifRevision }),
|
||||
confirmed: true,
|
||||
}, authorization.session.projectPath));
|
||||
} else {
|
||||
sendError(res, routeFailure(405, 'method_not_allowed', 'Preview data method is not allowed'));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
sendError(res, routeFailure(404, 'route_not_found', 'Preview data route was not found'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof PreviewDataRouteError) {
|
||||
sendError(res, routeFailure(error.status, error.code, error.message, error.retryable));
|
||||
return true;
|
||||
}
|
||||
sendError(res, unavailable());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const isPreviewDataRoute = isPreviewDataPath;
|
||||
@@ -6,6 +6,7 @@ import type { HostApiContext } from './context';
|
||||
import { sendJson, setCorsHeaders, requireJsonContentType } from './route-utils';
|
||||
import { rotateRendererCapability } from './renderer-capability';
|
||||
import { hostApiRouteHandlers } from './route-handlers';
|
||||
import { handlePreviewDataRoutes, isPreviewDataRoute } from './routes/runtime-data';
|
||||
|
||||
/**
|
||||
* Per-session secret token used to authenticate Host API requests.
|
||||
@@ -29,6 +30,16 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
||||
|
||||
// Preview data is a separate, capability-scoped data plane. It must not
|
||||
// pass through the broad Host token, Renderer CORS, preflight, or JSON
|
||||
// gates below: preview pages only have their exact Origin and ephemeral
|
||||
// data bearer. Keep this branch out of the shared in-process dispatcher.
|
||||
if (isPreviewDataRoute(requestUrl.pathname)) {
|
||||
await handlePreviewDataRoutes(req, res, requestUrl, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── CORS headers ─────────────────────────────────────────
|
||||
// Set origin-aware CORS headers early so every response
|
||||
// (including error responses) carries them consistently.
|
||||
|
||||
@@ -589,6 +589,7 @@ async function initialize(): Promise<void> {
|
||||
lifecycle: backgroundLifecycle,
|
||||
releaseJobs,
|
||||
codingProducts,
|
||||
previewDataSession: codingProducts.previewDataSession,
|
||||
};
|
||||
registerIpcHandlers(window, backgroundLifecycle, hostApiContext);
|
||||
|
||||
@@ -784,6 +785,7 @@ if (gotTheLock) {
|
||||
}
|
||||
|
||||
hostEventBus.closeAll();
|
||||
codingProducts?.previewDataSession?.invalidate('main_shutdown');
|
||||
hostApiServer?.close();
|
||||
projectProgressSync?.stop();
|
||||
backgroundLifecycle?.dispose();
|
||||
@@ -820,6 +822,7 @@ if (gotTheLock) {
|
||||
const emergencyRuntimeCleanup = (reason: string, error: unknown): void => {
|
||||
logger.error(`${reason}:`, error);
|
||||
projectProgressSync?.stop();
|
||||
codingProducts?.previewDataSession?.invalidate('main_shutdown');
|
||||
try {
|
||||
void agentBrowser?.dispose().catch(() => { /* ignore */ });
|
||||
} catch {
|
||||
|
||||
428
electron/services/preview-data-session.ts
Normal file
428
electron/services/preview-data-session.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type {
|
||||
AgentBrowserLifecycleEvent,
|
||||
} from '../agent-browser/module';
|
||||
import type { CodingProjectService } from '../coding-projects/project-service';
|
||||
import {
|
||||
getWorksSquareAccountBinding,
|
||||
getWorksSquareSessionSnapshot,
|
||||
isCurrentWorksSquareAccountBinding,
|
||||
subscribeWorksSquareSession,
|
||||
type WorksSquareAccountBinding,
|
||||
type WorksSquareSessionListener,
|
||||
} from './works-square-session';
|
||||
import { getPort } from '../utils/config';
|
||||
|
||||
export const PREVIEW_DATA_ROUTE_ROOT = '/api/runtime/data/v1';
|
||||
export const PREVIEW_DATA_CONTRACT_VERSION = 1 as const;
|
||||
export const PREVIEW_DATA_MAX_REQUEST_BYTES = 98_304;
|
||||
export const PREVIEW_DATA_MAX_RESPONSE_BYTES = 1_310_720;
|
||||
export const PREVIEW_DATA_RATE = 5;
|
||||
export const PREVIEW_DATA_BUCKET_CAPACITY = 30;
|
||||
|
||||
const PREVIEW_DATA_METHODS = new Set(['GET', 'PUT', 'DELETE', 'OPTIONS']);
|
||||
const PREVIEW_DATA_HEADERS = new Set(['authorization', 'content-type', 'if-match']);
|
||||
const PREVIEW_DATA_TRANSPORT_HEADERS = new Set([
|
||||
'accept',
|
||||
'accept-encoding',
|
||||
'accept-language',
|
||||
'cache-control',
|
||||
'connection',
|
||||
'content-length',
|
||||
'host',
|
||||
'origin',
|
||||
'priority',
|
||||
'pragma',
|
||||
'referer',
|
||||
'sec-ch-ua',
|
||||
'sec-ch-ua-mobile',
|
||||
'sec-ch-ua-platform',
|
||||
'sec-fetch-dest',
|
||||
'sec-fetch-mode',
|
||||
'sec-fetch-site',
|
||||
'sec-fetch-user',
|
||||
'transfer-encoding',
|
||||
'upgrade-insecure-requests',
|
||||
'user-agent',
|
||||
]);
|
||||
|
||||
export type PreviewDataSessionInvalidationReason =
|
||||
| 'preview_closed'
|
||||
| 'project_deactivated'
|
||||
| 'project_identity_changed'
|
||||
| 'session_cleared'
|
||||
| 'account_changed'
|
||||
| 'cross_origin_navigation'
|
||||
| 'browser_generation_replaced'
|
||||
| 'browser_detached'
|
||||
| 'browser_crashed'
|
||||
| 'main_shutdown'
|
||||
| 'replaced'
|
||||
| 'manual';
|
||||
|
||||
export type PreviewDataSessionOpenInput = {
|
||||
projectPath: string;
|
||||
origin: string;
|
||||
browserGeneration: number;
|
||||
};
|
||||
|
||||
export type PreviewDataSessionSnapshot = Readonly<{
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
origin: string;
|
||||
browserGeneration: number;
|
||||
createdAt: number;
|
||||
}>;
|
||||
|
||||
export type PreviewDataInjectionValue = Readonly<{
|
||||
endpoint: string;
|
||||
token: string;
|
||||
contractVersion: typeof PREVIEW_DATA_CONTRACT_VERSION;
|
||||
}>;
|
||||
|
||||
export type PreviewDataAuthorizationSuccess = Readonly<{
|
||||
ok: true;
|
||||
method: string;
|
||||
session: PreviewDataSessionSnapshot;
|
||||
}>;
|
||||
|
||||
export type PreviewDataAuthorizationFailure = Readonly<{
|
||||
ok: false;
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
retryAfterSeconds?: number;
|
||||
}>;
|
||||
|
||||
export type PreviewDataAuthorizationResult =
|
||||
| PreviewDataAuthorizationSuccess
|
||||
| PreviewDataAuthorizationFailure;
|
||||
|
||||
export class PreviewDataSessionError extends Error {
|
||||
constructor(
|
||||
readonly code: 'invalid_origin' | 'invalid_generation' | 'runtime_unavailable',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PreviewDataSessionError';
|
||||
}
|
||||
}
|
||||
|
||||
export type PreviewDataSessionManagerOptions = {
|
||||
projects: Pick<CodingProjectService, 'requireActiveRealProjectWithIdentity'>;
|
||||
hostPort?: number;
|
||||
now?: () => number;
|
||||
randomBytes?: (size: number) => Buffer;
|
||||
subscribeWorksSquareSession?: typeof subscribeWorksSquareSession;
|
||||
getWorksSquareSessionSnapshot?: typeof getWorksSquareSessionSnapshot;
|
||||
getWorksSquareAccountBinding?: typeof getWorksSquareAccountBinding;
|
||||
isCurrentWorksSquareAccountBinding?: typeof isCurrentWorksSquareAccountBinding;
|
||||
};
|
||||
|
||||
type InternalSession = {
|
||||
snapshot: PreviewDataSessionSnapshot;
|
||||
token: string;
|
||||
tokenBytes: Buffer;
|
||||
tokens: number;
|
||||
lastRefillMs: number;
|
||||
};
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
const resolved = path.resolve(value);
|
||||
return process.platform === 'win32' ? resolved.toLocaleLowerCase('en-US') : resolved;
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
return normalizePath(left) === normalizePath(right);
|
||||
}
|
||||
|
||||
function normalizeOrigin(value: string): string {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new PreviewDataSessionError('invalid_origin', 'Preview Origin is invalid');
|
||||
}
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
const isLoopback = hostname === 'localhost'
|
||||
|| hostname === '127.0.0.1'
|
||||
|| hostname === '[::1]';
|
||||
if (
|
||||
!isLoopback
|
||||
|| parsed.protocol !== 'http:'
|
||||
|| !parsed.port
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| parsed.pathname !== '/'
|
||||
|| parsed.search
|
||||
|| parsed.hash
|
||||
) {
|
||||
throw new PreviewDataSessionError('invalid_origin', 'Preview Origin is invalid');
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function normalizeGeneration(value: number): number {
|
||||
if (!Number.isSafeInteger(value) || value < 1) {
|
||||
throw new PreviewDataSessionError('invalid_generation', 'Browser generation is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function headerValue(req: IncomingMessage, name: string): string | undefined {
|
||||
const value = req.headers[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function failure(
|
||||
status: number,
|
||||
code: string,
|
||||
message: string,
|
||||
retryable = false,
|
||||
retryAfterSeconds?: number,
|
||||
): PreviewDataAuthorizationFailure {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
|
||||
};
|
||||
}
|
||||
|
||||
function requestHeadersAllowed(req: IncomingMessage): boolean {
|
||||
for (const [name, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) return false;
|
||||
if (PREVIEW_DATA_HEADERS.has(name) || PREVIEW_DATA_TRANSPORT_HEADERS.has(name)) continue;
|
||||
if (name === 'access-control-request-method') {
|
||||
if (value !== undefined && !PREVIEW_DATA_METHODS.has(value.toUpperCase())) return false;
|
||||
continue;
|
||||
}
|
||||
if (name === 'access-control-request-headers') {
|
||||
if (value === undefined) continue;
|
||||
const requested = value.split(',').map((item) => item.trim().toLowerCase()).filter(Boolean);
|
||||
if (requested.some((item) => !PREVIEW_DATA_HEADERS.has(item))) return false;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isExactToken(candidate: string, expected: Buffer): boolean {
|
||||
if (!candidate || !/^[A-Za-z0-9_-]+$/.test(candidate)) return false;
|
||||
const candidateBytes = Buffer.from(candidate, 'base64url');
|
||||
return candidateBytes.length === expected.length && timingSafeEqual(candidateBytes, expected);
|
||||
}
|
||||
|
||||
function retryAfterSeconds(tokens: number): number {
|
||||
return Math.max(1, Math.ceil((1 - tokens) / PREVIEW_DATA_RATE));
|
||||
}
|
||||
|
||||
export class PreviewDataSessionManager {
|
||||
private readonly projects: Pick<CodingProjectService, 'requireActiveRealProjectWithIdentity'>;
|
||||
private readonly hostPort: number;
|
||||
private readonly now: () => number;
|
||||
private readonly createRandomBytes: (size: number) => Buffer;
|
||||
private readonly getSessionSnapshot: typeof getWorksSquareSessionSnapshot;
|
||||
private readonly getAccountBinding: typeof getWorksSquareAccountBinding;
|
||||
private readonly isCurrentAccountBinding: typeof isCurrentWorksSquareAccountBinding;
|
||||
private readonly unsubscribeWorksSession: () => void;
|
||||
private session: InternalSession | null = null;
|
||||
private boundAccountBinding: WorksSquareAccountBinding | null;
|
||||
private disposed = false;
|
||||
|
||||
constructor(options: PreviewDataSessionManagerOptions) {
|
||||
this.projects = options.projects;
|
||||
this.hostPort = options.hostPort ?? getPort('NIANCODE_HOST_API');
|
||||
this.now = options.now ?? Date.now;
|
||||
this.createRandomBytes = options.randomBytes ?? randomBytes;
|
||||
this.getSessionSnapshot = options.getWorksSquareSessionSnapshot ?? getWorksSquareSessionSnapshot;
|
||||
this.getAccountBinding = options.getWorksSquareAccountBinding ?? getWorksSquareAccountBinding;
|
||||
this.isCurrentAccountBinding = options.isCurrentWorksSquareAccountBinding
|
||||
?? isCurrentWorksSquareAccountBinding;
|
||||
// The Works observer intentionally has no initial callback. Read the
|
||||
// current snapshot once, then rely on the unsubscribe-able observer for
|
||||
// subsequent logout/account transitions.
|
||||
const initialSession = this.getSessionSnapshot();
|
||||
this.boundAccountBinding = initialSession ? this.getAccountBinding() : null;
|
||||
|
||||
const subscribe = options.subscribeWorksSquareSession ?? subscribeWorksSquareSession;
|
||||
const listener: WorksSquareSessionListener = (nextSession) => {
|
||||
if (!nextSession) {
|
||||
this.invalidate('session_cleared');
|
||||
return;
|
||||
}
|
||||
if (!this.boundAccountBinding) {
|
||||
this.invalidate('account_changed');
|
||||
return;
|
||||
}
|
||||
if (!this.isCurrentAccountBinding(this.boundAccountBinding)) {
|
||||
this.invalidate('account_changed');
|
||||
}
|
||||
};
|
||||
this.unsubscribeWorksSession = subscribe(listener);
|
||||
}
|
||||
|
||||
async open(input: PreviewDataSessionOpenInput): Promise<PreviewDataSessionSnapshot> {
|
||||
if (this.disposed) {
|
||||
throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable');
|
||||
}
|
||||
const origin = normalizeOrigin(input.origin);
|
||||
const browserGeneration = normalizeGeneration(input.browserGeneration);
|
||||
const active = await this.projects.requireActiveRealProjectWithIdentity(input.projectPath);
|
||||
if (this.disposed) {
|
||||
throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable');
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
const tokenBytes = this.createRandomBytes(32);
|
||||
if (tokenBytes.length !== 32) {
|
||||
throw new PreviewDataSessionError('runtime_unavailable', 'Preview data runtime is unavailable');
|
||||
}
|
||||
const token = tokenBytes.toString('base64url');
|
||||
const snapshot: PreviewDataSessionSnapshot = Object.freeze({
|
||||
projectPath: active.path,
|
||||
projectId: active.projectId,
|
||||
origin,
|
||||
browserGeneration,
|
||||
createdAt: now,
|
||||
});
|
||||
this.session = {
|
||||
snapshot,
|
||||
token,
|
||||
tokenBytes,
|
||||
tokens: PREVIEW_DATA_BUCKET_CAPACITY,
|
||||
lastRefillMs: now,
|
||||
};
|
||||
this.boundAccountBinding = this.getAccountBinding();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
getSnapshot(): PreviewDataSessionSnapshot | null {
|
||||
return this.session?.snapshot ?? null;
|
||||
}
|
||||
|
||||
getInjectionValue(hostPort = this.hostPort): PreviewDataInjectionValue | null {
|
||||
const session = this.session;
|
||||
if (!session || !Number.isSafeInteger(hostPort) || hostPort < 1 || hostPort > 65_535) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
endpoint: `http://127.0.0.1:${hostPort}${PREVIEW_DATA_ROUTE_ROOT}`,
|
||||
token: session.token,
|
||||
contractVersion: PREVIEW_DATA_CONTRACT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
private accountBindingIsCurrent(): boolean {
|
||||
const currentSession = this.getSessionSnapshot();
|
||||
const currentBinding = this.getAccountBinding();
|
||||
if (!this.boundAccountBinding) return !currentSession && !currentBinding;
|
||||
return Boolean(currentBinding && this.isCurrentAccountBinding(this.boundAccountBinding));
|
||||
}
|
||||
|
||||
corsOrigin(req: IncomingMessage): string | null {
|
||||
const origin = headerValue(req, 'origin');
|
||||
const session = this.session;
|
||||
return session && origin === session.snapshot.origin ? origin : null;
|
||||
}
|
||||
|
||||
authorizeRequest(req: IncomingMessage): PreviewDataAuthorizationResult {
|
||||
const session = this.session;
|
||||
if (!session) return failure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true);
|
||||
if (!this.accountBindingIsCurrent()) {
|
||||
this.invalidate('account_changed');
|
||||
return failure(503, 'runtime_unavailable', 'Preview data runtime is unavailable', true);
|
||||
}
|
||||
|
||||
const origin = headerValue(req, 'origin');
|
||||
if (origin !== session.snapshot.origin) {
|
||||
return failure(403, 'origin_not_allowed', 'Preview Origin is not allowed');
|
||||
}
|
||||
if (!requestHeadersAllowed(req)) {
|
||||
return failure(400, 'invalid_request', 'Preview request headers are not allowed');
|
||||
}
|
||||
|
||||
const method = (req.method ?? '').toUpperCase();
|
||||
if (method === 'OPTIONS') {
|
||||
return { ok: true, method, session: session.snapshot };
|
||||
}
|
||||
|
||||
const authorization = headerValue(req, 'authorization') ?? '';
|
||||
if (!authorization.startsWith('Bearer ') || !isExactToken(authorization.slice(7), session.tokenBytes)) {
|
||||
return failure(401, 'authentication_required', 'Preview data authorization is required');
|
||||
}
|
||||
if (!PREVIEW_DATA_METHODS.has(method)) {
|
||||
return failure(405, 'method_not_allowed', 'Preview data method is not allowed');
|
||||
}
|
||||
|
||||
const now = this.now();
|
||||
const elapsed = Math.max(0, now - session.lastRefillMs);
|
||||
session.tokens = Math.min(
|
||||
PREVIEW_DATA_BUCKET_CAPACITY,
|
||||
session.tokens + elapsed / 1_000 * PREVIEW_DATA_RATE,
|
||||
);
|
||||
session.lastRefillMs = now;
|
||||
if (session.tokens < 1) {
|
||||
return failure(
|
||||
429,
|
||||
'rate_limited',
|
||||
'Preview data request rate limit exceeded',
|
||||
true,
|
||||
retryAfterSeconds(session.tokens),
|
||||
);
|
||||
}
|
||||
session.tokens -= 1;
|
||||
return { ok: true, method, session: session.snapshot };
|
||||
}
|
||||
|
||||
isCurrentSession(snapshot: PreviewDataSessionSnapshot): boolean {
|
||||
return this.session?.snapshot === snapshot;
|
||||
}
|
||||
|
||||
handleAgentBrowserLifecycle(event: AgentBrowserLifecycleEvent): void {
|
||||
const session = this.session;
|
||||
if (!session || !samePath(session.snapshot.projectPath, event.projectPath)) return;
|
||||
switch (event.type) {
|
||||
case 'closed':
|
||||
this.invalidate('preview_closed');
|
||||
break;
|
||||
case 'cross-origin-navigation':
|
||||
this.invalidate('cross_origin_navigation');
|
||||
break;
|
||||
case 'generation-replaced':
|
||||
this.invalidate('browser_generation_replaced');
|
||||
break;
|
||||
case 'detached':
|
||||
this.invalidate('browser_detached');
|
||||
break;
|
||||
case 'crashed':
|
||||
this.invalidate('browser_crashed');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(_reason: PreviewDataSessionInvalidationReason = 'manual'): void {
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.invalidate('main_shutdown');
|
||||
this.unsubscribeWorksSession();
|
||||
}
|
||||
}
|
||||
|
||||
export function createPreviewDataSessionManager(
|
||||
options: PreviewDataSessionManagerOptions,
|
||||
): PreviewDataSessionManager {
|
||||
return new PreviewDataSessionManager(options);
|
||||
}
|
||||
Reference in New Issue
Block a user