initial commit

This commit is contained in:
Sergey Krylov 2026-03-31 06:00:37 +03:00
commit bea3138401
9 changed files with 3826 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
*.tgz
.idea

3267
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View 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
View File

@ -0,0 +1,2 @@
export { RspackWaitPagePlugin } from './plugin';
export type { RspackWaitPagePluginOptions } from './plugin';

74
src/middleware.ts Normal file
View 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
View 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
View File

@ -0,0 +1,18 @@
export interface BuildState {
isBuilding: boolean;
percentage: number; // 0100
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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}

16
tsconfig.json Normal file
View 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"]
}