[Feature] SvelteKit adapter for Desktop Apps with Electron

This commit is contained in:
Patryk Rzucidlo (@PTKDev)
2023-02-06 01:22:41 +01:00
parent 26fe84a7d6
commit ecdb3aa039
39 changed files with 2236 additions and 1 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
node_modules
.svelte-kit
build

3
CHANGELOG.md Normal file
View File

@@ -0,0 +1,3 @@
## 0.1.0
- First release

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright (c) 2020 [these people](https://github.com/sveltejs/kit/graphs/contributors)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1 +1,9 @@
# sveltekit-electron-adapter
# @ptkdev/sveltekit-electron-adapter
[Adapter](https://kit.svelte.dev/docs/adapters) for SvelteKit apps that prerenders your entire site as a collection of static files for use with [Electron](https://electronjs.org). Create your desktop apps with SvelteKit and Electron.
This is a fork of official [sveltekit-static-adapter](https://kit.svelte.dev/docs/adapter-static)
## License
[MIT](LICENSE)

11
index.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import { Adapter } from '@sveltejs/kit';
export interface AdapterOptions {
pages?: string;
assets?: string;
fallback?: string;
precompress?: boolean;
strict?: boolean;
}
export default function plugin(options?: AdapterOptions): Adapter;

184
index.js Normal file
View File

@@ -0,0 +1,184 @@
import path from "path";
import glob from "tiny-glob";
import replace from "replace-in-file";
import { platforms } from "./platforms.js";
/** @type {import('.').default} */
export default function (options) {
return {
name: "@ptkdev/sveltekit-electron-adapter",
async adapt(builder) {
if (!options?.fallback) {
/** @type {string[]} */
const dynamic_routes = [];
// this is a bit of a hack — it allows us to know whether there are dynamic
// (i.e. prerender = false/'auto') routes without having dedicated API
// surface area for it
builder.createEntries((route) => {
dynamic_routes.push(route.id);
return {
id: "",
filter: () => false,
complete: () => {},
};
});
if (dynamic_routes.length > 0 && options?.strict !== false) {
const prefix = path.relative(".", builder.config.kit.files.routes);
const has_param_routes = dynamic_routes.some((route) =>
route.includes("[")
);
const config_option =
has_param_routes ||
JSON.stringify(builder.config.kit.prerender.entries) !== '["*"]'
? ` - adjust the \`prerender.entries\` config option ${
has_param_routes
? "(routes with parameters are not part of entry points by default)"
: ""
} — see https://kit.svelte.dev/docs/configuration#prerender for more info.`
: "";
builder.log.error(
`@ptkdev/sveltekit-electron-adapter: all routes must be fully prerenderable, but found the following routes that are dynamic:
${dynamic_routes.map((id) => ` - ${path.posix.join(prefix, id)}`).join("\n")}
You have the following options:
- set the \`fallback\` option — see https://github.com/sveltejs/kit/tree/master/packages/adapter-static#spa-mode for more info.
- add \`export const prerender = true\` to your root \`+layout.js/.ts\` or \`+layout.server.js/.ts\` file. This will try to prerender all pages.
- add \`export const prerender = true\` to any \`+server.js/ts\` files that are not fetched by page \`load\` functions.
${config_option}
- pass \`strict: false\` to \`adapter-static\` to ignore this error. Only do this if you are sure you don't need the routes in question in your final app, as they will be unavailable. See https://github.com/sveltejs/kit/tree/master/packages/adapter-static#strict for more info.
If this doesn't help, you may need to use a different adapter. @ptkdev/sveltekit-electron-adapter can only be used for sites that don't need a server for dynamic rendering, and can run on just a static file server.
See https://kit.svelte.dev/docs/page-options#prerender for more details`
);
throw new Error("Encountered dynamic routes");
}
}
const platform = platforms.find((platform) => platform.test());
if (platform) {
if (options) {
builder.log.warn(
`Detected ${platform.name}. Please remove adapter-static options to enable zero-config mode`
);
} else {
builder.log.info(`Detected ${platform.name}, using zero-config mode`);
}
}
const {
pages = "build",
assets = pages,
fallback,
precompress,
} = options ??
platform?.defaults ??
/** @type {import('./index').AdapterOptions} */ ({});
builder.rimraf(assets);
builder.rimraf(pages);
builder.writeClient(assets);
builder.writePrerendered(pages);
const HTML_pages = await glob("**/*.html", {
cwd: pages,
dot: true,
absolute: true,
filesOnly: true,
});
HTML_pages.forEach(async (path) => {
let href = path.split("/").pop();
let regex_input = new RegExp(
`href="/${href.replace(".html", "")}"`,
"g"
);
let regex_replace = `href="${"./" + href}"`;
if (href === "index.html") {
regex_input = new RegExp(`href="/"`, "g");
regex_replace = `href="./index.html"`;
}
await replace.sync({
files: [pages + "/**/*.html"],
// @ts-ignore
processor: (input) => input.replace(regex_input, regex_replace),
});
});
const HTML_assets = await glob("_app/**/*", {
cwd: pages,
dot: true,
absolute: false,
filesOnly: true,
});
HTML_assets.forEach(async (path) => {
let href = "/" + path;
console.log(href);
let regex_input = new RegExp(`[^.]/_app/immutable`, "g");
let regex_replace = `./_app/immutable`;
await replace.sync({
files: [pages + "/**/*"],
// @ts-ignore
processor: (input) => input.replace(regex_input, regex_replace),
});
});
let regex_input = new RegExp(`</body>`, "g");
let regex_replace = `<script defer src="renderer.js"></script></body>`;
await replace.sync({
files: [pages + "/**/*.html"],
// @ts-ignore
processor: (input) => input.replace(regex_input, regex_replace),
});
regex_input = new RegExp(
`http-equiv="content-security-policy" content=""`,
"g"
);
regex_replace = `http-equiv="content-security-policy" content="default-src 'self' http://localhost:5000 'unsafe-eval' 'unsafe-inline';"`;
await replace.sync({
files: [pages + "/**/*.html"],
// @ts-ignore
processor: (input) => input.replace(regex_input, regex_replace),
});
if (fallback) {
builder.generateFallback(path.join(pages, fallback));
}
if (precompress) {
builder.log.minor("Compressing assets and pages");
if (pages === assets) {
await builder.compress(assets);
} else {
await Promise.all([
builder.compress(assets),
builder.compress(pages),
]);
}
}
if (pages === assets) {
builder.log(`Wrote site to "${pages}"`);
} else {
builder.log(`Wrote pages to "${pages}" and assets to "${assets}"`);
}
if (!options) platform?.done(builder);
},
};
}

1510
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "@ptkdev/sveltekit-electron-adapter",
"version": "0.1.0",
"repository": {
"type": "git",
"url": "https://github.com/ptkdev/sveltekit-electron-adapter"
},
"license": "MIT",
"homepage": "https://github.com/ptkdev/sveltekit-electron-adapter",
"type": "module",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./index.js"
},
"./package.json": "./package.json"
},
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"platforms.js"
],
"scripts": {
"lint": "prettier --check . --config .prettierrc --ignore-path .gitignore",
"check": "tsc",
"format": "pnpm lint --write",
"test": "uvu test test.js"
},
"dependencies": {
"replace-in-file": "^6.3.5",
"tiny-glob": "^0.2.9"
},
"devDependencies": {
"@types/node": "^16.18.6",
"sirv": "^2.0.2",
"svelte": "^3.55.1",
"typescript": "^4.9.4",
"uvu": "^0.5.6",
"vite": "^4.0.4"
},
"peerDependencies": {
"@sveltejs/kit": "^1.0.0"
}
}

