chore: Update package dependencies and configuration for Electron adapter

- Removed package manager and engines from package.json.
- Updated pnpm-lock.yaml to reflect new versions of dependencies.
- Modified pnpm-workspace.yaml to include only built dependencies for Electron.
- Adjusted electron-builder.yaml to exclude unnecessary files and added output directory.
- Deleted obsolete electron.vite.config.ts file.
- Updated main entry point in examples/electron/package.json to use CommonJS format.
- Refactored scripts in examples/electron/package.json for better development experience.
- Enhanced error handling and logging in game logic within examples/electron/src/routes/sverdle/+page.server.ts.
- Updated adapter-electron to support new protocol handling and build processes.
This commit is contained in:
Luke Hagar
2025-07-12 23:45:12 -05:00
parent e2eda959b0
commit 196fc9d774
20 changed files with 5929 additions and 6441 deletions

View File

@@ -6,13 +6,16 @@ files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.{js,ts,mjs,cjs}"
- "!vite.electron.config.ts"
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
- "out/**/*"
asarUnpack:
- resources/**
# afterSign: build/notarize.js
win:
target: ["portable"]
executableName: electron-sveltekit
nsis:
artifactName: ${name}-${version}-setup.${ext}

View File

@@ -1,8 +0,0 @@
import { defineConfig, defineViteConfig } from 'electron-vite';
import config from './vite.config';
export default defineConfig({
main: defineViteConfig({}),
preload: defineViteConfig({}),
renderer: config
});

View File

@@ -8,11 +8,11 @@
"email": "lukeslakemail@gmail.com"
},
"homepage": "https://github.com/lukehagar/sveltekit-adapters",
"main": "./out/main/index.js",
"main": "./out/main/index.cjs",
"scripts": {
"start": "electron-vite preview",
"dev": "svelte-kit sync && electron-vite dev",
"build": "electron-vite build",
"start": "vite preview",
"dev": "svelte-kit sync && concurrently \"vite dev\" \"electron .\" --names \"sveltekit,electron\" --prefix-colors \"#ff3e00,blue\"",
"build": "vite build",
"build:all": "npm run build && electron-builder -mwl --config",
"build:win": "npm run build && electron-builder --win --config",
"build:mac": "npm run build && electron-builder --mac --config",
@@ -24,7 +24,7 @@
"@sveltejs/kit": "^2.22.2",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@types/eslint": "9.6.1",
"@types/node": "^24.0.10",
"@types/node": "^24.0.11",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"adapter-electron": "workspace:*",
@@ -36,7 +36,7 @@
"electron-is-dev": "^3.0.1",
"electron-log": "^5.4.1",
"electron-util": "^0.18.1",
"electron-vite": "^3.1.0",
"esbuild": "^0.25.0",
"eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.10.1",

View File

@@ -0,0 +1,57 @@
import { app, BrowserWindow } from 'electron';
import { setupHandler, getPreloadPath, registerAppScheme } from 'adapter-electron/functions/setupHandler';
import log from 'electron-log/main';
console.log = log.log;
let mainWindow: BrowserWindow | null = null;
let stopIntercept: (() => void) | undefined;
process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
// First register the app scheme
registerAppScheme();
async function createWindow() {
// Create the browser window
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
// Second configure the preload script
preload: getPreloadPath(),
contextIsolation: true,
devTools: true
}
});
mainWindow.once('ready-to-show', () => mainWindow?.webContents.openDevTools());
mainWindow.on('closed', () => {
mainWindow = null;
stopIntercept?.();
});
await app.whenReady();
// Third setup the handler
stopIntercept = await setupHandler(mainWindow);
return mainWindow;
}
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0 && !mainWindow) {
await createWindow();
}
});

View File

@@ -1,71 +0,0 @@
import { app, BrowserWindow } from 'electron';
import { start, load } from 'adapter-electron/functions';
import isDev from 'electron-is-dev';
import log from 'electron-log/main';
import nodePath from 'node:path';
import { fileURLToPath } from 'node:url';
// Handle __dirname in ES modules
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
log.info('Starting Electron app with SvelteKit protocol integration...');
// Initialize the protocol manager
const port = await start();
async function createWindow() {
// Create the browser window
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: nodePath.join(__dirname, '../preload/index.mjs'),
nodeIntegration: false,
contextIsolation: true,
webSecurity: true
}
});
// Load the app - all routing is handled by protocol interception
load(mainWindow);
if (isDev) {
mainWindow.webContents.openDevTools();
}
// Handle window events
mainWindow.webContents.on('did-finish-load', () => {
log.info('Window loaded successfully');
});
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
log.error('Window failed to load:', errorDescription);
});
return mainWindow;
}
app.whenReady().then(async () => {
log.info('App is ready');
log.info('Creating window...');
await createWindow();
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await createWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
// Handle render process crashes
app.on('render-process-gone', (event, webContents, details) => {
log.error('Render process crashed:', details.reason);
// You could restart the window here if needed
});

View File

@@ -0,0 +1 @@
console.log('Preload loaded');

View File

@@ -1,10 +0,0 @@
window.addEventListener('DOMContentLoaded', () => {
const replaceText = (selector, text) => {
const element = document.getElementById(selector);
if (element) element.innerText = text;
};
for (const type of ['chrome', 'node', 'electron']) {
replaceText(`${type}-version`, process.versions[type]);
}
});

View File

@@ -3,9 +3,12 @@ import { Game } from './game';
import type { PageServerLoad, Actions } from './$types';
export const load = (({ cookies }) => {
console.log("Loading game, getting cookie");
try {
const game = new Game(cookies.get('sverdle'));
return {
const gameState = {
/**
* The player's guessed words so far
*/
@@ -21,7 +24,14 @@ export const load = (({ cookies }) => {
* The correct answer, revealed if the game is over
*/
answer: game.answers.length >= 6 ? game.answer : null
};
}
console.log("Returning game state", gameState);
return gameState
} catch (e) {
console.log("Error getting cookie", e);
}
}) satisfies PageServerLoad;
export const actions = {
@@ -30,6 +40,7 @@ export const actions = {
* is available, this will happen in the browser instead of here
*/
update: async ({ request, cookies }) => {
console.log("Updating game, getting cookie");
const game = new Game(cookies.get('sverdle'));
const data = await request.formData();
@@ -43,7 +54,9 @@ export const actions = {
game.guesses[i] += key;
}
cookies.set('sverdle', game.toString(), { path: '/' });
const gameString = game.toString();
console.log("Setting cookie", gameString);
cookies.set('sverdle', gameString, { path: '/' });
},
/**
@@ -51,6 +64,8 @@ export const actions = {
* the server, so that people can't cheat by peeking at the JavaScript
*/
enter: async ({ request, cookies }) => {
console.log("Entering guess, getting cookie");
try {
const game = new Game(cookies.get('sverdle'));
const data = await request.formData();
@@ -60,10 +75,16 @@ export const actions = {
return fail(400, { badGuess: true });
}
cookies.set('sverdle', game.toString(), { path: '/' });
const gameString = game.toString();
console.log("Setting cookie", gameString);
cookies.set('sverdle', gameString, { path: '/' });
} catch (e) {
console.log("Error entering guess", e);
}
},
restart: async ({ cookies }) => {
console.log("Restarting game, deleting cookie");
cookies.delete('sverdle', { path: '/' });
}
} satisfies Actions;

View File

@@ -1,10 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { electronPlugin } from 'adapter-electron';
export default defineConfig({
build: {
target: 'chrome107'
},
logLevel: 'info',
plugins: [sveltekit()]
plugins: [
sveltekit(),
electronPlugin({
// The plugin will auto-detect src/main.ts and src/preload.ts
// You can override these paths if needed:
// mainEntry: 'src/main.ts',
// preloadEntry: 'src/preload.ts',
// mainOut: 'out/main/index.js',
// preloadOut: 'out/preload/index.js'
})
]
});

View File

@@ -16,9 +16,5 @@
"prettier": "^3.1.1",
"rimraf": "^5.0.5",
"turbo": "latest"
},
"packageManager": "pnpm@8.9.0",
"engines": {
"node": ">=18"
}
}

