edgespec
Version:
Write Winter-CG compatible routes with filesystem routing and tons of features
647 lines (631 loc) • 20.2 kB
JavaScript
;
var path = require('path');
var hash = require('object-hash');
var getPort = require('@ava/get-port');
var plugin = require('ava/plugin');
var worker_threads = require('worker_threads');
var fs = require('fs/promises');
var bundleRequire = require('bundle-require');
var EventEmitter = require('events');
var http = require('http');
var kleur = require('kleur');
var nodeUtils = require('@edge-runtime/node-utils');
var primitives = require('@edge-runtime/primitives');
var edgeRuntime = require('edge-runtime');
var birpc = require('birpc');
var Watcher = require('watcher');
var esbuild = require('esbuild');
var makeVfs = require('make-vfs');
var globby = require('globby');
var zod = require('zod');
var url = require('url');
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
function _interopNamespace(e) {
if (e && e.__esModule) return e;
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var path__default = /*#__PURE__*/_interopDefault(path);
var hash__default = /*#__PURE__*/_interopDefault(hash);
var getPort__default = /*#__PURE__*/_interopDefault(getPort);
var fs__default = /*#__PURE__*/_interopDefault(fs);
var EventEmitter__default = /*#__PURE__*/_interopDefault(EventEmitter);
var kleur__default = /*#__PURE__*/_interopDefault(kleur);
var primitives__default = /*#__PURE__*/_interopDefault(primitives);
var Watcher__default = /*#__PURE__*/_interopDefault(Watcher);
var esbuild__namespace = /*#__PURE__*/_interopNamespace(esbuild);
// src/testing/ava/fixture.ts
var cloneObjectAndDeleteUndefinedKeys = (obj) => {
const clone = { ...obj };
Object.keys(clone).forEach((key) => {
if (clone[key] === void 0) {
delete clone[key];
}
});
return clone;
};
var resolvePossibleRelativePath = (possibleRelativePath, configDirectory) => {
if (path__default.default.isAbsolute(possibleRelativePath)) {
return possibleRelativePath;
}
return path__default.default.resolve(configDirectory, possibleRelativePath);
};
var resolveConfig = (config) => {
const { rootDirectory, tsconfigPath, routesDirectory, ...rest } = cloneObjectAndDeleteUndefinedKeys(config);
const resolvedRootDirectory = path__default.default.resolve(config.rootDirectory);
return {
rootDirectory: resolvedRootDirectory,
tsconfigPath: resolvePossibleRelativePath(
tsconfigPath ?? "tsconfig.json",
resolvedRootDirectory
),
routesDirectory: resolvePossibleRelativePath(
routesDirectory ?? "api",
resolvedRootDirectory
),
platform: "wintercg-minimal",
...rest
};
};
var validateConfig = async (config) => {
try {
await fs__default.default.stat(config.routesDirectory);
} catch (error) {
throw new Error(`Could not find routes directory ${config.routesDirectory}`);
}
try {
await fs__default.default.stat(config.tsconfigPath);
} catch (error) {
throw new Error(`Could not find tsconfig.json at ${config.tsconfigPath}`);
}
return config;
};
var loadConfig = async (rootDirectory, overrides) => {
let loadedConfig = {};
let configInRootExists = false;
const potentialConfigPath = path__default.default.join(rootDirectory, "edgespec.config.ts");
try {
await fs__default.default.stat(potentialConfigPath);
configInRootExists = true;
} catch {
}
if (configInRootExists) {
const {
mod: { default: config }
} = await bundleRequire.bundleRequire({
filepath: potentialConfigPath
});
if (!config) {
throw new Error(
`Could not find a default export in ${potentialConfigPath}`
);
}
loadedConfig = config;
}
return await validateConfig(
resolveConfig({
rootDirectory,
...loadedConfig,
...cloneObjectAndDeleteUndefinedKeys(overrides ?? {})
})
);
};
var dependencies = {
...primitives__default.default,
Uint8Array
};
var transformToNodeBuilder = (options) => nodeUtils.buildToNodeHandler(dependencies, options);
// src/helpers.ts
var loadBundle = async (bundlePath) => {
const bundle = await import(bundlePath);
return bundle.default.default ?? bundle.default;
};
// src/dev/headless/request-handler-controller.ts
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.EdgeRuntime({
initialCode: `
addEventListener("fetch", (event) => {
event.respondWith(new Response("${BUILD_ERROR_MESSAGE}", { status: 500 }))
})
`
});
} else {
const contents = await fs__default.default.readFile(build.bundlePath, "utf-8");
const { middleware } = this;
this.cachedWinterCGRuntime = new edgeRuntime.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$1 = birpc.createBirpc(
{
onBuildStart: () => {
onBuildStart?.();
},
onBuildEnd: (result) => {
onBuildEnd?.(result);
}
},
rpcChannel
);
const controller = new RequestHandlerController(birpc$1, middleware);
const server = http.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__default.default.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 = EventEmitter.once(server, "listening");
server.listen(port);
await listeningPromise;
onListening?.(port);
return {
server,
stop: async () => {
const closePromise = EventEmitter.once(server, "close");
server.close();
await closePromise;
},
getBuildResult: async () => {
return birpc$1.waitForAvailableBuild();
}
};
};
var getTempPathInApp = async (rootDirectory) => {
const tempDir = path__default.default.resolve(path__default.default.join(rootDirectory, ".edgespec"));
await fs__default.default.mkdir(tempDir, { recursive: true });
return tempDir;
};
var createRouteMapFromDirectory = async (directoryPath) => {
const filePaths = await makeVfs.getMatchingFilePaths({
dirPath: directoryPath,
extensions: ["ts", "tsx"]
});
const routes = {};
for (let path7 of filePaths) {
let routeWithSlash = path7;
if (!path7.startsWith("/")) {
routeWithSlash = `/${path7}`;
}
if (path7.endsWith("index.ts") || path7.endsWith("index.tsx")) {
routes[`${routeWithSlash.replace(/\/index\.tsx*$/g, "")}`] = {
relativePath: path7
};
} else {
routes[`${routeWithSlash.replace(/\.tsx*$/g, "")}`] = {
relativePath: path7
};
}
}
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__default.default.resolve(
path__default.default.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 globby.isGitIgnored({
cwd: options.rootDirectory
});
const watcher = new Watcher__default.default(options.rootDirectory, {
recursive: true,
ignoreInitial: true,
debounce: 0,
ignore: (filePath) => {
if (filePath.includes(".edgespec")) {
return true;
}
if (!path__default.default.relative(options.rootDirectory, filePath).startsWith("..")) {
return ignore(filePath);
}
return true;
}
});
const tempDir = await getTempPathInApp(options.rootDirectory);
const manifestPath = path__default.default.join(tempDir, "dev-manifest.ts");
const rebuildWithErrorHandling = async () => {
try {
await ctx?.rebuild();
} catch {
}
};
const invalidateManifest = async () => {
await fs__default.default.writeFile(manifestPath, await constructManifest(options), "utf-8");
await rebuildWithErrorHandling();
};
const ctx = await esbuild__namespace.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__default.default {
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__default.default.join(tempDir, "dev-bundle.js");
const buildTracker = new AsyncWorkTracker();
const rpcFunctions = {
async waitForAvailableBuild() {
return buildTracker.waitForResult();
}
};
const birpc$1 = 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$1.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 esbuild.formatMessages(result.errors, {
kind: "error"
})).join("\n"),
buildUpdatedAtMs: Date.now()
};
}
buildTracker.finishAsyncWork(build2);
await birpc$1.broadcast.onBuildEnd(build2);
});
}
}
]
}
});
return {
stop,
birpc: birpc$1
};
};
// src/dev/dev-server.ts
var startDevServer = async (options) => {
const config = await loadConfig(
options.rootDirectory ?? process.cwd(),
options.config
);
const messageChannel = new worker_threads.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();
}
};
};
// src/dev/dev.ts
var devServer = {
startDevServer,
headless: {
startBundler: startHeadlessDevBundler,
startServer: startHeadlessDevServer
}
};
zod.z.object({
/**
* Defaults to the current working directory.
*/
rootDirectory: zod.z.string().optional(),
/**
* If this path is relative, it's resolved relative to the `rootDirectory` option.
*/
tsconfigPath: zod.z.string().optional(),
/**
* If this path is relative, it's resolved relative to the `rootDirectory` option.
*/
routesDirectory: zod.z.string().optional(),
/**
* The platform you're targeting.
*
* Defaults to `wintercg-minimal`, and you should use this whenever possible for maximal compatibility.
*
* Check [the docs](https://github.com/seamapi/edgespec/blob/main/docs/edgespec-config.md) for more information.
*/
platform: zod.z.enum(["node", "wintercg-minimal"]).default("wintercg-minimal").optional()
}).strict();
var getWorker = async (initialData) => {
const key = hash__default.default(initialData);
const dirname = path__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
if (process.env.IS_TESTING_EDGESPEC) {
const { registerSharedTypeScriptWorker } = await import('ava-typescript-worker');
return registerSharedTypeScriptWorker({
filename: new URL(
`file:${path__default.default.resolve(dirname, "worker-wrapper.ts")}#${key}`
),
initialData
});
}
return plugin.registerSharedWorker({
filename: new URL(
`file:${path__default.default.resolve(dirname, "worker-wrapper.js")}#${key}`
),
initialData,
supportedProtocols: ["ava-4"]
});
};
var getTestServer = async (t, options) => {
const rootDirectory = options?.rootDirectory ?? process.cwd();
const worker = await getWorker({
rootDirectory
});
const [port] = await Promise.all([getPort__default.default(), worker.available]);
let httpServerRpcCallback;
const onReceivedBuildResult = (build) => {
if (build.type === "failure") {
console.error(build.errorMessage);
t.fail("Failed to build");
}
};
const serverFixture = await devServer.headless.startServer({
port,
config: await loadConfig(rootDirectory),
rpcChannel: {
post: (data) => worker.publish(data),
on: (data) => {
httpServerRpcCallback = data;
}
},
middleware: options?.middleware ?? [],
onBuildEnd(build) {
onReceivedBuildResult(build);
}
});
const messageHandlerAbortController = new AbortController();
const messageHandlerPromise = Promise.race([
EventEmitter.once(messageHandlerAbortController.signal, "abort"),
(async () => {
for await (const msg of worker.subscribe()) {
httpServerRpcCallback(msg.data);
if (messageHandlerAbortController.signal.aborted) {
break;
}
}
})()
]);
t.teardown(async () => {
messageHandlerAbortController.abort();
await messageHandlerPromise;
await serverFixture.stop();
});
onReceivedBuildResult(await serverFixture.getBuildResult());
return {
port
};
};
exports.getTestServer = getTestServer;
//# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map