UNPKG

@stencil/dev-server

Version:

Development server for Stencil with DOM-based HMR

1,277 lines (1,268 loc) 42.8 kB
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} &#9889;" 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 };