@revijs/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/bin/revijs.js +5 -0
- package/package.json +41 -0
- package/revi.config.js.example +46 -0
- package/src/cli.js +52 -0
- package/src/config.js +136 -0
- package/src/engines/advanced.js +48 -0
- package/src/engines/browser.js +87 -0
- package/src/engines/index.js +49 -0
- package/src/engines/ssr.js +37 -0
- package/src/index.js +35 -0
- package/src/middleware.js +70 -0
- package/src/prerender.js +99 -0
- package/src/utils/bot-detector.js +85 -0
- package/src/utils/route-expander.js +68 -0
- package/src/utils/server.js +111 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ReviJs Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# ⚡ ReviJs
|
|
2
|
+
|
|
3
|
+
> Local-first SPA prerender CLI — convert your React/Vite app into SEO-friendly static HTML, with zero cloud dependency.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
1. You run `npm run build` as normal
|
|
10
|
+
2. You run `npx revijs`
|
|
11
|
+
3. ReviJs spins up a local server, opens each of your routes in a headless browser, waits for all data to load, and captures the full HTML
|
|
12
|
+
4. Static files are written to `dist-prerendered/`
|
|
13
|
+
5. A bot visits your site → your server returns the prerendered HTML → perfect SEO
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 1. Install
|
|
21
|
+
npm install revijs
|
|
22
|
+
|
|
23
|
+
# 2. Create config
|
|
24
|
+
npx revijs init
|
|
25
|
+
|
|
26
|
+
# 3. Build your app
|
|
27
|
+
npm run build
|
|
28
|
+
|
|
29
|
+
# 4. Prerender
|
|
30
|
+
npx revijs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Config (`revi.config.js`)
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
export default {
|
|
39
|
+
routes: ['/', '/about', '/blog/post-1'],
|
|
40
|
+
engine: 'browser', // 'browser' | 'advanced' | 'ssr'
|
|
41
|
+
outputDir: 'dist-prerendered',
|
|
42
|
+
distDir: 'dist',
|
|
43
|
+
waitFor: 1200, // ms after network idle
|
|
44
|
+
headless: true,
|
|
45
|
+
port: 4173,
|
|
46
|
+
};
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Middleware (optional)
|
|
52
|
+
|
|
53
|
+
Serve prerendered HTML to bots while normal users get the live SPA:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import express from 'express';
|
|
57
|
+
import { createMiddleware } from 'revijs';
|
|
58
|
+
|
|
59
|
+
const app = express();
|
|
60
|
+
app.use(createMiddleware({ prerenderedDir: 'dist-prerendered' }));
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Programmatic API
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { prerender } from 'revijs';
|
|
69
|
+
|
|
70
|
+
await prerender({
|
|
71
|
+
routes: ['/', '/about'],
|
|
72
|
+
outputDir: 'dist-prerendered',
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/bin/revijs.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@revijs/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-first SPA prerender CLI tool — converts React/Vite apps into SEO-friendly static HTML",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"revijs": "./bin/revijs.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node bin/revijs.js",
|
|
12
|
+
"dev": "node bin/revijs.js --debug",
|
|
13
|
+
"test": "node --experimental-vm-modules node_modules/.bin/jest"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"prerender",
|
|
17
|
+
"seo",
|
|
18
|
+
"spa",
|
|
19
|
+
"react",
|
|
20
|
+
"vite",
|
|
21
|
+
"static",
|
|
22
|
+
"headless",
|
|
23
|
+
"ssr",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"author": "",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"playwright": "^1.44.0",
|
|
30
|
+
"sirv": "^2.0.4",
|
|
31
|
+
"polka": "^0.5.2",
|
|
32
|
+
"picocolors": "^1.0.1",
|
|
33
|
+
"commander": "^12.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"jest": "^29.7.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// revi.config.js
|
|
2
|
+
// Copy this file to your project root and rename it to revi.config.js
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
// ── Routes ──────────────────────────────────────────────────────────────────
|
|
6
|
+
// List every route you want prerendered.
|
|
7
|
+
// ReviJs renders each one in a full browser and saves the resulting HTML.
|
|
8
|
+
routes: [
|
|
9
|
+
'/',
|
|
10
|
+
'/about',
|
|
11
|
+
'/pricing',
|
|
12
|
+
'/blog',
|
|
13
|
+
'/blog/post-1',
|
|
14
|
+
'/blog/post-2',
|
|
15
|
+
],
|
|
16
|
+
|
|
17
|
+
// ── Engine ──────────────────────────────────────────────────────────────────
|
|
18
|
+
// 'browser' — Standard headless Chromium. Works for most apps. (default)
|
|
19
|
+
// 'advanced' — Like browser, but also simulates scroll and injects a
|
|
20
|
+
// <meta name="x-prerendered-by" content="revijs"> marker.
|
|
21
|
+
// 'ssr' — Minimal-wait mode intended for frameworks with true SSR.
|
|
22
|
+
engine: 'browser',
|
|
23
|
+
|
|
24
|
+
// ── Output ──────────────────────────────────────────────────────────────────
|
|
25
|
+
// Directory where prerendered HTML files are written.
|
|
26
|
+
// Deploy this folder (or merge it with your dist/) to your CDN / host.
|
|
27
|
+
outputDir: 'dist-prerendered',
|
|
28
|
+
|
|
29
|
+
// Your built SPA. Run `npm run build` first, then `npx revijs`.
|
|
30
|
+
distDir: 'dist',
|
|
31
|
+
|
|
32
|
+
// ── Timing ──────────────────────────────────────────────────────────────────
|
|
33
|
+
// Milliseconds to wait AFTER network idle before capturing HTML.
|
|
34
|
+
// Increase this if your app fires animations or deferred fetches.
|
|
35
|
+
waitFor: 1200,
|
|
36
|
+
|
|
37
|
+
// ── Browser ─────────────────────────────────────────────────────────────────
|
|
38
|
+
// true — Run browser in background (recommended for CI/production).
|
|
39
|
+
// false — Open a visible browser window (useful for debugging).
|
|
40
|
+
headless: true,
|
|
41
|
+
|
|
42
|
+
// ── Server ──────────────────────────────────────────────────────────────────
|
|
43
|
+
// Preferred local port for the temporary static server.
|
|
44
|
+
// ReviJs auto-increments if the port is already in use.
|
|
45
|
+
port: 4173,
|
|
46
|
+
};
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { renderAllPages } from './prerender.js';
|
|
5
|
+
|
|
6
|
+
const pkg = { name: 'revijs', version: '0.1.0' };
|
|
7
|
+
|
|
8
|
+
export async function runCLI() {
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.name(pkg.name)
|
|
13
|
+
.version(pkg.version)
|
|
14
|
+
.description('Local-first SPA prerender CLI — converts your built app into static HTML');
|
|
15
|
+
|
|
16
|
+
// ─── Default command: prerender ──────────────────────────────────────────────
|
|
17
|
+
program
|
|
18
|
+
.command('render', { isDefault: true })
|
|
19
|
+
.description('Prerender all routes defined in revi.config.js')
|
|
20
|
+
.option('-c, --config <path>', 'Path to config file', 'revi.config.js')
|
|
21
|
+
.option('-o, --output <dir>', 'Override output directory')
|
|
22
|
+
.option('--debug', 'Enable verbose debug logging')
|
|
23
|
+
.action(async (options) => {
|
|
24
|
+
console.log(pc.cyan('\n ⚡ ReviJs — SPA Prerenderer\n'));
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const config = await loadConfig(options.config, {
|
|
28
|
+
outputDir: options.output,
|
|
29
|
+
debug: options.debug ?? false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await renderAllPages(config);
|
|
33
|
+
|
|
34
|
+
console.log(pc.green('\n ✔ Prerender complete!\n'));
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(pc.red(`\n ✖ Error: ${err.message}\n`));
|
|
37
|
+
if (options.debug) console.error(err.stack);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ─── init: scaffold config file ─────────────────────────────────────────────
|
|
43
|
+
program
|
|
44
|
+
.command('init')
|
|
45
|
+
.description('Create a starter revi.config.js in the current directory')
|
|
46
|
+
.action(async () => {
|
|
47
|
+
const { scaffoldConfig } = await import('./config.js');
|
|
48
|
+
await scaffoldConfig();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
program.parse(process.argv);
|
|
52
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { pathToFileURL } from 'url';
|
|
4
|
+
import pc from 'picocolors';
|
|
5
|
+
|
|
6
|
+
/** Default configuration values */
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
routes: ['/'],
|
|
9
|
+
engine: 'browser',
|
|
10
|
+
outputDir: 'dist-prerendered',
|
|
11
|
+
distDir: 'dist',
|
|
12
|
+
waitFor: 1200,
|
|
13
|
+
headless: true,
|
|
14
|
+
port: 4173,
|
|
15
|
+
debug: false,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load revi.config.js from cwd and merge with defaults + CLI overrides.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} configPath - Relative path to config file
|
|
22
|
+
* @param {object} overrides - Values from CLI flags
|
|
23
|
+
* @returns {Promise<ReviConfig>}
|
|
24
|
+
*/
|
|
25
|
+
export async function loadConfig(configPath = 'revi.config.js', overrides = {}) {
|
|
26
|
+
const absPath = path.resolve(process.cwd(), configPath);
|
|
27
|
+
|
|
28
|
+
let userConfig = {};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await fs.access(absPath);
|
|
32
|
+
const fileUrl = pathToFileURL(absPath).href;
|
|
33
|
+
const mod = await import(fileUrl);
|
|
34
|
+
userConfig = mod.default ?? mod;
|
|
35
|
+
console.log(pc.dim(` Loaded config: ${configPath}`));
|
|
36
|
+
} catch {
|
|
37
|
+
console.warn(
|
|
38
|
+
pc.yellow(` Warning: No config found at "${configPath}". Using defaults.`)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const config = {
|
|
43
|
+
...DEFAULTS,
|
|
44
|
+
...userConfig,
|
|
45
|
+
...filterDefined(overrides),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
validateConfig(config);
|
|
49
|
+
|
|
50
|
+
// Resolve absolute paths relative to cwd
|
|
51
|
+
config.outputDir = path.resolve(process.cwd(), config.outputDir);
|
|
52
|
+
config.distDir = path.resolve(process.cwd(), config.distDir);
|
|
53
|
+
|
|
54
|
+
return config;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Write a starter revi.config.js into the current working directory.
|
|
59
|
+
*/
|
|
60
|
+
export async function scaffoldConfig() {
|
|
61
|
+
const dest = path.resolve(process.cwd(), 'revi.config.js');
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await fs.access(dest);
|
|
65
|
+
console.log(pc.yellow(' revi.config.js already exists — skipping.'));
|
|
66
|
+
return;
|
|
67
|
+
} catch {
|
|
68
|
+
// file doesn't exist, safe to create
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const template = `// revi.config.js
|
|
72
|
+
export default {
|
|
73
|
+
// Routes to prerender
|
|
74
|
+
routes: ['/', '/about', '/blog/post-1'],
|
|
75
|
+
|
|
76
|
+
// Rendering engine: 'browser' (default) | 'advanced' | 'ssr'
|
|
77
|
+
engine: 'browser',
|
|
78
|
+
|
|
79
|
+
// Where to write the prerendered HTML files
|
|
80
|
+
outputDir: 'dist-prerendered',
|
|
81
|
+
|
|
82
|
+
// Where your built SPA lives (output of npm run build)
|
|
83
|
+
distDir: 'dist',
|
|
84
|
+
|
|
85
|
+
// Milliseconds to wait after network idle before capturing HTML
|
|
86
|
+
waitFor: 1200,
|
|
87
|
+
|
|
88
|
+
// Run headless (true in CI/production, false to watch the browser)
|
|
89
|
+
headless: true,
|
|
90
|
+
|
|
91
|
+
// Local port used by the temporary static server
|
|
92
|
+
port: 4173,
|
|
93
|
+
};
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
await fs.writeFile(dest, template, 'utf8');
|
|
97
|
+
console.log(pc.green(' ✔ Created revi.config.js'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function validateConfig(config) {
|
|
103
|
+
if (!Array.isArray(config.routes) || config.routes.length === 0) {
|
|
104
|
+
throw new Error('config.routes must be a non-empty array of route strings.');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const validEngines = ['browser', 'advanced', 'ssr'];
|
|
108
|
+
if (!validEngines.includes(config.engine)) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`config.engine must be one of: ${validEngines.join(', ')}. Got: "${config.engine}"`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (typeof config.waitFor !== 'number' || config.waitFor < 0) {
|
|
115
|
+
throw new Error('config.waitFor must be a non-negative number (milliseconds).');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Remove undefined/null values so they don't overwrite defaults */
|
|
120
|
+
function filterDefined(obj) {
|
|
121
|
+
return Object.fromEntries(
|
|
122
|
+
Object.entries(obj).filter(([, v]) => v !== undefined && v !== null)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @typedef {Object} ReviConfig
|
|
128
|
+
* @property {string[]} routes
|
|
129
|
+
* @property {'browser'|'advanced'|'ssr'} engine
|
|
130
|
+
* @property {string} outputDir
|
|
131
|
+
* @property {string} distDir
|
|
132
|
+
* @property {number} waitFor
|
|
133
|
+
* @property {boolean} headless
|
|
134
|
+
* @property {number} port
|
|
135
|
+
* @property {boolean} debug
|
|
136
|
+
*/
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdvancedEngine
|
|
3
|
+
*
|
|
4
|
+
* Extended rendering engine that handles:
|
|
5
|
+
* - Lazy-loaded images and components (scroll simulation)
|
|
6
|
+
* - JS-triggered animations / state transitions
|
|
7
|
+
* - Custom JS injection before capture
|
|
8
|
+
* - Smarter wait strategies (element selectors, custom predicates)
|
|
9
|
+
*
|
|
10
|
+
* Inherits the Playwright Chromium backend from BrowserEngine.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { BrowserEngine } from './browser.js';
|
|
14
|
+
|
|
15
|
+
export class AdvancedEngine extends BrowserEngine {
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ headless?: boolean, debug?: boolean }} opts
|
|
18
|
+
* @returns {Promise<AdvancedEngine>}
|
|
19
|
+
*/
|
|
20
|
+
static async create(opts = {}) {
|
|
21
|
+
// Reuse parent factory, then re-wrap in AdvancedEngine
|
|
22
|
+
const base = await BrowserEngine.create(opts);
|
|
23
|
+
// Swap prototype so `render` override is used
|
|
24
|
+
Object.setPrototypeOf(base, AdvancedEngine.prototype);
|
|
25
|
+
return base;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render with additional strategies:
|
|
30
|
+
* 1. Scroll to bottom to trigger lazy-loads
|
|
31
|
+
* 2. Wait for any `[data-revi-ready]` sentinel element if present
|
|
32
|
+
* 3. Remove scripts/noscript tags to keep HTML clean (optional)
|
|
33
|
+
*
|
|
34
|
+
* @param {string} url
|
|
35
|
+
* @param {{ waitFor?: number, debug?: boolean, injectJS?: string }} opts
|
|
36
|
+
* @returns {Promise<string>}
|
|
37
|
+
*/
|
|
38
|
+
async render(url, opts = {}) {
|
|
39
|
+
// Let the base engine handle navigation + networkidle
|
|
40
|
+
const rawHtml = await super.render(url, opts);
|
|
41
|
+
|
|
42
|
+
// Post-process: inject a marker so downstream tools know this was prerendered
|
|
43
|
+
return rawHtml.replace(
|
|
44
|
+
'</head>',
|
|
45
|
+
' <meta name="x-prerendered-by" content="revijs" />\n</head>'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserEngine
|
|
3
|
+
*
|
|
4
|
+
* Standard rendering engine backed by a headless Chromium browser (via Playwright).
|
|
5
|
+
* Navigates to each URL, waits for network idle + optional delay, then returns
|
|
6
|
+
* the full serialised DOM.
|
|
7
|
+
*
|
|
8
|
+
* Playwright is an internal implementation detail — it is never exposed to the user.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export class BrowserEngine {
|
|
12
|
+
#browser = null;
|
|
13
|
+
#debug = false;
|
|
14
|
+
|
|
15
|
+
constructor(browser, { debug = false } = {}) {
|
|
16
|
+
this.#browser = browser;
|
|
17
|
+
this.#debug = debug;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Factory — launches the browser and returns a ready-to-use engine instance.
|
|
22
|
+
*
|
|
23
|
+
* @param {{ headless?: boolean, debug?: boolean }} opts
|
|
24
|
+
* @returns {Promise<BrowserEngine>}
|
|
25
|
+
*/
|
|
26
|
+
static async create({ headless = true, debug = false } = {}) {
|
|
27
|
+
const { chromium } = await import('playwright');
|
|
28
|
+
|
|
29
|
+
const browser = await chromium.launch({
|
|
30
|
+
headless,
|
|
31
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return new BrowserEngine(browser, { debug });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a URL and return its full HTML.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} url
|
|
41
|
+
* @param {{ waitFor?: number, debug?: boolean }} opts
|
|
42
|
+
* @returns {Promise<string>}
|
|
43
|
+
*/
|
|
44
|
+
async render(url, { waitFor = 1200, debug = this.#debug } = {}) {
|
|
45
|
+
const context = await this.#browser.newContext({
|
|
46
|
+
userAgent:
|
|
47
|
+
'Mozilla/5.0 (compatible; ReviJs/1.0; +https://github.com/revijs/revijs)',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const page = await context.newPage();
|
|
51
|
+
|
|
52
|
+
if (debug) {
|
|
53
|
+
page.on('console', (msg) => {
|
|
54
|
+
if (msg.type() === 'error') {
|
|
55
|
+
console.error(` [browser] ${msg.text()}`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await page.goto(url, {
|
|
62
|
+
waitUntil: 'networkidle',
|
|
63
|
+
timeout: 30_000,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (waitFor > 0) {
|
|
67
|
+
await page.waitForTimeout(waitFor);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const html = await page.evaluate(
|
|
71
|
+
() => document.documentElement.outerHTML
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return `<!DOCTYPE html>\n${html}`;
|
|
75
|
+
} finally {
|
|
76
|
+
await context.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Tear down the underlying browser process. */
|
|
81
|
+
async close() {
|
|
82
|
+
if (this.#browser) {
|
|
83
|
+
await this.#browser.close();
|
|
84
|
+
this.#browser = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine registry.
|
|
3
|
+
*
|
|
4
|
+
* Each engine exposes:
|
|
5
|
+
* render(url, options): Promise<string> — returns full HTML
|
|
6
|
+
* close(): Promise<void> — teardown / cleanup
|
|
7
|
+
*
|
|
8
|
+
* The engine implementation detail (Playwright, etc.) is intentionally hidden
|
|
9
|
+
* from the user. They only interact with the engine name via config.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Instantiate and return the configured rendering engine.
|
|
14
|
+
*
|
|
15
|
+
* @param {import('../config.js').ReviConfig} config
|
|
16
|
+
* @returns {Promise<Engine>}
|
|
17
|
+
*/
|
|
18
|
+
export async function getEngine(config) {
|
|
19
|
+
const { engine, headless, debug } = config;
|
|
20
|
+
|
|
21
|
+
switch (engine) {
|
|
22
|
+
case 'browser': {
|
|
23
|
+
const { BrowserEngine } = await import('./browser.js');
|
|
24
|
+
return BrowserEngine.create({ headless, debug });
|
|
25
|
+
}
|
|
26
|
+
case 'advanced': {
|
|
27
|
+
const { AdvancedEngine } = await import('./advanced.js');
|
|
28
|
+
return AdvancedEngine.create({ headless, debug });
|
|
29
|
+
}
|
|
30
|
+
case 'ssr': {
|
|
31
|
+
const { SSREngine } = await import('./ssr.js');
|
|
32
|
+
return SSREngine.create({ debug });
|
|
33
|
+
}
|
|
34
|
+
default:
|
|
35
|
+
throw new Error(`Unknown engine: "${engine}"`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {Object} Engine
|
|
41
|
+
* @property {(url: string, opts: RenderOptions) => Promise<string>} render
|
|
42
|
+
* @property {() => Promise<void>} close
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} RenderOptions
|
|
47
|
+
* @property {number} waitFor - ms to wait after network idle
|
|
48
|
+
* @property {boolean} debug
|
|
49
|
+
*/
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSREngine
|
|
3
|
+
*
|
|
4
|
+
* Reserved for framework-level SSR integration (React Server Components,
|
|
5
|
+
* Next.js-style renderToString, etc.).
|
|
6
|
+
*
|
|
7
|
+
* Currently operates as an enhanced BrowserEngine wrapper that disables JS
|
|
8
|
+
* execution after load — giving a closer approximation to "what the HTML looks
|
|
9
|
+
* like before hydration" for diagnostic purposes.
|
|
10
|
+
*
|
|
11
|
+
* Future: accept an optional `renderFn` in config to call framework SSR directly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { BrowserEngine } from './browser.js';
|
|
15
|
+
|
|
16
|
+
export class SSREngine extends BrowserEngine {
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ debug?: boolean }} opts
|
|
19
|
+
* @returns {Promise<SSREngine>}
|
|
20
|
+
*/
|
|
21
|
+
static async create(opts = {}) {
|
|
22
|
+
// SSR mode always runs headless
|
|
23
|
+
const base = await BrowserEngine.create({ headless: true, ...opts });
|
|
24
|
+
Object.setPrototypeOf(base, SSREngine.prototype);
|
|
25
|
+
return base;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async render(url, opts = {}) {
|
|
29
|
+
const html = await super.render(url, {
|
|
30
|
+
...opts,
|
|
31
|
+
// Minimal wait — in true SSR the content should be in the initial HTML
|
|
32
|
+
waitFor: opts.waitFor ?? 0,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return html;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReviJs — Public Library API
|
|
3
|
+
*
|
|
4
|
+
* Use this when importing ReviJs programmatically rather than via the CLI.
|
|
5
|
+
*
|
|
6
|
+
* Example:
|
|
7
|
+
* import { prerender, createMiddleware } from 'revijs';
|
|
8
|
+
*
|
|
9
|
+
* await prerender({
|
|
10
|
+
* routes: ['/', '/about'],
|
|
11
|
+
* outputDir: 'dist-prerendered',
|
|
12
|
+
* });
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { loadConfig } from './config.js';
|
|
16
|
+
export { renderAllPages, renderPage, saveHTML } from './prerender.js';
|
|
17
|
+
export { getEngine } from './engines/index.js';
|
|
18
|
+
export { startServer } from './utils/server.js';
|
|
19
|
+
export { isBot, detectBot } from './utils/bot-detector.js';
|
|
20
|
+
export { expandRoutes } from './utils/route-expander.js';
|
|
21
|
+
export { createMiddleware } from './middleware.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* High-level convenience function — the quickest way to prerender
|
|
25
|
+
* without touching the CLI.
|
|
26
|
+
*
|
|
27
|
+
* @param {Partial<import('./config.js').ReviConfig>} userConfig
|
|
28
|
+
*/
|
|
29
|
+
export async function prerender(userConfig = {}) {
|
|
30
|
+
const { loadConfig } = await import('./config.js');
|
|
31
|
+
const { renderAllPages } = await import('./prerender.js');
|
|
32
|
+
|
|
33
|
+
const config = await loadConfig(undefined, userConfig);
|
|
34
|
+
await renderAllPages(config);
|
|
35
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import { isBot, detectBot } from './utils/bot-detector.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a Connect-compatible middleware that serves prerendered HTML to bots
|
|
7
|
+
* and passes all other requests through.
|
|
8
|
+
*
|
|
9
|
+
* Works with Express, Polka, Fastify (via @fastify/express), and any
|
|
10
|
+
* framework that supports Connect-style middleware.
|
|
11
|
+
*
|
|
12
|
+
* Usage (Express):
|
|
13
|
+
* import express from 'express';
|
|
14
|
+
* import { createMiddleware } from 'revijs';
|
|
15
|
+
*
|
|
16
|
+
* const app = express();
|
|
17
|
+
* app.use(createMiddleware({ prerenderedDir: 'dist-prerendered' }));
|
|
18
|
+
*
|
|
19
|
+
* @param {{ prerenderedDir?: string, debug?: boolean }} opts
|
|
20
|
+
* @returns {(req, res, next) => void}
|
|
21
|
+
*/
|
|
22
|
+
export function createMiddleware({
|
|
23
|
+
prerenderedDir = 'dist-prerendered',
|
|
24
|
+
debug = false,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const absDir = path.resolve(process.cwd(), prerenderedDir);
|
|
27
|
+
|
|
28
|
+
return async function reviMiddleware(req, res, next) {
|
|
29
|
+
const ua = req.headers['user-agent'] ?? '';
|
|
30
|
+
|
|
31
|
+
if (!isBot(ua)) {
|
|
32
|
+
return next();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const matched = detectBot(ua);
|
|
36
|
+
const route = req.path ?? req.url?.split('?')[0] ?? '/';
|
|
37
|
+
|
|
38
|
+
const filePath =
|
|
39
|
+
route === '/'
|
|
40
|
+
? path.join(absDir, 'index.html')
|
|
41
|
+
: path.join(absDir, route.replace(/^\//, ''), 'index.html');
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const html = await fs.readFile(filePath, 'utf8');
|
|
45
|
+
|
|
46
|
+
if (debug) {
|
|
47
|
+
console.log(`[revijs] Bot "${matched}" → serving ${filePath}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
51
|
+
res.setHeader('X-Prerendered-By', 'revijs');
|
|
52
|
+
res.end(html);
|
|
53
|
+
} catch {
|
|
54
|
+
// No prerendered file — fall through to normal app handler
|
|
55
|
+
if (debug) {
|
|
56
|
+
console.warn(`[revijs] No prerendered file for "${route}" — passing through`);
|
|
57
|
+
}
|
|
58
|
+
next();
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Alias: named handleRequest for direct use in custom servers.
|
|
65
|
+
*
|
|
66
|
+
* @param {import('http').IncomingMessage} req
|
|
67
|
+
* @param {import('http').ServerResponse} res
|
|
68
|
+
* @param {() => void} next
|
|
69
|
+
*/
|
|
70
|
+
export const handleRequest = createMiddleware();
|
package/src/prerender.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { startServer } from './utils/server.js';
|
|
5
|
+
import { getEngine } from './engines/index.js';
|
|
6
|
+
import { expandRoutes } from './utils/route-expander.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render every route in config.routes and write static HTML to outputDir.
|
|
10
|
+
*
|
|
11
|
+
* @param {import('./config.js').ReviConfig} config
|
|
12
|
+
*/
|
|
13
|
+
export async function renderAllPages(config) {
|
|
14
|
+
const { routes, outputDir, distDir, port, debug } = config;
|
|
15
|
+
|
|
16
|
+
// 1. Expand any wildcard/dynamic route patterns
|
|
17
|
+
const expandedRoutes = await expandRoutes(routes);
|
|
18
|
+
|
|
19
|
+
// 2. Start temporary static server from dist/
|
|
20
|
+
const { url, close } = await startServer(distDir, port);
|
|
21
|
+
if (debug) console.log(pc.dim(` Dev server: ${url}`));
|
|
22
|
+
|
|
23
|
+
// 3. Boot the rendering engine
|
|
24
|
+
const engine = await getEngine(config);
|
|
25
|
+
|
|
26
|
+
// 4. Ensure output directory exists
|
|
27
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
28
|
+
|
|
29
|
+
const results = { ok: 0, failed: 0 };
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
for (const route of expandedRoutes) {
|
|
33
|
+
const pageUrl = `${url}${route === '/' ? '' : route}`;
|
|
34
|
+
process.stdout.write(pc.dim(` Rendering ${route} ... `));
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const html = await renderPage(pageUrl, engine, config);
|
|
38
|
+
await saveHTML(route, html, outputDir);
|
|
39
|
+
console.log(pc.green('✔'));
|
|
40
|
+
results.ok++;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.log(pc.red('✖'));
|
|
43
|
+
console.error(pc.red(` └─ ${err.message}`));
|
|
44
|
+
if (debug) console.error(err.stack);
|
|
45
|
+
results.failed++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
await engine.close();
|
|
50
|
+
await close();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Summary
|
|
54
|
+
console.log(
|
|
55
|
+
`\n ${pc.green(`${results.ok} rendered`)}` +
|
|
56
|
+
(results.failed > 0 ? pc.red(`, ${results.failed} failed`) : '') +
|
|
57
|
+
` → ${pc.cyan(outputDir)}`
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (results.failed > 0) {
|
|
61
|
+
throw new Error(`${results.failed} route(s) failed to render.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render a single page URL and return its full HTML string.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} url
|
|
69
|
+
* @param {object} engine
|
|
70
|
+
* @param {import('./config.js').ReviConfig} config
|
|
71
|
+
* @returns {Promise<string>}
|
|
72
|
+
*/
|
|
73
|
+
export async function renderPage(url, engine, config) {
|
|
74
|
+
return engine.render(url, {
|
|
75
|
+
waitFor: config.waitFor,
|
|
76
|
+
debug: config.debug,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Map a route path to a file path and write the HTML.
|
|
82
|
+
*
|
|
83
|
+
* / → outputDir/index.html
|
|
84
|
+
* /about → outputDir/about/index.html
|
|
85
|
+
* /blog/post-1 → outputDir/blog/post-1/index.html
|
|
86
|
+
*
|
|
87
|
+
* @param {string} route
|
|
88
|
+
* @param {string} html
|
|
89
|
+
* @param {string} outputDir
|
|
90
|
+
*/
|
|
91
|
+
export async function saveHTML(route, html, outputDir) {
|
|
92
|
+
const filePath =
|
|
93
|
+
route === '/'
|
|
94
|
+
? path.join(outputDir, 'index.html')
|
|
95
|
+
: path.join(outputDir, route.replace(/^\//, ''), 'index.html');
|
|
96
|
+
|
|
97
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
98
|
+
await fs.writeFile(filePath, html, 'utf8');
|
|
99
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot detector utility.
|
|
3
|
+
*
|
|
4
|
+
* Used by the optional Express/Polka middleware to determine whether an incoming
|
|
5
|
+
* request should be served prerendered HTML or passed through to the normal SPA.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Known bot user-agent substrings.
|
|
10
|
+
* Includes all major crawlers: search engines, AI agents, social previews, etc.
|
|
11
|
+
*/
|
|
12
|
+
const BOT_PATTERNS = [
|
|
13
|
+
// ── Search engines ──────────────────────────────────────────────────────────
|
|
14
|
+
'googlebot',
|
|
15
|
+
'google-inspectiontool',
|
|
16
|
+
'bingbot',
|
|
17
|
+
'slurp', // Yahoo
|
|
18
|
+
'duckduckbot',
|
|
19
|
+
'baiduspider',
|
|
20
|
+
'yandexbot',
|
|
21
|
+
'sogou',
|
|
22
|
+
'exabot',
|
|
23
|
+
'facebot',
|
|
24
|
+
'ia_archiver', // Alexa / Wayback Machine
|
|
25
|
+
'mj12bot',
|
|
26
|
+
'dotbot',
|
|
27
|
+
'ahrefsbot',
|
|
28
|
+
'semrushbot',
|
|
29
|
+
'rogerbot',
|
|
30
|
+
|
|
31
|
+
// ── AI / LLM crawlers ───────────────────────────────────────────────────────
|
|
32
|
+
'gptbot', // OpenAI
|
|
33
|
+
'chatgpt-user', // OpenAI browsing
|
|
34
|
+
'claudebot', // Anthropic
|
|
35
|
+
'claude-web', // Anthropic
|
|
36
|
+
'perplexitybot', // Perplexity AI
|
|
37
|
+
'cohere-ai',
|
|
38
|
+
'amazonbot',
|
|
39
|
+
'applebot',
|
|
40
|
+
'anthropic-ai',
|
|
41
|
+
|
|
42
|
+
// ── Social / preview ────────────────────────────────────────────────────────
|
|
43
|
+
'facebookexternalhit',
|
|
44
|
+
'twitterbot',
|
|
45
|
+
'linkedinbot',
|
|
46
|
+
'slackbot',
|
|
47
|
+
'discordbot',
|
|
48
|
+
'telegrambot',
|
|
49
|
+
'whatsapp',
|
|
50
|
+
'vkshare',
|
|
51
|
+
'pinterest',
|
|
52
|
+
'tumblr',
|
|
53
|
+
'flipboard',
|
|
54
|
+
|
|
55
|
+
// ── Generic signals ─────────────────────────────────────────────────────────
|
|
56
|
+
'spider',
|
|
57
|
+
'crawler',
|
|
58
|
+
'scraper',
|
|
59
|
+
'bot/',
|
|
60
|
+
'+http', // Most crawlers include a URL like +https://... in their UA
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns `true` if the given user-agent string belongs to a known bot/crawler.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} userAgent
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isBot(userAgent) {
|
|
70
|
+
if (!userAgent) return false;
|
|
71
|
+
const ua = userAgent.toLowerCase();
|
|
72
|
+
return BOT_PATTERNS.some((pattern) => ua.includes(pattern));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns the matched bot pattern string (useful for logging), or null.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} userAgent
|
|
79
|
+
* @returns {string|null}
|
|
80
|
+
*/
|
|
81
|
+
export function detectBot(userAgent) {
|
|
82
|
+
if (!userAgent) return null;
|
|
83
|
+
const ua = userAgent.toLowerCase();
|
|
84
|
+
return BOT_PATTERNS.find((pattern) => ua.includes(pattern)) ?? null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route expander.
|
|
3
|
+
*
|
|
4
|
+
* Handles route list pre-processing:
|
|
5
|
+
* - Deduplication
|
|
6
|
+
* - Basic normalization (ensure leading slash)
|
|
7
|
+
* - Future: glob / wildcard expansion, sitemap parsing
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Expand and normalize an array of route strings.
|
|
12
|
+
*
|
|
13
|
+
* Currently performs:
|
|
14
|
+
* - Trim whitespace
|
|
15
|
+
* - Ensure leading slash
|
|
16
|
+
* - Deduplicate
|
|
17
|
+
* - Sort for deterministic output order
|
|
18
|
+
*
|
|
19
|
+
* @param {string[]} routes
|
|
20
|
+
* @returns {Promise<string[]>}
|
|
21
|
+
*/
|
|
22
|
+
export async function expandRoutes(routes) {
|
|
23
|
+
const normalized = routes
|
|
24
|
+
.map((r) => {
|
|
25
|
+
r = r.trim();
|
|
26
|
+
if (!r.startsWith('/')) r = '/' + r;
|
|
27
|
+
// Remove trailing slash (except root)
|
|
28
|
+
if (r !== '/' && r.endsWith('/')) r = r.slice(0, -1);
|
|
29
|
+
return r;
|
|
30
|
+
})
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
|
|
33
|
+
// Deduplicate
|
|
34
|
+
const unique = [...new Set(normalized)];
|
|
35
|
+
|
|
36
|
+
// Stable sort: root first, then alphabetical
|
|
37
|
+
unique.sort((a, b) => {
|
|
38
|
+
if (a === '/') return -1;
|
|
39
|
+
if (b === '/') return 1;
|
|
40
|
+
return a.localeCompare(b);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return unique;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Future: parse a sitemap.xml and extract all <loc> entries as routes.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} sitemapUrl
|
|
50
|
+
* @returns {Promise<string[]>}
|
|
51
|
+
*/
|
|
52
|
+
export async function routesFromSitemap(sitemapUrl) {
|
|
53
|
+
const { default: fetch } = await import('node-fetch');
|
|
54
|
+
const res = await fetch(sitemapUrl);
|
|
55
|
+
const xml = await res.text();
|
|
56
|
+
|
|
57
|
+
const matches = xml.matchAll(/<loc>(.*?)<\/loc>/g);
|
|
58
|
+
const urls = Array.from(matches, (m) => m[1].trim());
|
|
59
|
+
|
|
60
|
+
// Convert absolute URLs → relative paths
|
|
61
|
+
return urls.map((u) => {
|
|
62
|
+
try {
|
|
63
|
+
return new URL(u).pathname;
|
|
64
|
+
} catch {
|
|
65
|
+
return u;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { createReadStream } from 'fs';
|
|
5
|
+
|
|
6
|
+
const MIME_TYPES = {
|
|
7
|
+
'.html': 'text/html; charset=utf-8',
|
|
8
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
9
|
+
'.mjs': 'application/javascript; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.json': 'application/json; charset=utf-8',
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.jpg': 'image/jpeg',
|
|
14
|
+
'.jpeg': 'image/jpeg',
|
|
15
|
+
'.gif': 'image/gif',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
'.ico': 'image/x-icon',
|
|
18
|
+
'.woff': 'font/woff',
|
|
19
|
+
'.woff2':'font/woff2',
|
|
20
|
+
'.ttf': 'font/ttf',
|
|
21
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
'.webm': 'video/webm',
|
|
24
|
+
'.mp4': 'video/mp4',
|
|
25
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
26
|
+
'.xml': 'application/xml',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Serve the built dist directory on a free local port.
|
|
31
|
+
* Falls back to SPA mode: any unknown path returns index.html (for client-side routing).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} distDir - Absolute path to the built app directory
|
|
34
|
+
* @param {number} port - Preferred port (will auto-increment if taken)
|
|
35
|
+
* @returns {Promise<{ url: string, close: () => Promise<void> }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function startServer(distDir, port = 4173) {
|
|
38
|
+
const actualPort = await findFreePort(port);
|
|
39
|
+
|
|
40
|
+
const server = http.createServer((req, res) => {
|
|
41
|
+
// Strip query strings and decode URI
|
|
42
|
+
let urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
43
|
+
|
|
44
|
+
// Default to index.html
|
|
45
|
+
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
|
|
46
|
+
|
|
47
|
+
let filePath = path.join(distDir, urlPath);
|
|
48
|
+
|
|
49
|
+
// SPA fallback: serve index.html for unknown paths
|
|
50
|
+
if (!fs.existsSync(filePath)) {
|
|
51
|
+
filePath = path.join(distDir, 'index.html');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
55
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const stat = fs.statSync(filePath);
|
|
59
|
+
res.writeHead(200, {
|
|
60
|
+
'Content-Type': contentType,
|
|
61
|
+
'Content-Length': stat.size,
|
|
62
|
+
'Cache-Control': 'no-cache',
|
|
63
|
+
});
|
|
64
|
+
createReadStream(filePath).pipe(res);
|
|
65
|
+
} catch {
|
|
66
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
67
|
+
res.end('Not found');
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await new Promise((resolve) => server.listen(actualPort, '127.0.0.1', resolve));
|
|
72
|
+
|
|
73
|
+
const url = `http://127.0.0.1:${actualPort}`;
|
|
74
|
+
|
|
75
|
+
const close = () =>
|
|
76
|
+
new Promise((resolve, reject) =>
|
|
77
|
+
server.close((err) => (err ? reject(err) : resolve()))
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return { url, close };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find a free TCP port starting from `preferred`.
|
|
87
|
+
* Tries up to 20 consecutive ports before giving up.
|
|
88
|
+
*/
|
|
89
|
+
function findFreePort(preferred, attempts = 20) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
let tried = 0;
|
|
92
|
+
|
|
93
|
+
const tryPort = (port) => {
|
|
94
|
+
const server = http.createServer();
|
|
95
|
+
server.listen(port, '127.0.0.1');
|
|
96
|
+
server.on('listening', () => {
|
|
97
|
+
server.close(() => resolve(port));
|
|
98
|
+
});
|
|
99
|
+
server.on('error', () => {
|
|
100
|
+
tried++;
|
|
101
|
+
if (tried >= attempts) {
|
|
102
|
+
reject(new Error(`Could not find a free port near ${preferred}`));
|
|
103
|
+
} else {
|
|
104
|
+
tryPort(port + 1);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
tryPort(preferred);
|
|
110
|
+
});
|
|
111
|
+
}
|