75
platforms.js Normal file
View File

@@ -0,0 +1,75 @@
import fs from 'fs';
/**
* @typedef {{
* name: string;
* test: () => boolean;
* defaults: import('./index').AdapterOptions;
* done: (builder: import('@sveltejs/kit').Builder) => void;
* }}
* Platform */
// This function is duplicated in adapter-vercel
/** @param {import('@sveltejs/kit').Builder} builder */
function static_vercel_config(builder) {
/** @type {any[]} */
const prerendered_redirects = [];
/** @type {Record<string, { path: string }>} */
const overrides = {};
for (const [src, redirect] of builder.prerendered.redirects) {
prerendered_redirects.push({
src,
headers: {
Location: redirect.location
},
status: redirect.status
});
}
for (const [path, page] of builder.prerendered.pages) {
if (path.endsWith('/') && path !== '/') {
prerendered_redirects.push(
{ src: path, dest: path.slice(0, -1) },
{ src: path.slice(0, -1), status: 308, headers: { Location: path } }
);
overrides[page.file] = { path: path.slice(1, -1) };
} else {
overrides[page.file] = { path: path.slice(1) };
}
}
return {
version: 3,
routes: [
...prerendered_redirects,
{
src: `/${builder.getAppPath()}/immutable/.+`,
headers: {
'cache-control': 'public, immutable, max-age=31536000'
}
},
{
handle: 'filesystem'
}
],
overrides
};
}
/** @type {Platform[]} */
export const platforms = [
{
name: 'Vercel',
test: () => !!process.env.VERCEL,
defaults: {
pages: '.vercel/output/static'
},
done: (builder) => {
const config = static_vercel_config(builder);
fs.writeFileSync('.vercel/output/config.json', JSON.stringify(config, null, ' '));
}
}
];

