diff --git a/deno-bootstrap/index.ts b/deno-bootstrap/index.ts index f4d7ab3..21f8e8d 100644 --- a/deno-bootstrap/index.ts +++ b/deno-bootstrap/index.ts @@ -1,23 +1,12 @@ -const scriptType = Deno.args[0]; -const script = Deno.args[1]; +const socketFile = Deno.args[0]; +const scriptType = Deno.args[1]; +const script = Deno.args[2]; const importURL = scriptType == "import" ? script : "data:text/tsx," + encodeURIComponent(script); -const server = Deno.listen({ - hostname: "0.0.0.0", - port: 0, -}); - -const addr = server.addr as Deno.NetAddr; - -console.log(`deno-listening-port ${addr.port.toString().padStart(5, " ")} `); - -// Now that we're listening, start executing user-provided code. We could -// import while starting the server for a small performance improvement, -// but it would complicate reading the port from the Deno logs. const handler = await import(importURL); if (!handler.default) { throw new Error("No default export found in script."); @@ -26,32 +15,19 @@ if (typeof handler.default !== "function") { throw new Error("Default export is not a function."); } -const conn = await server.accept(); -(async () => { - // Reject all additional connections. - for await (const conn of server) { - conn.close(); - } -})(); - -// serveHttp is deprecated, but we don't have many other options if we'd like to -// keep this pattern of rejecting future connections at the TCP level. -// https://discord.com/channels/684898665143206084/1232398264947445810/1234614780111880303 -// -// deno-lint-ignore no-deprecated-deno-api -const httpConn = Deno.serveHttp(conn); -for await (const requestEvent of httpConn) { - (async () => { - let req = requestEvent.request; - const url = new URL(req.url); - url.host = req.headers.get("X-Deno-Worker-Host") || url.host; - url.protocol = req.headers.get("X-Deno-Worker-Protocol") + ":"; - url.port = req.headers.get("X-Deno-Worker-Port") || url.port; - req = new Request(url.toString(), req); - req.headers.delete("X-Deno-Worker-Host"); - req.headers.delete("X-Deno-Worker-Protocol"); - req.headers.delete("X-Deno-Worker-Port"); - - await requestEvent.respondWith(handler.default(req)); - })(); -} +Deno.serve({ path: socketFile }, (req: Request) => { + const url = new URL(req.url); + url.host = req.headers.get("X-Deno-Worker-Host") || url.host; + url.port = req.headers.get("X-Deno-Worker-Port") || url.port; + url.href = url.href.replace( + /^http\+unix:/, + req.headers.get("X-Deno-Worker-Protocol") || url.protocol + ); + // Deno Request headers are immutable so we must make a new Request in order to delete our headers + req = new Request(url.toString(), req); + req.headers.delete("X-Deno-Worker-Host"); + req.headers.delete("X-Deno-Worker-Protocol"); + req.headers.delete("X-Deno-Worker-Port"); + + return handler.default(req); +}); diff --git a/sockets/client.ts b/sockets/client.ts new file mode 100644 index 0000000..a4902c3 --- /dev/null +++ b/sockets/client.ts @@ -0,0 +1,6 @@ +import htt2 from "http2-wrapper"; +import net from "net"; + +const client = htt2.connect("http://whatever", { + createConnection: () => net.connect("787562857674825-deno-http.sock"), +}); diff --git a/sockets/deno.ts b/sockets/deno.ts new file mode 100644 index 0000000..bcef686 --- /dev/null +++ b/sockets/deno.ts @@ -0,0 +1,3 @@ +Deno.serve({ path: "./socket.sock" }, async (r: Request) => { + return Response.json({ ok: true }); +}); diff --git a/src/DenoHTTPWorker.test.ts b/src/DenoHTTPWorker.test.ts index 4048472..866cb69 100644 --- a/src/DenoHTTPWorker.test.ts +++ b/src/DenoHTTPWorker.test.ts @@ -4,8 +4,8 @@ import fs from "fs"; import path from "path"; // Uncomment this if you want to debug serial test execution -const it = _it.concurrent; -// const it = _it +// const it = _it.concurrent; +const it = _it; describe("DenoHTTPWorker", { timeout: 1000 }, () => { const echoFile = path.resolve(__dirname, "./test/echo-request.ts"); @@ -14,7 +14,8 @@ describe("DenoHTTPWorker", { timeout: 1000 }, () => { const vtScript = fs.readFileSync(vtFile, { encoding: "utf-8" }); it("json response multiple requests", async () => { - let worker = await newDenoHTTPWorker(` + let worker = await newDenoHTTPWorker( + ` export default async function (req: Request): Promise { let headers = {}; for (let [key, value] of req.headers.entries()) { @@ -22,13 +23,15 @@ describe("DenoHTTPWorker", { timeout: 1000 }, () => { } return Response.json({ ok: req.url, headers: headers }) } - `); + `, + { printCommandAndArguments: true, printOutput: true } + ); for (let i = 0; i < 10; i++) { let json = await worker.client - .get("https://localhost/", { headers: {} }) + .get("https://localhost/hello?isee=you", { headers: {} }) .json(); expect(json).toEqual({ - ok: "https://localhost/", + ok: "https://localhost/hello?isee=you", headers: { accept: "application/json", "accept-encoding": "gzip, deflate, br", @@ -53,24 +56,30 @@ describe("DenoHTTPWorker", { timeout: 1000 }, () => { ).rejects.toThrowError("with the address"); }); - it("should be able to import script", async () => { - const file = path.resolve(__dirname, "./test/echo-request.ts"); - const url = new URL(`file://${file}`); - let worker = await newDenoHTTPWorker(url, { - runFlags: [`--allow-read=${file}`], - printOutput: true, - }); - - let resp: any = await worker.client - .get("https://localhost/", { - headers: { "User-Agent": "some value" }, - }) - .json(); - await worker.terminate(); - }); + // it("should be able to import script", async () => { + // const file = path.resolve(__dirname, "./test/echo-request.ts"); + // const url = new URL(`file://${file}`); + // let worker = await newDenoHTTPWorker(url, { + // runFlags: [`--allow-read=${file}`], + // printOutput: true, + // }); + + // let resp: any = await worker.client + // .get("https://localhost/", { + // headers: { "User-Agent": "some value" }, + // }) + // .json(); + // await worker.terminate(); + // }); it("user agent is not overwritten", async () => { - let worker = await newDenoHTTPWorker(echoScript); + console.log(echoFile); + let worker = await newDenoHTTPWorker(echoScript, { + printCommandAndArguments: true, + printOutput: true, + runFlags: [`--unstable-http`], + }); + console.log("making request"); let resp: any = await worker.client .get("https://localhost/", { headers: { "User-Agent": "some value" }, @@ -116,41 +125,6 @@ describe("DenoHTTPWorker", { timeout: 1000 }, () => { worker.terminate(); }); - it("port log is not in output", async () => { - let worker = await newDenoHTTPWorker( - `console.log("Hi, I am here"); - export default async function (req: Request): Promise { - let body = await req.text(); - return Response.json({ length: body.length }) - }` - ); - let allStdout = ""; - - worker.stdout.on("data", (data) => { - allStdout += data; - }); - - await worker.client("https://hey.ho").text(); - worker.terminate(); - - expect(allStdout).toEqual("Hi, I am here\n"); - }); - - it("cannot make outside connection to deno server", async () => { - let worker = await newDenoHTTPWorker( - `export default async function (req: Request): Promise { - let body = await req.text(); - return Response.json({ length: body.length }) - }` - ); - - await expect( - fetch("http://localhost:" + worker.denoListeningPort) - ).rejects.toThrowError("fetch failed"); - - worker.terminate(); - }); - it("can implement val town", async () => { let worker = await newDenoHTTPWorker(vtScript, { printOutput: true }); diff --git a/src/DenoHTTPWorker.ts b/src/DenoHTTPWorker.ts index 87311bd..ea0c2f2 100644 --- a/src/DenoHTTPWorker.ts +++ b/src/DenoHTTPWorker.ts @@ -4,6 +4,8 @@ import { Readable, Writable, TransformCallback, Transform } from "stream"; import readline from "readline"; import http2 from "http2-wrapper"; import got, { Got } from "got"; +import fs from "fs/promises"; +import net from "net"; import { fileURLToPath } from "url"; @@ -105,10 +107,15 @@ export const newDenoHTTPWorker = async ( let scriptArgs: string[]; + let socketFile = `${Math.random()}-deno-http.sock`.substring(2); + + _options.runFlags.push(`--allow-read=${socketFile}`); + _options.runFlags.push(`--allow-write=${socketFile}`); + if (typeof script === "string") { - scriptArgs = ["script", script]; + scriptArgs = [socketFile, "script", script]; } else { - scriptArgs = ["import", script.href]; + scriptArgs = [socketFile, "import", script.href]; } let command = "deno"; @@ -127,7 +134,7 @@ export const newDenoHTTPWorker = async ( ]; } - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { const args = [ "run", ..._options.runFlags, @@ -135,12 +142,14 @@ export const newDenoHTTPWorker = async ( ...scriptArgs, ]; if (_options.printCommandAndArguments) { - console.log("Spawning deno process:", JSON.stringify([command, ...args])); + console.log("Spawning deno process:", [command, ...args]); } const process = spawn(command, args, _options.spawnOptions); let running = false; + let exited = false; let worker: DenoHTTPWorker; process.on("exit", (code: number, signal: string) => { + console.log("EXIT"); if (!running) { let stderr = process.stderr?.read()?.toString(); reject( @@ -149,6 +158,7 @@ export const newDenoHTTPWorker = async ( (stderr ? `\n${stderr}` : "") ) ); + exited = true; } else { worker.terminate(code, signal); } @@ -157,113 +167,100 @@ export const newDenoHTTPWorker = async ( const stdout = process.stdout; const stderr = process.stderr; - const onReadable = () => { - // We wait for stdout to be readable and then just read the bytes of the - // port number log line. If a user subscribes to the reader later they'll - // only see log output without the port line. + if (_options.printOutput) { + readline.createInterface({ input: stdout }).on("line", (line) => { + console.log("[deno]", line); + }); + readline.createInterface({ input: stderr }).on("line", (line) => { + console.error("[deno]", line); + }); + } - // Length is: DENO_PORT_LOG_PREFIX + " " + port + padding + " " + newline - let data = stdout.read(DENO_PORT_LOG_PREFIX.length + 1 + 5 + 1 + 1); - stdout.removeListener("readable", onReadable); - let strData = data.toString(); - if (!strData.startsWith(DENO_PORT_LOG_PREFIX)) { - reject( - new Error( - "First log output from deno process did not contain the expected port value" - ) - ); - return; + while (true) { + if (exited) { + break; + } + try { + await fs.stat(socketFile); + // File exists + break; + } catch (err) { + await new Promise((resolve) => setTimeout(resolve, 100)); } + } - const match = strData.match(/deno-listening-port +(\d+) /); - if (!match) { - reject( - new Error( - `First log output from deno process did not contain a valid port value: "${data}"` - ) - ); - return; + const _httpSession = http2.connect(`http://whatever`, { + createConnection: () => net.connect(socketFile), + }); + _httpSession.on("error", (err) => { + if (!running) { + reject(err); + } else { + worker.terminate(); + throw err; } - const port = match[1]; - const _httpSession = http2.connect(`http://localhost:${port}`); - _httpSession.on("error", (err) => { - if (!running) { - reject(err); - } else { - worker.terminate(); - throw err; - } - }); - _httpSession.on("connect", () => { - const _got = got.extend({ - hooks: { - beforeRequest: [ - (options) => { - // Ensure that we use our existing session - options.h2session = _httpSession; - options.http2 = true; - - // We follow got's example here: - // https://github.com/sindresorhus/got/blob/88e623a0d8140e02eef44d784f8d0327118548bc/documentation/examples/h2c.js#L32-L34 - // But, this still surfaces a type error for various - // differences between the implementation. Ignoring for now. - // - // @ts-ignore - options.request = http2.request; - - // Ensure the got user-agent string is never present. If a - // value is passed by the user it will override got's - // default value. - if ( - options.headers["user-agent"] === - "got (https://github.com/sindresorhus/got)" - ) { - delete options.headers["user-agent"]; - } - - // Got will block requests that have a scheme of https and - // will also add a :443 port when not port exists. We pass - // the parts of the url that we care about in headers so - // that we can successfully assemble the request on the - // other side. - if (typeof options.url === "string") { - options.url = new URL(options.url); - } - options.headers = { - ...options.headers, - "X-Deno-Worker-Host": options.url?.host, - "X-Deno-Worker-Port": options.url?.port, - "X-Deno-Worker-Protocol": options.url?.protocol, - }; - if (options.url && options.url?.protocol === "https:") { - options.url.protocol = "http:"; - } - }, - ], - }, - }); - if (_options.printOutput) { - readline.createInterface({ input: stdout }).on("line", (line) => { - console.log("[deno]", line); - }); - readline.createInterface({ input: stderr }).on("line", (line) => { - console.error("[deno]", line); - }); - } - - worker = new DenoHTTPWorker( - _httpSession, - port, - _got, - process, - stdout, - stderr - ); - running = true; - resolve(worker); + }); + + _httpSession.on("connect", () => { + const _got = got.extend({ + hooks: { + beforeRequest: [ + (options) => { + // Ensure that we use our existing session + options.h2session = _httpSession; + options.http2 = true; + + // We follow got's example here: + // https://github.com/sindresorhus/got/blob/88e623a0d8140e02eef44d784f8d0327118548bc/documentation/examples/h2c.js#L32-L34 + // But, this still surfaces a type error for various + // differences between the implementation. Ignoring for now. + // + // @ts-ignore + options.request = http2.request; + + // Ensure the got user-agent string is never present. If a + // value is passed by the user it will override got's + // default value. + if ( + options.headers["user-agent"] === + "got (https://github.com/sindresorhus/got)" + ) { + delete options.headers["user-agent"]; + } + + // Got will block requests that have a scheme of https and + // will also add a :443 port when not port exists. We pass + // the parts of the url that we care about in headers so + // that we can successfully assemble the request on the + // other side. + if (typeof options.url === "string") { + options.url = new URL(options.url); + } + options.headers = { + ...options.headers, + "X-Deno-Worker-Host": options.url?.host, + "X-Deno-Worker-Port": options.url?.port, + "X-Deno-Worker-Protocol": options.url?.protocol, + }; + if (options.url && options.url?.protocol === "https:") { + options.url.protocol = "http:"; + } + }, + ], + }, }); - }; - stdout.on("readable", onReadable); + + worker = new DenoHTTPWorker( + _httpSession, + 0, + _got, + process, + stdout, + stderr + ); + running = true; + resolve(worker); + }); }); }; export type { DenoHTTPWorker };