Writing a Native Messaging Host in Node

Build a robust Node.js native messaging host for an MV3 extension: a buffered frame parser, request ids, input validation, clean shutdown on EOF, and packaging into a single executable.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

Node is the fastest way to a working native host for a team that already writes the extension in JavaScript: the same language, the same JSON, and a shared module for message types. The quick version — read four bytes, read the body, reply — works in a demo and then fails in production when messages arrive faster than they are handled, when the browser disconnects mid-reply, or when the user’s machine has no Node installed. This guide builds the version that holds up. It sits under native messaging and host integration.

What a production host has to handle

The browser launches the host as a child process with three pipes and expects it to behave like a well-mannered server: parse an unbounded stream of frames, answer each request, push events when it has them, and exit as soon as stdin closes. Node’s streams make the first part deceptively simple — process.stdin emits data events with arbitrary chunks, and a naive parser that treats each chunk as one message works until two messages arrive in the same chunk or one message straddles two. The host also runs unattended: there is no console, an uncaught exception kills it with “Native host has exited” on the extension side, and a host that does not exit on EOF leaves orphan processes accumulating every time the extension reconnects.

Inside the Node hoststdin chunks feed a frame parser that buffers until a whole message is available, a dispatcher validates and routes it to a handler, and replies are framed and written to stdout; EOF triggers shutdown.stdin chunksarbitrary sizesFrameParserbuffer until completedispatch()validate + routehandler resolves with a result or throwshandlerasync workreply / errortagged with idstdoutframed write
The parser owns byte boundaries; handlers never see a partial message.

Step-by-step: the host

1. Protect stdout before anything else loads

1#!/usr/bin/env node
2// host/main.js — first lines
3const realStdoutWrite = process.stdout.write.bind(process.stdout);
4process.stdout.write = () => true;                       // swallow stray writes
5const log = (...a) => process.stderr.write(`[host ${process.pid}] ${a.join(" ")}\n`);
6console.log = console.info = console.warn = console.debug = log;

Execution context: the host process, before any require or import of dependencies. Saving the real write and replacing the public one means a dependency that prints to stdout is silently ignored instead of corrupting the stream. Only the framing code below uses realStdoutWrite. Chrome forwards stderr to its log when launched with --enable-logging=stderr; Firefox shows it in the Browser Console.

