Files
ai-code-app/src/app/main/previewRuntime.ts

457 lines
13 KiB
TypeScript

const PREVIEW_RUNTIME_QUERY_KEY = 'appPreviewMode';
const PREVIEW_RUNTIME_PARENT_ORIGIN_KEY = 'previewParentOrigin';
const PREVIEW_RUNTIME_TOKEN_QUERY_KEY = 'registeredAccessToken';
const PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY = 'previewDeviceMode';
const PREVIEW_RUNTIME_CONSOLE_BRIDGE_EVENT = 'sm-home.preview-runtime.console';
const PREVIEW_TARGET_TYPE_QUERY_KEY = 'previewTargetType';
const PREVIEW_TARGET_COMPONENT_ID_QUERY_KEY = 'previewComponentId';
const PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY = 'previewSampleId';
const PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY = 'work-app.preview-runtime.cache-reset.v1';
const PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY = '__previewRuntimeCacheReset';
const PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS = 2500;
const PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS = [
PREVIEW_RUNTIME_QUERY_KEY,
PREVIEW_RUNTIME_PARENT_ORIGIN_KEY,
PREVIEW_RUNTIME_TOKEN_QUERY_KEY,
PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY,
] as const;
export type PreviewTargetDescriptor =
| {
type: 'widget';
componentId: string;
sampleId?: string;
}
| null;
export type PreviewRuntimeConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
export type PreviewRuntimeConsoleBridgeMessage = {
source: typeof PREVIEW_RUNTIME_CONSOLE_BRIDGE_EVENT;
level: PreviewRuntimeConsoleLevel;
args: string[];
timestamp: string;
href: string;
};
export function isPreviewRuntime() {
if (typeof window === 'undefined') {
return false;
}
return new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_QUERY_KEY) === '1';
}
export function isPreviewAppOrigin() {
if (typeof window === 'undefined') {
return false;
}
return window.location.origin === resolvePreviewAppOrigin();
}
function readPreviewRuntimeCacheResetMarker() {
if (typeof window === 'undefined') {
return '';
}
try {
return window.sessionStorage.getItem(PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY)?.trim() ?? '';
} catch {
return '';
}
}
function writePreviewRuntimeCacheResetMarker(value: string) {
if (typeof window === 'undefined') {
return;
}
try {
if (value) {
window.sessionStorage.setItem(PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY, value);
} else {
window.sessionStorage.removeItem(PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY);
}
} catch {
// Ignore storage access failures in restricted runtimes.
}
}
function buildPreviewRuntimeLocationKey() {
if (typeof window === 'undefined') {
return '';
}
return `${window.location.origin}${window.location.pathname}`;
}
function buildPreviewRuntimeCacheResetUrl() {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.set(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY, `${Date.now()}`);
return nextUrl.toString();
}
async function clearPreviewRuntimeServiceWorkersAndCaches() {
if (typeof window === 'undefined') {
return false;
}
let changed = false;
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
try {
const registrations = await navigator.serviceWorker.getRegistrations();
if (registrations.length > 0) {
changed = true;
}
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
} catch {
// Ignore cleanup failure and continue.
}
}
if ('caches' in window) {
try {
const cacheKeys = await caches.keys();
if (cacheKeys.length > 0) {
changed = true;
}
await Promise.all(cacheKeys.map((cacheKey) => caches.delete(cacheKey).catch(() => false)));
} catch {
// Ignore cache cleanup failure and continue.
}
}
return changed;
}
async function withTimeout<T>(task: Promise<T>, timeoutMs: number, fallbackValue: T) {
return await Promise.race([
task,
new Promise<T>((resolve) => {
window.setTimeout(() => resolve(fallbackValue), timeoutMs);
}),
]);
}
export async function ensurePreviewRuntimeFreshState() {
if (typeof window === 'undefined' || !isPreviewRuntime()) {
return;
}
const currentLocationKey = buildPreviewRuntimeLocationKey();
const cleanedLocationKey = readPreviewRuntimeCacheResetMarker();
const resetSearchParam = new URL(window.location.href).searchParams.get(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY)?.trim() ?? '';
if (cleanedLocationKey === currentLocationKey && !resetSearchParam) {
return;
}
if (resetSearchParam) {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.delete(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY);
window.history.replaceState(window.history.state, '', nextUrl.pathname + nextUrl.search + nextUrl.hash);
writePreviewRuntimeCacheResetMarker(currentLocationKey);
return;
}
const changed = await withTimeout(clearPreviewRuntimeServiceWorkersAndCaches(), PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS, false);
if (!changed) {
writePreviewRuntimeCacheResetMarker(currentLocationKey);
return;
}
writePreviewRuntimeCacheResetMarker(currentLocationKey);
window.location.replace(buildPreviewRuntimeCacheResetUrl());
}
export function getPreviewRuntimeParentOrigin() {
if (typeof window === 'undefined') {
return '';
}
return new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_PARENT_ORIGIN_KEY)?.trim() ?? '';
}
function stringifyPreviewRuntimeConsoleArg(value: unknown, seen = new WeakSet<object>()): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
if (typeof value === 'undefined') {
return 'undefined';
}
if (value === null) {
return 'null';
}
if (typeof value === 'function') {
return `[function ${value.name || 'anonymous'}]`;
}
if (value instanceof Error) {
return value.stack || `${value.name}: ${value.message}`;
}
if (value instanceof URL) {
return value.toString();
}
if (typeof value === 'object') {
if (seen.has(value)) {
return '[circular]';
}
seen.add(value);
try {
return JSON.stringify(
value,
(_key, nestedValue) => {
if (typeof nestedValue === 'bigint') {
return nestedValue.toString();
}
if (nestedValue instanceof Error) {
return {
name: nestedValue.name,
message: nestedValue.message,
stack: nestedValue.stack,
};
}
if (nestedValue instanceof URL) {
return nestedValue.toString();
}
if (typeof nestedValue === 'function') {
return `[function ${nestedValue.name || 'anonymous'}]`;
}
if (nestedValue && typeof nestedValue === 'object') {
if (seen.has(nestedValue)) {
return '[circular]';
}
seen.add(nestedValue);
}
return nestedValue;
},
2,
);
} catch {
return Object.prototype.toString.call(value);
}
}
return String(value);
}
function postPreviewRuntimeConsoleMessage(level: PreviewRuntimeConsoleLevel, args: unknown[]) {
if (typeof window === 'undefined') {
return;
}
const parentOrigin = getPreviewRuntimeParentOrigin();
if (!parentOrigin || window.parent === window) {
return;
}
const payload: PreviewRuntimeConsoleBridgeMessage = {
source: PREVIEW_RUNTIME_CONSOLE_BRIDGE_EVENT,
level,
args: args.map((arg) => stringifyPreviewRuntimeConsoleArg(arg)),
timestamp: new Date().toISOString(),
href: window.location.href,
};
window.parent.postMessage(payload, parentOrigin);
}
export function installPreviewRuntimeConsoleBridge() {
if (typeof window === 'undefined' || !isPreviewRuntime()) {
return;
}
const consoleMethods: PreviewRuntimeConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug'];
const previewConsole = window.console as Console & {
__smHomePreviewConsoleBridgeInstalled?: boolean;
};
if (previewConsole.__smHomePreviewConsoleBridgeInstalled) {
return;
}
previewConsole.__smHomePreviewConsoleBridgeInstalled = true;
consoleMethods.forEach((level) => {
const originalMethod = previewConsole[level].bind(previewConsole);
previewConsole[level] = (...args: unknown[]) => {
postPreviewRuntimeConsoleMessage(level, args);
originalMethod(...args);
};
});
window.addEventListener('error', (event) => {
postPreviewRuntimeConsoleMessage('error', [
event.message || 'Unknown error',
event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : '',
event.error ?? '',
]);
});
window.addEventListener('unhandledrejection', (event) => {
postPreviewRuntimeConsoleMessage('error', ['Unhandled promise rejection', event.reason ?? '']);
});
postPreviewRuntimeConsoleMessage('info', ['Preview runtime console bridge connected']);
}
export function readPreviewRuntimeDeviceModeFromUrl(): 'desktop' | 'mobile' | null {
if (typeof window === 'undefined') {
return null;
}
const value = new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY)?.trim() ?? '';
if (value === 'desktop' || value === 'mobile') {
return value;
}
return null;
}
export function readPreviewRuntimeTokenFromUrl() {
if (typeof window === 'undefined') {
return '';
}
return new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_TOKEN_QUERY_KEY)?.trim() ?? '';
}
export function clearPreviewRuntimeTokenFromUrl() {
if (typeof window === 'undefined') {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has(PREVIEW_RUNTIME_TOKEN_QUERY_KEY)) {
return;
}
url.searchParams.delete(PREVIEW_RUNTIME_TOKEN_QUERY_KEY);
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`);
}
export function resolvePreviewAppOrigin() {
return 'https://preview.sm-home.cloud';
}
export function appendPreviewRuntimeSearch(path: string, sourceSearch = '') {
if (!path) {
return path;
}
const [rawPathname, rawHash = ''] = path.split('#', 2);
const [pathname, rawSearch = ''] = rawPathname.split('?', 2);
const nextSearchParams = new URLSearchParams(rawSearch);
const sourceSearchParams = new URLSearchParams(sourceSearch.startsWith('?') ? sourceSearch.slice(1) : sourceSearch);
PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS.forEach((key) => {
const value = sourceSearchParams.get(key)?.trim() ?? '';
if (value) {
nextSearchParams.set(key, value);
return;
}
nextSearchParams.delete(key);
});
const nextSearch = nextSearchParams.toString();
const nextHash = rawHash ? `#${rawHash}` : '';
return `${pathname}${nextSearch ? `?${nextSearch}` : ''}${nextHash}`;
}
export function readPreviewTargetDescriptorFromUrl(): PreviewTargetDescriptor {
if (typeof window === 'undefined') {
return null;
}
const searchParams = new URLSearchParams(window.location.search);
const targetType = searchParams.get(PREVIEW_TARGET_TYPE_QUERY_KEY)?.trim() ?? '';
if (targetType !== 'widget') {
return null;
}
const componentId = searchParams.get(PREVIEW_TARGET_COMPONENT_ID_QUERY_KEY)?.trim() ?? '';
const sampleId = searchParams.get(PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY)?.trim() ?? '';
if (!componentId) {
return null;
}
return {
type: 'widget',
componentId,
sampleId: sampleId || undefined,
};
}
export function buildPreviewRuntimeUrl(
pathname: string,
search = '',
token = '',
targetDescriptor: PreviewTargetDescriptor = null,
deviceMode: 'desktop' | 'mobile' = 'desktop',
) {
const targetUrl = new URL(pathname || '/', resolvePreviewAppOrigin());
if (search) {
const sourceParams = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
sourceParams.forEach((value, key) => {
targetUrl.searchParams.set(key, value);
});
}
targetUrl.searchParams.set(PREVIEW_RUNTIME_QUERY_KEY, '1');
if (typeof window !== 'undefined') {
targetUrl.searchParams.set(PREVIEW_RUNTIME_PARENT_ORIGIN_KEY, window.location.origin);
}
targetUrl.searchParams.set(PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY, deviceMode);
if (token.trim()) {
targetUrl.searchParams.set(PREVIEW_RUNTIME_TOKEN_QUERY_KEY, token.trim());
}
if (targetDescriptor?.type === 'widget') {
targetUrl.searchParams.set(PREVIEW_TARGET_TYPE_QUERY_KEY, 'widget');
targetUrl.searchParams.set(PREVIEW_TARGET_COMPONENT_ID_QUERY_KEY, targetDescriptor.componentId);
if (targetDescriptor.sampleId?.trim()) {
targetUrl.searchParams.set(PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY, targetDescriptor.sampleId.trim());
} else {
targetUrl.searchParams.delete(PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY);
}
}
return targetUrl.toString();
}