View File

@@ -1,97 +1,110 @@
# adapter-electron
# @sveltejs/adapter-electron
## A SvelteKit adapter for Electron Desktop Apps using protocol interception
This adapter provides a complete solution for building Electron desktop applications with SvelteKit by **embedding the full SvelteKit app directly into the Electron main process**. It uses protocol interception to handle all routing, SSR, and API endpoints without requiring a separate HTTP server.
A SvelteKit adapter for Electron desktop apps that uses native protocol handling for production and seamless Vite dev server integration for development.
## Features
### 🚀 **Embedded SvelteKit**
- **Full SvelteKit app** embedded in Electron main process
- **Server-side rendering** for all routes
- **API endpoints** handled natively
- **Static asset serving** via custom protocol
### 🔄 **Protocol Interception**
- **HTTP protocol interception** for SvelteKit routes
- **Custom file protocol** for static assets
- **No external HTTP server** required
- **Seamless development and production** experience
### ⚡ **Performance Optimizations**
- **Response caching** with configurable TTL
- **Automatic cache cleanup** to prevent memory leaks
- **Efficient static file serving** from built assets
- **Minimal overhead** compared to external servers
### 🛠️ **Developer Experience**
- **Hot reload** support in development
- **Comprehensive logging** for debugging
- **Easy configuration** with sensible defaults
- **TypeScript support** throughout
-**Native Protocol Handling**: Uses Electron's `protocol.handle()` API for production
- **Development Integration**: Seamless Vite dev server integration with HMR
- **No HTTP Server**: Bypasses Node.js HTTP servers entirely in production
- **Full SvelteKit Support**: SSR, API routes, static assets, prerendered pages, form actions
- **Clean Architecture**: All Electron integration code is encapsulated
-**Production Ready**: Works with electron-builder and similar tools
-**TypeScript Support**: Full type definitions included
- **Proper Error Handling**: User-friendly error reporting with GitHub issue links
## Installation
```bash
npm install adapter-electron
npm install @sveltejs/adapter-electron
```
## Basic Setup
## Quick Start
### 1. Configure SvelteKit
In your `svelte.config.js`:
```js
// svelte.config.js
import adapter from 'adapter-electron';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import adapter from '@sveltejs/adapter-electron';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
adapter: adapter({
// All options are optional with sensible defaults
out: 'out', // Output directory (default: 'out')
assets: true, // Include static assets (default: true)
fallback: undefined, // Fallback page for client-side routing (default: undefined)
precompress: false, // Precompress assets (default: false)
strict: true // Strict mode (default: true)
})
}
};
export default config;
```
### 2. Set up Electron Main Process
### 2. Set up Vite Configuration
```js
// src/main/index.js
In your `vite.config.ts`:
```ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { electronPlugin } from 'adapter-electron';
export default defineConfig({
plugins: [
sveltekit(),
electronPlugin({
// Auto-detects src/main.ts and src/preload.ts by default
// Override paths if needed:
// mainEntry: 'src/main.ts', // Main process entry (default: 'src/main.ts')
// preloadEntry: 'src/preload.ts', // Preload script entry (default: 'src/preload.ts')
// mainOut: 'out/main/index.js', // Main output (default: 'out/main/index.js')
// preloadOut: 'out/preload/index.js' // Preload output (default: 'out/preload/index.js')
})
]
});
```
### 3. Create Electron Main Process
Create `src/main.ts`:
```ts
import { app, BrowserWindow } from 'electron';
import { start, load, protocolUtils } from 'adapter-electron/functions';
import isDev from 'electron-is-dev';
import log from 'electron-log/main';
import nodePath from 'node:path';
import { setupHandler, getPreloadPath, registerAppScheme } from 'adapter-electron/functions/setupHandler';
const port = await start();
let mainWindow: BrowserWindow | null = null;
let stopIntercept: (() => void) | undefined;
// IMPORTANT: Register the app scheme before app.ready
registerAppScheme();
async function createWindow() {
const mainWindow = new BrowserWindow({
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: nodePath.join(__dirname, '../preload/index.mjs'),
preload: getPreloadPath(), // Auto-configured preload path
contextIsolation: true,
nodeIntegration: false,
contextIsolation: true
webSecurity: true
}
});
// Load the app - all routing is handled by protocol interception
load(mainWindow, port);
mainWindow.on('closed', () => {
mainWindow = null;
stopIntercept?.();
});
if (isDev) {
mainWindow.webContents.openDevTools();
}
// Setup the protocol handler (handles dev vs prod automatically)
stopIntercept = await setupHandler(mainWindow);
}
app.whenReady().then(createWindow);
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
@@ -99,201 +112,262 @@ app.on('window-all-closed', () => {
}
});
// Optional: Configure protocol settings
protocolUtils.configure({
baseUrl: 'http://localhost:3000',
staticProtocol: 'app',
enableCaching: true,
cacheTimeout: 300000 // 5 minutes
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0 && !mainWindow) {
await createWindow();
}
});
```
### 4. Create Preload Script
Create `src/preload.ts`:
```ts
// Your preload script content
console.log('Preload loaded');
// Example: Expose APIs to renderer process
import { contextBridge } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
// Your APIs here
});
```
### 5. Build and Run
```bash
# Development (uses Vite dev server with HMR)
npm run dev
# Production build
npm run build
# Run built Electron app
npm start # or your preferred Electron launcher
```
## How It Works
### **Protocol Interception**
The adapter uses Electron's protocol API to intercept all HTTP requests to your SvelteKit app:
### Development Mode
- Uses Vite dev server (`http://localhost:5173` by default)
- Full hot module replacement (HMR) support
- No protocol interception needed
- Set `VITE_DEV_SERVER` environment variable to customize dev server URL
1. **HTTP Protocol Interception**: All requests to `http://localhost:3000/*` are intercepted
2. **SvelteKit Handler**: Requests are routed through SvelteKit's built handler
3. **Static Asset Protocol**: Static files are served via a custom `app://` protocol
4. **Response Caching**: Successful responses are cached for performance
### Production Mode
The adapter uses Electron's native `protocol.handle()` API to intercept `http://127.0.0.1` requests:
### **Request Flow**
```
Browser Request → Protocol Interception → SvelteKit Handler → Response
```
1. **Static Assets**: Serves files from the `client/` directory
2. **Prerendered Pages**: Serves static HTML from the `prerendered/` directory
3. **SSR/API Routes**: Calls SvelteKit's `Server.respond()` directly
4. **Form Actions**: Full support for SvelteKit form actions
5. **Cookie Handling**: Automatic cookie synchronization with Electron session
### Build Output Structure
After running `npm run build`, you'll have:
### **Static Assets**
```
Static Asset Request → Custom Protocol → File System → Response
out/
├── client/ # SvelteKit client assets (JS, CSS, images)
├── server/ # SvelteKit server files for SSR
│ ├── index.js # SvelteKit server
│ ├── manifest.js # App manifest
│ └── chunks/ # Server chunks
├── prerendered/ # Prerendered static HTML pages
├── functions/ # Protocol handler code
│ ├── setupHandler.js # Main protocol handler
│ └── setupHandler.d.ts # TypeScript definitions
├── main/ # Compiled main process
│ └── index.js
└── preload/ # Compiled preload script
└── index.js
```
## Configuration Options
### **Protocol Configuration**
### SvelteKit Adapter Options
```js
import { protocolUtils } from 'adapter-electron/functions';
protocolUtils.configure({
baseUrl: 'http://localhost:3000', // Base URL for SvelteKit app
staticProtocol: 'app', // Protocol for static assets
enableCaching: true, // Enable response caching
cacheTimeout: 300000 // Cache TTL in milliseconds
});
adapter({
out: 'out', // Output directory
assets: true, // Include static assets from /static
fallback: undefined, // Fallback page for client-side routing
precompress: false, // Precompress assets with gzip/brotli
strict: true // Enable strict mode
})
```
### **Cache Management**
### Electron Plugin Options
```js
// Clear all cached responses
protocolUtils.clearCache();
electronPlugin({
mainEntry: 'src/main.ts', // Main process entry point
preloadEntry: 'src/preload.ts', // Preload script entry point
mainOut: 'out/main/index.js', // Main process output
preloadOut: 'out/preload/index.js' // Preload script output
})
```
// Get cache statistics
const stats = protocolUtils.getCacheStats();
console.log(`Cache enabled: ${stats.enabled}, Size: ${stats.size}`);
### Environment Variables
// Manual cache cleanup
protocolUtils.cleanupCache();
| Variable | Description | Default |
|----------|-------------|---------|
| `VITE_DEV_SERVER` | Development server URL | `http://localhost:5173` |
| `VITE_APP_URL` | Production app URL | `http://127.0.0.1` |
## API Reference
### Protocol Handler Functions
#### `setupHandler(mainWindow: BrowserWindow): Promise<() => void>`
Sets up the protocol handler and loads the appropriate URL based on environment.
- **Development**: Loads Vite dev server
- **Production**: Sets up protocol interception and loads app
- **Returns**: Cleanup function to stop protocol interception
#### `registerAppScheme(): void`
Registers the HTTP scheme as privileged. **Must be called before `app.ready`.**
#### `getPreloadPath(): string`
Returns the correct preload script path for current environment.
- **Development**: Points to source preload script
- **Production**: Points to built preload script
### Request Flow
```
Development:
Electron Window → Vite Dev Server (http://localhost:5173)
Hot Module Replacement
Production:
Electron Request (http://127.0.0.1/page)
Protocol Handler
1. Check static files (client/)
2. Check prerendered pages (prerendered/)
3. Handle SSR/API (server.respond())
Response with Cookie Sync
```
## Advanced Usage
### **Custom Protocol Configuration**
### Custom Error Handling
```js
// Use a custom base URL
protocolUtils.configure({
baseUrl: 'http://myapp.local',
staticProtocol: 'myapp-assets'
});
The adapter includes built-in error reporting. Errors are:
- Logged to console in development
- Shown as dialog boxes in production
- Include GitHub issue reporting instructions
// Disable caching for development
if (isDev) {
protocolUtils.configure({
enableCaching: false
});
### Cookie Management
Cookies are automatically synchronized between SvelteKit and Electron's session:
- Request cookies are extracted from Electron session
- Response `Set-Cookie` headers are applied to Electron session
- Supports all cookie attributes (secure, httpOnly, etc.)
### Security Features
- Path traversal protection for static files
- Automatic CORS handling
- Secure cookie handling
- Context isolation enforced
## Production Packaging
### With electron-builder
```json
{
"scripts": {
"build": "vite build",
"dist": "npm run build && electron-builder"
},
"build": {
"directories": {
"output": "dist",
"buildResources": "out"
},
"files": [
"out/**/*",
"package.json"
],
"mac": {
"icon": "assets/icon.icns"
},
"win": {
"icon": "assets/icon.ico"
}
}
}
```
### **Performance Monitoring**
### With electron-forge
```js
// Monitor cache performance
setInterval(() => {
const stats = protocolUtils.getCacheStats();
if (stats.enabled) {
console.log(`Cache size: ${stats.size} entries`);
}
}, 30000); // Every 30 seconds
```
### **Error Handling**
```js
// The adapter automatically handles errors and provides logging
// You can also add custom error handling in your main process
app.on('render-process-gone', (event, webContents, details) => {
console.error('Render process crashed:', details.reason);
// Restart the window or handle the crash
});
```
## Development vs Production
### **Development Mode**
- Uses external dev server when available
- Hot reload support
- Detailed logging
- Cache disabled by default
### **Production Mode**
- Full protocol interception
- Response caching enabled
- Optimized static asset serving
- Minimal logging
## API Reference
### **Core Functions**
- `start()` - Initialize the protocol manager and set up interception
- `load(mainWindow, port, path)` - Load the app in an Electron window
- `protocolUtils.configure(options)` - Configure protocol settings
- `protocolUtils.clearCache()` - Clear all cached responses
- `protocolUtils.getCacheStats()` - Get cache statistics
- `protocolUtils.cleanupCache()` - Clean up expired cache entries
### **Configuration Options**
```typescript
interface ProtocolOptions {
baseUrl?: string; // Base URL for SvelteKit app
staticProtocol?: string; // Protocol for static assets
enableCaching?: boolean; // Enable response caching
cacheTimeout?: number; // Cache TTL in milliseconds
// forge.config.js
module.exports = {
packagerConfig: {
dir: './out'
},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {}
}
]
};
```
## Troubleshooting
### **Common Issues**
### Common Issues
1. **App not loading**: Check that the SvelteKit build exists in the expected location
2. **Static assets not loading**: Verify the static protocol is correctly configured
3. **Performance issues**: Enable caching and adjust cache timeout
4. **Memory leaks**: Ensure cache cleanup is running periodically
**Protocol not working in production:**
- Ensure `registerAppScheme()` is called before `app.ready`
- Check that `setupHandler()` is called after window creation
### **Debug Mode**
**Development server not loading:**
- Verify Vite dev server is running on expected port
- Set `VITE_DEV_SERVER` environment variable if using custom port
Enable detailed logging by setting the environment variable:
**Form actions not working:**
- Ensure you're using proper Web API Request objects (handled automatically)
- Check that cookies are being synchronized properly
```bash
DEBUG=electron-protocol npm run dev
```
**Build errors:**
- Verify all dependencies are installed
- Check that TypeScript configuration includes Electron types
### **Cache Issues**
### Debug Mode
If you're experiencing stale content:
Enable verbose logging:
```js
// Clear cache manually
protocolUtils.clearCache();
// Or disable caching temporarily
protocolUtils.configure({ enableCaching: false });
// In main process
process.env.ELECTRON_ENABLE_LOGGING = 'true';
```
## Migration from Standard Adapter
### Getting Help
If you're migrating from the standard adapter-electron:
If you encounter issues:
1. **Update imports** to include protocolUtils
2. **Remove any external server setup** - it's no longer needed
3. **Configure protocol settings** as needed
4. **Test static asset loading** with the new protocol
The adapter maintains backward compatibility while providing the new protocol-based functionality.
## Performance Benefits
### **Compared to External Server**
- **Faster startup** - no server initialization
- **Lower memory usage** - no separate Node.js process
- **Better integration** - direct access to Electron APIs
- **Simplified deployment** - single executable
### **Caching Benefits**
- **Reduced computation** - cached SSR responses
- **Faster navigation** - cached API responses
- **Better user experience** - instant page loads
## Contributing
This adapter is part of the SvelteKit adapters collection. Contributions are welcome!
1. Check the console for error messages
2. Verify your configuration matches the examples
3. File an issue with error details and reproduction steps
## License
MIT License - see the main repository for details.
MIT

View File

@@ -1,14 +0,0 @@
import type { BrowserWindow } from 'electron';
export interface ProtocolOptions {
baseUrl?: string;
}
export interface ProtocolUtils {
configure: (options: ProtocolOptions) => void;
isConfigured: () => boolean;
}
export function start(): Promise<string | undefined>;
export function load(mainWindow: BrowserWindow, path?: string): void;
export const protocolUtils: ProtocolUtils;

View File

@@ -1,152 +0,0 @@
import isDev from 'electron-is-dev';
import path from 'node:path';
import log from 'electron-log/main';
import { protocol } from 'electron';
import { fileURLToPath } from 'node:url';
import { pathToFileURL } from 'node:url';
const __dirname = fileURLToPath(new URL('../renderer', import.meta.url));
// Module-level state
let handler = null;
let isConfigured = false;
let baseUrl = 'http://localhost:3000';
// Initialize the protocol manager
async function initialize() {
if (isConfigured) {
log.warn('SvelteKit protocol already configured');
return true;
}
try {
// Import the built SvelteKit handler
const handlerModule = await import(`file://${path.join(__dirname, 'handler.js')}`);
handler = handlerModule.handler;
log.info('SvelteKit handler loaded successfully');
return true;
} catch (error) {
log.error('Failed to load SvelteKit handler:', error);
return false;
}
}
// Configure protocol settings
function configure(options = {}) {
const {
baseUrl: newBaseUrl = 'http://localhost:3000'
} = options;
baseUrl = newBaseUrl;
log.info(`SvelteKit protocol configured with baseUrl: ${baseUrl}`);
}
// Set up protocols
async function setupProtocols() {
if (!handler) {
throw new Error('SvelteKit handler not initialized. Call initialize() first.');
}
// Register HTTP protocol handler
protocol.handle('http', async (req) => {
const { host, pathname } = new URL(req.url);
// Only handle requests to our configured base URL
if (host !== 'localhost:3000') {
return new Response('Not Found', {
status: 404,
headers: { 'content-type': 'text/plain' }
});
}
// Handle SvelteKit routes and API endpoints
try {
// Create a Request object for the SvelteKit handler
const sveltekitReq = new Request(req.url, {
method: req.method,
headers: req.headers,
body: req.body
});
// Handle request through SvelteKit
const res = await handler(sveltekitReq);
log.debug(`Handled SvelteKit request: ${req.method} ${pathname} -> ${res.status}`);
return res;
} catch (error) {
log.error(`Error handling SvelteKit request ${pathname}:`, error);
return new Response('Internal Server Error', {
status: 500,
headers: { 'content-type': 'text/plain' }
});
}
});
isConfigured = true;
log.info('SvelteKit protocols configured successfully');
}
/** @type {import('./index.js').start} */
export const start = async () => {
if (isDev) return undefined;
try {
log.info('Initializing SvelteKit protocol manager...');
// Initialize the protocol manager
const initialized = await initialize();
if (!initialized) {
throw new Error('Failed to initialize SvelteKit protocol manager');
}
// Configure with default settings
configure({
baseUrl: 'http://localhost:3000'
});
// Set up protocols
await setupProtocols();
log.info('SvelteKit protocol manager started successfully');
return '3000'; // Return port for compatibility
} catch (error) {
log.error('Failed to start SvelteKit protocol manager:', error);
throw error;
}
};
/** @type {import('./index.js').load} */
export const load = (mainWindow, path = '') => {
if (isDev && process.env['ELECTRON_RENDERER_URL']) {
const url = `${process.env['ELECTRON_RENDERER_URL']}${path}`;
log.info(`Loading development URL: ${url}`);
mainWindow.loadURL(url);
} else {
const url = `http://localhost:3000${path}`;
log.info(`Loading production URL: ${url}`);
mainWindow.loadURL(url);
}
// Set up window event handlers for better integration
mainWindow.webContents.on('did-finish-load', () => {
log.info('Window loaded successfully');
});
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
log.error('Window failed to load:', errorDescription);
});
};
// Export protocol utilities for advanced usage
export const protocolUtils = {
// Configure the protocol manager
configure,
// Check if configured
isConfigured: () => isConfigured
};

