Skip to content

Commit

Permalink
Switch implementation to use sockets
Browse files Browse the repository at this point in the history
  • Loading branch information
maxmcd committed May 8, 2024
1 parent 7e3775a commit 7b7106a
Show file tree
Hide file tree
Showing 10 changed files with 256 additions and 308 deletions.
8 changes: 2 additions & 6 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,11 @@ jobs:
run: npm ci
env:
CI: true
- name: build
run: npm run build
- name: prepare
run: npm run prepare
env:
CI: true
- name: vitest
run: npm run test
env:
CI: true
- name: deno lint
run: |
cd deno-bootstrap
deno lint
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# deno-http-worker

[![NPM version](https://img.shields.io/npm/v/deno-http-worker.svg?style=flat)](https://npmjs.org/package/deno-http-worker)

Similarly to [deno-vm](https://github.com/casual-simulation/node-deno-vm), deno-http-worker lets you securely spawn Deno http servers.

## Usage

```ts
import { newDenoHTTPWorker } from 'deno-http-worker';

let worker = await newDenoHTTPWorker(
`export default async function (req: Request): Promise<Response> {
return Response.json({ ok: req.url })
}`,
{ printOutput: true, runFlags: ["--alow-net"] }
);

let json = await worker.client
.get("https://hello/world?query=param")
.json();
console.log(json) // => { ok: 'https://hello/world?query=param' }

worker.terminate();
```

## Internals

Deno-http-worker connects to the Deno process over a single Unix socket http2 connection to make requests. This is for performance and efficiency. As a result, the worker does not provide an address or url, but instead returns an instance of a [got](https://www.npmjs.com/package/got) client that you can make requests with. This ensures that only the underlying `http2.ClientHttp2Session` is used to make requests.

If you need more advanced usage that cannot be covered by `got`, please open a ticket.
65 changes: 22 additions & 43 deletions deno-bootstrap/index.ts
Original file line number Diff line number Diff line change
@@ -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.");
Expand All @@ -26,32 +15,22 @@ 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));
})();
}
// Use an empty onListen callback to prevent Deno from logging
Deno.serve({ path: socketFile, onListen: () => {} }, (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;
// Setting url.protocol did not replace the protocol correctly for a unix
// socket. Replacing the href value seems to work well.
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);
});
11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
{
"name": "deno-http-worker",
"version": "0.0.0",
"version": "0.0.2",
"description": "",
"main": "dist/plugin.js",
"types": "./dist/plugin.d.ts",
"main": "dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"lint" : "npm run lint:deno-bootstrap && npm run lint:deno-test-files",
"lint:deno-bootstrap": "cd deno-bootstrap && deno lint",
"lint:deno-test-files": "cd src/test && deno lint",
"build": "tsc --build",
"prepare": "npm run build"
"prepare": "npm run lint && npm run build"
},
"repository": {
"type": "git",
Expand Down
86 changes: 30 additions & 56 deletions src/DenoHTTPWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from "path";

// Uncomment this if you want to debug serial test execution
const it = _it.concurrent;
// const it = _it
// const it = _it;

describe("DenoHTTPWorker", { timeout: 1000 }, () => {
const echoFile = path.resolve(__dirname, "./test/echo-request.ts");
Expand All @@ -14,21 +14,24 @@ 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<Response> {
let headers = {};
for (let [key, value] of req.headers.entries()) {
headers[key] = value;
}
return Response.json({ ok: req.url, headers: headers })
}
`);
`,
{ 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",
Expand All @@ -38,27 +41,30 @@ describe("DenoHTTPWorker", { timeout: 1000 }, () => {
worker.terminate();
});

it("deny-net not always allowed", async () => {
expect(
newDenoHTTPWorker(echoScript, {
runFlags: [`--deny-net`],
describe("runFlags editing", () => {
it.each([
"--allow-read",
"--allow-write",
"--allow-read=/dev/null",
"--allow-write=/dev/null",
"--allow-read=foo,/dev/null",
"--allow-write=bar,/dev/null",
])("should handle %s", async (flag) => {
let worker = await newDenoHTTPWorker(echoScript, {
printOutput: true,
})
).rejects.toThrowError("not supported");
expect(
newDenoHTTPWorker(echoScript, {
runFlags: [`--deny-net=0.0.0.0:0`],
printOutput: true,
})
).rejects.toThrowError("with the address");
runFlags: [flag],
});
await worker.client.get("https://localhost/").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,
printCommandAndArguments: true,
});

let resp: any = await worker.client
Expand All @@ -70,7 +76,9 @@ describe("DenoHTTPWorker", { timeout: 1000 }, () => {
});

it("user agent is not overwritten", async () => {
let worker = await newDenoHTTPWorker(echoScript);
let worker = await newDenoHTTPWorker(echoScript, {
printOutput: true,
});
let resp: any = await worker.client
.get("https://localhost/", {
headers: { "User-Agent": "some value" },
Expand Down Expand Up @@ -116,55 +124,21 @@ 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<Response> {
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<Response> {
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 });

let first = worker.client.post("https://localhost:8080/", {
body:
"data:text/tsx," +
encodeURIComponent(`export default async function (req: Request): Promise<Response> {
return Response.json({ ok: true })
} ${"///".repeat(8000)}`),
return Response.json({ ok: true })
} ${"///".repeat(8000)}`),
});
// We send a request to initialize and when the first request is in flight
// we send another request
let second = worker.client("https://foo.web.val.run");

expect((await first).statusCode).toEqual(200);
await first.text();
expect(await second.text()).toEqual('{"ok":true}');

Expand Down
Loading

0 comments on commit 7b7106a

Please sign in to comment.