initial commit
This commit is contained in:
commit
bea3138401
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
.idea
|
||||
3267
package-lock.json
generated
Normal file
3267
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "rspack-waite-page-plugin",
|
||||
"version": "0.2.4",
|
||||
"description": "Shows a build progress page in the browser while rspack is compiling",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"keywords": [
|
||||
"rspack",
|
||||
"plugin",
|
||||
"dev-server",
|
||||
"progress",
|
||||
"wait-page"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@rspack/core": ">=0.7.0",
|
||||
"@rspack/dev-server": ">=0.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rspack/core": "^1.0.0",
|
||||
"@rspack/dev-server": "^1.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
2
src/index.ts
Normal file
2
src/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { RspackWaitPagePlugin } from './plugin';
|
||||
export type { RspackWaitPagePluginOptions } from './plugin';
|
||||
74
src/middleware.ts
Normal file
74
src/middleware.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { BuildState } from './state';
|
||||
import { renderWaitPage } from './template';
|
||||
|
||||
export interface MiddlewareOptions {
|
||||
title: string;
|
||||
disableAfterFirstBuild: boolean;
|
||||
pollInterval: number;
|
||||
}
|
||||
|
||||
type NextFunction = () => void;
|
||||
|
||||
export const PROGRESS_ENDPOINT = '/rspack-wait-page/progress';
|
||||
|
||||
export function createWaitPageMiddleware(
|
||||
buildState: BuildState,
|
||||
options: MiddlewareOptions
|
||||
) {
|
||||
return function waitPageMiddleware(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
next: NextFunction
|
||||
): void {
|
||||
// Serve the JSON progress endpoint (used by the polling script)
|
||||
if (req.method === 'GET' && req.url === PROGRESS_ENDPOINT) {
|
||||
const body = JSON.stringify({
|
||||
isBuilding: buildState.isBuilding,
|
||||
percentage: buildState.percentage,
|
||||
message: buildState.message,
|
||||
moduleName: buildState.moduleName,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
// After first successful build, let the real app handle everything
|
||||
if (options.disableAfterFirstBuild && buildState.hasBeenValid) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Not building — let the real app respond
|
||||
if (!buildState.isBuilding) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only intercept top-level document navigations (not JS/CSS/fonts/etc.)
|
||||
const accept = req.headers['accept'] ?? '';
|
||||
if (!accept.includes('text/html')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const html = renderWaitPage({
|
||||
title: options.title,
|
||||
percentage: buildState.percentage,
|
||||
message: buildState.message,
|
||||
moduleName: buildState.moduleName,
|
||||
pollInterval: options.pollInterval,
|
||||
progressEndpoint: PROGRESS_ENDPOINT,
|
||||
});
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
});
|
||||
res.end(html);
|
||||
};
|
||||
}
|
||||
119
src/plugin.ts
Normal file
119
src/plugin.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import type { Compiler } from '@rspack/core';
|
||||
import { createBuildState } from './state';
|
||||
import { createWaitPageMiddleware } from './middleware';
|
||||
|
||||
export interface RspackWaitPagePluginOptions {
|
||||
/** Browser tab title shown on the wait page. Default: "Building…" */
|
||||
title?: string;
|
||||
/** Stop intercepting requests after the first successful build. Default: true */
|
||||
disableAfterFirstBuild?: boolean;
|
||||
/**
|
||||
* Artificial delay in ms added after the build finishes before the wait page
|
||||
* disappears. Useful for testing/debugging the wait page UI. Default: 0
|
||||
*/
|
||||
delay?: number;
|
||||
/**
|
||||
* How often (in ms) the browser polls the /progress endpoint.
|
||||
* Lower values = smoother progress bar, slightly more requests. Default: 100
|
||||
*/
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
const PLUGIN_NAME = 'RspackWaitPagePlugin';
|
||||
|
||||
export class RspackWaitPagePlugin {
|
||||
private readonly options: Required<RspackWaitPagePluginOptions>;
|
||||
|
||||
|
||||
constructor(options: RspackWaitPagePluginOptions = {}) {
|
||||
this.options = {
|
||||
title: options.title ?? 'Building…',
|
||||
disableAfterFirstBuild: options.disableAfterFirstBuild ?? true,
|
||||
delay: options.delay ?? 0,
|
||||
pollInterval: options.pollInterval ?? 100,
|
||||
};
|
||||
}
|
||||
|
||||
apply(compiler: Compiler): void {
|
||||
const buildState = createBuildState();
|
||||
|
||||
// Rspack ProgressPlugin internally does: userFn(percentage, msg, ...items)
|
||||
// where items is string[] from Rust. In practice rspack passes exactly one
|
||||
// item: either the current module path (file path) or a phase label like
|
||||
// "compilation" / "finish make". There are no separate modules-count or
|
||||
// active-modules fields — those are webpack-only.
|
||||
new compiler.webpack.ProgressPlugin((
|
||||
percentage: number,
|
||||
message: string,
|
||||
...args: string[]
|
||||
) => {
|
||||
buildState.percentage = Math.round(percentage * 100);
|
||||
buildState.message = message ?? '';
|
||||
|
||||
// args[0] is either a module path (contains '/') or a phase label like
|
||||
// "finish make" / "plugins". We keep both — the template decides display.
|
||||
buildState.moduleName = args[0] ?? '';
|
||||
}).apply(compiler);
|
||||
|
||||
// Initial build start (watch mode)
|
||||
compiler.hooks.watchRun.tap(PLUGIN_NAME, () => {
|
||||
buildState.isBuilding = true;
|
||||
buildState.percentage = 0;
|
||||
buildState.message = 'starting';
|
||||
});
|
||||
|
||||
// File changed — rebuild started
|
||||
compiler.hooks.invalid.tap(PLUGIN_NAME, () => {
|
||||
buildState.isBuilding = true;
|
||||
buildState.percentage = 0;
|
||||
buildState.message = '';
|
||||
});
|
||||
|
||||
// Build finished (success or error — let HMR overlay handle errors).
|
||||
// tapPromise lets us hold `isBuilding=true` for an artificial delay before
|
||||
// the browser polling detects completion and triggers location.reload().
|
||||
compiler.hooks.done.tapPromise(PLUGIN_NAME, async () => {
|
||||
buildState.percentage = 100;
|
||||
buildState.message = 'done';
|
||||
|
||||
if (this.options.delay > 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, this.options.delay));
|
||||
}
|
||||
|
||||
buildState.isBuilding = false;
|
||||
buildState.hasBeenValid = true;
|
||||
});
|
||||
|
||||
// Inject middleware by wrapping setupMiddlewares before the dev server starts.
|
||||
// afterEnvironment fires after user config is applied but before server boot.
|
||||
compiler.hooks.afterEnvironment.tap(PLUGIN_NAME, () => {
|
||||
const devServerOptions = (compiler.options as { devServer?: Record<string, unknown> }).devServer;
|
||||
|
||||
// No devServer config — running as `rspack build`, nothing to do
|
||||
if (!devServerOptions) return;
|
||||
|
||||
const originalSetupMiddlewares = devServerOptions['setupMiddlewares'] as
|
||||
| ((middlewares: unknown[], devServer: unknown) => unknown[])
|
||||
| undefined;
|
||||
|
||||
const middleware = createWaitPageMiddleware(buildState, {
|
||||
title: this.options.title,
|
||||
disableAfterFirstBuild: this.options.disableAfterFirstBuild,
|
||||
pollInterval: this.options.pollInterval,
|
||||
});
|
||||
|
||||
devServerOptions['setupMiddlewares'] = (
|
||||
middlewares: unknown[],
|
||||
devServer: unknown
|
||||
): unknown[] => {
|
||||
// Prepend our middleware so it runs before all others
|
||||
middlewares.unshift({ name: PLUGIN_NAME, middleware });
|
||||
|
||||
if (typeof originalSetupMiddlewares === 'function') {
|
||||
return originalSetupMiddlewares(middlewares, devServer);
|
||||
}
|
||||
return middlewares;
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
18
src/state.ts
Normal file
18
src/state.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export interface BuildState {
|
||||
isBuilding: boolean;
|
||||
percentage: number; // 0–100
|
||||
message: string;
|
||||
/** currently processed module path (file path from rspack progress args) */
|
||||
moduleName: string;
|
||||
hasBeenValid: boolean; // true after first successful build
|
||||
}
|
||||
|
||||
export function createBuildState(): BuildState {
|
||||
return {
|
||||
isBuilding: false,
|
||||
percentage: 0,
|
||||
message: '',
|
||||
moduleName: '',
|
||||
hasBeenValid: false,
|
||||
};
|
||||
}
|
||||
293
src/template.ts
Normal file
293
src/template.ts
Normal file
@ -0,0 +1,293 @@
|
||||
export interface TemplateOptions {
|
||||
title: string;
|
||||
percentage: number;
|
||||
message: string;
|
||||
moduleName: string;
|
||||
pollInterval: number;
|
||||
progressEndpoint: string;
|
||||
}
|
||||
|
||||
export function renderWaitPage({
|
||||
title,
|
||||
percentage,
|
||||
message,
|
||||
moduleName,
|
||||
pollInterval,
|
||||
progressEndpoint,
|
||||
}: TemplateOptions): string {
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeMessage = escapeHtml(message);
|
||||
const safeModuleName = escapeHtml(formatDetail(moduleName));
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${safeTitle}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 48px);
|
||||
padding: 40px 36px;
|
||||
background: #1a1d27;
|
||||
border: 1px solid #2d3148;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: linear-gradient(135deg, #e8612c, #f5a623);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #f1f5f9;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
background: #2d3148;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: ${percentage}%;
|
||||
background: linear-gradient(90deg, #e8612c, #f5a623);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.progress-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-message {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-pct {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #f5a623;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* Module details panel */
|
||||
.details {
|
||||
background: #13151f;
|
||||
border: 1px solid #252840;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 76px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #475569;
|
||||
flex-shrink: 0;
|
||||
width: 90px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-value.current { color: #818cf8; }
|
||||
|
||||
/* shown when value is empty */
|
||||
.detail-value.is-empty {
|
||||
color: #2d3148;
|
||||
font-style: italic;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #e8612c;
|
||||
animation: bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
|
||||
40% { transform: translateY(-5px); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<div class="logo-icon">R</div>
|
||||
<span class="logo-text">rspack dev server</span>
|
||||
</div>
|
||||
<h1>Building your app<span class="dots"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span></h1>
|
||||
<p class="subtitle">${safeTitle}</p>
|
||||
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" id="progress-fill"></div>
|
||||
</div>
|
||||
<div class="progress-footer">
|
||||
<span class="progress-message" id="progress-message">${safeMessage}</span>
|
||||
<span class="progress-pct" id="progress-pct">${percentage}%</span>
|
||||
</div>
|
||||
|
||||
<div class="details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Current</span>
|
||||
<span class="detail-value current${safeModuleName ? '' : ' is-empty'}" id="detail-current">${safeModuleName || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var fill = document.getElementById('progress-fill');
|
||||
var msg = document.getElementById('progress-message');
|
||||
var pct = document.getElementById('progress-pct');
|
||||
var current = document.getElementById('detail-current');
|
||||
|
||||
function setDetail(el, val) {
|
||||
if (val) {
|
||||
el.textContent = val;
|
||||
el.classList.remove('is-empty');
|
||||
} else {
|
||||
el.textContent = '—';
|
||||
el.classList.add('is-empty');
|
||||
}
|
||||
}
|
||||
|
||||
function formatDetail(s) {
|
||||
if (!s) return '';
|
||||
if (s.indexOf('/') === -1) return s; // phase label — show as-is
|
||||
var parts = s.replace(/\\\\/g, '/').split('/');
|
||||
return parts.length > 2 ? ('\u2026/' + parts.slice(-2).join('/')) : s;
|
||||
}
|
||||
|
||||
fill.style.width = '${percentage}%';
|
||||
|
||||
(function poll() {
|
||||
fetch('${progressEndpoint}')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
fill.style.width = data.percentage + '%';
|
||||
pct.textContent = data.percentage + '%';
|
||||
msg.textContent = data.message || '';
|
||||
setDetail(current, formatDetail(data.moduleName || ''));
|
||||
|
||||
if (!data.isBuilding) {
|
||||
location.reload();
|
||||
} else {
|
||||
setTimeout(poll, ${pollInterval});
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
setTimeout(poll, ${pollInterval * 5});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For file paths: keep last 2 segments to avoid overflow.
|
||||
* For phase labels ("finish make", "plugins"): show as-is.
|
||||
*/
|
||||
function formatDetail(value: string): string {
|
||||
if (!value) return '';
|
||||
if (!value.includes('/')) return value; // phase label
|
||||
const parts = value.replace(/\\/g, '/').split('/');
|
||||
return parts.length > 2 ? '\u2026/' + parts.slice(-2).join('/') : value;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES2018"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user