View File

@@ -0,0 +1,21 @@
import type { ProtocolRequest, GlobalRequest } from "electron";
import type { BrowserWindow, Session } from "electron/main";
import { IncomingMessage } from 'node:http';
/**
* Sets up the native Electron protocol handler for SvelteKit
*
* This function:
* 1. Initializes the SvelteKit server
* 2. Registers the 'app' protocol scheme as privileged
* 3. Handles all app:// requests for static assets, prerendered pages, and SSR
*
* @returns Promise that resolves when the protocol handler is set up
*/
export function setupHandler(mainWindow: BrowserWindow): Promise<() => void>;
export function registerAppScheme(): void;
export function getPreloadPath(): string;
export function createRequest(request: GlobalRequest, session: Session): Promise<IncomingMessage>;

View File

@@ -0,0 +1,345 @@
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 { Socket } from 'node:net';
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;
let Protocol = 'http';
let Host = '127.0.0.1';
let 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/your-repo/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);
}
/**
* @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;
}
/**
* @type {import('./setupHandler.d').registerAppScheme}
*/
export function registerAppScheme() {
protocol.registerSchemesAsPrivileged([
{
scheme: Protocol,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
}
}
]);
}
/**
* @type {import('./setupHandler.d').createRequest}
*/
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;
}
}
/**
* @type {import('./setupHandler.d').setupHandler}
*/
export async function setupHandler(mainWindow) {
assert(mainWindow, 'mainWindow is required');
assert(mainWindow.webContents.session, 'mainWindow.webContents.session is required');
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) => {
assert(request.url.startsWith(url), 'External HTTP not supported, use HTTPS');
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('http');
};
}
const fileExists = async (filePath) => {
try {
return (await fs.stat(filePath)).isFile();
} catch {
return false;
}
};
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';
}
// Helper to check for directory traversal
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;
};