5
test/apps/prerendered/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.DS_Store
node_modules
/.svelte-kit
/build
/functions

View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -0,0 +1,16 @@
{
"name": "~TODO~",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "vite start"
},
"devDependencies": {
"@sveltejs/kit": "workspace:^",
"svelte": "^3.55.1",
"vite": "^4.0.4"
},
"type": "module"
}

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>

1
test/apps/prerendered/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@sveltejs/kit" />

View File

@@ -0,0 +1 @@
export const prerender = true;

View File

@@ -0,0 +1 @@
<slot />

View File

@@ -0,0 +1,5 @@
/** @type {import('./$types').PageLoad} */
export async function load({ fetch }) {
const res = await fetch('/endpoint/implicit.json');
return await res.json();
}

View File

@@ -0,0 +1,7 @@
<script>
/** @type {import('./$types').PageData} */
export let data;
</script>
<h1>This page was prerendered</h1>
<p>answer: {data.answer}</p>

View File

@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
export const prerender = true;
/** @type {import('./$types').RequestHandler} */
export function GET() {
return json({ answer: 42 });
}

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
// no export const prerender here, it should be prerendered by virtue
// of being fetched from a prerendered page
/** @type {import('./$types').RequestHandler} */
export function GET() {
return json({ answer: 42 });
}

View File

@@ -0,0 +1,10 @@
import adapter from '../../../index.js';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;

View File

@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
build: {
minify: false
},
plugins: [sveltekit()]
};
export default config;

5
test/apps/spa/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.DS_Store
node_modules
/.svelte-kit
/build
/functions

1
test/apps/spa/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

36
test/apps/spa/README.md Normal file
View File

@@ -0,0 +1,36 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte);
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm init svelte
# create a new project in my-app
npm init svelte my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Before creating a production version of your app, install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. Then:
```bash
npm run build
```
> You can preview the built app with `npm run preview`, regardless of whether you installed an adapter. This should _not_ be used to serve your app in production.

View File

@@ -0,0 +1,4 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
}

View File

@@ -0,0 +1,18 @@
{
"name": "~TODO~",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "sirv -s 200.html build"
},
"devDependencies": {
"@sveltejs/adapter-node": "workspace:^",
"@sveltejs/kit": "workspace:^",
"sirv-cli": "^2.0.2",
"svelte": "^3.55.1",
"vite": "^4.0.4"
},
"type": "module"
}

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,5 @@
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.status}</h1>

View File

@@ -0,0 +1,6 @@
<nav>
<a href="/">home</a>
<a href="/about">about</a>
</nav>
<slot></slot>

View File

@@ -0,0 +1 @@
<h1>This page was not prerendered</h1>

View File

@@ -0,0 +1 @@
export const prerender = true;

View File

@@ -0,0 +1 @@
<h1>This page was prerendered</h1>

View File

@@ -0,0 +1 @@
<h1>the fallback page was rendered</h1>

View File

@@ -0,0 +1,12 @@
import adapter from '../../../index.js';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
fallback: '200.html'
})
}
};
export default config;

View File

@@ -0,0 +1,11 @@
import { sveltekit } from '@sveltejs/kit/vite';
/** @type {import('vite').UserConfig} */
const config = {
build: {
minify: false
},
plugins: [sveltekit()]
};
export default config;

50
test/test.js Normal file
View File

