Skip to content

Commit 0a8ccbf

Browse files
committed
feat: replace electron-serve
sindresorhus/electron-serve#29
1 parent 7ccc66a commit 0a8ccbf

8 files changed

+223
-9
lines changed

electron/main/app.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Event } from 'electron';
22
import { BrowserWindow, app, dialog, shell } from 'electron';
33
import path from 'node:path';
4-
import prodServe from 'electron-serve';
54
import trimEnd from 'lodash-es/trimEnd.js';
65
import { runInBackground } from './async/run-in-background.js';
76
import type { IpcController } from './ipc/ipc.controller.js';
@@ -41,7 +40,8 @@ const appPreloadPath = path.join(appBuildPath, 'preload');
4140
// When running in production, serve the app from these paths.
4241
const prodRendererPath = path.join(appBuildPath, 'renderer');
4342
const prodAppScheme = 'app';
44-
const prodAppUrl = `${prodAppScheme}://-`;
43+
const prodAppHost = '-'; // arbitrary, mimicking electron-serve module
44+
const prodAppUrl = `${prodAppScheme}://${prodAppHost}`;
4545

4646
// When running in development, serve the app from these paths.
4747
const devRendererPath = path.join(appElectronPath, 'renderer');
@@ -63,11 +63,11 @@ logger.debug('app paths', {
6363
// Registering the protocol must be done before the app is ready.
6464
// This is necessary for both security and for single-page apps.
6565
// https://bishopfox.com/blog/reasonably-secure-electron
66-
// https://github.com/sindresorhus/electron-serve
6766
if (appEnvIsProd) {
67+
const { prodServe } = await import('./electron-next/prod-server.js');
6868
prodServe({
6969
scheme: prodAppScheme,
70-
directory: prodRendererPath,
70+
dirPath: prodRendererPath,
7171
});
7272
}
7373

@@ -83,7 +83,7 @@ const createMainWindow = async (): Promise<void> => {
8383
const { devServe } = await import('./electron-next/dev-server.js');
8484
await devServe({
8585
port: devPort,
86-
directory: devRendererPath,
86+
dirPath: devRendererPath,
8787
});
8888
}
8989

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { isSafePath } from '../is-safe-path.js';
3+
4+
describe('is-safe-path', () => {
5+
beforeEach(() => {
6+
vi.useFakeTimers({ shouldAdvanceTime: true });
7+
});
8+
9+
afterEach(() => {
10+
vi.clearAllMocks();
11+
vi.clearAllTimers();
12+
vi.useRealTimers();
13+
});
14+
15+
describe('#isSafePath', () => {
16+
it('returns true when file path is within the directory', async () => {
17+
expect(
18+
isSafePath({
19+
dirPath: '/a/b/',
20+
filePath: '/a/b/c.html',
21+
})
22+
).toBe(true);
23+
24+
expect(
25+
isSafePath({
26+
dirPath: '/a/b/',
27+
filePath: '/a/b/c/d.html',
28+
})
29+
).toBe(true);
30+
});
31+
32+
it('returns false when file path is outside the directory', async () => {
33+
expect(
34+
isSafePath({
35+
dirPath: '/a/b/',
36+
filePath: '',
37+
})
38+
).toBe(false);
39+
40+
expect(
41+
isSafePath({
42+
dirPath: '/a/b/',
43+
filePath: '/a/b/',
44+
})
45+
).toBe(false);
46+
47+
expect(
48+
isSafePath({
49+
dirPath: '/a/b/',
50+
filePath: './a/b/c.html',
51+
})
52+
).toBe(false);
53+
54+
expect(
55+
isSafePath({
56+
dirPath: '/a/b/',
57+
filePath: '../a/b/c.html',
58+
})
59+
).toBe(false);
60+
});
61+
});
62+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { pathToFileURL } from '../path-to-file-url.js';
3+
4+
describe('path-to-file-url', () => {
5+
beforeEach(() => {
6+
vi.useFakeTimers({ shouldAdvanceTime: true });
7+
});
8+
9+
afterEach(() => {
10+
vi.clearAllMocks();
11+
vi.clearAllTimers();
12+
vi.useRealTimers();
13+
});
14+
15+
describe('#pathToFileURL', () => {
16+
it('...', async () => {
17+
expect(
18+
pathToFileURL({
19+
dirPath: '/a/b/',
20+
filePath: '/a/b/c.html',
21+
})
22+
).toBe(true);
23+
});
24+
});
25+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "../../tsconfig.test.json"
3+
}

electron/main/electron-next/dev-server.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ export const devServe = async (options: {
1717
/**
1818
* The directory to serve, relative to the app root directory.
1919
*/
20-
directory: string;
20+
dirPath: string;
2121
/**
2222
* The port to serve the renderer on.
2323
*/
2424
port: number;
2525
}): Promise<void> => {
26-
const { directory, port = 3000 } = options;
26+
const { dirPath, port = 3000 } = options;
2727

2828
logger.info('starting nextjs dev server', {
29-
directory,
29+
dirPath,
3030
port,
3131
});
3232

@@ -38,7 +38,7 @@ export const devServe = async (options: {
3838
options: NextServerOptions
3939
) => NextServer;
4040

41-
const nextServer = createNextServer({ dev: true, dir: directory });
41+
const nextServer = createNextServer({ dev: true, dir: dirPath });
4242

4343
const requestHandler = nextServer.getRequestHandler();
4444

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import path from 'node:path';
2+
3+
/**
4+
* Determine if a file path is safe to serve by
5+
* ensuring that it is within the directory path.
6+
* Protects against directory traversal attacks.
7+
*/
8+
export const isSafePath = (options: {
9+
/**
10+
* Known safe directory to host files from.
11+
* Example: '/path/to/directory'
12+
*/
13+
dirPath: string;
14+
/**
15+
* A file path to verify is within the directory.
16+
* Example: '/path/to/directory/file.txt'
17+
*/
18+
filePath: string;
19+
}): boolean => {
20+
const { dirPath, filePath } = options;
21+
22+
const relativePath = path.relative(dirPath, filePath);
23+
24+
const isSafe =
25+
relativePath.length > 0 &&
26+
!relativePath.startsWith('..') &&
27+
!path.isAbsolute(relativePath);
28+
29+
return isSafe;
30+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import path from 'node:path';
2+
import url from 'node:url';
3+
4+
/**
5+
* Converts a file path to an absolute file URL.
6+
* Example: '/path/to/file.txt' -> 'file:///path/to/file.txt'
7+
*/
8+
export const pathToFileURL = (options: {
9+
dirPath: string;
10+
filePath: string;
11+
}): string => {
12+
const { dirPath, filePath } = options;
13+
return url.pathToFileURL(path.join(dirPath, filePath)).toString();
14+
};
+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* This module enables serving static files generated by Nextjs in production.
3+
* It's inspired by the project `electron-serve` developed by Sindre Sorhus.
4+
* https://github.com/sindresorhus/electron-serve
5+
*
6+
* It registers a custom protocol to serve files from a directory.
7+
* https://www.electronjs.org/docs/latest/api/protocol
8+
*
9+
* After porting my project to ESM, the `electron-serve` module was no longer
10+
* resolving the file paths correctly.
11+
* https://github.com/sindresorhus/electron-serve/issues/29
12+
*
13+
* As a workaround, I re-implemented the logic here.
14+
*/
15+
16+
import { app, net, protocol } from 'electron';
17+
import path from 'node:path';
18+
import { isSafePath } from './is-safe-path.js';
19+
import { logger } from './logger.js';
20+
import { pathToFileURL } from './path-to-file-url.js';
21+
22+
export const prodServe = (options: {
23+
/**
24+
* The protocol to serve the directory on.
25+
* All URL requests that use this protocol will be served from the directory.
26+
*/
27+
scheme: string;
28+
/**
29+
* The directory to serve, relative to the app root directory.
30+
*/
31+
dirPath: string;
32+
}): void => {
33+
const { scheme, dirPath } = options;
34+
35+
logger.info('registering protocol scheme', {
36+
scheme,
37+
dirPath,
38+
});
39+
40+
const error404Page = pathToFileURL({ dirPath, filePath: '404.html' });
41+
42+
const requestHandler = async (httpReq: Request): Promise<Response> => {
43+
const requestURL = new URL(httpReq.url);
44+
45+
let pageToServe = error404Page;
46+
47+
let pathname = requestURL.pathname;
48+
if (pathname === '/') {
49+
pathname = 'index.html';
50+
}
51+
52+
// Prevent loading files outside of the renderer directory.
53+
const pathToServe = path.join(dirPath, pathname);
54+
const isSafe = isSafePath({ dirPath, filePath: pathToServe });
55+
56+
if (isSafe) {
57+
pageToServe = pathToFileURL({ dirPath, filePath: pathToServe });
58+
} else {
59+
pageToServe = error404Page;
60+
}
61+
62+
return net.fetch(pageToServe);
63+
};
64+
65+
protocol.registerSchemesAsPrivileged([
66+
{
67+
scheme,
68+
privileges: {
69+
standard: true,
70+
secure: true,
71+
supportFetchAPI: true,
72+
allowServiceWorkers: true,
73+
},
74+
},
75+
]);
76+
77+
app.on('ready', () => {
78+
protocol.handle(scheme, requestHandler);
79+
});
80+
};

0 commit comments

Comments
 (0)