View File

@@ -1,10 +1,49 @@
import { Adapter } from '@sveltejs/kit';
import type { AdapterOptions as NodeAdapterOptions } from '@sveltejs/adapter-node';
import './ambient.js';
import type { Adapter } from '@sveltejs/kit';
interface AdapterOptions {
export interface AdapterOptions {
/**
* Output directory for the Electron app
* @default 'out'
*/
out?: string;
options?: NodeAdapterOptions;
/**
* Directory name for the protocol handler functions
* @default 'functions'
*/
functions?: string;
/**
* Whether to precompress static assets
* @default false
*/
precompress?: boolean;
}
export default function plugin(options?: AdapterOptions): Adapter;
/**
* SvelteKit adapter for Electron desktop apps
*
* This adapter:
* 1. Builds the SvelteKit app using the static adapter for client assets
* 2. Copies server files for SSR support
* 3. Copies prerendered pages
* 4. Provides a native Electron protocol handler that bypasses HTTP servers
* 5. Outputs a complete Electron app structure ready for packaging
*/
declare function adapter(options?: AdapterOptions): Adapter;
export default adapter;
export interface ElectronPluginOptions {
mainEntry?: string;
preloadEntry?: string;
mainOut?: string;
preloadOut?: string;
externalMain?: string[];
externalPreload?: string[];
}
/**
* Vite plugin to build Electron main/preload files
*/
export declare function electronPlugin(options?: ElectronPluginOptions): any;

View File

@@ -1,21 +1,180 @@
// adapter-electron.js
import adapter from '@sveltejs/adapter-node';
import { readFileSync, writeFileSync } from 'node:fs';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { rollup, watch as rollupWatch } from 'rollup';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
/**
* Build an Electron entrypoint (main or preload) using Rollup
* @param {string} entry - Entry file path
* @param {string} outfile - Output file path
* @param {string[]} external - External dependencies
* @param {boolean} isDev - Whether to watch (dev) or build (prod)
*/
async function buildEntryWithRollup(entry, outfile, external, isDev = false) {
const inputOptions = {
input: path.resolve(process.cwd(), entry),
external,
plugins: [
nodeResolve({ preferBuiltins: true }),
commonjs(),
json()
]
};
const outputOptions = {
file: path.resolve(process.cwd(), outfile),
format: 'cjs',
sourcemap: true
};
if (isDev) {
const watcher = rollupWatch({
...inputOptions,
output: [outputOptions],
watch: { clearScreen: false }
});
watcher.on('event', (event) => {
if (event.code === 'ERROR') {
console.error(event.error);
} else if (event.code === 'BUNDLE_END') {
console.log(`[electron-entry] Rebuilt: ${entry}${outfile}`);
}
});
console.log(`[electron-entry] Watching: ${entry}${outfile}`);
} else {
const bundle = await rollup(inputOptions);
await bundle.write(outputOptions);
await bundle.close();
console.log(`[electron-entry] Built: ${entry}${outfile}`);
}
}
/** @type {import('./index.js').default} */
export default function (opts = {}) {
const { out = 'out/renderer', options } = opts;
const {
out = 'out',
precompress = false
} = opts;
return {
name: 'adapter-electron',
async adapt(builder) {
builder.rimraf(out);
builder.mkdirp(out);
const tmp = builder.getBuildDirectory('adapter-electron');
await adapter({ out, ...options }).adapt(builder);
builder.rimraf(out);
builder.rimraf(tmp);
builder.mkdirp(tmp);
builder.log.minor('Copying assets');
builder.writeClient(`${out}/client${builder.config.kit.paths.base}`);
builder.writePrerendered(`${out}/prerendered${builder.config.kit.paths.base}`);
if (precompress) {
builder.log.minor('Compressing assets');
await Promise.all([
builder.compress(`${out}/client`),
builder.compress(`${out}/prerendered`)
]);
}
builder.log.minor('Building server');
builder.writeServer(tmp);
writeFileSync(
`${tmp}/manifest.js`,
[
`export const manifest = ${builder.generateManifest({ relativePath: './' })};`,
`export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});`,
`export const base = ${JSON.stringify(builder.config.kit.paths.base)};`
].join('\n\n')
);
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
// Bundle the Vite output so that deployments only need
// their production dependencies. Anything in devDependencies
// will get included in the bundled code
const serverBundle = await rollup({
input: {
index: `${tmp}/index.js`,
manifest: `${tmp}/manifest.js`
},
external: [
// dependencies could have deep exports, so we need a regex
...Object.keys(pkg.dependencies || {}).map((d) => new RegExp(`^${d}(\/.*)?$`))
],
plugins: [
nodeResolve({
preferBuiltins: true,
exportConditions: ['node']
}),
// @ts-ignore https://github.com/rollup/plugins/issues/1329
commonjs({ strictRequires: true }),
// @ts-ignore https://github.com/rollup/plugins/issues/1329
json()
]
});
await serverBundle.write({
dir: `${out}/server`,
format: 'esm',
sourcemap: true,
chunkFileNames: 'chunks/[name]-[hash].js'
});
const mainOut = `${tmp}/main/index.js`;
const preloadOut = `${tmp}/preload/index.js`;
// Build main and preload files directly in the adapter using Rollup
await buildEntryWithRollup('src/main.ts', mainOut, ['electron', 'SERVER', 'MANIFEST'], false);
await buildEntryWithRollup('src/preload.ts', preloadOut, ['electron'], false);
const replace = {
SERVER: '../server/index.js',
MANIFEST: '../server/manifest.js',
PRELOAD: '../preload/index.js'
};
builder.copy(mainOut, `${out}/main/index.cjs`, {
replace,
});
builder.copy(preloadOut, `${out}/preload/index.js`, {
replace,
});
},
supports: {
read: () => true
}
};
}
/**
* Vite plugin to build Electron main/preload files using Rollup
* Usage: import { electronPlugin } from 'adapter-electron'
*/
export function electronPlugin(options = {}) {
const {
mainEntry = 'src/main.ts',
preloadEntry = 'src/preload.ts',
mainOut = 'out/main/index.cjs',
preloadOut = 'out/preload/index.cjs',
externalMain = ['electron', 'electron-log', 'electron-is-dev', "SERVER", "MANIFEST"],
externalPreload = ['electron']
} = options;
return {
name: 'sveltekit-electron',
apply: 'serve',
async buildStart() {
const isDev = process.env.NODE_ENV === 'development';
await buildEntryWithRollup(mainEntry, mainOut, externalMain, isDev);
await buildEntryWithRollup(preloadEntry, preloadOut, externalPreload, isDev);
}
};
}

View File

@@ -23,23 +23,27 @@
"types": "./index.d.ts",
"import": "./index.js"
},
"./functions": {
"types": "./functions/index.d.ts",
"import": "./functions/index.js"
"./functions/setupHandler": {
"types": "./functions/setupHandler.d.ts",
"import": "./functions/setupHandler.js"
}
},
"peerDependencies": {
"svelte": "^4.0.0"
},
"devDependencies": {
"@sveltejs/adapter-node": "^4.0.1",
"@rollup/plugin-commonjs": "^27.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@sveltejs/kit": "^2.4.0",
"@sveltejs/package": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/node": "^20.11.17",
"@types/node": "^20.19.5",
"esbuild": "^0.25.6",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"publint": "^0.1.9",
"rollup": "^4.9.0",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
@@ -47,8 +51,11 @@
"vite": "^5.0.11"
},
"dependencies": {
"cookie": "^1.0.2",
"electron": "^37.2.1",
"electron-is-dev": "^3.0.1",
"electron-log": "^5.1.1"
"electron-log": "^5.1.1",
"set-cookie-parser": "^2.7.1"
},
"type": "module"
}

10859
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,9 @@
packages:
- "examples/*"
- "packages/*"
- examples/*
- packages/*
onlyBuiltDependencies:
- electron
- electron-winstaller
- esbuild
- svelte-preprocess