UNPKG

edgespec

Version:

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

419 lines (411 loc) 12.7 kB
import { loadBundle } from './chunk-PRZQE4JB.js'; import { transformToNodeBuilder } from './chunk-BA3OE4WS.js'; import { loadConfig } from './chunk-TMYKADNK.js'; import EventEmitter, { once } from 'events'; import { createServer } from 'http'; import kleur from 'kleur'; import fs from 'fs/promises'; import { EdgeRuntime } from 'edge-runtime'; import { createBirpc, createBirpcGroup } from 'birpc'; import path from 'path'; import Watcher from 'watcher'; import * as esbuild from 'esbuild'; import { formatMessages } from 'esbuild'; import { getMatchingFilePaths } from 'make-vfs'; import { isGitIgnored } from 'globby'; import { MessageChannel } from 'worker_threads'; var BUILD_ERROR_MESSAGE = "Could not build your app. Check your terminal for more information."; var RequestHandlerController = class { constructor(bundlerRpc, middleware) { this.bundlerRpc = bundlerRpc; this.middleware = middleware; this.buildLastUpdatedAt = 0; } /** * You **should not** cache the result of this function. Call it every time you want to use the runtime. */ async getWinterCGRuntime() { const { buildUpdatedAtMs, ...build } = await this.bundlerRpc.waitForAvailableBuild(); if (this.buildLastUpdatedAt === buildUpdatedAtMs && this.cachedWinterCGRuntime) { return this.cachedWinterCGRuntime; } if (build.type === "failure") { this.cachedWinterCGRuntime = new EdgeRuntime({ initialCode: ` addEventListener("fetch", (event) => { event.respondWith(new Response("${BUILD_ERROR_MESSAGE}", { status: 500 })) }) ` }); } else { const contents = await fs.readFile(build.bundlePath, "utf-8"); const { middleware } = this; this.cachedWinterCGRuntime = new EdgeRuntime({ initialCode: contents, extend(context2) { context2._injectedEdgeSpecMiddleware = middleware; return context2; } }); } this.buildLastUpdatedAt = buildUpdatedAtMs; return this.cachedWinterCGRuntime; } /** * You **should not** cache the result of this function. Call it every time you want to use the handler. */ async getNodeHandler() { const { buildUpdatedAtMs, ...build } = await this.bundlerRpc.waitForAvailableBuild(); if (this.buildLastUpdatedAt === buildUpdatedAtMs && this.cachedNodeHandler) { return this.cachedNodeHandler; } if (build.type === "failure") { this.cachedNodeHandler = async () => new Response(BUILD_ERROR_MESSAGE, { status: 500 }); } else { const edgeSpecModule = await loadBundle( `file:${build.bundlePath}#${Date.now()}` ); this.cachedNodeHandler = async (req) => edgeSpecModule.makeRequest(req, { middleware: this.middleware }); } this.buildLastUpdatedAt = buildUpdatedAtMs; return this.cachedNodeHandler; } }; var startHeadlessDevServer = async ({ port, config, rpcChannel, middleware = [], onListening, onBuildStart, onBuildEnd }) => { const birpc = createBirpc( { onBuildStart: () => { onBuildStart?.(); }, onBuildEnd: (result) => { onBuildEnd?.(result); } }, rpcChannel ); const controller = new RequestHandlerController(birpc, middleware); const server = createServer( transformToNodeBuilder({ defaultOrigin: `http://localhost:${port}` })(async (req) => { try { if (config.platform === "wintercg-minimal") { const runtime = await controller.getWinterCGRuntime(); const response = await runtime.dispatchFetch(req.url, req); await response.waitUntil(); return response; } const nodeHandler = await controller.getNodeHandler(); return await nodeHandler(req); } catch (error) { if (error instanceof Error) { process.stderr.write( kleur.bgRed("\nUnhandled exception:\n") + (error.stack ?? error.message) + "\n" ); } else { process.stderr.write( "Unhandled exception:\n" + (error.stack ? error.stack : JSON.stringify(error)) + "\n" ); } return new Response("Internal server error", { status: 500 }); } }) ); const listeningPromise = once(server, "listening"); server.listen(port); await listeningPromise; onListening?.(port); return { server, stop: async () => { const closePromise = once(server, "close"); server.close(); await closePromise; }, getBuildResult: async () => { return birpc.waitForAvailableBuild(); } }; }; var getTempPathInApp = async (rootDirectory) => { const tempDir = path.resolve(path.join(rootDirectory, ".edgespec")); await fs.mkdir(tempDir, { recursive: true }); return tempDir; }; var createRouteMapFromDirectory = async (directoryPath) => { const filePaths = await getMatchingFilePaths({ dirPath: directoryPath, extensions: ["ts", "tsx"] }); const routes = {}; for (let path5 of filePaths) { let routeWithSlash = path5; if (!path5.startsWith("/")) { routeWithSlash = `/${path5}`; } if (path5.endsWith("index.ts") || path5.endsWith("index.tsx")) { routes[`${routeWithSlash.replace(/\/index\.tsx*$/g, "")}`] = { relativePath: path5 }; } else { routes[`${routeWithSlash.replace(/\.tsx*$/g, "")}`] = { relativePath: path5 }; } } if (routes[""]) { routes["/"] = routes[""]; delete routes[""]; } return routes; }; // src/bundle/construct-manifest.ts var alphabet = "zyxwvutsrqponmlkjihgfedcba"; var getRandomId = (length) => { let str = ""; let num = length; while (num--) str += alphabet[Math.random() * alphabet.length | 0]; return str; }; var constructManifest = async (options) => { const routeMap = await createRouteMapFromDirectory(options.routesDirectory); const routes = Object.entries(routeMap).map(([route, { relativePath }]) => { return { route, relativePath, id: getRandomId(16) }; }); return ` import {getRouteMatcher} from "next-route-matcher" import { makeRequestAgainstEdgeSpec } from "edgespec" ${routes.map( ({ id, relativePath }) => `import * as ${id} from "${path.resolve( path.join(options.routesDirectory, relativePath) )}"` ).join("\n")} const routeMapWithHandlers = { ${routes.map(({ id, route }) => `"${route}": ${id}.default`).join(",")} } const edgeSpec = { routeMatcher: getRouteMatcher(Object.keys(routeMapWithHandlers)), routeMapWithHandlers, makeRequest: async (req, options) => makeRequestAgainstEdgeSpec(edgeSpec, options)(req) } ${options.bundledAdapter === "wintercg-minimal" ? ` import {addFetchListener} from "edgespec/adapters/wintercg-minimal" addFetchListener(edgeSpec) ` : "export default edgeSpec"} `.trim(); }; var bundleAndWatch = async (options) => { const ignore = await isGitIgnored({ cwd: options.rootDirectory }); const watcher = new Watcher(options.rootDirectory, { recursive: true, ignoreInitial: true, debounce: 0, ignore: (filePath) => { if (filePath.includes(".edgespec")) { return true; } if (!path.relative(options.rootDirectory, filePath).startsWith("..")) { return ignore(filePath); } return true; } }); const tempDir = await getTempPathInApp(options.rootDirectory); const manifestPath = path.join(tempDir, "dev-manifest.ts"); const rebuildWithErrorHandling = async () => { try { await ctx?.rebuild(); } catch { } }; const invalidateManifest = async () => { await fs.writeFile(manifestPath, await constructManifest(options), "utf-8"); await rebuildWithErrorHandling(); }; const ctx = await esbuild.context({ entryPoints: [manifestPath], bundle: true, format: "esm", write: false, sourcemap: "inline", logLevel: "silent", ...options.esbuild }); await invalidateManifest(); watcher.on("change", async () => { await rebuildWithErrorHandling(); }); watcher.on("add", async () => { await invalidateManifest(); }); watcher.on("unlink", async () => { await invalidateManifest(); }); watcher.on("unlinkDir", async () => { await invalidateManifest(); }); return { stop: async () => { watcher.close(); await ctx.dispose(); } }; }; var AsyncWorkTracker = class extends EventEmitter { constructor() { super(...arguments); this.state = "idle"; } /** * If work is still pending, this waits for the next work result. If work is already resolved, it returns the last result. */ async waitForResult() { if (this.state === "pending" || !this.lastResult) { return new Promise((resolve) => { this.once("result", resolve); }); } if (!this.lastResult) { throw new Error("No last result (this should never happen)"); } return this.lastResult; } /** * Call this when you start async work. */ beginAsyncWork() { this.state = "pending"; } /** * Call this when the async work is done with the result. */ finishAsyncWork(result) { this.state = "resolved"; this.emit("result", result); this.lastResult = result; } }; // src/dev/headless/start-bundler.ts var startHeadlessDevBundler = async ({ config, initialRpcChannels }) => { const tempDir = await getTempPathInApp(config.rootDirectory); const devBundlePath = path.join(tempDir, "dev-bundle.js"); const buildTracker = new AsyncWorkTracker(); const rpcFunctions = { async waitForAvailableBuild() { return buildTracker.waitForResult(); } }; const birpc = createBirpcGroup( rpcFunctions, initialRpcChannels ?? [], { eventNames: ["onBuildStart", "onBuildEnd"] } ); const { stop } = await bundleAndWatch({ rootDirectory: config.rootDirectory, routesDirectory: config.routesDirectory, bundledAdapter: config.platform === "wintercg-minimal" ? "wintercg-minimal" : void 0, esbuild: { platform: config.platform === "wintercg-minimal" ? "browser" : "node", packages: config.platform === "node" ? "external" : void 0, format: config.platform === "wintercg-minimal" ? "cjs" : "esm", outfile: devBundlePath, write: true, plugins: [ { name: "watch", setup(build) { build.onStart(async () => { buildTracker.beginAsyncWork(); await birpc.broadcast.onBuildStart(); }); build.onEnd(async (result) => { let build2; if (result.errors.length === 0) { build2 = { type: "success", bundlePath: devBundlePath, buildUpdatedAtMs: Date.now() }; } else { build2 = { type: "failure", errorMessage: (await formatMessages(result.errors, { kind: "error" })).join("\n"), buildUpdatedAtMs: Date.now() }; } buildTracker.finishAsyncWork(build2); await birpc.broadcast.onBuildEnd(build2); }); } } ] } }); return { stop, birpc }; }; var startDevServer = async (options) => { const config = await loadConfig( options.rootDirectory ?? process.cwd(), options.config ); const messageChannel = new MessageChannel(); const httpServerRpcChannel = { post: (data) => messageChannel.port2.postMessage(data), on: (data) => messageChannel.port2.on("message", data) }; const port = options.port ?? 3e3; const headlessServer = await startHeadlessDevServer({ port, config, rpcChannel: httpServerRpcChannel, middleware: options.middleware, onListening: options.onListening, onBuildStart: options.onBuildStart, onBuildEnd: options.onBuildEnd }); const bundlerRpcChannel = { post: (data) => messageChannel.port1.postMessage(data), on: (data) => messageChannel.port1.on("message", data) }; const headlessBundler = await startHeadlessDevBundler({ config, initialRpcChannels: [bundlerRpcChannel] }); return { port: headlessServer.server.address().port.toString(), stop: async () => { await Promise.all([headlessServer.stop(), headlessBundler.stop()]); messageChannel.port1.close(); messageChannel.port2.close(); } }; }; export { constructManifest, createRouteMapFromDirectory, startDevServer, startHeadlessDevBundler, startHeadlessDevServer }; //# sourceMappingURL=chunk-TZHMALR4.js.map //# sourceMappingURL=chunk-TZHMALR4.js.map