@@ -0,0 +1,50 @@
import fs from 'fs';
import * as assert from 'uvu/assert';
import { run } from './utils.js';
run('prerendered', (test) => {
test('generates HTML files', ({ cwd }) => {
assert.ok(fs.existsSync(`${cwd}/build/index.html`));
});
test('prerenders a page', async ({ base, page }) => {
await page.goto(base);
assert.equal(await page.textContent('h1'), 'This page was prerendered');
assert.equal(await page.textContent('p'), 'answer: 42');
});
test('prerenders an unreferenced endpoint with explicit `prerender` setting', async ({ cwd }) => {
assert.ok(fs.existsSync(`${cwd}/build/endpoint/explicit.json`));
});
test('prerenders a referenced endpoint with implicit `prerender` setting', async ({ cwd }) => {
assert.ok(fs.existsSync(`${cwd}/build/endpoint/implicit.json`));
});
});
run('spa', (test) => {
test('generates a fallback page', async ({ base, cwd, page }) => {
assert.ok(fs.existsSync(`${cwd}/build/200.html`));
await page.goto(`${base}/fallback/a/b/c`);
assert.equal(await page.textContent('h1'), 'the fallback page was rendered');
});
test('does not prerender pages without prerender=true', ({ cwd }) => {
assert.ok(!fs.existsSync(`${cwd}/build/index.html`));
});
test('prerenders page with prerender=true', ({ cwd }) => {
assert.ok(fs.existsSync(`${cwd}/build/about.html`));
});
test('renders content in fallback page when JS runs', async ({ base, page }) => {
await page.goto(base);
assert.equal(await page.textContent('h1'), 'This page was not prerendered');
});
test('renders error page for missing page', async ({ base, page }) => {
await page.goto(`${base}/nosuchpage`);
assert.equal(await page.textContent('h1'), '404');
});
});

122
test/utils.js Normal file
View File

@@ -0,0 +1,122 @@
import { execSync } from 'child_process';
import fs from 'fs';
import http from 'http';
import { fileURLToPath } from 'url';
import sirv from 'sirv';
import { chromium, webkit, firefox } from 'playwright';
import * as uvu from 'uvu';
const known_browsers = {
chromium: chromium,
firefox: firefox,
webkit: webkit
};
const test_browser_name = /** @type {keyof typeof } */ (process.env.KIT_E2E_BROWSER ?? 'chromium');
const test_browser = known_browsers[test_browser_name];
if (!test_browser) {
throw new Error(
`invalid test browser specified: KIT_E2E_BROWSER=${
process.env.KIT_E2E_BROWSER
}. Allowed values: ${Object.keys().join(', ')}`
);
}
/**
* @typedef {{
* cwd: string;
* server: import('http').Server;
* base: string;
* browser: import('playwright-chromium').Browser;
* page: import('playwright-chromium').Page;
* }} TestContext
*/
/**
* @param {string} app
* @param {(test: import('uvu').Test<TestContext>) => void} callback
*/
export function run(app, callback) {
/** @type {import('uvu').Test<TestContext>} */
const suite = uvu.suite(app);
suite.before(async (context) => {
try {
const cwd = fileURLToPath(new URL(`apps/${app}`, import.meta.url));
rimraf(`${cwd}/build`);
try {
execSync(`npm run build`, { cwd, stdio: 'pipe' });
console.log(`✅ build successful`);
} catch (e) {
console.error(`❌ failed to build ${app}`);
console.error(`---\nstdout:\n${e.stdout}`);
console.error(`---\nstderr:\n${e.stderr}`);
console.groupEnd();
}
context.cwd = cwd;
const handler = sirv(`${cwd}/build`, {
single: '200.html'
});
context.server = await create_server(context.port, handler);
const { port } = /** @type {import('net').AddressInfo} */ (context.server.address());
if (!port) {
throw new Error(
`Could not find port from server ${JSON.stringify(context.server.address())}`
);
}
context.port = port;
context.base = `http://localhost:${context.port}`;
context.browser = await test_browser.launch();
context.page = await context.browser.newPage();
} catch (e) {
// TODO remove unnecessary try-catch https://github.com/lukeed/uvu/pull/61
console.error(e);
}
});
suite.after(async (context) => {
if (context.browser) {
try {
await context.browser.close();
} catch (e) {
console.error('failed to close test browser', e);
}
}
if (context.server) {
try {
await context.server.close();
} catch (e) {
console.error('failed to close test server', e);
}
}
});
callback(suite);
suite.run();
}
/**
* @param {number} port
* @param {(req: http.IncomingMessage, res: http.ServerResponse) => void} handler
* @returns {Promise<http.Server>}
*/
function create_server(port, handler) {
return new Promise((fulfil) => {
const server = http.createServer(handler);
server.listen(port, () => {
fulfil(server);
});
});
}
/** @param {string} path */
function rimraf(path) {
(fs.rmSync || fs.rmdirSync)(path, { recursive: true, force: true });
}

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"noImplicitAny": true,
"module": "es2022",
"target": "es2022",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../kit/types/index"]
}
},
"include": ["index.js"]
}