442 lines
12 KiB
JavaScript
442 lines
12 KiB
JavaScript
import path from 'node:path';
|
|
import react from '@vitejs/plugin-react';
|
|
import { createLogger, defineConfig } from 'vite';
|
|
import inlineEditPlugin from './plugins/visual-editor/vite-plugin-react-inline-editor.js';
|
|
import editModeDevPlugin from './plugins/visual-editor/vite-plugin-edit-mode.js';
|
|
import iframeRouteRestorationPlugin from './plugins/vite-plugin-iframe-route-restoration.js';
|
|
import sitePagesPlugin from './plugins/vite-plugin-site-pages.js';
|
|
import pocketbaseAuthPlugin from './plugins/vite-plugin-pocketbase-auth.js';
|
|
import sessionJournalPlugin from './plugins/session-journal/vite-plugin-session-journal.js';
|
|
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
|
|
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'));
|
|
const allDeps = Object.keys(pkg.dependencies || {});
|
|
|
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
|
|
// Only the Horizons editor may read this dev server cross-origin. `cors: true`
|
|
// sends `Access-Control-Allow-Origin: *`, which lets any site read the source
|
|
// transforms and call the dev-only APIs.
|
|
const AllowedEditorOrigins = [
|
|
'https://horizons.hostinger.com',
|
|
'https://horizons.hostinger.dev',
|
|
];
|
|
|
|
const configHorizonsViteErrorHandler = `
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
for (const addedNode of mutation.addedNodes) {
|
|
if (
|
|
addedNode.nodeType === Node.ELEMENT_NODE &&
|
|
(
|
|
addedNode.tagName?.toLowerCase() === 'vite-error-overlay' ||
|
|
addedNode.classList?.contains('backdrop')
|
|
)
|
|
) {
|
|
handleViteOverlay(addedNode);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
observer.observe(document.documentElement, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
|
|
function handleViteOverlay(node) {
|
|
if (!node.shadowRoot) {
|
|
return;
|
|
}
|
|
|
|
const backdrop = node.shadowRoot.querySelector('.backdrop');
|
|
|
|
if (backdrop) {
|
|
const overlayHtml = backdrop.outerHTML;
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(overlayHtml, 'text/html');
|
|
const messageBodyElement = doc.querySelector('.message-body');
|
|
const fileElement = doc.querySelector('.file');
|
|
const messageText = messageBodyElement ? messageBodyElement.textContent.trim() : '';
|
|
const fileText = fileElement ? fileElement.textContent.trim() : '';
|
|
const error = messageText + (fileText ? ' File:' + fileText : '');
|
|
|
|
window.parent.postMessage({
|
|
type: 'horizons-vite-error',
|
|
error,
|
|
}, '*');
|
|
}
|
|
}
|
|
`;
|
|
|
|
const configHorizonsRuntimeErrorHandler = `
|
|
window.onerror = (message, source, lineno, colno, errorObj) => {
|
|
const errorDetails = errorObj ? JSON.stringify({
|
|
name: errorObj.name,
|
|
message: errorObj.message,
|
|
stack: errorObj.stack,
|
|
source,
|
|
lineno,
|
|
colno,
|
|
}) : null;
|
|
|
|
window.parent.postMessage({
|
|
type: 'horizons-runtime-error',
|
|
message,
|
|
error: errorDetails
|
|
}, '*');
|
|
};
|
|
`;
|
|
|
|
const configHorizonsConsoleErrorHandler = `
|
|
const originalConsoleError = console.error;
|
|
const MATCH_LINE_COL_REGEX = /:(\\d+):(\\d+)\\)?\\s*$/; // regex to match the :lineNum:colNum
|
|
const MATCH_AT_REGEX = /^\\s*at\\s+(?:async\\s+)?(?:.*?\\s+)?\\(?/; // regex to remove the 'at' keyword and any 'async' or function name
|
|
const MATCH_PATH_REGEX = /^\\//; // regex to remove the leading slash
|
|
|
|
function parseStackFrameLine(line) {
|
|
const lineColMatch = line.match(MATCH_LINE_COL_REGEX);
|
|
if (!lineColMatch) return null;
|
|
const [, lineNum, colNum] = lineColMatch;
|
|
const suffix = \`:\${lineNum}:\${colNum}\`;
|
|
const idx = line.lastIndexOf(suffix);
|
|
if (idx === -1) return null;
|
|
const before = line.substring(0, idx);
|
|
const path = before.replace(MATCH_AT_REGEX, '').trim();
|
|
|
|
if (!path) return null;
|
|
|
|
try {
|
|
const pathname = new URL(path).pathname;
|
|
const filePath = pathname.replace(MATCH_PATH_REGEX, '') || pathname;
|
|
return \`\${filePath}:\${lineNum}:\${colNum}\`;
|
|
} catch (e) {
|
|
const filePath = path.replace(MATCH_PATH_REGEX, '') || path;
|
|
return \`\${filePath}:\${lineNum}:\${colNum}\`;
|
|
}
|
|
}
|
|
|
|
function getFilePathFromStack(stack, skipFrames = 0) {
|
|
if (!stack || typeof stack !== 'string') return null;
|
|
const lines = stack.split('\\n').slice(1);
|
|
|
|
const frames = lines.map(line => parseStackFrameLine(line.replace(/\\r$/, ''))).filter(Boolean);
|
|
|
|
return frames[skipFrames] ?? null;
|
|
}
|
|
|
|
function formatConsoleMessage(args, skipStackFrames = 1) {
|
|
let messageString = '';
|
|
let filePath = null;
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg instanceof Error) {
|
|
filePath = getFilePathFromStack(arg.stack, 0);
|
|
messageString = \`\${arg.name}: \${arg.message}\`;
|
|
if (filePath) {
|
|
messageString = \`\${messageString} at \${filePath}\`;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!messageString) {
|
|
messageString = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ');
|
|
const stack = new Error().stack;
|
|
filePath = getFilePathFromStack(stack, skipStackFrames);
|
|
if (filePath) {
|
|
messageString = \`\${messageString} at \${filePath}\`;
|
|
}
|
|
}
|
|
|
|
return messageString;
|
|
}
|
|
|
|
console.error = function(...args) {
|
|
originalConsoleError.apply(console, args);
|
|
|
|
window.parent.postMessage({
|
|
type: 'horizons-console-error',
|
|
error: formatConsoleMessage(args, 1)
|
|
}, '*');
|
|
};
|
|
|
|
const originalConsoleWarn = console.warn;
|
|
|
|
console.warn = function(...args) {
|
|
originalConsoleWarn.apply(console, args);
|
|
|
|
window.parent.postMessage({
|
|
type: 'horizons-console-warn',
|
|
warning: formatConsoleMessage(args, 1)
|
|
}, '*');
|
|
};
|
|
`;
|
|
|
|
const configWindowFetchMonkeyPatch = `
|
|
// Prevents authentication failures (PB returns 400) from crashing the app — use console.info, not console.error/PM2.
|
|
// [urlPattern, bodyPattern] tuples; align with sandboxErrorUtils ERROR_BLACKLIST.
|
|
const BENIGN_FETCH_ERRORS = [
|
|
[/hcgi\\/platform\\/api\\/collections\\/.*auth-with-password.*/i, /Failed to authenticate/i],
|
|
[/hcgi\\/api\\//i, /Insufficient credits/i],
|
|
// A user-supplied integration secret (Stripe/PayPal/Twilio/...) is not set yet — an expected setup state, not a bug to fix.
|
|
[/hcgi\\/api\\//i, /INTEGRATION_NOT_CONFIGURED/i],
|
|
];
|
|
|
|
function isBenignFetchError(url, body) {
|
|
return BENIGN_FETCH_ERRORS.some(([urlPattern, bodyPattern]) =>
|
|
urlPattern.test(url) && (!bodyPattern || bodyPattern.test(body)));
|
|
}
|
|
|
|
const PLATFORM_URL_PATTERN = /hcgi\\/platform\\//i;
|
|
const VALIDATION_CODE_TEXT_PATTERN = /validation_/;
|
|
|
|
function hasValidationCode(value) {
|
|
if (value == null) {
|
|
return false;
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value.startsWith('validation_');
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.some(hasValidationCode);
|
|
}
|
|
if (typeof value === 'object') {
|
|
return Object.values(value).some(hasValidationCode);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isValidationFetchWarning(url, body) {
|
|
if (!PLATFORM_URL_PATTERN.test(url)) {
|
|
return false;
|
|
}
|
|
try {
|
|
return hasValidationCode(JSON.parse(body));
|
|
} catch {
|
|
return VALIDATION_CODE_TEXT_PATTERN.test(body);
|
|
}
|
|
}
|
|
|
|
const originalFetch = window.fetch;
|
|
|
|
window.fetch = function(...args) {
|
|
const url = args[0] instanceof Request ? args[0].url : args[0];
|
|
|
|
// Skip WebSocket URLs
|
|
if (url.startsWith('ws:') || url.startsWith('wss:')) {
|
|
return originalFetch.apply(this, args);
|
|
}
|
|
|
|
return originalFetch.apply(this, args)
|
|
.then(async response => {
|
|
const contentType = response.headers.get('Content-Type') || '';
|
|
|
|
// Exclude HTML document responses
|
|
const isDocumentResponse =
|
|
contentType.includes('text/html') ||
|
|
contentType.includes('application/xhtml+xml');
|
|
|
|
// no-cors requests (e.g. Google Analytics/Ads beacons) always return an opaque
|
|
const isOpaqueResponse = response.type === 'opaque';
|
|
|
|
if (!response.ok && !isDocumentResponse && !isOpaqueResponse) {
|
|
const responseClone = response.clone();
|
|
const errorFromRes = await responseClone.text();
|
|
const requestUrl = response.url;
|
|
const errorMessage = \`Fetch error from \${requestUrl}: \${errorFromRes}\`;
|
|
|
|
if (isBenignFetchError(requestUrl, errorFromRes)) {
|
|
console.info(errorMessage);
|
|
} else if (isValidationFetchWarning(requestUrl, errorFromRes)) {
|
|
console.warn(errorMessage);
|
|
} else {
|
|
console.error(errorMessage);
|
|
}
|
|
}
|
|
|
|
return response;
|
|
})
|
|
.catch(error => {
|
|
if (!url.match(/\.html?$/i)) {
|
|
// Cancelled in-flight fetch (e.g. preview reload) — not a server failure.
|
|
if (error?.name === 'AbortError') {
|
|
console.info(error);
|
|
} else {
|
|
console.error(error);
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
});
|
|
};
|
|
`;
|
|
|
|
const configNavigationHandler = `
|
|
if (window.navigation && window.self !== window.top) {
|
|
window.navigation.addEventListener('navigate', (event) => {
|
|
const url = event.destination.url;
|
|
|
|
try {
|
|
const destinationUrl = new URL(url);
|
|
const destinationOrigin = destinationUrl.origin;
|
|
const currentOrigin = window.location.origin;
|
|
|
|
if (destinationOrigin === currentOrigin) {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
|
|
window.parent.postMessage({
|
|
type: 'horizons-navigation-error',
|
|
url,
|
|
}, '*');
|
|
});
|
|
}
|
|
`;
|
|
|
|
const addTransformIndexHtml = {
|
|
name: 'add-transform-index-html',
|
|
transformIndexHtml(html) {
|
|
const tags = [
|
|
{
|
|
tag: 'script',
|
|
attrs: { type: 'module' },
|
|
children: configHorizonsRuntimeErrorHandler,
|
|
injectTo: 'head',
|
|
},
|
|
{
|
|
tag: 'script',
|
|
attrs: { type: 'module' },
|
|
children: configHorizonsViteErrorHandler,
|
|
injectTo: 'head',
|
|
},
|
|
{
|
|
tag: 'script',
|
|
attrs: { type: 'module' },
|
|
children: configHorizonsConsoleErrorHandler,
|
|
injectTo: 'head',
|
|
},
|
|
{
|
|
tag: 'script',
|
|
attrs: { type: 'module' },
|
|
children: configWindowFetchMonkeyPatch,
|
|
injectTo: 'head',
|
|
},
|
|
{
|
|
tag: 'script',
|
|
attrs: { type: 'module' },
|
|
children: configNavigationHandler,
|
|
injectTo: 'head',
|
|
},
|
|
];
|
|
|
|
if (!isDev && process.env.TEMPLATE_BANNER_SCRIPT_URL && process.env.TEMPLATE_REDIRECT_URL) {
|
|
tags.push(
|
|
{
|
|
tag: 'script',
|
|
attrs: {
|
|
src: process.env.TEMPLATE_BANNER_SCRIPT_URL,
|
|
'template-redirect-url': process.env.TEMPLATE_REDIRECT_URL,
|
|
...(process.env.TEMPLATE_BANNER_MAIN_TEXT && { 'template-main-text': process.env.TEMPLATE_BANNER_MAIN_TEXT }),
|
|
...(process.env.TEMPLATE_BANNER_CTA_TEXT && { 'template-cta-text': process.env.TEMPLATE_BANNER_CTA_TEXT }),
|
|
...(process.env.TEMPLATE_BANNER_THEME && { 'template-theme': process.env.TEMPLATE_BANNER_THEME }),
|
|
},
|
|
injectTo: 'head',
|
|
}
|
|
);
|
|
}
|
|
|
|
return {
|
|
html,
|
|
tags,
|
|
};
|
|
},
|
|
};
|
|
|
|
// Vite serves public files only at their exact path; the published site also serves this shell at `/checkout/`.
|
|
const domainCheckoutDevPlugin = {
|
|
name: 'horizons-domain-checkout',
|
|
apply: 'serve',
|
|
configureServer(server) {
|
|
const shellFile = path.join(server.config.publicDir, 'checkout', 'index.html');
|
|
|
|
server.middlewares.use((req, _res, next) => {
|
|
const url = new URL(req.url, 'http://localhost');
|
|
|
|
if ((url.pathname === '/checkout' || url.pathname === '/checkout/') && existsSync(shellFile)) {
|
|
req.url = `/checkout/index.html${url.search}`;
|
|
}
|
|
|
|
next();
|
|
});
|
|
},
|
|
};
|
|
|
|
console.warn = () => { };
|
|
|
|
const logger = createLogger()
|
|
const loggerError = logger.error
|
|
|
|
logger.error = (msg, options) => {
|
|
if (options?.error?.toString().includes('CssSyntaxError: [postcss]')) {
|
|
return;
|
|
}
|
|
|
|
loggerError(msg, options);
|
|
}
|
|
|
|
export default defineConfig({
|
|
optimizeDeps: {
|
|
include: allDeps,
|
|
},
|
|
customLogger: logger,
|
|
plugins: [
|
|
...(isDev ? [inlineEditPlugin(), editModeDevPlugin(), iframeRouteRestorationPlugin(), sitePagesPlugin(), pocketbaseAuthPlugin(), sessionJournalPlugin()] : []),
|
|
react(),
|
|
addTransformIndexHtml,
|
|
domainCheckoutDevPlugin,
|
|
],
|
|
server: {
|
|
port: 3000,
|
|
cors: { origin: AllowedEditorOrigins },
|
|
headers: {
|
|
'Cross-Origin-Embedder-Policy': 'credentialless',
|
|
},
|
|
allowedHosts: [
|
|
'.app-preview.com',
|
|
'.app-preview.io',
|
|
],
|
|
fs: {
|
|
strict: true,
|
|
allow: [
|
|
path.resolve(__dirname),
|
|
path.join(path.resolve(__dirname, '../..'), 'node_modules'),
|
|
],
|
|
},
|
|
},
|
|
resolve: {
|
|
extensions: ['.jsx', '.js', '.json'],
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
},
|
|
},
|
|
build: {
|
|
rollupOptions: {
|
|
external: [
|
|
'@babel/parser',
|
|
'@babel/traverse',
|
|
'@babel/generator',
|
|
'@babel/types'
|
|
],
|
|
checks: {
|
|
pluginTimings: false,
|
|
}
|
|
}
|
|
}
|
|
});
|