2. Parse frames from a growing buffer

 1class FrameParser {
 2  #buf = Buffer.alloc(0);
 3  constructor(onMessage) { this.onMessage = onMessage; }
 4
 5  push(chunk) {
 6    this.#buf = Buffer.concat([this.#buf, chunk]);
 7    while (this.#buf.length >= 4) {
 8      const len = this.#buf.readUInt32LE(0);
 9      if (len > 64 * 1024 * 1024) throw new Error(`frame too large: ${len}`);
10      if (this.#buf.length < 4 + len) break;              // wait for the rest
11      const body = this.#buf.subarray(4, 4 + len);
12      this.#buf = this.#buf.subarray(4 + len);
13      this.onMessage(JSON.parse(body.toString("utf8")));
14    }
15  }
16}

Execution context: the host process. The loop handles both cases a naive parser misses: several frames in one chunk (the while) and one frame across several chunks (the break). The size guard stops a corrupted length — say, from a stray write — from making the host try to buffer gigabytes before failing.

3. Frame replies and push events

1function send(msg) {
2  const payload = Buffer.from(JSON.stringify(msg), "utf8");
3  if (payload.length > 1024 * 1024) {
4    return send({ id: msg.id, error: { code: "too-large", bytes: payload.length } });
5  }
6  const header = Buffer.alloc(4);
7  header.writeUInt32LE(payload.length, 0);
8  realStdoutWrite(Buffer.concat([header, payload]));
9}

Execution context: the host process. Writing header and payload in a single write call keeps them adjacent even if another async handler writes concurrently — Node does not interleave the bytes of a single write. The 1 MB check mirrors Chrome’s host-to-browser limit; failing with a typed error is far easier to debug than a disconnect.

4. Dispatch with request ids and validation

 1const handlers = {
 2  async hello() { return { protocol: 3, version: "2.0.0", features: ["vault", "watch"] }; },
 3  async unlockState() { return { unlocked: vault.isUnlocked() }; },
 4  async readItem({ itemId }) {
 5    if (typeof itemId !== "string" || !/^[a-z0-9-]{1,64}$/.test(itemId)) {
 6      throw Object.assign(new Error("bad itemId"), { code: "invalid" });
 7    }
 8    return vault.read(itemId);
 9  },
10};
11
12async function dispatch(msg) {
13  const { id, type, ...args } = msg ?? {};
14  const fn = Object.hasOwn(handlers, type) ? handlers[type] : null;
15  if (!fn) return send({ id, error: { code: "unknown-type", type } });
16  try {
17    send({ id, result: await fn(args) });
18  } catch (err) {
19    send({ id, error: { code: err.code ?? "internal", message: err.message } });
20  }
21}

Execution context: the host process. Every request carries an id the extension chose, and every reply echoes it, so the extension can match replies arriving out of order from concurrent handlers. Object.hasOwn stops a message with type: "constructor" from reaching a prototype method. The allow-list of handlers is the security boundary — never add a generic exec or readFile(path) handler.

Concurrent requests over one portThe extension sends requests 1 and 2; the host handles them concurrently and replies to 2 before 1; the extension matches replies by id.Service workerHost dispatcherVault{id:1, type:readItem}{id:2, type:unlockState}read (slow){id:2, result:{unlocked:true}}item data{id:1, result:{…}}
Ids make ordering irrelevant — a slow handler never blocks a fast one.

5. Shut down cleanly on EOF and on errors

 1const parser = new FrameParser((m) => { dispatch(m); });
 2process.stdin.on("data", (chunk) => {
 3  try { parser.push(chunk); }
 4  catch (err) { log("fatal parse error:", err.message); process.exit(2); }
 5});
 6process.stdin.on("end", () => { log("stdin closed; exiting"); process.exit(0); });
 7process.on("uncaughtException", (err) => { log("uncaught:", err.stack); process.exit(1); });
 8process.on("unhandledRejection", (err) => { log("unhandled:", err?.stack ?? err); });
 9
10log("started with args", JSON.stringify(process.argv.slice(2)));

Execution context: the host process. end fires when the browser disconnects the port or, for sendNativeMessage, after it has read the reply. Exiting immediately frees resources and avoids orphans. Logging argv at startup tells you which browser launched the host — Chrome passes an origin, Firefox passes a manifest path and add-on id.

6. Package it so users do not need Node

1# Node 22+ single executable application
2node --experimental-sea-config sea-config.json
3cp "$(command -v node)" dist/vault-host
4npx postject dist/vault-host NODE_SEA_BLOB sea-prep.blob \
5  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

Execution context: your build machine, once per target OS and architecture. The host manifest’s path must point at an executable; pointing it at main.js works on macOS and Linux only if the shebang resolves to a Node the browser’s environment can find — and browsers launch hosts with a minimal PATH, so it usually cannot. A single executable removes that dependency. On macOS, sign and notarise the binary or Gatekeeper blocks it on first launch. Shipping is covered in shipping a native host with an installer.

The extension side, for completeness

 1// sw.js
 2const pending = new Map();
 3let nextId = 1;
 4
 5export function request(port, msg, timeoutMs = 10_000) {
 6  const id = nextId++;
 7  port.postMessage({ id, ...msg });
 8  return new Promise((resolve, reject) => {
 9    const t = setTimeout(() => { pending.delete(id); reject(new Error("host timeout")); }, timeoutMs);
10    pending.set(id, { resolve, reject, t });
11  });
12}
13
14export function onHostMessage({ id, result, error }) {
15  const p = pending.get(id);
16  if (!p) return handleHostEvent(result);           // unsolicited push
17  pending.delete(id); clearTimeout(p.t);
18  error ? p.reject(Object.assign(new Error(error.message ?? error.code), error)) : p.resolve(result);
19}

Execution context: the service worker. Rejecting every pending request in port.onDisconnect completes the picture, so callers never wait forever on a host that crashed. Firefox and Chrome behave identically here.

Cross-browser variation

  • Chrome / Edge: argv[2] is the caller origin (after node and the script path when run unpackaged); on Windows a --parent-window argument follows. Chrome kills the host when the port closes.
  • Firefox: argv carries the host manifest path and the add-on id. Firefox also closes stdin on disconnect, and the same EOF handling works unchanged.
  • Safari: a Node host is not used. The containing macOS app handles messages in Swift; a Node core can still be bundled and invoked from the app if you need to share logic.
Packaging options for a Node hostComparison of shipping a script with a system Node, a single executable application, and a bundled Node runtime on install size, startup time and dependency risk.ConcernScript + system NodeSingle executableBundled runtime dirUser needs NodeYesNoNoInstall sizeTiny~90 MB~90 MBPATH problemsCommonNoneNoneCode signingScript unsignedOne binaryMany files
A single executable is the default choice for consumer installs.

Verification

  1. Run the host by hand with framed input, as in the native messaging wire format, and confirm a correctly framed hello reply.
  2. Send two frames in a single write and one frame split across two writes; both must be answered.
  3. Close stdin (Ctrl-D) and confirm the process exits with code 0 and logs “stdin closed”.
  4. From the extension, open a port, send ten concurrent readItem requests, and confirm every promise resolves with the right item.
  5. Kill the host process from a terminal and confirm every pending request rejects and the port’s onDisconnect fires.

FAQ

Can the host use ES modules?

Yes. Add "type": "module" to the host’s package.json or use .mjs. Single executable applications currently expect a CommonJS entry point, so bundle with esbuild to one CommonJS file before packaging.

How do I debug the host with breakpoints?

Have the host open the inspector on startup when an environment variable is set: if (process.env.HOST_DEBUG) require("inspector").open(9229, "127.0.0.1", true). Browsers do not pass your shell environment through, so set it in a wrapper script the manifest points to during development.

Should one host serve several extensions?

It can — list each extension’s origin in allowed_origins — but check the caller origin argument and scope capabilities per extension. A shared host that trusts every caller equally widens the attack surface of all of them.

Other Core APIs & Cross-Browser Data Management Resources