diff --git a/package-lock.json b/package-lock.json index 8158ecf..19c8299 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "devDependencies": { "@ava/get-port": "^2.0.0", "@ava/typescript": "^4.1.0", - "@types/node": "^20.10.0", + "@types/node": "^18.13.0", "ava": "^5.3.1", "axios": "^1.6.2", "edge-runtime": "^2.5.7", @@ -884,9 +884,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz", - "integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==", + "version": "18.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.7.tgz", + "integrity": "sha512-IGRJfoNX10N/PfrReRZ1br/7SQ+2vF/tK3KXNwzXz82D32z5dMQEoOlFew18nLSN+vMNcLY4GrKfzwi/yWI8/w==", "dev": true, "dependencies": { "undici-types": "~5.26.4" diff --git a/package.json b/package.json index 465f4e5..383a90f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "devDependencies": { "@ava/get-port": "^2.0.0", "@ava/typescript": "^4.1.0", - "@types/node": "^20.10.0", + "@types/node": "^18.13.0", "ava": "^5.3.1", "axios": "^1.6.2", "edge-runtime": "^2.5.7", diff --git a/src/adapters/node.ts b/src/adapters/node.ts index dbd0418..d7bce8c 100644 --- a/src/adapters/node.ts +++ b/src/adapters/node.ts @@ -1,32 +1,24 @@ import http from "node:http" -import {once} from "node:events" +import { transformToNodeBuilder } from "src/edge-runtime/transform-to-node" export const startServer = (edgeSpec: any, port?: number) => { - const server = http.createServer(async (req, res) => { - const chunks: Uint8Array[] = [] - req.on("data", (chunk) => chunks.push(chunk)) - await once(req, "end") - const body = Buffer.concat(chunks) - - const fullUrl = `http://localhost:${req.socket.localPort}${req.url}` - const fetchRequest = new Request(fullUrl, { - method: req.method, - headers: Object.entries(req.headers) as [string, string][], - body: req.method === "GET" ? undefined : body, - }) + const transformToNode = transformToNodeBuilder({ + defaultOrigin: `http://localhost${port ? `:${port}` : ""}`, + }) - const {matchedRoute, routeParams} = edgeSpec.routeMatcher(new URL(fetchRequest.url).pathname) - const handler = edgeSpec.routeMapWithHandlers[matchedRoute] - fetchRequest.pathParams = routeParams + const server = http.createServer( + transformToNode(async (fetchRequest) => { + const { matchedRoute, routeParams } = edgeSpec.routeMatcher( + new URL(fetchRequest.url).pathname + ) + const handler = edgeSpec.routeMapWithHandlers[matchedRoute] + ;(fetchRequest as Request & { pathParams?: any }).pathParams = routeParams - const fetchResponse: Response = await handler(fetchRequest) + const fetchResponse: Response = await handler(fetchRequest) - res.statusCode = fetchResponse.status - fetchResponse.headers.forEach((value, key) => { - res.setHeader(key, value) + return fetchResponse }) - res.end(await fetchResponse.text()) - }) + ) server.listen(port) return server diff --git a/src/adapters/wintercg-minimal.ts b/src/adapters/wintercg-minimal.ts index 8442089..671bd6f 100644 --- a/src/adapters/wintercg-minimal.ts +++ b/src/adapters/wintercg-minimal.ts @@ -1,8 +1,19 @@ +import type { FetchEvent } from "@edge-runtime/primitives" + export const addFetchListener = (edgeSpec: any) => { addEventListener("fetch", async (event) => { - const {matchedRoute, routeParams} = edgeSpec.routeMatcher(new URL(event.request.url).pathname) + // TODO: find better way to do this cast... + const fetchEvent = event as unknown as FetchEvent + + const { matchedRoute, routeParams } = edgeSpec.routeMatcher( + new URL(fetchEvent.request.url).pathname + ) const handler = edgeSpec.routeMapWithHandlers[matchedRoute] - event.request.pathParams = routeParams - event.respondWith(await handler(event.request)) + + // TODO: make this a proper type + ;(fetchEvent.request as Request & { pathParams?: any }).pathParams = + routeParams + + fetchEvent.respondWith(await handler(fetchEvent.request)) }) } diff --git a/src/create-with-edge-spec.ts b/src/create-with-edge-spec.ts index 689a714..d5123a8 100644 --- a/src/create-with-edge-spec.ts +++ b/src/create-with-edge-spec.ts @@ -1,10 +1,11 @@ import { RouteFn } from "./create-with-edge-spec.types.js" import { SetupParams } from "./types/setup-params.js" +import { Request, Response } from "./std/index.js" export const createWithEdgeSpec = (globalSpec: SetupParams) => { return (routeSpec: any) => (routeFn: RouteFn) => - async (req: Request, _res: Response) => { + async (req: Request): Promise => { // Identify environment this is being executed in and convert to WinterCG- // compatible request @@ -16,7 +17,8 @@ export const createWithEdgeSpec = (globalSpec: SetupParams) => { try { return await routeFn(req) } catch (e) { - // Use exception handling middleware to handle failure + // TODO: Use exception handling middleware to handle failure + throw e } } } diff --git a/src/edge-runtime/transform-to-node.ts b/src/edge-runtime/transform-to-node.ts new file mode 100644 index 0000000..bd0b56e --- /dev/null +++ b/src/edge-runtime/transform-to-node.ts @@ -0,0 +1,15 @@ +import { + buildToNodeHandler, + type RequestOptions, +} from "@edge-runtime/node-utils" +import primitives from "@edge-runtime/primitives" + +export interface TransformToNodeOptions extends RequestOptions {} + +const dependencies = { + ...primitives, + Uint8Array, +} + +export const transformToNodeBuilder = (options: TransformToNodeOptions) => + buildToNodeHandler(dependencies, options) diff --git a/src/serve/create-server-from-route-map.ts b/src/serve/create-server-from-route-map.ts index a2c1379..810c60f 100644 --- a/src/serve/create-server-from-route-map.ts +++ b/src/serve/create-server-from-route-map.ts @@ -1,71 +1,57 @@ +import * as STD from "src/std/index.js" import { createServer } from "node:http" import { getRouteMatcher } from "next-route-matcher" -import {EdgeRuntime} from "edge-runtime" import { normalizeRouteMap } from "../lib/normalize-route-map.js" +import { + type TransformToNodeOptions, + transformToNodeBuilder, +} from "src/edge-runtime/transform-to-node.js" export const createServerFromRouteMap = async ( - routeMap: Record + routeMap: Record< + string, + (req: STD.Request) => STD.Response | Promise + >, + transformToNodeOptions: TransformToNodeOptions ) => { - // We should use edge runtime here but it's currently broken: - // https://github.com/vercel/edge-runtime/issues/716 - // const server = await createServer( - // transformToNode(async (req) => { - // // TODO Route to proper route handler - // return new primitives.Response(req.body) - // // return Object.values(routeMap)[0](req) - // }), - // ) - const formattedRoutes = normalizeRouteMap(routeMap) const routeMatcher = getRouteMatcher(Object.keys(formattedRoutes)) - const server = createServer(async (nReq, nRes) => { - if (!nReq.url) { - nRes.statusCode = 400 - nRes.end("no url provided") - return - } - - const { matchedRoute, routeParams } = routeMatcher(nReq.url) ?? {} - if (!matchedRoute) { - nRes.statusCode = 404 - nRes.end("Not found") - return - } - const routeFn = routeMap[formattedRoutes[matchedRoute]] - - try { - const webReq = new Request(`http://localhost${nReq.url}`, { - headers: nReq.headers, - method: nReq.method, - body: ["GET", "HEAD"].includes(nReq.method ?? "") ? undefined : nReq, - duplex: "half", - } as any) - - const res = await routeFn(webReq) - - if (res.headers) { - for (const [key, value] of Object.entries(res.headers)) { - nRes.setHeader(key, value as any) - } + const transformToNode = transformToNodeBuilder(transformToNodeOptions) + + const server = createServer( + transformToNode(async (req) => { + // TODO: put routeParams on request object... + const { matchedRoute, routeParams } = + routeMatcher(new URL(req.url).pathname) ?? {} + const routeFn = + matchedRoute && + formattedRoutes[matchedRoute] && + routeMap[formattedRoutes[matchedRoute]] + + if (!matchedRoute || !routeFn) { + console.log({ + matchedRoute, + formattedRoutes, + routeMap, + url: req.url, + transformToNodeOptions, + }) + return new STD.Response("Not found", { + status: 404, + }) } - nRes.statusCode = res.status ?? 200 - if (res.body instanceof ReadableStream) { - for await (const chunk of res.body) { - nRes.write(chunk) - } - nRes.end() - } else { - // If body is not a stream, write it directly - nRes.end(res.body) + try { + return await routeFn(req) + } catch (e: any) { + return new STD.Response(e.toString(), { + status: 500, + }) } - } catch (e: any) { - nRes.statusCode = 500 - nRes.end(e.toString()) - } - }) + }) + ) return server } diff --git a/src/std/Request.d.ts b/src/std/Request.d.ts index 887f48f..b7f3809 100644 --- a/src/std/Request.d.ts +++ b/src/std/Request.d.ts @@ -1,2 +1 @@ -import { Request } from "@types/node" export { Request } diff --git a/src/std/Response.d.ts b/src/std/Response.d.ts index 9d0a12e..0ca3ee9 100644 --- a/src/std/Response.d.ts +++ b/src/std/Response.d.ts @@ -1,2 +1 @@ -export { Response } from "@types/node" export { Response } diff --git a/tests/adapters/node.test.ts b/tests/adapters/node.test.ts index 50ef328..35c646e 100644 --- a/tests/adapters/node.test.ts +++ b/tests/adapters/node.test.ts @@ -3,13 +3,13 @@ import { bundle } from "src/bundle/bundle" import path from "node:path" import fs from "node:fs/promises" import { fileURLToPath } from "node:url" -import {execa} from "execa" +import { execa } from "execa" import getPort from "@ava/get-port" import pRetry from "p-retry" import axios from "axios" import { once } from "node:events" -test("test bundle with Node adapter", async t => { +test("test bundle with Node adapter", async (t) => { const currentDirectory = path.dirname(fileURLToPath(import.meta.url)) const bundled = await bundle({ @@ -19,28 +19,56 @@ test("test bundle with Node adapter", async t => { const port = await getPort() const bundlePath = path.join(currentDirectory, "bundled.js") - const bundleEntrypointPath = path.join(currentDirectory, "bundled.entrypoint.mjs") + const bundleEntrypointPath = path.join( + currentDirectory, + "bundled.entrypoint.mjs" + ) await fs.writeFile(bundlePath, bundled, "utf-8") - await fs.writeFile(bundleEntrypointPath, ` + await fs.writeFile( + bundleEntrypointPath, + ` import {startServer} from "../../dist/adapters/node.js" import bundle from "./bundled.js" startServer(bundle, ${port}) - `, "utf-8") + `, + "utf-8" + ) const cmd = execa("node", [bundleEntrypointPath]) + + cmd.stderr!.on("data", (data) => { + t.log(data.toString()) + }) + + cmd.addListener("close", (code) => { + if (code) t.fail(`Server exited with code ${code}`) + }) + + const waitForExit = once(cmd, "exit") + t.teardown(async () => { - const waitForExit = once(cmd, "exit") - cmd.kill('SIGTERM') + cmd.kill("SIGTERM") await waitForExit }) - await pRetry(async () => { - const response = await axios.get(`http://localhost:${port}/health`) - t.deepEqual(response.data, { - ok: true - }) - }) + await pRetry( + async () => { + try { + const response = await axios.get(`http://localhost:${port}/health`) + + t.deepEqual(response.data, { + ok: true, + }) + } catch (e: any) { + t.log("axios failed: " + e.message) + throw e + } + }, + { + retries: 3, + } + ) t.pass() }) diff --git a/tests/edge-runtime-node/edge-runtime-node.test.ts b/tests/edge-runtime-node/edge-runtime-node.test.ts index 7be93f5..6246540 100644 --- a/tests/edge-runtime-node/edge-runtime-node.test.ts +++ b/tests/edge-runtime-node/edge-runtime-node.test.ts @@ -2,15 +2,14 @@ import test from "ava" import { once } from "node:events" import { createServer } from "node:http" -import { buildToNodeHandler } from "@edge-runtime/node-utils" -import * as primitives from "@edge-runtime/primitives" +import { transformToNodeBuilder } from "src/edge-runtime/transform-to-node" // 1. builds a transformer, using Node.js@18 globals, and a base url for URL constructor. -const transformToNode = buildToNodeHandler(global as any, { - defaultOrigin: "http://example.com", +const transformToNode = transformToNodeBuilder({ + defaultOrigin: "https://example.com", }) -test.skip("convert node server to standard web request server", async (t) => { +test("convert node server to standard web request server", async (t) => { const server = await createServer( // 2. takes an web compliant request handler, that uses Web globals like Request and Response, // and turn it into a Node.js compliant request handler. @@ -21,8 +20,15 @@ test.skip("convert node server to standard web request server", async (t) => { server.listen() await once(server, "listening") + const serverAddress = server.address() + + if (typeof serverAddress !== "object" || !serverAddress) { + t.fail("server.address() should return an object") + return + } + // 4. invoke the request handler - const response = await fetch(`http://localhost:${server.address().port}`, { + const response = await fetch(`http://localhost:${serverAddress.port}`, { method: "POST", body: "hello world", }) diff --git a/tests/endpoints/basic-01.test.ts b/tests/endpoints/basic-01.test.ts index 27decb4..c008d38 100644 --- a/tests/endpoints/basic-01.test.ts +++ b/tests/endpoints/basic-01.test.ts @@ -3,7 +3,7 @@ import { Response } from "src/std/Response.js" import { getTestRoute } from "../fixtures/get-test-route.js" test("basic-01", async (t) => { - const { axios, serverUrl } = await getTestRoute(t, { + const { axios } = await getTestRoute(t, { globalSpec: {}, routeSpec: { auth: "none", @@ -14,7 +14,7 @@ test("basic-01", async (t) => { return new Response( JSON.stringify({ ok: true, - }), + }) ) }, }) diff --git a/tests/fixtures/get-test-route.ts b/tests/fixtures/get-test-route.ts index 067bdba..b7a01dd 100644 --- a/tests/fixtures/get-test-route.ts +++ b/tests/fixtures/get-test-route.ts @@ -21,9 +21,14 @@ export const getTestRoute = async ( const port = await getPort() - const app: any = await createServerFromRouteMap({ - [opts.routePath]: wrappedRouteFn, - }) + const app: any = await createServerFromRouteMap( + { + [opts.routePath]: wrappedRouteFn, + }, + { + defaultOrigin: `http://localhost:${port}`, + } + ) // const app = http.createServer(async (nReq, nRes) => { // try {