UNPKG

winterspec

Version:

Write Winter-CG compatible routes with filesystem routing and tons of features

251 lines (250 loc) 11.4 kB
import { loadConfig } from "../config/utils.js"; import Watcher from "watcher"; import * as esbuild from "esbuild"; import http from "node:http"; import path from "node:path"; import fs from "node:fs/promises"; import { isGitIgnored } from "globby"; import { getTempPathInApp } from "../bundle/get-temp-path-in-app.js"; import { constructManifest } from "../bundle/construct-manifest.js"; import { formatMessages } from "esbuild"; import { getNodeHandler } from "../adapters/node.js"; import { pathToFileURL } from "node:url"; import { once } from "node:events"; // Unlike the original dev server, startDevServer2 takes a much simpler approach. // When it detects a relevant file system change, it rebundles the entire app. // It does not use a headless dev server bundler and RPC export const startDevServer2 = async (options) => { const config = await loadConfig(options.rootDirectory ?? process.cwd(), options.config); const port = options.port ?? 3000; let isFirstBuildListening = true; const rootDirectory = config.rootDirectory; const tempDir = await getTempPathInApp(rootDirectory); const manifestPath = path.join(tempDir, "dev-manifest.ts"); const devBundlePath = path.join(tempDir, "dev-bundle.js"); let server = null; let buildContext = null; const mutableHttpHandler = { current: (_req, res) => { // This handler is used before the first build or if a build fails to produce a handler. res.writeHead(503, { "Content-Type": "text/plain" }); res.end("Server is initializing or a build is in progress..."); }, }; const ignore = await isGitIgnored({ cwd: rootDirectory }); const updateManifest = async () => { const manifestContent = await constructManifest({ routesDirectory: config.routesDirectory, bundledAdapter: config.platform === "wintercg-minimal" ? "wintercg-minimal" : undefined, }); await fs.writeFile(manifestPath, manifestContent, "utf-8"); }; const build = async () => { options.onBuildStart?.(); const buildStartedAt = performance.now(); try { if (!buildContext) { await updateManifest(); buildContext = await esbuild.context({ entryPoints: [manifestPath], bundle: true, platform: config.platform === "wintercg-minimal" ? "browser" : "node", packages: config.platform === "node" ? "external" : undefined, format: config.platform === "wintercg-minimal" ? "cjs" : "esm", outfile: devBundlePath, write: true, sourcemap: "inline", logLevel: "silent", }); } const result = await buildContext.rebuild(); const durationMs = performance.now() - buildStartedAt; if (result.errors.length === 0) { options.onBuildEnd?.({ type: "success", bundlePath: devBundlePath, buildUpdatedAtMs: Date.now(), }); const bundleUrl = pathToFileURL(devBundlePath).href + `?t=${Date.now()}`; if ("cache" in require) { // Clear bun's cache for this file delete require.cache[path.resolve(devBundlePath)]; } const importedModule = await import(bundleUrl); // Update the currentHttpHandler based on the build result if (config.platform === "node") { const winterSpecBundle = importedModule.default; if (!winterSpecBundle) { const errorMessage = "Node bundle did not export default WinterSpec object."; console.error(errorMessage); options.onBuildEnd?.({ type: "failure", errorMessage, buildUpdatedAtMs: Date.now(), }); // Set handler to show error mutableHttpHandler.current = (_req, res) => { res.writeHead(500, { "Content-Type": "text/plain" }); res.end(errorMessage); }; return; // Return early as the build effectively failed to produce a usable handler } mutableHttpHandler.current = getNodeHandler(winterSpecBundle, { port: port, // Used by getNodeHandler for its internal logic if needed middleware: options.middleware, }); } else { // wintercg-minimal const errorMessage = "Built for wintercg-minimal. This bundle calls addFetchListener and is not directly servable by startDevServer2's Node.js server. The bundle was executed, but the HTTP server may not reflect changes or serve content as expected."; console.warn(errorMessage); // Set handler to show informational message for wintercg-minimal mutableHttpHandler.current = (_req, res) => { res.writeHead(500, { "Content-Type": "text/plain" }); res.end(errorMessage); }; // The bundle (importedModule) was executed, which is the primary effect for wintercg-minimal in this context. } // Create and start the server only if it hasn't been started yet. if (!server) { server = http.createServer((req, res) => mutableHttpHandler.current(req, res)); server.on("error", (err) => { if (err.code === "EADDRINUSE") { const errorMessage = `Port ${port} is already in use. Please choose a different port.`; console.error(errorMessage); options.onBuildEnd?.({ type: "failure", errorMessage, buildUpdatedAtMs: Date.now(), }); // Consider stopping the watcher and context here. } else { console.error("Server error:", err); options.onBuildEnd?.({ type: "failure", errorMessage: `Server error: ${err.message}`, buildUpdatedAtMs: Date.now(), }); } }); server.listen(port, () => { const address = server.address(); if (isFirstBuildListening) { options.onListening?.(address.port); isFirstBuildListening = false; } }); await once(server, "listening").catch((err) => { // This catch might be redundant if 'error' event handles EADDRINUSE before `await once` resolves/rejects if (err.code !== "EADDRINUSE") { // EADDRINUSE is handled by the 'error' listener console.error("Failed to start server after build:", err); options.onBuildEnd?.({ type: "failure", errorMessage: `Failed to start server: ${err.message}`, buildUpdatedAtMs: Date.now(), }); } }); } } else { const errorMessages = await formatMessages(result.errors, { kind: "error", }); options.onBuildEnd?.({ type: "failure", errorMessage: errorMessages.join("\n"), buildUpdatedAtMs: Date.now(), }); } } catch (err) { console.error("Build process error:", err); options.onBuildEnd?.({ type: "failure", errorMessage: err.message, buildUpdatedAtMs: Date.now(), }); } }; const watcher = new Watcher(rootDirectory, { recursive: true, ignoreInitial: false, // Build on initial scan debounce: 200, ignore: (filePath) => { if (filePath.includes(".winterspec")) { return true; } if (!path.relative(rootDirectory, filePath).startsWith("..")) { return ignore(filePath); } return true; }, }); const handleFileChange = async (isManifestChange = false) => { if (isManifestChange) { await updateManifest(); } await build(); }; watcher.on("change", async (file) => { await handleFileChange(false); }); // add is initially called for all files, just ignoring for now // watcher.on("add", async (file) => { // await handleFileChange(true) // }) watcher.on("unlink", async (file) => { await handleFileChange(true); }); watcher.on("unlinkDir", async (dir) => { await handleFileChange(true); }); // Initial build is triggered by watcher's ignoreInitial: false // If ignoreInitial were true, you'd call: await updateManifest(); await build(); const stop = async () => { watcher.close(); if (server) { await new Promise((resolve) => server.close(() => resolve())); } if (buildContext) { await buildContext.dispose(); } }; process.on("SIGINT", async () => { await stop(); process.exit(0); }); process.on("SIGTERM", async () => { await stop(); process.exit(0); }); // Wait for the first listening event to ensure port is correctly reported. // This relies on the initial build completing and server starting. if (isFirstBuildListening && server) { await once(server, "listening"); } else if (isFirstBuildListening && !server) { // If server isn't created yet, wait for a build that might create it. // This path is tricky; initial build should set up the server. // A robust way is to await the first successful onListening call. await new Promise((resolve) => { const originalOnListening = options.onListening; options.onListening = (p) => { originalOnListening?.(p); resolve(); }; }); } return { // Port might not be available until the first successful build and listen. // The caller should ideally get the port from the onListening callback. // For now, return the configured port, with the understanding it might fail. port: port.toString(), stop, }; };