edgespec
Version:
Write Winter-CG compatible routes with filesystem routing and tons of features
419 lines (411 loc) • 12.7 kB
JavaScript
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