UNPKG

edgespec

Version:

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

647 lines (631 loc) 20.2 kB
'use strict'; 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