457 lines
13 KiB
TypeScript
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();
|
|
}
|