Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix buildToNodeHandler, refactor node <-> edge bridge code #16

Merged
merged 2 commits into from
Jan 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 14 additions & 22 deletions src/adapters/node.ts
Original file line number Diff line number Diff line change
@@ -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())
})
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should have been documented better, but we don't want to run within the edge runtime here--this is for when you're targeting Node, either because you want to use Node APIs or you're unable to use the edge runtime (I think you might not be allowed to use it in Vercel endpoints)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see! I'll revert this in a future pr 👍

server.listen(port)

return server
Expand Down
17 changes: 14 additions & 3 deletions src/adapters/wintercg-minimal.ts
Original file line number Diff line number Diff line change
@@ -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))
})
}
6 changes: 4 additions & 2 deletions src/create-with-edge-spec.ts
Original file line number Diff line number Diff line change
@@ -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<Response> => {
// Identify environment this is being executed in and convert to WinterCG-
// compatible request

Expand All @@ -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
}
}
}
15 changes: 15 additions & 0 deletions src/edge-runtime/transform-to-node.ts
Original file line number Diff line number Diff line change
@@ -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)
96 changes: 41 additions & 55 deletions src/serve/create-server-from-route-map.ts
Original file line number Diff line number Diff line change
@@ -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<string, Function>
routeMap: Record<
string,
(req: STD.Request) => STD.Response | Promise<STD.Response>
>,
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
}
1 change: 0 additions & 1 deletion src/std/Request.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
import { Request } from "@types/node"
export { Request }
1 change: 0 additions & 1 deletion src/std/Response.d.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export { Response } from "@types/node"
export { Response }
54 changes: 41 additions & 13 deletions tests/adapters/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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()
})
18 changes: 12 additions & 6 deletions tests/edge-runtime-node/edge-runtime-node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
})
Expand Down
4 changes: 2 additions & 2 deletions tests/endpoints/basic-01.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -14,7 +14,7 @@ test("basic-01", async (t) => {
return new Response(
JSON.stringify({
ok: true,
}),
})
)
},
})
Expand Down
Loading