mirror of
https://github.com/LukeHagar/sveltekit-adapters.git
synced 2025-12-06 04:21:32 +00:00
- Replaced assertions with explicit error throwing for missing mainWindow and session. - Updated protocol handler to return a 400 response for unsupported HTTP requests, enhancing security and clarity. - Improved internal server error response formatting.
422 lines
13 KiB
JavaScript
422 lines
13 KiB
JavaScript
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import isDev from 'electron-is-dev';
|
|
import { protocol, net, dialog, app } from 'electron';
|
|
import { pathToFileURL } from 'url';
|
|
import assert from 'node:assert';
|
|
import { parse as parseCookie, splitCookiesString } from 'set-cookie-parser';
|
|
import { serialize as serializeCookie } from 'cookie';
|
|
|
|
let server;
|
|
let clientDir;
|
|
let prerenderedDir;
|
|
const Protocol = 'http';
|
|
const Host = '127.0.0.1';
|
|
const Origin = `${Protocol}://${Host}`;
|
|
|
|
/**
|
|
* Reports errors to the user in a way that can be filed on GitHub
|
|
* @param {Error} error - The error to report
|
|
* @param {string} context - Additional context about where the error occurred
|
|
*/
|
|
function reportError(error, context = '') {
|
|
const errorMessage = `SvelteKit Electron Adapter Error${context ? ` (${context})` : ''}:
|
|
|
|
${error.message}
|
|
|
|
Stack trace:
|
|
${error.stack}
|
|
|
|
Please report this issue at: https://github.com/lukehagar/sveltekit-adapters/issues`;
|
|
|
|
console.error(errorMessage);
|
|
|
|
if (!isDev) {
|
|
// Show error dialog to user in production
|
|
dialog.showErrorBox('SvelteKit Electron Adapter Error', errorMessage);
|
|
}
|
|
|
|
// Optionally crash the app in severe cases
|
|
// app.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Gets the absolute path to the preload script
|
|
*
|
|
* In development mode, points to the source preload script.
|
|
* In production, points to the built preload script.
|
|
*
|
|
* @returns {string} Absolute path to the preload script
|
|
* @type {import('./setupHandler.d').getPreloadPath}
|
|
*/
|
|
export function getPreloadPath() {
|
|
let preloadPath = path.resolve(path.join(__dirname, 'PRELOAD'))
|
|
|
|
if (isDev) {
|
|
preloadPath = path.resolve(path.join(__dirname, '..', 'preload', 'index.js'))
|
|
}
|
|
|
|
return preloadPath;
|
|
}
|
|
|
|
/**
|
|
* Registers the HTTP scheme as privileged for Electron
|
|
*
|
|
* This must be called before the app is ready. It configures the HTTP protocol
|
|
* to have standard web privileges including:
|
|
* - Standard scheme behavior
|
|
* - Secure context
|
|
* - Fetch API support
|
|
*
|
|
* @type {import('./setupHandler.d').registerAppScheme}
|
|
*/
|
|
export function registerAppScheme() {
|
|
protocol.registerSchemesAsPrivileged([
|
|
{
|
|
scheme: Protocol,
|
|
privileges: {
|
|
standard: true,
|
|
secure: true,
|
|
supportFetchAPI: true,
|
|
}
|
|
}
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Converts an Electron protocol request to a Web API Request object
|
|
*
|
|
* This function:
|
|
* 1. Extracts headers from the Electron request and normalizes them
|
|
* 2. Retrieves cookies from the session and adds them to headers
|
|
* 3. Handles request body data from uploadData or request.body
|
|
* 4. Creates a proper Web API Request object that SvelteKit expects
|
|
*
|
|
* @param {GlobalRequest} request - The Electron protocol request object
|
|
* @param {Session} session - The Electron session for cookie access
|
|
* @returns {Promise<Request>} A Web API Request object compatible with SvelteKit
|
|
* @type {import('./setupHandler.d').createRequest}
|
|
*/
|
|
export async function createRequest(request, session) {
|
|
try {
|
|
const url = new URL(request.url);
|
|
|
|
// Create a proper Headers object that SvelteKit expects
|
|
const headers = new Headers();
|
|
request.headers.forEach((value, key) => {
|
|
headers.set(key.toLowerCase(), value);
|
|
});
|
|
headers.set('origin', Origin);
|
|
|
|
try {
|
|
// @see https://github.com/electron/electron/issues/39525#issue-1852825052
|
|
const cookies = await session.cookies.get({
|
|
url: url.toString(),
|
|
});
|
|
|
|
if (cookies.length) {
|
|
const cookiesHeader = [];
|
|
|
|
for (const cookie of cookies) {
|
|
const { name, value, ...options } = cookie;
|
|
cookiesHeader.push(serializeCookie(name, value)); // ...(options as any)?
|
|
}
|
|
|
|
headers.set('cookie', cookiesHeader.join('; '));
|
|
}
|
|
} catch (e) {
|
|
reportError(e, 'Cookie retrieval');
|
|
}
|
|
|
|
// Handle body data
|
|
let body = null;
|
|
if (request.uploadData && request.uploadData.length > 0) {
|
|
const buffers = request.uploadData
|
|
.filter(part => part.bytes)
|
|
.map(part => Buffer.from(part.bytes));
|
|
|
|
body = Buffer.concat(buffers);
|
|
} else if (request.body) {
|
|
body = Buffer.from(await request.arrayBuffer());
|
|
}
|
|
|
|
// Create a proper Web API Request object that SvelteKit expects
|
|
const webRequest = new Request(url.toString(), {
|
|
method: request.method,
|
|
headers: headers,
|
|
body: body
|
|
});
|
|
|
|
return webRequest;
|
|
} catch (error) {
|
|
reportError(error, 'Request creation');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up the protocol handler for serving SvelteKit app content
|
|
*
|
|
* This function handles both development and production modes:
|
|
*
|
|
* **Development Mode:**
|
|
* - Loads the dev server URL (VITE_DEV_SERVER or localhost:5173)
|
|
* - Returns early without protocol interception
|
|
*
|
|
* **Production Mode:**
|
|
* - Initializes the SvelteKit server with the built app
|
|
* - Sets up directory paths for client assets and prerendered pages
|
|
* - Registers HTTP protocol handler that serves:
|
|
* 1. Static client assets (with caching headers)
|
|
* 2. Prerendered pages from the prerendered directory
|
|
* 3. SSR/API routes via the SvelteKit server
|
|
* - Synchronizes cookies between Electron session and SvelteKit responses
|
|
* - Validates requests to prevent external HTTP access
|
|
* - Protects against path traversal attacks
|
|
*
|
|
* @param {BrowserWindow} mainWindow - The main Electron browser window
|
|
* @returns {Promise<() => void>} A cleanup function that unregisters the protocol handler
|
|
* @type {import('./setupHandler.d').setupHandler}
|
|
*/
|
|
export async function setupHandler(mainWindow) {
|
|
if (!mainWindow) {
|
|
throw new Error('mainWindow is required for setupHandler');
|
|
}
|
|
|
|
if (!mainWindow.webContents?.session) {
|
|
throw new Error('mainWindow.webContents.session is required for setupHandler');
|
|
}
|
|
|
|
let url = process.env.VITE_DEV_SERVER || Origin
|
|
|
|
if (isDev) {
|
|
await mainWindow.loadURL(process.env.VITE_DEV_SERVER || 'http://localhost:5173');
|
|
return () => { }; // No interception in dev
|
|
|
|
} else {
|
|
|
|
try {
|
|
// Dynamically import server and manifest after build
|
|
const { Server } = await import('SERVER');
|
|
const { manifest, prerendered, base } = await import('MANIFEST');
|
|
|
|
// Initialize server
|
|
server = new Server(manifest);
|
|
await server.init({
|
|
env: process.env,
|
|
read: async (file) => {
|
|
return fs.readFile(path.join(clientDir, file));
|
|
}
|
|
});
|
|
|
|
// Set up directories
|
|
clientDir = path.join(__dirname, '..', 'client', base);
|
|
prerenderedDir = path.join(__dirname, '..', 'prerendered');
|
|
|
|
// Handle all http://127.0.0.1 requests
|
|
protocol.handle(Protocol, async (request) => {
|
|
|
|
if (!request.url.startsWith(url)) {
|
|
return new Response('External HTTP not supported, use HTTPS instead', {
|
|
status: 400,
|
|
headers: { 'content-type': 'text/plain' }
|
|
});
|
|
}
|
|
|
|
const req = await createRequest(request, mainWindow.webContents.session);
|
|
|
|
try {
|
|
const { host, pathname } = new URL(req.url);
|
|
|
|
// Only handle requests from the host
|
|
if (host !== Host) {
|
|
return new Response('Not found', { status: 404 });
|
|
}
|
|
|
|
// 1. Serve static client assets
|
|
const staticFilePath = path.join(clientDir, pathname);
|
|
if (await fileExists(staticFilePath)) {
|
|
if (!isSafePath(clientDir, staticFilePath)) {
|
|
reportError(new Error(`Unsafe static file path detected: ${staticFilePath}`), 'Path traversal attempt');
|
|
return new Response('bad', { status: 400, headers: { 'content-type': 'text/html' } });
|
|
}
|
|
return net.fetch(pathToFileURL(staticFilePath).toString(), {
|
|
headers: {
|
|
'content-type': getMimeType(staticFilePath),
|
|
'cache-control': 'public, max-age=31536000' // 1 year cache for static assets
|
|
}
|
|
});
|
|
}
|
|
|
|
// 2. Serve prerendered pages
|
|
if (prerendered.has(pathname)) {
|
|
const prerenderedPath = path.join(prerenderedDir, pathname, 'index.html');
|
|
if (await fileExists(prerenderedPath)) {
|
|
if (!isSafePath(prerenderedDir, prerenderedPath)) {
|
|
reportError(new Error(`Unsafe prerendered file path detected: ${prerenderedPath}`), 'Path traversal attempt');
|
|
return new Response('bad', { status: 400, headers: { 'content-type': 'text/html' } });
|
|
}
|
|
return net.fetch(pathToFileURL(prerenderedPath).toString());
|
|
}
|
|
}
|
|
|
|
// 3. Trailing slash redirect for prerendered
|
|
let alt = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname + '/';
|
|
if (prerendered.has(alt)) {
|
|
return new Response(null, {
|
|
status: 308,
|
|
headers: {
|
|
location: alt,
|
|
'cache-control': 'no-cache'
|
|
}
|
|
});
|
|
}
|
|
|
|
// 4. SSR/API fallback
|
|
const response = await server.respond(req, {
|
|
platform: {},
|
|
getClientAddress: () => Host
|
|
});
|
|
|
|
try {
|
|
// SvelteKit response headers are an array of [key, value] pairs
|
|
const setCookieHeaders = [];
|
|
for (const [key, value] of response.headers) {
|
|
if (key.toLowerCase() === 'set-cookie') {
|
|
setCookieHeaders.push(value);
|
|
}
|
|
}
|
|
|
|
if (setCookieHeaders.length > 0) {
|
|
const cookies = parseCookie(splitCookiesString(setCookieHeaders));
|
|
|
|
for (const cookie of cookies) {
|
|
const { name, value, path, domain, secure, httpOnly, expires, maxAge } = cookie;
|
|
|
|
const expirationDate = expires
|
|
? expires.getTime()
|
|
: maxAge
|
|
? Date.now() + maxAge * 1000
|
|
: undefined;
|
|
|
|
if (expirationDate && expirationDate < Date.now()) {
|
|
await mainWindow.webContents.session.cookies.remove(request.url, name);
|
|
continue;
|
|
}
|
|
|
|
await mainWindow.webContents.session.cookies.set({
|
|
url: request.url,
|
|
expirationDate,
|
|
name,
|
|
value,
|
|
path,
|
|
domain,
|
|
secure,
|
|
httpOnly,
|
|
maxAge,
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
reportError(e, 'Cookie synchronization');
|
|
}
|
|
|
|
return response;
|
|
|
|
} catch (error) {
|
|
reportError(error, 'Protocol handler');
|
|
return new Response('Internal Server Error', {
|
|
status: 500,
|
|
headers: { 'content-type': 'text/plain' }
|
|
});
|
|
}
|
|
});
|
|
} catch (error) {
|
|
reportError(error, 'Server initialization');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
await mainWindow.loadURL(url);
|
|
|
|
return function stopIntercept() {
|
|
protocol.unhandle(Protocol);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Checks if a file exists and is a regular file
|
|
*
|
|
* @param {string} filePath - Path to the file to check
|
|
* @returns {Promise<boolean>} True if the file exists and is a regular file, false otherwise
|
|
*/
|
|
export const fileExists = async (filePath) => {
|
|
try {
|
|
return (await fs.stat(filePath)).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Determines the MIME type of a file based on its extension
|
|
*
|
|
* @param {string} filePath - Path to the file
|
|
* @returns {string} The MIME type string, defaults to 'application/octet-stream' for unknown extensions
|
|
*/
|
|
export function getMimeType(filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mimeTypes = {
|
|
'.html': 'text/html',
|
|
'.htm': 'text/html',
|
|
'.js': 'application/javascript',
|
|
'.mjs': 'application/javascript',
|
|
'.css': 'text/css',
|
|
'.json': 'application/json',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.ico': 'image/x-icon',
|
|
'.woff': 'font/woff',
|
|
'.woff2': 'font/woff2',
|
|
'.ttf': 'font/ttf',
|
|
'.eot': 'application/vnd.ms-fontobject',
|
|
'.otf': 'font/otf',
|
|
'.webp': 'image/webp',
|
|
'.avif': 'image/avif',
|
|
'.mp4': 'video/mp4',
|
|
'.webm': 'video/webm',
|
|
'.mp3': 'audio/mpeg',
|
|
'.wav': 'audio/wav',
|
|
'.pdf': 'application/pdf',
|
|
'.zip': 'application/zip',
|
|
'.txt': 'text/plain',
|
|
'.md': 'text/markdown',
|
|
'.xml': 'application/xml',
|
|
'.csv': 'text/csv'
|
|
};
|
|
|
|
return mimeTypes[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
/**
|
|
* Validates that a target path is safe relative to a base directory
|
|
*
|
|
* Prevents directory traversal attacks by ensuring the target path:
|
|
* - Is within the base directory (no .. traversal)
|
|
* - Is not an absolute path outside the base
|
|
*
|
|
* @param {string} base - The base directory path
|
|
* @param {string} target - The target file path to validate
|
|
* @returns {boolean} True if the path is safe, false if it's a potential security risk
|
|
*/
|
|
export const isSafePath = (base, target) => {
|
|
const relative = path.relative(base, target);
|
|
const safe = !relative || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
if (!safe) {
|
|
reportError(new Error(`Unsafe path detected: base=${base}, target=${target}, relative=${relative}`), 'Path traversal attempt');
|
|
}
|
|
return safe;
|
|
};
|