Hedronite · Frontend Dev · TypeScript · 2026-10-23

Deno 2.9 as the server shell for a Vite app permissions, deno.json, and one binary

Serve the built SPA and its API from one Deno process that can touch only what you granted, pin every dependency, and ship it as one file.

Lesson Class: Dev (TypeScript runtime · Deno)
Tested with: deno 2.9.8 · @std/http 1.1.4 · @std/assert 1.0.19 · vite 8.3.5 · macOS + Linux
Lab: shelf · deno test (5 tests) · curl transcripts · deno compile binary run from another directory
Source: Deno docs (security, deno.json, modules, packages, HTTP server, serve, compile, testing) · JSR @std/http · JavaScript: The Definitive Guide §15.10.4 · Web Development with Node and Express, ch. 1
Grant, Don't Hope
The process starts with no file, network or environment access; a permission set grants back only ./dist, ./data, one host and one variable.
Lock What You Ship
deno.lock pins exact versions and integrity hashes; --frozen turns drift into a failed build.
One File Out
deno compile bakes code, assets and permissions into a single executable that runs from any directory.
Give the server only what it needs, and write the grant down.

Versions used: deno 2.9.8 (v8 15.0.245.2, TypeScript 6.0.3) on macOS (Apple silicon) and Linux x86_64, with jsr:@std/http 1.1.4, jsr:@std/assert 1.0.19 and npm:vite 8.3.5. Permission sets in deno.json are marked experimental in this release.

The problem

Shelf is a small notes app. Its front end is built with Vite, a build tool that turns TypeScript and HyperText Markup Language (HTML) into static files in a dist/ folder. The result is a single-page application (SPA): the server sends one HTML page, and JavaScript in the browser draws every view. Shelf also needs a tiny JSON (JavaScript Object Notation) application programming interface (API). So one small server has to hand out dist/ and answer /api/notes.

The obvious plan is a Node server, and it hurts in four places. A Node process can read any file, reach any host and read every environment variable. Deno's security documentation says that in Node, dependencies are "automatically granted full access to all system I/O" (input and output). One compromised package therefore gets your disk, your network and your secrets. A plain static server answers 404 for a deep link such as /about, because no file has that name, even though the app can render that page. Deploys install whatever dependency versions resolve that day, so Tuesday's build can differ from Monday's. And the target machine needs a runtime installed before the server can start.

The idea in plain words

Deno starts every program with no access to files, network or environment variables, and you grant back only what the program needs, written down in a config file. Vite builds the front end into dist/. A single Deno file exports a request handler that serves those files, falls back to index.html for client-side routes, and answers the JSON API. A lockfile pins every dependency, ordinary tests call the handler directly without opening a port, and deno compile packs the runtime, the code, dist/ and the data into one executable. The permissions travel inside that executable.

Concept A: permissions

A permission is a switch the runtime checks before a program touches the file system, the network or the environment. Deno's documentation says: "Unless you specifically enable it, a program run with Deno has no access to sensitive APIs, such as file system access, network connectivity, or environment access." You turn switches on with flags such as --allow-read=./data, which limits reads to one directory, or --allow-env=API_KEY, which limits environment access to a single variable. A bare --allow-net with no value grants the whole category.

Typing long flag lists on every command is error-prone. Since Deno 2.5 you can store them in deno.json as a permission set: a named group of grants that you select with -P. A set called default is chosen by a bare -P, and -P=name picks another.

The reason to scope grants is that a refused operation is stopped at the source. Without scoping, a dependency inside your server can read ~/.ssh or post your environment to a stranger. With scoping, Deno throws a NotCapable error instead. If stdout is a terminal and you passed no flag, Deno pauses and asks you instead of failing. Two other rules matter here. --deny-* flags take precedence over their --allow-* counterparts, and -A (--allow-all) turns the sandbox off entirely.

Here is the whole deno.json of the lab. Concept B returns to its other keys.

deno.json

{
  "tasks": {
    "build": "deno run -A npm:[email protected] build",
    "start": "deno serve -P --port=8000 server.ts",
    "test": "deno test -P",
    "compile": "deno compile -P --include dist --include data -o shelf main.ts"
  },
  "imports": {
    "@std/http": "jsr:@std/[email protected]",
    "@std/assert": "jsr:@std/[email protected]"
  },
  "permissions": {
    "default": {
      "read": [
        "./dist",
        "./data"
      ],
      "net": [
        "127.0.0.1"
      ],
      "env": [
        "PORT"
      ]
    }
  },
  "test": {
    "permissions": {
      "read": [
        "./dist",
        "./data"
      ]
    }
  }
}

