@stencil/dev-server
Version:
Development server for Stencil with DOM-based HMR
1,277 lines (1,268 loc) • 42.8 kB
JavaScript
import * as path from "node:path";
import * as http from "node:http";
import * as https from "node:https";
import * as net from "node:net";
import { WebSocketServer } from "ws";
import * as fs from "node:fs";
import { inspect } from "node:util";
import * as zlib from "node:zlib";
import { fork } from "node:child_process";
//#region src/server/utils.ts
const DEV_SERVER_URL = "/~dev-server";
const DEV_MODULE_URL = "/~dev-module";
const DEV_SERVER_INIT_URL = `${DEV_SERVER_URL}-init`;
const OPEN_IN_EDITOR_URL = `${DEV_SERVER_URL}-open-in-editor`;
const VERSION = "5.0.0";
const DEFAULT_HEADERS = {
"cache-control": "no-cache, no-store, must-revalidate, max-age=0",
expires: "0",
date: "Wed, 1 Jan 2000 00:00:00 GMT",
server: `Stencil Dev Server ${VERSION}`,
"access-control-allow-origin": "*",
"access-control-expose-headers": "*"
};
function responseHeaders(headers, httpCache = false) {
const result = {
...DEFAULT_HEADERS,
...headers
};
if (httpCache) {
result["cache-control"] = "max-age=3600";
delete result["date"];
delete result["expires"];
}
return result;
}
function getBrowserUrl(protocol, address, port, basePath, pathname) {
address = address === "0.0.0.0" ? "localhost" : address;
const portSuffix = !port || port === 80 || port === 443 ? "" : ":" + port;
let path = basePath;
if (pathname.startsWith("/")) pathname = pathname.substring(1);
path += pathname;
protocol = protocol.replace(/:/g, "");
return `${protocol}://${address}${portSuffix}${path}`;
}
function getDevServerClientUrl(devServerConfig, host, protocol) {
let address = devServerConfig.address;
let port = devServerConfig.port;
if (host) {
address = host;
port = null;
}
return getBrowserUrl(protocol ?? devServerConfig.protocol, address, port, devServerConfig.basePath, DEV_SERVER_URL);
}
const CONTENT_TYPES = {
html: "text/html",
htm: "text/html",
css: "text/css",
js: "text/javascript",
mjs: "text/javascript",
json: "application/json",
xml: "application/xml",
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
ico: "image/x-icon",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
eot: "application/vnd.ms-fontobject",
mp3: "audio/mpeg",
mp4: "video/mp4",
webm: "video/webm",
ogg: "audio/ogg",
wav: "audio/wav",
pdf: "application/pdf",
zip: "application/zip",
wasm: "application/wasm",
map: "application/json",
txt: "text/plain",
md: "text/markdown",
ts: "text/typescript",
tsx: "text/typescript-jsx"
};
function getContentType(filePath) {
const last = filePath.replace(/^.*[/\\]/, "").toLowerCase();
const ext = last.replace(/^.*\./, "").toLowerCase();
const hasPath = last.length < filePath.length;
return (ext.length < last.length - 1 || !hasPath) && CONTENT_TYPES[ext] || "application/octet-stream";
}
function isHtmlFile(filePath) {
const lower = filePath.toLowerCase().trim();
return lower.endsWith(".html") || lower.endsWith(".htm");
}
function isCssFile(filePath) {
return filePath.toLowerCase().trim().endsWith(".css");
}
const TXT_EXT = [
"css",
"html",
"htm",
"js",
"json",
"svg",
"xml",
"mjs",
"ts",
"tsx",
"md",
"txt"
];
function isSimpleText(filePath) {
const ext = filePath.toLowerCase().trim().split(".").pop();
return ext ? TXT_EXT.includes(ext) : false;
}
function isExtensionLessPath(pathname) {
const parts = pathname.split("/");
return !parts[parts.length - 1].includes(".");
}
function isSsrStaticDataPath(pathname) {
const parts = pathname.split("/");
return parts[parts.length - 1].split("?")[0] === "page.state.json";
}
function getSsrStaticDataPath(req) {
const parts = req.url.href.split("/");
const fileNameParts = parts[parts.length - 1].split("?");
parts.pop();
let ssrPath = new URL(parts.join("/")).href;
if (!ssrPath.endsWith("/") && req.headers) {
if (new Headers(req.headers).get("referer")?.endsWith("/")) ssrPath += "/";
}
return {
ssrPath,
fileName: fileNameParts[0],
hasQueryString: typeof fileNameParts[1] === "string" && fileNameParts[1].length > 0
};
}
function isDevClient(pathname) {
return pathname.startsWith(DEV_SERVER_URL);
}
function isDevModule(pathname) {
return pathname.includes(DEV_MODULE_URL);
}
function isOpenInEditor(pathname) {
return pathname === OPEN_IN_EDITOR_URL;
}
function isInitialDevServerLoad(pathname) {
return pathname === DEV_SERVER_INIT_URL;
}
function isDevServerClient(pathname) {
return pathname === DEV_SERVER_URL;
}
function shouldCompress(devServerConfig, req) {
if (!devServerConfig.gzip) return false;
if (req.method !== "GET") return false;
const acceptEncoding = req.headers?.["accept-encoding"];
if (typeof acceptEncoding !== "string") return false;
return acceptEncoding.includes("gzip");
}
/**
* Normalize a file path to use forward slashes and remove redundant slashes.
*/
function normalizePath(path) {
let normalized = path.replace(/\\/g, "/");
normalized = normalized.replace(/\/+/g, "/");
if (path.startsWith("\\\\")) normalized = "/" + normalized;
return normalized;
}
//#endregion
//#region src/server/context.ts
/**
* Server context factory.
* Creates the shared context object passed to request handlers.
*/
function createServerContext(sys, sendMsg, devServerConfig, buildResultsResolves, compilerRequestResolves) {
const logRequest = (req, status) => {
if (devServerConfig) sendMsg({ requestLog: {
method: req.method || "?",
url: req.pathname || "?",
status
} });
};
const serve500 = (req, res, error, xSource) => {
try {
if (res.headersSent) {
res.end();
return;
}
res.writeHead(500, responseHeaders({
"content-type": "text/plain; charset=utf-8",
"x-source": xSource
}));
res.write(inspect(error));
res.end();
logRequest(req, 500);
} catch (e) {
sendMsg({ error: { message: "serve500: " + e } });
}
};
const serve404 = (req, res, xSource, content = null) => {
try {
if (res.headersSent) {
res.end();
return;
}
if (req.pathname === "/favicon.ico") {
const defaultFavicon = path.join(devServerConfig.devServerDir, "static", "favicon.ico");
const rs = fs.createReadStream(defaultFavicon);
rs.on("error", () => {
if (!res.headersSent) res.writeHead(404);
res.end();
});
res.writeHead(200, responseHeaders({
"content-type": "image/x-icon",
"x-source": `favicon: ${xSource}`
}));
rs.pipe(res);
return;
}
if (content == null) content = [
"404 File Not Found",
"Url: " + req.pathname,
"File: " + req.filePath
].join("\n");
res.writeHead(404, responseHeaders({
"content-type": "text/plain; charset=utf-8",
"x-source": xSource
}));
res.write(content);
res.end();
logRequest(req, 404);
} catch (e) {
serve500(req, res, e, xSource);
}
};
const serve302 = (req, res, pathname = null) => {
logRequest(req, 302);
res.writeHead(302, { location: pathname || devServerConfig.basePath || "/" });
res.end();
};
const getBuildResults = () => new Promise((resolve, reject) => {
if (serverCtx.isServerListening) {
buildResultsResolves.push({
resolve,
reject
});
sendMsg({ requestBuildResults: true });
} else reject("dev server closed");
});
const getCompilerRequest = (compilerRequestPath) => new Promise((resolve, reject) => {
if (serverCtx.isServerListening) {
compilerRequestResolves.push({
path: compilerRequestPath,
resolve,
reject
});
sendMsg({ compilerRequestPath });
} else reject("dev server closed");
});
const serverCtx = {
connectorHtml: null,
dirTemplate: null,
getBuildResults,
getCompilerRequest,
isServerListening: false,
logRequest,
prerenderConfig: null,
serve302,
serve404,
serve500,
sys
};
return serverCtx;
}
//#endregion
//#region src/server/editor.ts
async function openInBrowser(opts) {
const { default: open } = await import("open");
await open(opts.url);
}
let launchEditorLoaded = false;
let launchEditor = null;
async function loadLaunchEditor() {
if (launchEditorLoaded) return;
try {
const mod = await import("launch-editor");
launchEditor = mod.default || mod;
} catch (e) {
console.warn("launch-editor package is not available. Open in editor functionality will be disabled.");
launchEditor = null;
}
launchEditorLoaded = true;
}
async function serveOpenInEditor(serverCtx, req, res) {
let status = 200;
const data = {};
try {
await parseEditorData(serverCtx.sys, req, data);
await openDataInEditor(data);
} catch (e) {
data.error = String(e);
status = 500;
}
serverCtx.logRequest(req, status);
res.writeHead(status, responseHeaders({ "content-type": "application/json; charset=utf-8" }));
res.write(JSON.stringify(data, null, 2));
res.end();
}
async function parseEditorData(sys, req, data) {
const qs = req.searchParams;
if (!qs.has("file")) {
data.error = "missing file";
return;
}
data.file = qs.get("file");
if (qs.has("line") && !isNaN(Number(qs.get("line")))) data.line = parseInt(qs.get("line"), 10);
if (typeof data.line !== "number" || data.line < 1) data.line = 1;
if (qs.has("column") && !isNaN(Number(qs.get("column")))) data.column = parseInt(qs.get("column"), 10);
if (typeof data.column !== "number" || data.column < 1) data.column = 1;
if (qs.has("editor")) data.editor = qs.get("editor");
data.exists = (await sys.stat(data.file)).isFile;
}
async function openDataInEditor(data) {
if (!data.exists || data.error) return;
await loadLaunchEditor();
if (!launchEditor) {
data.error = "launch-editor not available";
return;
}
try {
const fileSpec = `${data.file}:${data.line}:${data.column}`;
await new Promise((resolve, reject) => {
let errorCalled = false;
launchEditor(fileSpec, data.editor || process.env.EDITOR, (_fileName, errorMessage) => {
errorCalled = true;
const errMsg = errorMessage || "Unknown error";
console.error("Editor launch failed.");
console.error("The \"code\" executable was not found in your PATH.");
console.error("This usually means your editor's command-line tool isn't installed.");
console.error("Try running:");
console.error(" code --version");
console.error("If that fails, install your editor's CLI command and ensure it's in your PATH.\n");
data.error = errMsg;
reject(new Error(errMsg));
});
setTimeout(() => {
if (!errorCalled) {
data.open = fileSpec;
resolve();
}
}, 100);
});
} catch (e) {
if (!data.error) data.error = String(e);
}
}
function getEditors() {
return Promise.resolve([
{
id: "code",
name: "Visual Studio Code"
},
{
id: "cursor",
name: "Cursor"
},
{
id: "code-insiders",
name: "VS Code Insiders"
},
{
id: "webstorm",
name: "WebStorm"
},
{
id: "idea",
name: "IntelliJ IDEA"
},
{
id: "sublime",
name: "Sublime Text"
},
{
id: "atom",
name: "Atom"
},
{
id: "vim",
name: "Vim"
},
{
id: "emacs",
name: "Emacs"
}
]);
}
//#endregion
//#region src/server/ssr.ts
/**
* SSR (Server-Side Rendering) request handling.
* Migrated from ssr-request.ts.
*/
async function ssrPageRequest(devServerConfig, serverCtx, req, res) {
try {
let status = 500;
let content = "";
const { hydrateApp, srcIndexHtml, diagnostics } = await setupHydrateApp(devServerConfig, serverCtx);
if (!diagnostics.some((diagnostic) => diagnostic.level === "error")) try {
const opts = getSsrHydrateOptions(devServerConfig, serverCtx, req.url);
const ssrResults = await hydrateApp.renderToString(srcIndexHtml, opts);
diagnostics.push(...ssrResults.diagnostics);
status = ssrResults.httpStatus ?? 500;
content = ssrResults.html ?? "";
} catch (e) {
catchError(diagnostics, e);
}
if (diagnostics.some((diagnostic) => diagnostic.level === "error")) {
content = getSsrErrorContent(diagnostics);
status = 500;
}
if (devServerConfig.websocket) content = appendDevServerClientScript(devServerConfig, req, content);
serverCtx.logRequest(req, status);
res.writeHead(status, responseHeaders({
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(content, "utf8")
}));
res.write(content);
res.end();
} catch (e) {
serverCtx.serve500(req, res, e, "ssrPageRequest");
}
}
async function ssrStaticDataRequest(devServerConfig, serverCtx, req, res) {
try {
const data = {};
let httpCache = false;
const { hydrateApp, srcIndexHtml, diagnostics } = await setupHydrateApp(devServerConfig, serverCtx);
if (!diagnostics.some((diagnostic) => diagnostic.level === "error")) try {
const { ssrPath, hasQueryString } = getSsrStaticDataPath(req);
const opts = getSsrHydrateOptions(devServerConfig, serverCtx, new URL(ssrPath, req.url));
const ssrResults = await hydrateApp.renderToString(srcIndexHtml, opts);
diagnostics.push(...ssrResults.diagnostics);
ssrResults.staticData.forEach((s) => {
if (s.type === "application/json") data[s.id] = JSON.parse(s.content);
else data[s.id] = s.content;
});
data.components = ssrResults.components.map((c) => c.tag).sort();
httpCache = hasQueryString;
} catch (e) {
catchError(diagnostics, e);
}
if (diagnostics.length > 0) data.diagnostics = diagnostics;
const status = diagnostics.some((diagnostic) => diagnostic.level === "error") ? 500 : 200;
const content = JSON.stringify(data);
serverCtx.logRequest(req, status);
res.writeHead(status, responseHeaders({
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(content, "utf8")
}, httpCache && status === 200));
res.write(content);
res.end();
} catch (e) {
serverCtx.serve500(req, res, e, "ssrStaticDataRequest");
}
}
async function setupHydrateApp(devServerConfig, serverCtx) {
let srcIndexHtml = null;
let hydrateApp = null;
const buildResults = await serverCtx.getBuildResults();
const diagnostics = [];
if (serverCtx.prerenderConfig == null && isString(devServerConfig.prerenderConfig)) try {
const prerenderConfigResults = (await import("@stencil/core/compiler")).nodeRequire(devServerConfig.prerenderConfig);
diagnostics.push(...prerenderConfigResults.diagnostics);
if (prerenderConfigResults.module?.config) serverCtx.prerenderConfig = prerenderConfigResults.module.config;
} catch (e) {
catchError(diagnostics, e);
}
if (!isString(buildResults.hydrateAppFilePath)) diagnostics.push({
messageText: "Missing hydrateAppFilePath",
level: "error",
type: "ssr",
lines: []
});
else if (!isString(devServerConfig.srcIndexHtml)) diagnostics.push({
messageText: "Missing srcIndexHtml",
level: "error",
type: "ssr",
lines: []
});
else {
srcIndexHtml = await serverCtx.sys.readFile(devServerConfig.srcIndexHtml, "utf8");
if (!isString(srcIndexHtml)) diagnostics.push({
level: "error",
lines: [],
messageText: `Unable to load src index html: ${devServerConfig.srcIndexHtml}`,
type: "ssr"
});
else {
const hydrateAppFilePath = path.resolve(buildResults.hydrateAppFilePath);
try {
const hydrateModule = await import(`file://${hydrateAppFilePath}${`?t=${Date.now()}`}`);
hydrateApp = hydrateModule.default || hydrateModule;
} catch (e) {
catchError(diagnostics, e);
}
}
}
return {
hydrateApp,
srcIndexHtml,
diagnostics
};
}
function getSsrHydrateOptions(devServerConfig, serverCtx, url) {
const opts = {
url: url.href,
addModulePreloads: false,
approximateLineWidth: 120,
inlineExternalStyleSheets: false,
minifyScriptElements: false,
minifyStyleElements: false,
removeAttributeQuotes: false,
removeBooleanAttributeQuotes: false,
removeEmptyAttributes: false,
removeHtmlComments: false,
prettyHtml: true
};
const prerenderConfig = serverCtx?.prerenderConfig;
if (isFunction(prerenderConfig?.hydrateOptions)) {
const userOpts = prerenderConfig.hydrateOptions(url);
if (userOpts) Object.assign(opts, userOpts);
}
if (isFunction(serverCtx.sys.applyPrerenderGlobalPatch)) {
const orgBeforeHydrate = opts.beforeHydrate;
const applyPatch = serverCtx.sys.applyPrerenderGlobalPatch;
opts.beforeHydrate = (document) => {
const devServerHostUrl = new URL(devServerConfig.browserUrl).origin;
applyPatch({
devServerHostUrl,
window: document.defaultView
});
if (typeof orgBeforeHydrate === "function") return orgBeforeHydrate(document);
};
}
return opts;
}
function getSsrErrorContent(diagnostics) {
return `<!doctype html>
<html>
<head>
<title>SSR Error</title>
<style>
body {
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace !important;
}
</style>
</head>
<body>
<h1>SSR Dev Error</h1>
${diagnostics.map((diagnostic) => `
<p>
${diagnostic.messageText}
</p>
`).join("")}
</body>
</html>`;
}
function catchError(diagnostics, err) {
const diagnostic = {
level: "error",
type: "runtime",
messageText: "",
lines: []
};
if (err instanceof Error) {
diagnostic.messageText = err.message;
if (err.stack) diagnostic.messageText += "\n" + err.stack;
} else diagnostic.messageText = String(err);
diagnostics.push(diagnostic);
}
function isString(val) {
return typeof val === "string";
}
function isFunction(val) {
return typeof val === "function";
}
//#endregion
//#region src/server/handlers.ts
/**
* Request handlers.
* Consolidated from request-handler.ts, serve-file.ts, serve-dev-client.ts,
* serve-dev-node-module.ts, and serve-directory-index.ts.
*/
function createRequestHandler(devServerConfig, serverCtx) {
let userRequestHandler = null;
let userHandlerLoaded = false;
return async function(incomingReq, res) {
if (!userHandlerLoaded && typeof devServerConfig.requestListenerPath === "string") {
userHandlerLoaded = true;
try {
const userModule = await import(devServerConfig.requestListenerPath);
userRequestHandler = userModule.default || userModule;
} catch (e) {
console.error("Failed to load user request handler:", e);
}
}
async function defaultHandler() {
try {
const req = normalizeHttpRequest(devServerConfig, incomingReq);
if (!req.url) return serverCtx.serve302(req, res);
if (devServerConfig.pingRoute !== null && req.pathname === devServerConfig.pingRoute) {
try {
if (!(await serverCtx.getBuildResults()).hasSuccessfulBuild) return serverCtx.serve500(req, res, "Build not successful", "build error");
res.writeHead(200, "OK");
res.write("OK");
res.end();
} catch {
serverCtx.serve500(req, res, "Error getting build results", "ping error");
}
return;
}
if (isDevClient(req.pathname) && devServerConfig.websocket) return serveDevClient(devServerConfig, serverCtx, req, res);
if (isDevModule(req.pathname)) return serveDevNodeModule(serverCtx, req, res);
if (!isValidUrlBasePath(devServerConfig.basePath, req.url)) return serverCtx.serve404(req, res, "invalid basePath", `404 File Not Found, base path: ${devServerConfig.basePath}`);
if (devServerConfig.ssr) {
if (isExtensionLessPath(req.url.pathname)) return ssrPageRequest(devServerConfig, serverCtx, req, res);
if (isSsrStaticDataPath(req.url.pathname)) return ssrStaticDataRequest(devServerConfig, serverCtx, req, res);
}
req.stats = await serverCtx.sys.stat(req.filePath);
if (req.stats.isFile) return serveFile(devServerConfig, serverCtx, req, res);
if (req.stats.isDirectory) return serveDirectoryIndex(devServerConfig, serverCtx, req, res);
const xSource = ["notfound"];
const validHistoryApi = isValidHistoryApi(devServerConfig, req);
xSource.push(`validHistoryApi: ${validHistoryApi}`);
if (validHistoryApi) try {
const indexFilePath = path.join(devServerConfig.root, devServerConfig.historyApiFallback.index);
xSource.push(`indexFilePath: ${indexFilePath}`);
req.stats = await serverCtx.sys.stat(indexFilePath);
if (req.stats.isFile) {
req.filePath = indexFilePath;
return serveFile(devServerConfig, serverCtx, req, res);
}
} catch (e) {
xSource.push(`notfound error: ${e}`);
}
return serverCtx.serve404(req, res, xSource.join(", "));
} catch (e) {
const errorReq = {
method: (incomingReq.method || "GET").toUpperCase(),
acceptHeader: "",
url: null,
searchParams: null
};
return serverCtx.serve500(errorReq, res, e, "not found error");
}
}
if (typeof userRequestHandler === "function") await userRequestHandler(incomingReq, res, defaultHandler);
else await defaultHandler();
};
}
function normalizeHttpRequest(devServerConfig, incomingReq) {
const req = {
method: (incomingReq.method || "GET").toUpperCase(),
headers: incomingReq.headers,
acceptHeader: incomingReq.headers && typeof incomingReq.headers.accept === "string" && incomingReq.headers.accept || "",
host: incomingReq.headers && typeof incomingReq.headers.host === "string" && incomingReq.headers.host || void 0,
url: null,
searchParams: null
};
if ((incomingReq.url || "").trim() || null) {
if (req.host) req.url = new URL(incomingReq.url, `http://${req.host}`);
else req.url = new URL(incomingReq.url, "http://dev.stenciljs.com");
req.searchParams = req.url.searchParams;
}
if (req.url) {
req.pathname = req.url.pathname.replace(/\\/g, "/").split("/").map((part) => decodeURIComponent(part)).join("/");
if (req.pathname.length > 0 && !isDevClient(req.pathname)) req.pathname = "/" + req.pathname.substring(devServerConfig.basePath.length);
req.filePath = normalizePath(path.normalize(path.join(devServerConfig.root, path.relative("/", req.pathname))));
}
return req;
}
function isValidUrlBasePath(basePath, url) {
let pathname = url.pathname;
if (!pathname.endsWith("/")) pathname += "/";
if (!basePath.endsWith("/")) basePath += "/";
return pathname.startsWith(basePath);
}
function isValidHistoryApi(devServerConfig, req) {
if (!devServerConfig.historyApiFallback) return false;
if (req.method !== "GET") return false;
if (!req.acceptHeader.includes("text/html")) return false;
if (!devServerConfig.historyApiFallback.disableDotRule && req.pathname?.includes(".")) return false;
return true;
}
const urlVersionIds = /* @__PURE__ */ new Map();
async function serveFile(devServerConfig, serverCtx, req, res) {
try {
if (isSimpleText(req.filePath)) {
let content = await serverCtx.sys.readFile(req.filePath, "utf8");
if (devServerConfig.websocket && isHtmlFile(req.filePath) && !isDevServerClient(req.pathname)) content = appendDevServerClientScript(devServerConfig, req, content);
else if (isCssFile(req.filePath)) content = updateStyleUrls(req.url, content);
if (shouldCompress(devServerConfig, req)) {
res.writeHead(200, responseHeaders({
"content-type": getContentType(req.filePath) + "; charset=utf-8",
"content-encoding": "gzip",
vary: "Accept-Encoding"
}));
zlib.gzip(content, { level: 9 }, (_, data) => {
res.end(data);
});
} else {
res.writeHead(200, responseHeaders({
"content-type": getContentType(req.filePath) + "; charset=utf-8",
"content-length": Buffer.byteLength(content, "utf8")
}));
res.write(content);
res.end();
}
} else {
const readStream = fs.createReadStream(req.filePath);
readStream.on("error", (err) => {
if (!res.headersSent) serverCtx.serve500(req, res, err, "serveFile");
else res.end();
});
res.writeHead(200, responseHeaders({
"content-type": getContentType(req.filePath),
"content-length": req.stats.size
}));
readStream.pipe(res);
}
serverCtx.logRequest(req, 200);
} catch (e) {
serverCtx.serve500(req, res, e, "serveFile");
}
}
function updateStyleUrls(url, oldCss) {
const versionId = url.searchParams.get("s-hmr");
const hmrUrls = url.searchParams.get("s-hmr-urls");
if (versionId && hmrUrls) hmrUrls.split(",").forEach((hmrUrl) => {
urlVersionIds.set(hmrUrl, versionId);
});
const reg = /url\((['"]?)(.*)\1\)/gi;
let result;
let newCss = oldCss;
while ((result = reg.exec(oldCss)) !== null) {
const oldUrl = result[2];
const parsedUrl = new URL(oldUrl, url);
const fileName = path.basename(parsedUrl.pathname);
const cachedVersionId = urlVersionIds.get(fileName);
if (!cachedVersionId) continue;
parsedUrl.searchParams.set("s-hmr", cachedVersionId);
newCss = newCss.replace(oldUrl, parsedUrl.pathname);
}
return newCss;
}
function appendDevServerClientScript(devServerConfig, req, content) {
return appendDevServerClientIframe(content, `<iframe title="Stencil Dev Server Connector ${VERSION} ⚡" src="${getDevServerClientUrl(devServerConfig, req.headers?.["x-forwarded-host"] ?? req.host, req.headers?.["x-forwarded-proto"])}" style="display:block;width:0;height:0;border:0;visibility:hidden" aria-hidden="true"></iframe>`);
}
function appendDevServerClientIframe(content, iframe) {
if (content.includes("</body>")) return content.replace("</body>", `${iframe}</body>`);
if (content.includes("</html>")) return content.replace("</html>", `${iframe}</html>`);
return `${content}${iframe}`;
}
async function serveDevClient(devServerConfig, serverCtx, req, res) {
try {
if (isOpenInEditor(req.pathname)) return serveOpenInEditor(serverCtx, req, res);
if (isDevServerClient(req.pathname)) return serveDevClientScript(devServerConfig, serverCtx, req, res);
if (isInitialDevServerLoad(req.pathname)) req.filePath = path.join(devServerConfig.devServerDir, "templates", "initial-load.html");
else {
const subPath = req.pathname.replace(DEV_SERVER_URL + "/", "");
if (subPath.startsWith("client/")) req.filePath = path.join(devServerConfig.devServerDir, subPath);
else req.filePath = path.join(devServerConfig.devServerDir, "static", subPath);
}
try {
req.stats = await serverCtx.sys.stat(req.filePath);
if (req.stats.isFile) return serveFile(devServerConfig, serverCtx, req, res);
return serverCtx.serve404(req, res, "serveDevClient not file");
} catch (e) {
return serverCtx.serve404(req, res, `serveDevClient stats error ${e}`);
}
} catch (e) {
return serverCtx.serve500(req, res, e, "serveDevClient");
}
}
async function serveDevClientScript(devServerConfig, serverCtx, req, res) {
try {
if (serverCtx.connectorHtml == null) {
const filePath = path.join(devServerConfig.devServerDir, "connector.html");
serverCtx.connectorHtml = serverCtx.sys.readFileSync(filePath, "utf8");
if (typeof serverCtx.connectorHtml !== "string") return serverCtx.serve404(req, res, "serveDevClientScript");
const devClientConfig = {
basePath: devServerConfig.basePath,
editors: await getEditors(),
reloadStrategy: devServerConfig.reloadStrategy
};
serverCtx.connectorHtml = serverCtx.connectorHtml.replace("window.__DEV_CLIENT_CONFIG__", JSON.stringify(devClientConfig));
}
res.writeHead(200, responseHeaders({ "content-type": "text/html; charset=utf-8" }));
res.write(serverCtx.connectorHtml);
res.end();
} catch (e) {
return serverCtx.serve500(req, res, e, "serveDevClientScript");
}
}
async function serveDevNodeModule(serverCtx, req, res) {
try {
const results = await serverCtx.getCompilerRequest(req.pathname);
const headers = {
"content-type": "application/javascript; charset=utf-8",
"content-length": Buffer.byteLength(results.content, "utf8"),
"x-dev-node-module-id": results.nodeModuleId,
"x-dev-node-module-version": results.nodeModuleVersion,
"x-dev-node-module-resolved-path": results.nodeResolvedPath,
"x-dev-node-module-cache-path": results.cachePath,
"x-dev-node-module-cache-hit": results.cacheHit
};
res.writeHead(results.status, responseHeaders(headers));
res.write(results.content);
res.end();
} catch (e) {
serverCtx.serve500(req, res, e, "serveDevNodeModule");
}
}
async function serveDirectoryIndex(devServerConfig, serverCtx, req, res) {
const indexFilePath = path.join(req.filePath, "index.html");
req.stats = await serverCtx.sys.stat(indexFilePath);
if (req.stats.isFile) {
req.filePath = indexFilePath;
return serveFile(devServerConfig, serverCtx, req, res);
}
if (!req.pathname.endsWith("/")) return serverCtx.serve302(req, res, req.pathname + "/");
try {
const dirFilePaths = await serverCtx.sys.readDir(req.filePath);
try {
if (serverCtx.dirTemplate == null) {
const dirTemplatePath = path.join(devServerConfig.devServerDir, "templates", "directory-index.html");
serverCtx.dirTemplate = serverCtx.sys.readFileSync(dirTemplatePath);
}
const files = await getDirectoryFiles(serverCtx.sys, req.url, dirFilePaths);
const templateHtml = serverCtx.dirTemplate.replace("{{title}}", req.pathname).replace("{{nav}}", getDirectoryNav(req.pathname)).replace("{{files}}", files);
serverCtx.logRequest(req, 200);
res.writeHead(200, responseHeaders({
"content-type": "text/html; charset=utf-8",
"x-directory-index": req.pathname
}));
res.write(templateHtml);
res.end();
} catch (e) {
return serverCtx.serve500(req, res, e, "serveDirectoryIndex");
}
} catch {
return serverCtx.serve404(req, res, "serveDirectoryIndex");
}
}
async function getDirectoryFiles(sys, baseUrl, dirItemNames) {
const items = await getDirectoryItems(sys, baseUrl, dirItemNames);
if (baseUrl.pathname !== "/") items.unshift({
isDirectory: true,
pathname: "../",
name: ".."
});
return items.map((item) => {
return `
<li class="${item.isDirectory ? "directory" : "file"}">
<a href="${item.pathname}">
<span class="icon"></span>
<span>${item.name}</span>
</a>
</li>`;
}).join("");
}
async function getDirectoryItems(sys, baseUrl, dirFilePaths) {
return await Promise.all(dirFilePaths.map(async (dirFilePath) => {
const fileName = path.basename(dirFilePath);
const url = new URL(fileName, baseUrl);
const stats = await sys.stat(dirFilePath);
return {
name: fileName,
pathname: url.pathname,
isDirectory: stats.isDirectory
};
}));
}
function getDirectoryNav(pathName) {
const dirs = pathName.split("/");
dirs.pop();
let url = "";
return dirs.map((dir, index) => {
url += dir + "/";
return `<a href="${url}">${index === 0 ? "~" : dir}</a>`;
}).join("<span>/</span>") + "<span>/</span>";
}
//#endregion
//#region src/server/server.ts
/**
* HTTP and WebSocket server.
* Consolidated from server-process.ts, server-http.ts, and server-web-socket.ts.
* Uses native Node 22+ WebSocket instead of the 'ws' package.
*/
function createHttpServer(devServerConfig, serverCtx) {
const reqHandler = createRequestHandler(devServerConfig, serverCtx);
const credentials = devServerConfig.https;
return credentials ? https.createServer(credentials, reqHandler) : http.createServer(reqHandler);
}
async function findClosestOpenPort(host, port, strictPort = false) {
if (!await isPortTaken(host, port)) return port;
if (strictPort) throw new Error(`Port ${port} is already in use. Please specify a different port or set strictPort to false.`);
async function findNext(portToCheck) {
if (!await isPortTaken(host, portToCheck)) return portToCheck;
return findNext(portToCheck + 1);
}
return findNext(port + 1);
}
function isPortTaken(host, port) {
return new Promise((resolve, reject) => {
const tester = net.createServer().once("error", () => {
resolve(true);
}).once("listening", () => {
tester.once("close", () => resolve(false)).close();
}).on("error", (err) => {
reject(err);
}).listen(port, host);
});
}
function createWebSocket(httpServer, onMessageFromClient) {
const wsServer = new WebSocketServer({ server: httpServer });
wsServer.on("connection", (rawWs) => {
const ws = rawWs;
ws.isAlive = true;
ws.on("message", (data) => {
try {
onMessageFromClient(JSON.parse(data.toString()));
} catch (e) {
console.error("WebSocket message parse error:", e);
}
});
ws.on("pong", () => {
ws.isAlive = true;
});
ws.on("error", (err) => {
console.error("WebSocket error:", err);
});
});
const pingInterval = setInterval(() => {
wsServer.clients.forEach((ws) => {
const devWs = ws;
if (!devWs.isAlive) return devWs.close(1e3);
devWs.isAlive = false;
devWs.ping();
});
}, 1e4);
return {
sendToBrowser: (msg) => {
if (msg && wsServer && wsServer.clients) {
const data = JSON.stringify(msg);
wsServer.clients.forEach((ws) => {
if (ws.readyState === ws.OPEN) ws.send(data);
});
}
},
close: () => {
return new Promise((resolve, reject) => {
clearInterval(pingInterval);
wsServer.clients.forEach((ws) => {
ws.close(1e3);
});
wsServer.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
};
}
function initServerProcess(sendMsg) {
let server = null;
let webSocket = null;
let serverCtx = null;
const buildResultsResolves = [];
const compilerRequestResolves = [];
const createNodeSys = async () => {
const { createNodeSys: createSys } = await import("@stencil/core/sys/node");
return createSys({ process });
};
const startServer = async (msg) => {
const devServerConfig = msg.startServer;
devServerConfig.port = await findClosestOpenPort(devServerConfig.address, devServerConfig.port, devServerConfig.strictPort);
devServerConfig.browserUrl = getBrowserUrl(devServerConfig.protocol, devServerConfig.address, devServerConfig.port, devServerConfig.basePath, "/");
devServerConfig.root = normalizePath(devServerConfig.root);
serverCtx = createServerContext(await createNodeSys(), sendMsg, devServerConfig, buildResultsResolves, compilerRequestResolves);
server = createHttpServer(devServerConfig, serverCtx);
webSocket = devServerConfig.websocket ? createWebSocket(server, sendMsg) : null;
server.listen(devServerConfig.port, devServerConfig.address);
serverCtx.isServerListening = true;
if (devServerConfig.openBrowser) openInBrowser({ url: getBrowserUrl(devServerConfig.protocol, devServerConfig.address, devServerConfig.port, devServerConfig.basePath, devServerConfig.initialLoadUrl || DEV_SERVER_INIT_URL) });
sendMsg({ serverStarted: devServerConfig });
};
const closeServer = () => {
const promises = [];
buildResultsResolves.forEach((r) => r.reject("dev server closed"));
buildResultsResolves.length = 0;
compilerRequestResolves.forEach((r) => r.reject("dev server closed"));
compilerRequestResolves.length = 0;
if (serverCtx?.sys) promises.push(serverCtx.sys.destroy());
if (webSocket) {
promises.push(webSocket.close());
webSocket = null;
}
if (server) promises.push(new Promise((resolve) => {
server.close((err) => {
if (err) console.error(`close error: ${err}`);
resolve();
});
}));
Promise.all(promises).finally(() => {
sendMsg({ serverClosed: true });
});
};
const receiveMessageFromMain = (msg) => {
try {
if (msg) {
if (msg.startServer) startServer(msg);
else if (msg.closeServer) closeServer();
else if (msg.compilerRequestResults) for (let i = compilerRequestResolves.length - 1; i >= 0; i--) {
const r = compilerRequestResolves[i];
if (r.path === msg.compilerRequestResults.path) {
r.resolve(msg.compilerRequestResults);
compilerRequestResolves.splice(i, 1);
}
}
else if (serverCtx) {
if (msg.buildResults && !msg.isActivelyBuilding) {
buildResultsResolves.forEach((r) => r.resolve(msg.buildResults));
buildResultsResolves.length = 0;
}
if (webSocket) webSocket.sendToBrowser(msg);
}
}
} catch (e) {
let stack = null;
if (e instanceof Error) stack = e.stack ?? null;
sendMsg({ error: {
message: String(e),
stack
} });
}
};
return receiveMessageFromMain;
}
//#endregion
//#region src/server/worker-main.ts
/**
* Worker process proxy for dev server.
* Forks a child process to run the HTTP and WebSocket server in isolation.
*/
/**
* Initialize the dev server in a forked worker process.
* This provides process isolation so that server crashes don't affect the main compiler.
*
* @param sendToMain - Callback to send messages from worker to main process
* @returns Function to send messages from main to worker process
*/
function initServerProcessWorkerProxy(sendToMain) {
let serverProcess = fork(path.join(import.meta.dirname, "worker-thread.js"), [], {
execArgv: process.execArgv.filter((v) => !/^--(debug|inspect)/.test(v)),
env: process.env,
cwd: process.cwd(),
stdio: [
"pipe",
"pipe",
"pipe",
"ipc"
]
});
/**
* Send a message from main to the worker process
*/
const receiveFromMain = (msg) => {
if (serverProcess && serverProcess.connected) serverProcess.send(msg);
else if (msg.closeServer) sendToMain({ serverClosed: true });
};
serverProcess.on("message", (msg) => {
if (msg.serverClosed && serverProcess) {
serverProcess.kill("SIGINT");
serverProcess = null;
}
sendToMain(msg);
});
serverProcess.stdout?.on("data", (data) => {
console.log(`dev server: ${data}`);
});
serverProcess.stderr?.on("data", (data) => {
sendToMain({ error: {
message: "stderr: " + data.toString(),
type: "stderr",
stack: null
} });
});
serverProcess.on("error", (error) => {
sendToMain({ error: {
message: error.message,
type: "worker-error",
stack: error.stack || null
} });
});
serverProcess.on("exit", (code) => {
if (code !== 0 && code !== null) sendToMain({ error: {
message: `Worker process exited with code ${code}`,
type: "worker-exit",
stack: null
} });
if (serverProcess) {
serverProcess = null;
sendToMain({ serverClosed: true });
}
});
return receiveFromMain;
}
//#endregion
//#region src/server/index.ts
/**
* @stencil/dev-server
*
* A modern development server for Stencil with DOM-based HMR.
* Designed for lazy-loading component architectures where module graphs
* are discovered at runtime from the DOM.
*/
/**
* Start the Stencil development server.
*
* @param stencilDevServerConfig - Configuration for the dev server
* @param logger - Logger instance for output
* @param watcher - Optional compiler watcher for build events
* @returns Promise resolving to the DevServer instance
*/
function start(stencilDevServerConfig, logger, watcher) {
return new Promise(async (resolve, reject) => {
try {
const devServerConfig = {
devServerDir: import.meta.dirname,
...stencilDevServerConfig
};
if (!path.isAbsolute(devServerConfig.root)) devServerConfig.root = path.join(process.cwd(), devServerConfig.root);
let initServerProcessFn;
if (stencilDevServerConfig.worker === true || stencilDevServerConfig.worker === void 0) initServerProcessFn = initServerProcessWorkerProxy;
else initServerProcessFn = initServerProcess;
startServer(devServerConfig, logger, watcher, initServerProcessFn, resolve, reject);
} catch (e) {
reject(e);
}
});
}
function startServer(devServerConfig, logger, watcher, initServerProcessFn, resolve, reject) {
const timespan = logger.createTimeSpan("starting dev server", true);
const startupTimeout = logger.getLevel() !== "debug" || devServerConfig.startupTimeout !== 0 ? setTimeout(() => {
reject("dev server startup timeout");
}, devServerConfig.startupTimeout ?? 15e3) : null;
let isActivelyBuilding = false;
let lastBuildResults = null;
let devServer = null;
let removeWatcher = null;
let closeResolve = null;
let hasStarted = false;
let browserUrl = "";
let sendToServer = null;
const closePromise = new Promise((res) => {
closeResolve = res;
});
const close = async () => {
if (startupTimeout) clearTimeout(startupTimeout);
isActivelyBuilding = false;
if (removeWatcher) removeWatcher();
if (devServer) devServer = null;
if (sendToServer) {
sendToServer({ closeServer: true });
sendToServer = null;
}
return closePromise;
};
const emit = (eventName, data) => {
if (sendToServer) {
if (eventName === "buildFinish") {
isActivelyBuilding = false;
lastBuildResults = { ...data };
sendToServer({
buildResults: { ...lastBuildResults },
isActivelyBuilding
});
} else if (eventName === "buildLog") sendToServer({ buildLog: { ...data } });
else if (eventName === "buildStart") isActivelyBuilding = true;
}
};
const serverStarted = (msg) => {
hasStarted = true;
if (startupTimeout) clearTimeout(startupTimeout);
devServerConfig = msg.serverStarted;
devServer = {
address: devServerConfig.address,
basePath: devServerConfig.basePath,
browserUrl: devServerConfig.browserUrl,
protocol: devServerConfig.protocol,
port: devServerConfig.port,
root: devServerConfig.root,
emit,
close
};
browserUrl = devServerConfig.browserUrl;
timespan.finish(`dev server started: ${browserUrl}`);
resolve(devServer);
};
const requestLog = (msg) => {
if (devServerConfig.logRequests && msg.requestLog) if (msg.requestLog.status >= 500) logger.info(logger.red(`${msg.requestLog.method} ${msg.requestLog.url} (${msg.requestLog.status})`));
else if (msg.requestLog.status >= 400) logger.info(logger.dim(logger.red(`${msg.requestLog.method} ${msg.requestLog.url} (${msg.requestLog.status})`)));
else if (msg.requestLog.status >= 300) logger.info(logger.dim(logger.magenta(`${msg.requestLog.method} ${msg.requestLog.url} (${msg.requestLog.status})`)));
else logger.info(logger.dim(`${logger.cyan(msg.requestLog.method)} ${msg.requestLog.url}`));
};
const serverError = async (msg) => {
if (msg.error) if (hasStarted) logger.error(msg.error.message + " " + msg.error.stack);
else {
await close();
reject(msg.error.message);
}
};
const requestBuildResults = () => {
if (sendToServer) if (lastBuildResults != null) {
const msg = {
buildResults: { ...lastBuildResults },
isActivelyBuilding
};
delete msg.buildResults.hmr;
sendToServer(msg);
} else sendToServer({ isActivelyBuilding: true });
};
const compilerRequest = async (compilerRequestPath) => {
if (watcher?.request && sendToServer) {
const compilerRequestResults = await watcher.request({ path: compilerRequestPath });
sendToServer({ compilerRequestResults });
}
};
const receiveFromServer = (msg) => {
try {
if (msg.serverStarted) serverStarted(msg);
else if (msg.serverClosed) {
logger.debug(`dev server closed: ${browserUrl}`);
closeResolve?.();
} else if (msg.requestBuildResults) requestBuildResults();
else if (msg.compilerRequestPath) compilerRequest(msg.compilerRequestPath);
else if (msg.requestLog) requestLog(msg);
else if (msg.error) serverError(msg);
else logger.debug(`server msg not handled: ${JSON.stringify(msg)}`);
} catch (e) {
logger.error("receiveFromServer: " + e);
}
};
try {
if (watcher) removeWatcher = watcher.on(emit);
sendToServer = initServerProcessFn(receiveFromServer);
sendToServer({ startServer: devServerConfig });
} catch (e) {
close();
reject(e);
}
}
//#endregion
export { initServerProcess, start };