Look at the permissions key first. The default set allows reads from ./dist and ./data, network access to 127.0.0.1, and the single environment variable PORT. There is no write access, no other host and no subprocess. The test key holds permissions for deno test, and they are narrower still: reads only, because the tests call the handler directly and never open a socket. Every -P run in 2.9.8 prints Warning Permissions in the config file is an experimental feature and may change in the future. That is expected. The build task uses -A on purpose, since Vite is a trusted build tool that reads and writes the project. The server never gets -A.

Permissions in the config file need an explicit opt-in because the threat model matches deno task: a script could edit deno.json to raise its own permissions. So Deno does not load them by default. Once a test permissions block exists, plain deno test stops with error: Test permissions were found in the config file. Did you mean to run with -P or a permission flag?

Three limits are worth knowing. All code on the same thread shares one privilege level, so you cannot give one dependency less than the rest of the process. Modules in the initial static import graph load without consulting the permission system. And --allow-run and --allow-ffi should be treated as equivalent to --allow-all, because a subprocess runs as a separate program with its own permissions. Shelf grants neither.

Common mistake: starting the server without -P

Run deno serve --port=8132 server.ts with no permission flag and no terminal attached, so Deno cannot prompt. Every request fails:

$ curl -s -w ' -> %{http_code}\n' http://127.0.0.1:8132/api/notes
Internal Server Error -> 500
(/ and /about also: Internal Server Error -> 500)
deno serve: Listening on http://0.0.0.0:8132/
NotCapable: Requires read access to "", run again with the --allow-read flag
    at Object.readTextFile (ext:deno_fs/30_fs.js:1:10048)
    at Object.handler [as fetch] (file:///.../server.ts:14:41)

Symptom: /api/notes, / and /about all return Internal Server Error -> 500, yet the log shows a failure only for the API call.

Cause: The process has no read grant. The API route throws NotCapable and the log prints it. The static routes fail the same way, but serveDir is called with quiet: true, and the library turns every error other than "not found" into a 500. The log line is suppressed, so those failures leave no trace.

Fix: Run with -P (or deno task start). While debugging, drop quiet. The server stays up after each failure, because an error in a handler is isolated to that request.

Concept B: deno.json, specifiers, the import map, tasks and the lockfile

deno.json is where you configure Deno itself: tasks, dependencies, and tools such as the TypeScript compiler, linter and formatter. It is optional, and Deno finds it in the current directory or any parent. Deno also reads package.json, so an existing Node project keeps working.

A specifier is the string in an import statement. Third-party modules start with jsr: or npm:. JSR is the JavaScript Registry, a package registry that serves TypeScript directly, and npm: reaches the npm registry. The import map is the imports field of deno.json, which lets you write import ... from "@std/http" while the file names the exact version once. Tasks are named commands, run with deno task build. The lockfile, deno.lock, records the exact version and integrity hash of every dependency and verifies them on later runs.

Without these pieces the problems from the start return. Version strings scatter across files, so upgrades are inconsistent. And without a committed lockfile, a fresh install resolves whatever is newest that day, so the build is not reproducible. Deno maintains the lockfile for you, and the documentation says to commit it.

The imports key pins @std/http and @std/assert to exact versions. The build task runs Vite from npm without a separate install step:

Task build deno run -A npm:[email protected] build
vite v8.3.5 building client environment for production...
✓ 4 modules transformed.
dist/index.html                0.33 kB │ gzip: 0.24 kB
dist/assets/index-BGYGos_o.js  1.18 kB │ gzip: 0.63 kB
✓ built in 22ms

This output shows the whole front-end build: one HTML file and one JavaScript file, 1.51 kB together. Vite puts a content hash in the script's name (BGYGos_o), so a new build produces a new file name and a browser that cached the old script fetches the new one instead of reusing it. The same build on macOS and on Linux produced the identical name. Every built asset ends in an extension such as .js, and the server relies on that dot later to tell an asset request from a page request. The -A in the task belongs to the build alone.

The lockfile now pins the registry packages. This excerpt of its specifiers section is real:

deno.lock (excerpt)

"jsr:@std/[email protected]": "1.0.19",
"jsr:@std/[email protected]": "1.1.4",
"jsr:@std/path@^1.1.6": "1.1.6",
"npm:[email protected]": "8.3.5"

Each left-hand string is what a specifier asked for, and each right-hand string is what Deno resolved. @std/path appears although Shelf never imports it: @std/http 1.1.4 pulled in ten other @std packages (cli, encoding, fmt, fs, html, internal, media-types, net, path and streams). Those dependencies ask for caret ranges, where ^1.1.6 accepts any later 1.x release, and the lockfile records the exact version each range resolved to. A second section holds an integrity hash per package, for example "@std/[email protected]": { "integrity": "eaada96e..." }, and Deno verifies downloaded code against it, so different bytes under the same version number are caught instead of run. The documentation recommends deno ci for continuous integration (CI) and production installs, and says you can also enforce a frozen lockfile on any command with --frozen.

Common mistake: letting the lockfile drift

Suppose someone edits deno.json and changes @std/assert from jsr:@std/[email protected] to 1.0.18 without updating the lockfile. Then deno test -P --frozen fails:

error: The lockfile is out of date. Run `deno install --frozen=false`, or rerun with `--frozen=false` to update it.
changes:
  4 | -    "jsr:@std/[email protected]": "1.0.19",
...
  7 | +    "@std/[email protected]": {
  8 | +      "integrity": "270245e9c2c13b446286de475131dc688ca9abcd94fc5db41d43a219b34d1c78"

Symptom: The command exits with code 1 and prints The lockfile is out of date, plus a diff of what would change. deno.lock itself is left untouched.

Cause: deno.json and deno.lock disagree, and --frozen forbids Deno from rewriting the lockfile on its own.

Fix: If the change was intended, run deno install --frozen=false, review the diff and commit the new lockfile. If it was not, revert deno.json. Use --frozen in your build pipeline so that drift fails loudly there, instead of shipping a version nobody reviewed.

Concept C: the server shell

deno serve runs a file as an HTTP (Hypertext Transfer Protocol) server. The file must export a default object with a fetch handler, a function that takes a web-standard Request and returns a Response. Deno calls Deno.serve() for you. By default it listens on 0.0.0.0, and it allows listening without --allow-net. Other permissions, such as file reads, still have to be granted explicitly.

This design gives you a plain function to test. If the server were tangled up with sockets and process startup, every test would need a running server and a free port. With a bare fetch handler, a test builds a Request and inspects the Response, so tests need no network at all.

The SPA needs one more idea. In a single-page app the browser changes the address bar with history.pushState() and draws the new view without asking the server. David Flanagan notes in JavaScript: The Definitive Guide that applications save a URL with each state so users can bookmark and share links to internal states. When someone opens that saved link, the server receives a request for a path like /about that matches no file. The server must answer it with index.html and let the client code take over. This is the SPA fallback. Here is the front end it serves:

index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Shelf</title>
  </head>
  <body>
    <nav><a href="/" data-link>Notes</a> | <a href="/about" data-link>About</a></nav>
    <main id="app">Loading…</main>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

The page is a navigation bar, an empty <main id="app"> that the script fills, and one module script. The data-link attribute marks links that the script handles itself, so the page never reloads. Vite rewrites the script tag to the hashed file under dist/assets/ during the build. The text "Loading…" is what a visitor sees until the script runs, and the tests later look for the <main id="app"> element to prove that the fallback served this page.

src/main.ts

type Note = { id: number; title: string };

const app = document.querySelector<HTMLElement>("#app")!;

async function render(path: string) {
  if (path === "/about") {
    app.textContent = "Shelf keeps short notes.";
    return;
  }
  const res = await fetch("/api/notes");
  const notes: Note[] = await res.json();
  app.innerHTML = "";
  for (const note of notes) {
    const li = document.createElement("li");
    li.textContent = note.title;
    app.append(li);
  }
}

document.addEventListener("click", (event) => {
  const link = (event.target as HTMLElement).closest("a[data-link]");
  if (!link) return;
  event.preventDefault();
  history.pushState(null, "", link.getAttribute("href"));
  render(location.pathname);
});
addEventListener("popstate", () => render(location.pathname));
render(location.pathname);

The render function is the whole router: /about shows a sentence, and anything else fetches /api/notes and lists the titles. A click on a data-link anchor cancels the browser's navigation, pushes the new path into history and renders it. The popstate listener handles the Back button. On first load, render(location.pathname) runs with whatever path the server delivered, which is why a request for /about must come back as the HTML page and not as a 404.

data/notes.json

[
  { "id": 1, "title": "Permissions are flags" },
  { "id": 2, "title": "The lockfile pins jsr and npm" }
]

This file is the API's entire database. The server reads it on every request, which is why ./data appears in the read grant. Now the server itself:

server.ts

import { serveDir } from "@std/http/file-server";

const root = import.meta.dirname!;
const distDir = `${root}/dist`;
const notesFile = `${root}/data/notes.json`;

export async function handler(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (url.pathname === "/api/health") {
    return Response.json({ ok: true, deno: Deno.version.deno });
  }
  if (url.pathname === "/api/notes") {
    const notes = JSON.parse(await Deno.readTextFile(notesFile));
    return Response.json(notes);
  }
  if (url.pathname.startsWith("/api/")) {
    return Response.json({ error: "no such endpoint" }, { status: 404 });
  }

  const res = await serveDir(req, { fsRoot: distDir, quiet: true });
  const looksLikeFile = url.pathname.split("/").pop()!.includes(".");
  if (res.status === 404 && !looksLikeFile) {
    const html = await Deno.readFile(`${distDir}/index.html`);
    return new Response(html, {
      headers: { "content-type": "text/html; charset=UTF-8" },
    });
  }
  return res;
}

export default { fetch: handler } satisfies Deno.ServeDefaultExport;

Three details carry the design. First, import.meta.dirname is the directory that holds server.ts, so every path is anchored to the source file instead of the shell's current directory. Second, the /api/ branches come before any file lookup, and an unknown API path returns a JSON 404 so that a typo in an API call never receives HTML. Third, serveDir handles content types, range requests (requests for only part of a file) and directory traversal protection (refusing paths such as /../ that try to climb out of dist/). Only when it reports 404 for a path whose last segment has no dot does the handler return index.html. /about falls back, while /assets/nope.js stays a 404, because a missing file should look missing: answering it with the app's HTML would hide a broken deploy behind a 200. The satisfies clause checks the export shape at compile time without changing it. With the app built, deno serve -P --port=8131 server.ts produced these results:

GET /api/health -> 200 application/json
GET /api/notes -> 200 application/json
GET /api/nope -> 404 application/json
GET /about -> 200 text/html; charset=UTF-8
GET /assets/index-BGYGos_o.js -> 200 text/javascript; charset=UTF-8
GET /missing.png -> 404 text/plain;charset=UTF-8

Each line is a status and a content type from curl. The health body was {"ok":true,"deno":"2.9.8"}. The deep link /about returns 200 HTML, the real asset returns JavaScript, and both a missing image and a missing API route stay 404, one as plain text and the other as JSON. That is the behavior a plain static server could not give.

Now the tests, which call handler directly:

server_test.ts

import { assertEquals, assertRejects, assertStringIncludes } from "@std/assert";
import { handler } from "./server.ts";

const get = (path: string) => handler(new Request(`http://shelf.test${path}`));

Deno.test("API returns notes as JSON", async () => {
  const res = await get("/api/notes");
  assertEquals(res.status, 200);
  assertEquals(res.headers.get("content-type"), "application/json");
  const notes = await res.json();
  assertEquals(notes.length, 2);
});

Deno.test("unknown API path is a JSON 404, not the app shell", async () => {
  const res = await get("/api/nope");
  assertEquals(res.status, 404);
  assertEquals(await res.json(), { error: "no such endpoint" });
});

Deno.test("client route falls back to index.html", async () => {
  const res = await get("/about");
  assertEquals(res.status, 200);
  assertStringIncludes(await res.text(), '<main id="app">');
});

Deno.test("missing asset stays a 404", async () => {
  const res = await get("/assets/nope.js");
  assertEquals(res.status, 404);
});

Deno.test("reads outside the grant throw NotCapable", async () => {
  await assertRejects(
    () => Deno.readTextFile("/etc/hosts"),
    Deno.errors.NotCapable,
    'Requires read access to "/etc/hosts"',
  );
});

Deno.test() registers a test, and deno test with no file name finds files matching {*_,*.,}test.{ts, tsx, mts, js, mjs, jsx} in the current directory and below, so server_test.ts is picked up automatically. The get helper builds a Request for a made-up host and passes it straight to the handler, so there is no port, no socket and no network permission. The first four tests cover the routing rules from the server walk-through. The last one proves the sandbox is active during testing: reading /etc/hosts must be refused with the exact NotCapable message. If the grant were too wide, that test would fail. The run, with deno task test:

Warning Permissions in the config file is an experimental feature and may change in the future.
Check server_test.ts
running 5 tests from ./server_test.ts
API returns notes as JSON ... ok (2ms)
unknown API path is a JSON 404, not the app shell ... ok (959µs)
client route falls back to index.html ... ok (769µs)
missing asset stays a 404 ... ok (334µs)
reads outside the grant throw NotCapable ... ok (588µs)

ok | 5 passed | 0 failed (6ms)

All five pass in six milliseconds. The suite is that fast because it runs in one process and calls the handler as an ordinary function. The first line is the experimental notice that every -P run prints in 2.9.8. Check server_test.ts is Deno type-checking the test file and the server it imports before any test runs, so a type error in either file fails the run before a single assertion executes. Note that the tests ran with the narrower test permission set, and the server code still worked, because reads from ./dist and ./data are all it needs.

Common mistake: running the server file with deno run

A developer used to Node types deno run server.ts, and nothing seems to happen.

warning: Detected export default { fetch }, did you mean to run "deno serve"?

Symptom: The command prints the warning above, exits with code 0 and never listens on a port. Compiling server.ts and running the binary does the same.

Cause: server.ts only exports a handler. With deno serve, Deno calls Deno.serve() for you. With deno run, you must call Deno.serve() yourself, and this file does not.

Fix: Start it with deno serve -P server.ts. For an executable, write a small entry file that calls Deno.serve itself, which the next section does.

Putting it together: one executable

deno compile embeds your program into denort, a stripped build of Deno that contains only what a compiled program needs. The output is a single file you can copy to a machine that has no Deno installed. That removes the fourth pain from the start, the runtime on the target box. The entry file for it is short:

main.ts

import { handler } from "./server.ts";

const port = Number(Deno.env.get("PORT") ?? "8000");
const server = Deno.serve({ port, hostname: "127.0.0.1" }, handler);

Deno.addSignalListener("SIGTERM", async () => {
  await server.shutdown();
});

This file imports the same handler, so tests and production run the same code. It reads PORT, which the default permission set allows, and calls Deno.serve itself on 127.0.0.1. The net grant for that address covers the listening socket, because deno serve's automatic listen permission does not apply when you call Deno.serve yourself. Leave the grant out and the process dies at startup:

$ PORT=8140 deno run --allow-read=./dist,./data --allow-env=PORT main.ts
error: Uncaught (in promise) NotCapable: Requires net access to "127.0.0.1:8140", run again with the --allow-net flag

That failure is useful: a server that cannot open its port stops with exit code 1 before it accepts any traffic, so a process manager sees the crash immediately. The signal listener handles SIGTERM, the termination signal that process managers send to ask a program to stop. It calls server.shutdown(), which stops accepting new connections and lets in-flight requests finish, and then the process exits.

The compile task is in deno.json: deno compile -P --include dist --include data -o shelf main.ts. The documentation states that runtime flags, including permission flags, "must be specified at compilation time", so -P bakes the default set into the executable. The --include flags (available since Deno 2.1) add the dist and data directories. Running deno task compile printed:

Task compile deno compile -P --include dist --include data -o shelf main.ts
Check main.ts
Compile main.ts to shelf

Embedded Files

shelf
├── data/* (109B)
├── dist/* (1.49KB)
├── main.ts (730B)
└── server.ts (2.98KB)

Files: 7.52KB
Metadata: 1.73KB
Remote modules: 966.52KB

The listing confirms what rides inside the executable: both source files, the built front end and the notes. Your own files total 7.52 KB, and the largest addition is the 966.52 KB of remote modules, mostly the @std packages. The executable itself is 69,216,866 bytes on macOS Apple silicon and 105,988,752 bytes on Linux x86_64, since it contains the whole Deno runtime. By default compiled executables serve embedded files from an in-memory virtual file system. On macOS the result gets an ad-hoc signature (a local code signature with no developer identity attached), and codesign --verify -vv ./shelf reports ./shelf: valid on disk and ./shelf: satisfies its Designated Requirement. The docs also describe cross compilation with --target, for example x86_64-unknown-linux-gnu. We built each binary on its own platform.

Next, run it from a different directory:

$ cd /tmp && PORT=8133 ~/src/shelf-lab/shelf
Listening on http://127.0.0.1:8133/
GET /api/health -> 200 26 bytes
GET /api/notes -> 200 91 bytes
GET /about -> 200 336 bytes
GET /assets/index-BGYGos_o.js -> 200 1188 bytes

The executable works from /tmp because every path in server.ts comes from import.meta.dirname, and inside the binary that value points into the embedded virtual file system. The current directory never matters. Sending kill -TERM to the process ends it through the shutdown handler.

To check that the baked-in grants really bind the binary, compile a small probe with the same -P set and only data/ embedded:

probe.ts

try {
  await Deno.readTextFile("/etc/hosts");
  console.log("read /etc/hosts: allowed");
} catch (err) {
  console.log(`read /etc/hosts: ${(err as Error).name}: ${(err as Error).message}`);
}
console.log("read data/notes.json:", (await Deno.readTextFile(`${import.meta.dirname}/data/notes.json`)).length, "chars");

The probe tries one file outside the grant and one inside it. It catches the first failure and prints the error's name and message instead of crashing, so both results appear in one run. The second read builds its path from import.meta.dirname, exactly as server.ts does, so it reads the copy of data/notes.json embedded in the binary. Compile it with the same -P set and run it from the root directory:

$ deno compile -P --include data -o /tmp/probe probe.ts
$ cd / && /tmp/probe
read /etc/hosts: NotCapable: Requires read access to "/etc/hosts", specify the required permissions during compilation using `deno compile --allow-read`
read data/notes.json: 109 chars

The outside read fails with NotCapable, and the message changes for compiled programs: it no longer suggests a runtime flag, it says to grant the permission during compilation, because a compiled binary has no flags of its own to pass. The embedded notes file reads fine (109 characters, matching the data/* (109B) line in the compile listing). So a compromised dependency inside shelf cannot read files outside ./dist and ./data, and it cannot open connections beyond the 127.0.0.1 grant.

Compile diagnostic: the docs are ahead of your binary

The current compile documentation also describes an --include-as-is flag for pre-built Vite output. On 2.9.8 it fails:

error: unexpected argument '--include-as-is' found

Symptom: deno compile -P --include-as-is dist ... stops with the message above.

Cause: The installed Deno is older than the documentation page you read, and it does not know the flag.

Fix: Use --include dist on 2.9.8, and run deno compile --help to see the flags your version accepts.

Return to the four pains. The sandbox with scoped grants limits what a compromised dependency can reach. The fallback in handler makes /about work as a deep link. deno.lock with --frozen makes the dependency set the same on every deploy. And the compiled shelf file needs no installed runtime on the server. Build the front end with deno task build, test with deno task test, and ship shelf.

Check yourself

  1. **deno serve needs no --allow-net, but the compiled binary needs a net grant. Why?** deno serve automatically allows the server to listen. In main.ts you call Deno.serve yourself, so the 127.0.0.1 grant in the default set, baked in by -P at compile time, covers the socket.
  2. Every route returns 500 and the log is nearly empty. What are the two likely causes? The server was started without -P, so reads throw NotCapable. And serveDir runs with quiet: true, which hides the logged error for static routes. Run with -P and drop quiet while debugging.
  3. **What does --frozen change?** The command fails with "The lockfile is out of date" if deno.json and deno.lock disagree, instead of silently rewriting the lockfile.
  4. **Why does /about return index.html while /assets/nope.js returns 404?** After serveDir reports 404, the handler falls back only when the last path segment contains no dot. A missing asset answered with HTML would hide a broken deploy behind a 200.
  5. **Why compile main.ts and not server.ts?** server.ts only exports a fetch handler, so the compiled binary would print the "did you mean to run deno serve?" warning and exit. main.ts calls Deno.serve itself.

Sources