GitHub avatar

Fox's Blog

✨ AI Generated Article

Running a Node.js library in the browser without Wasm --

How Fortune hand-rewrote node:fs, node:crypto, and a dozen more

Running a Node.js library in the browser without Wasm -- typescript-virtual-container's polyfills

I recently spent a good chunk of time diving into the source code of typescript-virtual-container, Fortune (Chloé Rolzhausen)'s project. And the part that surprised me the most wasn't the VFS, wasn't the virtual network, wasn't the 170 Unix commands reimplemented in TypeScript. It was the polyfills/ directory.

Because the module runs in the browser, without Wasm, and for that Fortune reimplemented by hand the entire node:* layer the library needs. About 640 lines of handcrafted JavaScript replacing node:fs, node:crypto, node:os, node:net, and a few others.

This article explains how it works, polyfill by polyfill.


The basic problem

A Node.js library uses APIs that don't exist in the browser. When you write import { readFileSync } from 'node:fs', that's a system call on the Node side -- real disk access via libuv. In the browser, node:fs doesn't exist at all.

The usual solutions are:

  • A Wasm runtime (Emscripten, WASIp1/WASIp2) -- compile Node.js to Wasm and run it. Result: 10-50 MB bundles, noticeable load time, significant deployment complexity.
  • Generic polyfills (browserify, webpack node: polyfills) -- npm libraries providing approximations of each Node module. Often too heavy, poorly suited to the specific use case.
  • Write the polyfills by hand -- more work, but optimal result.

Fortune chose the third option. And the result is a browser bundle that's just the library, starts instantly, and doesn't depend on any external infrastructure.


The build mechanism

Everything relies on esbuild and its alias option. Each node:* import is redirected to a local file:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

The inject option is worth noting: it injects process.js and buffer.js at the top of every bundled file, making process and Buffer globally available without any explicit import. Exactly like Node.js exposes them natively.


buffer.js -- Buffer on Uint8Array

This is one of the two injected globals. Buffer is heavily used in the code -- every SSH operation, every VFS snapshot, every binary read/write goes through it.

The solution: a BrowserBuffer class extending Uint8Array that implements the full Node.js Buffer API.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

What's implemented in total:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • All write methods: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • All corresponding read methods
  • toString with hex, base64, utf8 support
  • copy, equals, slice, subarray

That's 116 lines. For what it replaces, that's remarkably compact.

The main trick is using DataView for multi-byte access, which correctly handles endianness without having to manipulate bits by hand for every type:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- the minimal process global

The other injected global. Tiny but necessary -- the code often checks process.env.NODE_ENV, process.platform, process.nextTick, etc.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

The nextTick → queueMicrotask mapping is the most important detail here. process.nextTick in Node.js schedules a callback at the end of the current phase of the event loop, before I/O. queueMicrotask in the browser does something semantically very close -- it schedules a microtask that runs before the next render or event. It's not identical, but it's close enough that all the code using nextTick works correctly in the browser.


node:fs -- IndexedDB as a synchronous file system

This is the most sophisticated polyfill, and by far the most technically interesting.

The problem is tricky: node:fs exposes a synchronous API (readFileSync, writeFileSync, etc.), but browser storage APIs are all asynchronous (IndexedDB, Cache API, etc.). You can't await in the middle of a synchronous function.

Fortune's solution: a two-level cache.

Level 1 -- In-memory Map (synchronous)
All reads happen from an in-memory Map<string, Uint8Array>. Instant, synchronous, no API mismatch.

Level 2 -- IndexedDB (asynchronous, background)
On startup, all IndexedDB content is loaded into the Map. Writes happen immediately to the Map and trigger an asynchronous write to IndexedDB without blocking.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload on startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Async write to IndexedDB (non-blocking)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

The exposed API is complete: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (with recursive option), mkdirSync (with recursive option), readdirSync, statSync, renameSync.

There's even a file descriptor management layer (openSync, writeSync, closeSync) so the VFS's WAL journal works in browser mode -- the journal opens an fd, writes to it, closes it, and the data ends up in IndexedDB.

The ready property is exported so code can know when the initial preload is finished:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

This is how VFS snapshots survive page reloads in the browser. When you reload the demo, the VFS is restored exactly as you left it, from IndexedDB, without any server involvement.


node:crypto -- SHA-256, HMAC, PBKDF2 in pure JS

Rather than importing a crypto library compiled to Wasm, Fortune implemented the needed primitives directly.

SHA-256 is implemented from scratch with the FIPS 180-4 constants:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 more constants */ 
]);

function sha256(data) {
  // FIPS 180-4 padding
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 compression rounds
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

On top of SHA-256, HMAC-SHA256 and PBKDF2-HMAC-SHA256 are built. These two primitives are used for key derivation in SSH exchanges and internal authentication.

The exported API mirrors Node.js:

// Standard hash
const hash = createHash('sha256').update('data').digest('hex');

// Random bytes (via Web Crypto API)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Timing-safe comparison
const ok = timingSafeEqual(a, b);

// scrypt (approximated via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Note: scryptSync is approximated via PBKDF2 with an iteration count calibrated to the N parameter. It's not real scrypt (which uses a different memory-hard scheme), but it's sufficient for the project's use cases.

Functions that can't reasonably be emulated in the browser (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) throw an explicit error if called. Honest behavior.


node:os -- reading real browser specs

Instead of returning fixed values, this polyfill reads browser APIs to return information matching the user's real machine.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Concrete result: when you run neofetch in the browser demo, the core count and RAM shown match your actual machine. It's a detail, but it contributes enormously to the terminal's believability.

Other exports: freemem (40% of total memory, reasonable approximation), platform → 'browser', type → 'Linux', release → 'web', uptime via performance.now(), endianness → 'LE' (true on all consumer x86/ARM CPUs), loadavg → [0, 0, 0].


node:net -- clean TCP stubs

The browser doesn't have access to raw TCP sockets (WebSocket doesn't count -- it's a different application-layer protocol). node:net is therefore a stub, but a well-written stub.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

The key point: the event registration methods (on, once, off, emit) return this and don't throw. This lets code doing new net.Socket().on('connect', cb) work without crashing, even though the connection never happens. Only methods that actually attempt to connect throw an error.

isIP, isIPv4, isIPv6 are implemented correctly (not stubs) because they're used by the virtual network code to validate addresses without ever opening a socket.


node:path -- POSIX path operations

Complete reimplementation of POSIX path operations, adapted to context (no Windows backslashes, paths always absolute with /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Simple, compact, correct for the project's use cases.


node:url -- delegation to browser APIs

This one is elegant in its simplicity. The URL and URLSearchParams APIs already exist natively in the browser -- just re-export them.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Only fileURLToPath and pathToFileURL need an implementation, since they're Node-specific:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

This is the ideal approach when the target platform (the browser) already provides the native equivalent.


node:zlib -- identity

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Two lines. The library uses fflate for actual compression (which works in the browser natively). node:zlib is only imported in code paths that don't execute in the browser context -- so a passthrough is sufficient.

Sometimes the right implementation is two lines.


node:events -- minimal EventEmitter

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Node.js's full EventEmitter implementation is ~600 lines with maxListeners, once, prependListener, etc. Here it's 12 lines for the 4 methods actually used. Mental tree-shaking before the build tool's tree-shaking.


ssh2 and roxify -- explicit stubs

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

The SSH server doesn't run in the browser (it wouldn't make sense -- who would connect?). But the code that talks about SSH on the client side -- the classes building SSH packets, the protocol parsers -- exists in the library. These stubs let all that code be bundled without error, while ensuring a clear error is thrown if someone tries to call a method that needs a real socket.

roxify is a proprietary compression format used for VFS snapshots in Node mode. In the browser, fflate is used instead -- the polyfill just throws an error if roxify is called directly.


node:worker_threads -- re-exporting Web Workers

This is the subtlest one. node:worker_threads in Node.js and Web Workers in the browser are different APIs, but they're conceptually close. The polyfill maps them:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel and MessagePort are re-exported directly from the browser (same API). Worker itself needs a wrapper because the constructor differs (Node expects a module path, the browser expects a URL). isMainThread is always true on the browser side in this context.


Overview: what these 640 lines represent

Polyfill Lines Strategy
buffer.js 116 Uint8Array + DataView, full Buffer API
node:crypto 166 SHA-256/HMAC/PBKDF2 from scratch + Web Crypto for randoms
node:fs 210 In-memory Map + async IndexedDB
node:net 70 Chainable stubs + real IP validation
ssh2 74 Explicit stubs
process.js 14 Minimal viable process
node:path ~30 POSIX path ops
node:url ~25 Browser API delegation
node:events ~12 EventEmitter 4 methods
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Web Workers re-export
roxify.js 8 Stubs

640 lines. Zero npm dependencies. Zero Wasm. And it produces a browser bundle that starts in under a second and runs without any server-side infrastructure.


Takeaways

Next time you want to port a Node.js library to the browser, here's what Fortune's approach demonstrates:

  1. Identify what's actually used. No need to implement the full EventEmitter if the code only uses on, emit, and removeListener.

  2. Delegate to browser APIs when possible. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- the browser already has them, might as well use them.

  3. Synchronous cache in front of an async API. The Map + IndexedDB solution for node:fs is the most reusable pattern in the entire polyfill directory.

  4. Honest stubs are better than silently incomplete implementations. An explicit throw new Error('not implemented in browser') is infinitely more useful than a return undefined that lets the bug surface 10 calls later.

  5. esbuild alias + inject is underrated. It's the perfect tool for this kind of porting -- zero webpack config, zero plugins, just a list of replacements.


The code is in the repo: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Every file fits on a single page, readable directly on GitHub. Highly recommended if you're working on a similar project.

✨ AI Generated Article

Faire tourner une bibliothèque Node.js dans le navigateur sans Wasm --

Comment Fortune a réimplémenté à la main node:fs, node:crypto et

Faire tourner une bibliothèque Node.js dans le navigateur sans Wasm -- les polyfills de typescript-virtual-container

J'ai récemment passé pas mal de temps à éplucher le code source de typescript-virtual-container, le projet de Fortune (Chloé Rolzhausen). Et la partie qui m'a le plus surpris, c'est pas le VFS, c'est pas le réseau virtuel, c'est pas les 170 commandes Unix réimplémentées en TypeScript. C'est le dossier polyfills/.

Parce que le module tourne dans le navigateur, sans Wasm, et que pour ça, Fortune a réimplémenté à la main toute la couche node:* dont la bibliothèque a besoin. Environ 640 lignes de JavaScript artisanal qui remplacent node:fs, node:crypto, node:os, node:net, et quelques autres.

Cet article explique comment ça marche, polyfill par polyfill.


Le problème de base

Une bibliothèque Node.js utilise des APIs qui n'existent pas dans le navigateur. Quand tu écris import { readFileSync } from 'node:fs', c'est un appel système côté Node -- un vrai accès disque via libuv. Dans le navigateur, node:fs n'existe pas du tout.

Les solutions habituelles sont :

  • Un runtime Wasm (type Emscripten, WASIp1/WASIp2) -- tu compile Node.js en Wasm et tu l'exécutes. Résultat : des bundles de 10-50 MB, un temps de chargement notable, une complexité de déploiement significative.
  • Des polyfills génériques (type browserify, webpack node: polyfills) -- des bibliothèques npm qui fournissent des approximations de chaque module Node. Souvent trop lourdes, mal adaptées au cas spécifique.
  • Récrire les polyfills à la main -- plus de travail, mais résultat optimal.

Fortune a choisi la troisième option. Et le résultat, c'est un bundle navigateur qui est juste la bibliothèque, qui démarre instantanément, et qui ne dépend d'aucune infrastructure extérieure.


La mécanique de build

Tout repose sur esbuild et son option alias. Chaque import node:* est redirigé vers un fichier local :

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

L'option inject mérite d'être notée : elle permet d'injecter process.js et buffer.js en tête de chaque fichier du bundle, ce qui rend process et Buffer disponibles globalement sans aucun import explicite. Exactement comme Node.js les expose nativement.


buffer.js -- Buffer sur Uint8Array

C'est l'un des deux globaux injectés. Buffer est massivement utilisé dans le code -- chaque opération SSH, chaque snapshot VFS, chaque lecture/écriture binaire passe par là.

La solution : une classe BrowserBuffer qui étend Uint8Array et implémente toute l'API Buffer de Node.js.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

Ce qui est implémenté au total :

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Toutes les méthodes d'écriture : writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Toutes les méthodes de lecture correspondantes
  • toString avec support hex, base64, utf8
  • copy, equals, slice, subarray

Ça représente 116 lignes. Pour ce que ça remplace, c'est remarquablement compact.

L'astuce principale est l'utilisation de DataView pour les accès multi-octets, ce qui gère correctement l'endianness sans avoir à manipuler les bits à la main pour chaque type :

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- le global process minimal

L'autre global injecté. Minuscule mais nécessaire -- le code teste souvent process.env.NODE_ENV, process.platform, process.nextTick, etc.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

Le mapping nextTick → queueMicrotask est le détail le plus important ici. process.nextTick dans Node.js schedule un callback à la fin de la phase courante de la boucle d'événements, avant les I/O. queueMicrotask dans le navigateur fait quelque chose de sémantiquement très proche -- il schedule une microtâche, qui s'exécute avant le prochain rendu ou événement. C'est pas identique, mais c'est suffisamment proche pour que tout le code qui utilise nextTick fonctionne correctement dans le browser.


node:fs -- IndexedDB comme système de fichiers synchrone

C'est le polyfill le plus sophistiqué, et de loin le plus intéressant techniquement.

Le problème est délicat : node:fs expose une API synchrone (readFileSync, writeFileSync, etc.), mais les APIs de stockage browser sont toutes asynchrones (IndexedDB, Cache API, etc.). On ne peut pas faire un await au milieu d'une fonction synchrone.

La solution de Fortune : un double niveau de cache.

Niveau 1 -- Map en mémoire (synchrone)
Toutes les lectures se font depuis un Map<string, Uint8Array> en mémoire. Instantané, synchrone, pas de problème d'API.

Niveau 2 -- IndexedDB (asynchrone, en arrière-plan)
Au démarrage, tout le contenu d'IndexedDB est chargé dans la Map. Les écritures se font immédiatement dans la Map et lancent une écriture asynchrone vers IndexedDB sans bloquer.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload au démarrage
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Écriture async vers IndexedDB (non-bloquante)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

L'API exposée est complète : readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (avec option recursive), mkdirSync (avec option recursive), readdirSync, statSync, renameSync.

Il y a même une couche de gestion de file descriptors (openSync, writeSync, closeSync) pour que le journal WAL du VFS fonctionne en mode browser -- le journal ouvre un fd, écrit dedans, le ferme, et les données se retrouvent dans IndexedDB.

La propriété ready est exportée pour permettre au code de savoir quand le preload initial est terminé :

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

C'est grâce à ça que les snapshots VFS survivent aux rechargements de page dans le navigateur. Quand tu recharges le démo, le VFS est restauré exactement dans l'état où tu l'avais laissé, depuis IndexedDB, sans aucun serveur impliqué.


node:crypto -- SHA-256, HMAC, PBKDF2 en JS pur

Plutôt que d'importer une bibliothèque crypto compilée en Wasm, Fortune a implémenté les primitives nécessaires directement.

SHA-256 est implémenté from scratch avec les constantes FIPS 180-4 :

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 autres constantes */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 rounds de compression
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

Par-dessus SHA-256, HMAC-SHA256 et PBKDF2-HMAC-SHA256 sont construits. Ces deux primitives sont utilisées pour la dérivation de clés dans les échanges SSH et l'authentification interne.

L'API exportée ressemble à celle de Node.js :

// Hash classique
const hash = createHash('sha256').update('data').digest('hex');

// Bytes aléatoires (via l'API Web Crypto standard)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Comparaison timing-safe
const ok = timingSafeEqual(a, b);

// scrypt (approché via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Note : scryptSync est approximé via PBKDF2 avec un nombre d'itérations calé sur le paramètre N. C'est pas un vrai scrypt (qui utilise un schéma mémoire différent), mais pour les usages du projet c'est suffisant.

Les fonctions qui ne peuvent pas être émulées raisonnablement dans le browser (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) lancent une erreur explicite si elles sont appelées. Comportement honnête.


node:os -- lire les vraies specs du navigateur

Au lieu de retourner des valeurs fixes, ce polyfill lit les APIs du navigateur pour retourner des informations qui correspondent à la machine réelle de l'utilisateur.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB par défaut
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Résultat concret : quand tu lances neofetch dans le démo navigateur, le nombre de coeurs et la RAM affichés correspondent à ta machine. C'est un détail, mais ça contribue énormément à la vraisemblance du terminal.

Les autres exports : freemem (40% de la mémoire totale, approximation raisonnable), platform → 'browser', type → 'Linux', release → 'web', uptime via performance.now(), endianness → 'LE' (little-endian, vrai sur tous les processeurs x86/ARM grand public), loadavg → [0, 0, 0].


node:net -- stubs TCP propres

Le navigateur n'a pas accès aux sockets TCP bruts (WebSocket ne compte pas -- c'est un protocole applicatif différent). node:net est donc un stub, mais un stub bien écrit.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chaînable
  once() { return this; }  // chaînable
  pipe() { return this; }  // chaînable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

Le point important : les méthodes de registre d'événements (on, once, off, emit) retournent this et ne lancent pas d'erreur. Ça permet au code qui fait new net.Socket().on('connect', cb) de fonctionner sans planter, même si la connexion ne se fait jamais. Seules les méthodes qui tentent réellement de se connecter lancent une erreur.

isIP, isIPv4, isIPv6 sont implémentés correctement (pas des stubs) car ils sont utilisés par le code réseau virtuel pour valider des adresses, sans jamais ouvrir de socket.


node:path -- POSIX path operations

Réimplémentation complète des opérations de chemin POSIX, adaptée au contexte (pas de backslash Windows, chemins toujours absolus avec /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Simple, compact, correct pour les usages du projet.


node:url -- délégation aux APIs navigateur

Celle-là est élégante par sa simplicité. L'API URL et URLSearchParams existe déjà nativement dans le navigateur -- il suffit de les ré-exporter.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Seules fileURLToPath et pathToFileURL nécessitent une implémentation, car elles sont propres à Node :

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

C'est l'approche idéale quand la plateforme cible (le navigateur) fournit déjà l'équivalent natif.


node:zlib -- identité

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Deux lignes. La bibliothèque utilise fflate pour la compression réelle (qui fonctionne en browser nativement). node:zlib n'est importé que dans des chemins de code qui ne s'exécutent pas dans le contexte browser -- donc un passthrough est suffisant.

Parfois la bonne implémentation c'est deux lignes.


node:events -- EventEmitter minimal

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

L'implémentation complète de EventEmitter de Node.js fait ~600 lignes avec la gestion des maxListeners, once, prependListener, etc. Ici c'est 12 lignes pour les 4 méthodes réellement utilisées. Tree-shaking mental avant même le tree-shaking de l'outil de build.


ssh2 et roxify -- stubs explicites

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

Le serveur SSH ne tourne pas dans le navigateur (ça n'aurait aucun sens -- qui se connecterait ?). Mais le code qui parle de SSH côté client -- les classes qui construisent des paquets SSH, les parseurs de protocole -- existe dans la bibliothèque. Ces stubs permettent à tout ce code d'être bundlé sans erreur, tout en garantissant qu'une erreur claire est levée si quelqu'un tente d'appeler une méthode qui nécessite un vrai socket.

roxify est un format de compression propriétaire utilisé pour les snapshots VFS en mode Node. Dans le navigateur, c'est fflate qui est utilisé à la place -- le polyfill se contente de lancer une erreur si roxify est appelé directement.


node:worker_threads -- réexport des Web Workers

C'est le plus subtil. node:worker_threads dans Node.js et les Web Workers du navigateur sont deux APIs différentes, mais elles sont conceptuellement proches. Le polyfill fait le mapping :

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel et MessagePort sont ré-exportés directement depuis le browser (même API). Worker lui-même nécessite un wrapper car le constructeur est différent (Node attend un chemin de module, le browser attend une URL). isMainThread est toujours true côté navigateur dans ce contexte.


Vue d'ensemble : ce que représentent ces 640 lignes

Polyfill Lignes Stratégie
buffer.js 116 Uint8Array + DataView, toute l'API Buffer
node:crypto 166 SHA-256/HMAC/PBKDF2 from scratch + Web Crypto pour les randoms
node:fs 210 Map en mémoire + IndexedDB async
node:net 70 Stubs chainables + validations IP réelles
ssh2 74 Stubs explicites
process.js 14 Minimal viable process
node:path ~30 POSIX path ops
node:url ~25 Délégation aux APIs browser
node:events ~12 EventEmitter 4 méthodes
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Réexport Web Workers
roxify.js 8 Stubs

640 lignes. Aucune dépendance npm. Aucun Wasm. Et ça donne un bundle navigateur qui démarre en moins d'une seconde et tourne sans aucune infrastructure côté serveur.


Ce qu'on peut en tirer

La prochaine fois que tu veux porter une bibliothèque Node.js dans le navigateur, voilà ce que l'approche de Fortune démontre :

  1. Identifie ce qui est réellement utilisé. Pas besoin d'implémenter EventEmitter en entier si le code n'utilise que on, emit, et removeListener.

  2. Délègue aux APIs navigateur quand c'est possible. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- le navigateur les a déjà, autant les utiliser.

  3. Le cache synchrone devant une API async. La solution Map + IndexedDB pour node:fs est le pattern le plus réutilisable de tout le dossier.

  4. Les stubs honnêtes valent mieux que les implémentations incomplètes silencieuses. Un throw new Error('not implemented in browser') explicit est infiniment plus utile qu'un return undefined qui laisse le bug se manifester 10 appels plus loin.

  5. esbuild alias + inject est sous-estimé. C'est l'outil parfait pour ce genre de portage -- zéro configuration webpack, zéro plugin, juste une liste de remplacements.


Le code est dans le repo : github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Chaque fichier tient en une seule page, c'est lisible directement sur GitHub. Fortement recommandé si tu travailles sur un projet similaire.

✨ AI Generated Article

如何让 Node.js 库在浏览器中运行而无需 Wasm -- typescript-virtual-container 的 polyfill

Fortune 如何用 640 行 JavaScript 手动重新实现了 node:fs、node:crypto 等一打 Node

如何让 Node.js 库在浏览器中运行而无需 Wasm -- typescript-virtual-container 的 polyfill

我最近花了不少时间阅读 typescript-virtual-container 的源代码,这是 Fortune (Chloé Rolzhausen) 的项目。最让我惊讶的部分不是 VFS,不是虚拟网络,也不是用 TypeScript 重写的 170 个 Unix 命令,而是 polyfills/ 文件夹。

因为这个模块在浏览器中运行,没有 Wasm,而且为此 Fortune 手动重写了库所需的整个 node:* 层。大约 640 行手工 JavaScript,替代了 node:fs、node:crypto、node:os、node:net 等模块。

这篇文章将逐一解释每个 polyfill 的工作原理。


基本问题

Node.js 库使用了浏览器中不存在的 API。当你写 import { readFileSync } from 'node:fs' 时,这是 Node 端的系统调用 ---- 通过 libuv 的真实磁盘访问。在浏览器中,node:fs 根本不存在。

常见的解决方案:

  • Wasm 运行时(如 Emscripten、WASIp1/WASIp2)---- 你把 Node.js 编译成 Wasm 然后运行它。结果:10-50 MB 的 bundle,明显的加载时间,显著的部署复杂度。
  • 通用 polyfill(如 browserify、webpack node: polyfills)---- 提供每个 Node 模块近似实现的 npm 库。通常过于臃肿,不适合特定场景。
  • 手写 polyfill ---- 工作量更大,但结果最优。

Fortune 选择了第三个方案。结果就是:一个纯粹的浏览器端 bundle,即刻启动,不依赖任何外部基础设施。


构建机制

一切都基于 esbuild 及其 alias 选项。每个 node:* 导入都被重定向到本地文件:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

inject 选项值得注意:它允许将 process.js 和 buffer.js 注入到 bundle 中每个文件的开头,使 process 和 Buffer 全局可用,无需任何显式导入。这正是 Node.js 原生提供它们的方式。


buffer.js -- 基于 Uint8Array 的 Buffer

这是两个全局注入文件之一。Buffer 在代码中被大量使用 ---- 每次 SSH 操作、每个 VFS 快照、每次二进制读写都经过它。

解决方案:一个继承 Uint8Array 并实现 Node.js 全部 Buffer API 的 BrowserBuffer 类。

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

总共实现了:

  • Buffer.from、Buffer.alloc、Buffer.allocUnsafe、Buffer.isBuffer、Buffer.concat、Buffer.byteLength
  • 所有写入方法:writeUInt8/16/32BE/LE、writeInt8/16/32BE/LE、writeBigUInt64BE/LE、writeFloat/DoubleLE/BE
  • 所有相应的读取方法
  • 支持 hex、base64、utf8 的 toString
  • copy、equals、slice、subarray

这只有 116 行。考虑到它替代的内容,相当紧凑。

主要的技巧是使用 DataView 进行多字节访问,它正确处理了字节序,无需为每种类型手动操作位:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- 最小的全局 process

另一个全局注入文件。很小但必不可少 ---- 代码经常检查 process.env.NODE_ENV、process.platform、process.nextTick 等。

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

nextTick → queueMicrotask 的映射是最重要的细节。Node.js 中的 process.nextTick 在当前事件循环阶段结束时、I/O 之前调度一个回调。浏览器中的 queueMicrotask 在语义上非常接近 ---- 它调度一个微任务,在下次渲染或事件之前执行。这不完全相同,但对于使用 nextTick 的所有代码来说,已经足够在浏览器中正常运行。


node:fs -- 将 IndexedDB 用作同步文件系统

这是最复杂的 polyfill,也是技术上最有趣的一个。

问题很微妙:node:fs 提供的是同步 API(readFileSync、writeFileSync 等),但浏览器的存储 API 都是异步的(IndexedDB、Cache API 等)。你不能在同步函数中间执行 await。

Fortune 的解决方案:双层缓存。

第一层 -- 内存中的 Map(同步)
所有读取都从内存中的 Map<string, Uint8Array> 进行。即时、同步、没有 API 问题。

第二层 -- IndexedDB(异步、后台)
启动时,IndexedDB 的所有内容被加载到 Map 中。写入立即进入 Map 并启动异步写入 IndexedDB,不会阻塞。

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload at startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Async write to IndexedDB (non-blocking)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

暴露的 API 很完整:readFileSync、writeFileSync、appendFileSync、existsSync、unlinkSync、rmSync(支持 recursive 选项)、mkdirSync(支持 recursive 选项)、readdirSync、statSync、renameSync。

甚至还有一个文件描述符管理层(openSync、writeSync、closeSync),让 VFS 的 WAL 日志在浏览器模式下工作 ---- 日志打开一个 fd,写入数据,关闭它,数据最终存入 IndexedDB。

导出 ready 属性,让代码知道初始预加载何时完成:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

正是凭借这一点,VFS 快照才能在浏览器中跨页面刷新而持久存在。当你刷新演示页面时,VFS 会从 IndexedDB 恢复到之前的状态,无需任何服务器参与。


node:crypto -- 纯 JS 实现的 SHA-256、HMAC、PBKDF2

Fortune 没有导入编译成 Wasm 的加密库,而是直接实现了所需的原语。

SHA-256 使用 FIPS 180-4 常量从头实现:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 other constants */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 rounds of compression
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

在 SHA-256 之上构建了 HMAC-SHA256 和 PBKDF2-HMAC-SHA256。这两个原语用于 SSH 密钥交换和内部认证中的密钥派生。

导出的 API 类似于 Node.js 的 API:

// Classic hash
const hash = createHash('sha256').update('data').digest('hex');

// Random bytes (via standard Web Crypto API)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Timing-safe comparison
const ok = timingSafeEqual(a, b);

// scrypt (approximated via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

注意:scryptSync 是通过 PBKDF2 近似的,迭代次数与参数 N 对应。这不是真正的 scrypt(它使用不同的内存方案),但对项目的用途来说已经足够了。

无法合理在浏览器中模拟的函数(generateKeyPairSync、createCipheriv、createDecipheriv、createSign)在被调用时会抛出明确的错误。诚实的行为。


node:os -- 读取浏览器的真实规格

这个 polyfill 不是返回固定值,而是读取浏览器的 API 来返回用户真实机器的信息。

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

实际效果:当你在浏览器演示中运行 neofetch 时,显示的核心数和内存数量与实际机器一致。这是一个小细节,但对终端的真实感贡献巨大。

其他导出项:freemem(总内存的 40%,合理的近似值)、platform → 'browser'、type → 'Linux'、release → 'web'、通过 performance.now() 实现的 uptime、endianness → 'LE'(小端序,对所有主流 x86/ARM 处理器都成立)、loadavg → [0, 0, 0]。


node:net -- 干净的存根

浏览器无法访问原始 TCP 套接字(WebSocket 不算 ---- 这是一个不同的应用层协议)。所以 node:net 是一个存根,但是一个写得很好的存根。

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

关键点:事件注册方法(on、once、off、emit)返回 this 且不抛出错误。这使得执行 new net.Socket().on('connect', cb) 的代码能正常运行而不会崩溃,即使连接从未建立。只有实际尝试连接的方法才抛出错误。

isIP、isIPv4、isIPv6 被正确实现(不是存根),因为它们被虚拟网络代码用来验证地址,而无需打开套接字。


node:path -- POSIX 路径操作

完整的 POSIX 路径操作重实现,适应当前上下文(没有 Windows 反斜杠,路径总是以 / 开头的绝对路径)。

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

简单、紧凑、对项目用途来说正确。


node:url -- 委托给浏览器 API

这个因其简洁性而优雅。URL 和 URLSearchParams API 已经原生存在于浏览器中 ---- 只需重新导出它们即可。

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

只有 fileURLToPath 和 pathToFileURL 需要实现,因为它们特定于 Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

当目标平台(浏览器)已经提供了原生等价物时,这是理想的做法。


node:zlib -- 透传

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

两行代码。库使用 fflate 进行实际的压缩(它在浏览器中原生工作)。node:zlib 只在不会在浏览器上下文中执行的代码路径中被导入 ---- 因此透传就足够了。

有时候正确的实现就是两行代码。


node:events -- 最小的 EventEmitter

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Node.js 中 EventEmitter 的完整实现约 600 行,包含 maxListeners、once、prependListener 等处理。这里只有 12 行代码处理实际使用的 4 个方法。在构建工具进行 tree-shaking 之前,先做了心智层面的 tree-shaking。


ssh2 和 roxify -- 显式存根

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

SSH 服务器不在浏览器中运行(这没有意义 ---- 谁会连接进来?)。但是在客户端侧谈论 SSH 的代码 ---- 构建 SSH 包的类、协议解析器 ---- 存在于库中。这些存根让所有这些代码能够被打包而不会出错,同时确保如果有人尝试调用需要真实套接字的方法,会抛出明确的错误。

roxify 是一种专有压缩格式,用于 Node 模式下的 VFS 快照。在浏览器中,使用 fflate 替代 ---- polyfill 只是在 roxify 被直接调用时抛出一个错误。


node:worker_threads -- 重新导出 Web Workers

这是最微妙的部分。Node.js 中的 node:worker_threads 和浏览器中的 Web Workers 是两个不同的 API,但它们在概念上是相似的。Polyfill 做了映射:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel 和 MessagePort 直接从浏览器重新导出(相同的 API)。Worker 本身需要一个包装,因为构造函数不同(Node 接受一个模块路径,浏览器接受一个 URL)。isMainThread 在浏览器端始终为 true。


概览:这 640 行代表了什么

Polyfill 行数 策略
buffer.js 116 Uint8Array + DataView,完整的 Buffer API
node:crypto 166 从头实现 SHA-256/HMAC/PBKDF2 + Web Crypto 生成随机数
node:fs 210 内存 Map + 异步 IndexedDB
node:net 70 可链式调用的存根 + 真实的 IP 验证
ssh2 74 显式存根
process.js 14 最小可行的 process
node:path ~30 POSIX 路径操作
node:url ~25 委托给浏览器 API
node:events ~12 4 个方法的 EventEmitter
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 透传
node:worker_threads ~30 重新导出 Web Workers
roxify.js 8 存根

640 行。没有 npm 依赖。没有 Wasm。结果是一个不到一秒就能启动、无需任何服务器端基础设施即可运行的浏览器 bundle。


可以学到的经验

下次你想把 Node.js 库移植到浏览器时,Fortune 的方法展示了以下几点:

  1. 找出实际使用的内容。 如果代码只使用 on、emit 和 removeListener,就无需实现完整的 EventEmitter。

  2. 尽可能委托给浏览器 API。 URL、URLSearchParams、MessageChannel、MessagePort、crypto.getRandomValues ---- 浏览器已经有了,直接使用它们。

  3. 在异步 API 前加同步缓存。 Map + IndexedDB 用于 node:fs 的方案是整个文件夹中最可复用的模式。

  4. 诚实的存根胜过沉默的不完整实现。 明确的 throw new Error('not implemented in browser') ---- 比让 bug 在 10 个调用后才显现的 return undefined 有用无数倍。

  5. esbuild 的 alias + inject 被低估了。 这是进行此类移植的完美工具 ---- 零 webpack 配置、零插件,只是一个替换列表。


代码在仓库中:github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills。每个文件只有一页,可以直接在 GitHub 上阅读。如果你在做类似的项目,强烈推荐。

✨ AI Generated Article

WasmなしでNode.jsライブラリをブラウザで動かす -- typescript-virtual-containerのpolyfill群

Fortuneがnode:fs、node:crypto、その他十数個のNodeモジュールを640行のJavaScriptで手書き再実装して、Wasmなしでコンテナをブラウザで動かす方法

WasmなしでNode.jsライブラリをブラウザで動かす -- typescript-virtual-containerのpolyfill群

最近、typescript-virtual-containerのソースコードをじっくり読んでたんだ。Fortune (Chloé Rolzhausen)のプロジェクトでね。で、一番驚いたのは、VFSでも仮想ネットワークでも、TypeScriptで再実装された170のUnixコマンドでもない。polyfills/ディレクトリだ。

なぜかって?このモジュールはブラウザで動くんだ、Wasmなしで。そのためにFortuneは、ライブラリが必要とするnode:*レイヤー全体を手書きで再実装してる。約640行の手作りJavaScriptで、node:fs、node:crypto、node:os、node:net、その他いくつかを置き換えてる。

この記事では、polyfillごとにどう動いてるかを説明する。


基本的な問題

Node.jsライブラリはブラウザに存在しないAPIを使う。import { readFileSync } from 'node:fs'と書くと、それはNode側のシステムコール -- libuv経由の実際のディスクアクセスだ。ブラウザにはnode:fsなんてまったくない。

よくある解決策は:

  • Wasmランタイム (Emscripten、WASIp1/WASIp2など) -- Node.jsをWasmにコンパイルして実行する。結果:10-50MBのバンドル、目立つロード時間、かなりのデプロイ複雑性。
  • 汎用polyfill (browserify、webpack node: polyfillsなど) -- 各Nodeモジュールの近似を提供するnpmライブラリ。大抵は重すぎて、特定のユースケースに合わない。
  • polyfillを手書きする -- 手間はかかるが、結果は最適。

Fortuneは3番目の選択肢を選んだ。その結果、ブラウザバンドルはライブラリそのものだけになり、即座に起動し、外部インフラに一切依存しない。


ビルドの仕組み

すべてはesbuildとそのaliasオプションに基づいてる。各node:*インポートはローカルファイルにリダイレクトされる:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

injectオプションは注目に値する:バンドル内の各ファイルの先頭にprocess.jsとbuffer.jsを注入し、明示的なインポートなしでprocessとBufferをグローバルに使えるようにしてる。まさにNode.jsがネイティブに公開しているのと同じ方法だ。


buffer.js -- Uint8Array上のBuffer

これは注入される2つのグローバルのうちの1つ。Bufferはコード内で大量に使われてる -- SSH操作、VFSスナップショット、バイナリ読み書きのすべてがこれを通る。

解決策:Uint8Arrayを拡張してNode.jsのBuffer API全体を実装したBrowserBufferクラス。

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

実装されてるもの:

  • Buffer.from、Buffer.alloc、Buffer.allocUnsafe、Buffer.isBuffer、Buffer.concat、Buffer.byteLength
  • すべての書き込みメソッド:writeUInt8/16/32BE/LE、writeInt8/16/32BE/LE、writeBigUInt64BE/LE、writeFloat/DoubleLE/BE
  • 対応するすべての読み取りメソッド
  • hex、base64、utf8をサポートするtoString
  • copy、equals、slice、subarray

116行だ。これが置き換えてるものを考えると、驚くほどコンパクトだ。

主なトリックは、マルチバイトアクセスにDataViewを使っていること。これでエンディアンネスを正しく処理でき、型ごとに手動でビット操作する必要がない:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- 最小限のprocessグローバル

もう1つの注入されるグローバル。小さいけど必要 -- コードはしばしばprocess.env.NODE_ENV、process.platform、process.nextTickなどをチェックする。

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

nextTick → queueMicrotaskのマッピングがここで最も重要な詳細だ。Node.jsのprocess.nextTickはイベントループの現在のフェーズの最後、I/Oの前にコールバックをスケジュールする。ブラウザのqueueMicrotaskは意味的に非常に近いことをする -- マイクロタスクをスケジュールし、次のレンダリングやイベントの前に実行される。同一ではないが、nextTickを使うすべてのコードがブラウザで正しく動作するのに十分近い。


node:fs -- 同期ファイルシステムとしてのIndexedDB

これが最も洗練されたpolyfillで、技術的に断然一番面白い。

問題は厄介だ:node:fsは同期API(readFileSync、writeFileSyncなど)を公開しているが、ブラウザのストレージAPIはすべて非同期だ(IndexedDB、Cache APIなど)。同期関数の途中でawaitはできない。

Fortuneの解決策:二重キャッシュ。

レベル1 -- インメモリMap(同期) すべての読み取りはインメモリのMap<string, Uint8Array>から行われる。即座、同期、APIの問題なし。

レベル2 -- IndexedDB(非同期、バックグラウンド) 起動時に、IndexedDBの全コンテンツがMapにロードされる。書き込みは即座にMapに行われ、かつブロックせずに非同期でIndexedDBへの書き込みを開始する。

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload at startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Async write to IndexedDB (non-blocking)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

公開されるAPIは完全だ:readFileSync、writeFileSync、appendFileSync、existsSync、unlinkSync、rmSync(recursiveオプション付き)、mkdirSync(recursiveオプション付き)、readdirSync、statSync、renameSync。

VFSのWALジャーナルがブラウザモードで動作するように、ファイルディスクリプタ管理レイヤー(openSync、writeSync、closeSync)まである -- ジャーナルはfdを開き、書き込み、閉じると、データがIndexedDBに反映される。

readyプロパティがエクスポートされていて、初期プリロードが完了したことをコードが知ることができる:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

これのおかげで、VFSスナップショットがブラウザでのページ再読み込み後も保持される。デモをリロードすると、VFSはサーバーを一切介さずに、IndexedDBから正確に以前の状態に復元される。


node:crypto -- 純粋なJSのSHA-256、HMAC、PBKDF2

Wasmにコンパイルされた暗号ライブラリをインポートする代わりに、Fortuneは必要なプリミティブを直接実装した。

SHA-256はFIPS 180-4定数を使ってスクラッチから実装されている:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 more constants */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 rounds of compression
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

SHA-256の上に、HMAC-SHA256とPBKDF2-HMAC-SHA256が構築されている。これらの2つのプリミティブは、SSHハンドシェイクと内部認証での鍵導出に使われる。

エクスポートされるAPIはNode.jsのものに近い:

// Classic hash
const hash = createHash('sha256').update('data').digest('hex');

// Random bytes (via standard Web Crypto API)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Timing-safe comparison
const ok = timingSafeEqual(a, b);

// scrypt (approximated via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

注意:scryptSyncはPBKDF2経由で近似され、イテレーション回数はパラメータNに合わせて調整されている。本当のscrypt(メモリの異なるスキームを使う)ではないが、プロジェクトの用途には十分だ。

ブラウザで合理的にエミュレートできない関数(generateKeyPairSync、createCipheriv、createDecipheriv、createSign)は、呼び出されると明示的なエラーを投げる。正直な振る舞いだ。


node:os -- ブラウザの実際のスペックを読む

固定値を返す代わりに、このpolyfillはブラウザのAPIを読み取って、ユーザーの実際のマシンに対応する情報を返す。

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB by default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

実際の結果:ブラウザデモでneofetchを実行すると、表示されるコア数とRAMが自分のマシンに対応する。細かいことだが、ターミナルのリアリティに大きく貢献している。

その他のエクスポート:freemem(合計メモリの40%、妥当な近似)、platform → 'browser'、type → 'Linux'、release → 'web'、uptimeはperformance.now()経由、endianness → 'LE'(リトルエンディアン、すべての一般的なx86/ARMプロセッサで正しい)、loadavg → [0, 0, 0]。


node:net -- きれいなTCPスタブ

ブラウザは生のTCPソケットにアクセスできない(WebSocketは別のアプリケーション層プロトコルなのでカウントされない)。なのでnode:netはスタブだが、よく書かれたスタブだ。

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

重要な点:イベント登録メソッド(on、once、off、emit)はthisを返し、エラーを投げない。これにより、new net.Socket().on('connect', cb)のようなコードが、接続が実際に行われることはなくても、クラッシュせずに動作する。実際に接続を試みるメソッドだけがエラーを投げる。

isIP、isIPv4、isIPv6は(スタブではなく)正しく実装されている。なぜなら、仮想ネットワークコードがソケットを開くことなくアドレスを検証するために使っているからだ。


node:path -- POSIXパス操作

POSIXパス操作の完全な再実装。コンテキストに合わせて調整済み(Windowsのバックスラッシュなし、パスは常に/で絶対パス)。

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

シンプル、コンパクト、プロジェクトの用途には正しい。


node:url -- ブラウザAPIへの委譲

これはそのシンプルさゆえにエレガントだ。URLとURLSearchParamsのAPIはブラウザにネイティブに存在する -- 再エクスポートするだけでいい。

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

fileURLToPathとpathToFileURLだけは実装が必要だ。なぜなら、これらはNode固有だから:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

ターゲットプラットフォーム(ブラウザ)がすでにネイティブの同等機能を提供している場合の理想的なアプローチだ。


node:zlib -- アイデンティティ

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

2行。ライブラリは実際の圧縮にfflateを使っている(ブラウザでネイティブに動作する)。node:zlibはブラウザコンテキストでは実行されないコードパスでのみインポートされる -- なのでパススルーで十分だ。

時には正しい実装は2行で済む。


node:events -- 最小限のEventEmitter

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Node.jsの完全なEventEmitterの実装は、maxListeners、once、prependListenerなどの管理を含めて約600行だ。ここでは実際に使われている4つのメソッドに対して12行。ビルドツールのtree-shakingの前に、メンタルなtree-shakingを行っている。


ssh2とroxify -- 明示的なスタブ

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

SSHサーバーはブラウザでは動作しない(意味がない -- 誰が接続するんだ?)。しかし、SSHについてクライアント側で語るコード -- SSHパケットを構築するクラス、プロトコルパーサー -- はライブラリ内に存在する。これらのスタブにより、そのようなコードがすべてエラーなくバンドルされ、実際のソケットを必要とするメソッドを誰かが呼び出そうとすると明確なエラーが発生することが保証される。

roxifyはNodeモードでVFSスナップショットに使われる独自の圧縮フォーマットだ。ブラウザでは代わりにfflateが使われる -- polyfillはroxifyが直接呼び出された場合にエラーを投げるだけだ。


node:worker_threads -- Web Workersの再エクスポート

これが最も微妙だ。Node.jsのnode:worker_threadsとブラウザのWeb Workersは異なるAPIだが、概念的には近い。polyfillがマッピングを行う:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannelとMessagePortはブラウザから直接再エクスポートされる(同じAPI)。Worker自体はコンストラクタが異なるため(Nodeはモジュールパス、ブラウザはURLを期待する)ラッパーが必要。isMainThreadはこのコンテキストではブラウザ側で常にtrueだ。


概要:これらの640行が表すもの

Polyfill 行数 戦略
buffer.js 116 Uint8Array + DataView、完全なBuffer API
node:crypto 166 SHA-256/HMAC/PBKDF2をスクラッチから + ランダム用Web Crypto
node:fs 210 インメモリMap + 非同期IndexedDB
node:net 70 チェーン可能なスタブ + 実際のIP検証
ssh2 74 明示的なスタブ
process.js 14 最小 viable process
node:path ~30 POSIXパス操作
node:url ~25 ブラウザAPIへの委譲
node:events ~12 4メソッドのEventEmitter
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 パススルー
node:worker_threads ~30 Web Workersの再エクスポート
roxify.js 8 スタブ

640行。npmの依存関係ゼロ。Wasmなし。そして、1秒足らずで起動し、サーバーサイドインフラを一切必要としないブラウザバンドルができあがる。


ここから学べること

次にNode.jsライブラリをブラウザに移植したいとき、Fortuneのアプローチが示しているのは:

  1. 実際に使われているものを特定する。 コードがon、emit、removeListenerしか使っていないなら、EventEmitter全体を実装する必要はない。

  2. 可能な限りブラウザAPIに委譲する。 URL、URLSearchParams、MessageChannel、MessagePort、crypto.getRandomValues -- ブラウザはすでに持っている、使おう。

  3. 非同期APIの前の同期キャッシュ。 node:fsのMap + IndexedDBの解決策は、このディレクトリ全体で最も再利用可能なパターンだ。

  4. 正直なスタブは、不完全な実装を黙って行うよりまし。 明示的なthrow new Error('not implemented in browser')は、10呼び出し先でバグが顕在化するreturn undefinedよりも無限に役立つ。

  5. esbuildのalias + injectは過小評価されている。 この種の移植に最適なツールだ -- webpack設定ゼロ、プラグインゼロ、単なる置き換えリスト。


コードはリポジトリにある:github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills。各ファイルは1ページに収まり、GitHub上で直接読める。似たようなプロジェクトに取り組んでいるなら、強くおすすめする。

✨ AI Generated Article

Wasm 없이 Node.js 라이브러리를 브라우저에서 돌리기 -- typescript-virtual-container의 polyfill들

Fortune이 node:fs, node:crypto, 그리고 수십 개의 Node 모듈을 640줄의 JavaScript로

Wasm 없이 Node.js 라이브러리를 브라우저에서 돌리기 -- typescript-virtual-container의 polyfill들

최근에 typescript-virtual-container 소스 코드를 열심히 뜯어봤어. Fortune (Chloé Rolzhausen)의 프로젝트인데. 가장 놀라웠던 부분은 VFS도, 가상 네트워크도, TypeScript로 재구현한 170개의 Unix 명령어도 아니야. polyfills/ 디렉토리였어.

왜냐고? 이 모듈은 브라우저에서 돌아가거든, Wasm 없이. 그리고 그걸 위해 Fortune은 라이브러리가 필요한 node:* 레이어 전체를 손수 재구현했어. 약 640줄의 수제 JavaScript로 node:fs, node:crypto, node:os, node:net 등을 대체했지.

이 글은 polyfill별로 어떻게 동작하는지 설명할게.


기본적인 문제

Node.js 라이브러리는 브라우저에 존재하지 않는 API를 써. import { readFileSync } from 'node:fs'라고 쓰면, 그건 Node 쪽 시스템 콜이야 -- libuv를 통한 실제 디스크 접근. 브라우저에는 node:fs 같은 건 아예 없어.

일반적인 해결책은:

  • Wasm 런타임 (Emscripten, WASIp1/WASIp2 등) -- Node.js를 Wasm으로 컴파일해서 실행. 결과: 10-50MB 번들, 눈에 띄는 로딩 시간, 상당한 배포 복잡성.
  • 범용 polyfill (browserify, webpack node: polyfills 등) -- 각 Node 모듈의 근사치를 제공하는 npm 라이브러리. 보통 너무 무겁고 특정 유스케이스에 맞지 않아.
  • polyfill을 손수 작성 -- 작업량은 더 들지만 결과는 최적.

Fortune은 세 번째 옵션을 골랐어. 그 결과 브라우저 번들은 라이브러리 그 자체만 있고, 즉시 시작되며, 외부 인프라에 전혀 의존하지 않아.


빌드의 메커니즘

전부 esbuild와 그 alias 옵션에 기반해. 각 node:* 임포트는 로컬 파일로 리다이렉트돼:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

inject 옵션은 주목할 만해: 번들 내 각 파일의 앞부분에 process.js와 buffer.js를 주입해서, 명시적인 임포트 없이 process와 Buffer를 전역에서 사용할 수 있게 해줘. 정확히 Node.js가 네이티브로 노출하는 방식이야.


buffer.js -- Uint8Array 위의 Buffer

이것은 주입되는 두 전역 중 하나야. Buffer는 코드 내에서 엄청나게 사용돼 -- 모든 SSH 작업, 모든 VFS 스냅샷, 모든 바이너리 읽기/쓰기가 이걸 거쳐가.

해결책: Uint8Array를 확장해서 Node.js Buffer API 전체를 구현한 BrowserBuffer 클래스.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

구현된 것들:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • 모든 쓰기 메서드: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • 대응되는 모든 읽기 메서드
  • hex, base64, utf8을 지원하는 toString
  • copy, equals, slice, subarray

116줄이야. 이게 대체하는 걸 생각하면 놀랍도록 컴팩트해.

주요 트릭은 멀티바이트 접근에 DataView를 사용하는 거야. 이걸로 엔디언을 올바르게 처리할 수 있고, 타입마다 수동으로 비트를 조작할 필요가 없어:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- 최소한의 process 전역

또 다른 주입되는 전역. 작지만 필요해 -- 코드는 종종 process.env.NODE_ENV, process.platform, process.nextTick 등을 체크하거든.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

nextTick → queueMicrotask 매핑이 여기서 가장 중요한 세부사항이야. Node.js의 process.nextTick은 이벤트 루프의 현재 페이즈 끝, I/O 직전에 콜백을 스케줄링해. 브라우저의 queueMicrotask는 의미상 매우 비슷한 일을 해 -- 마이크로태스크를 스케줄링해서 다음 렌더링이나 이벤트 전에 실행되지. 완전히 동일하지는 않지만, nextTick을 사용하는 모든 코드가 브라우저에서 올바르게 동작할 만큼 충분히 가까워.


node:fs -- 동기 파일시스템으로서의 IndexedDB

이것이 가장 정교한 polyfill이고, 기술적으로 단연코 가장 흥미로워.

문제는 까다로워: node:fs는 동기 API(readFileSync, writeFileSync 등)를 노출하는데, 브라우저의 스토리지 API는 전부 비동기야(IndexedDB, Cache API 등). 동기 함수 중간에 await를 쓸 수가 없어.

Fortune의 해결책: 이중 캐시.

레벨 1 -- 인메모리 Map (동기) 모든 읽기는 인메모리 Map<string, Uint8Array>에서 이루어져. 즉시, 동기, API 문제 없음.

레벨 2 -- IndexedDB (비동기, 백그라운드) 시작할 때 IndexedDB의 전체 콘텐츠가 Map으로 로드돼. 쓰기는 즉시 Map에 되고, 그리고 블로킹 없이 비동기로 IndexedDB에 쓰기를 시작해.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload at startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Async write to IndexedDB (non-blocking)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

노출되는 API는 완전해: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (recursive 옵션 포함), mkdirSync (recursive 옵션 포함), readdirSync, statSync, renameSync.

VFS의 WAL 저널이 브라우저 모드에서 동작하도록 파일 디스크립터 관리 레이어(openSync, writeSync, closeSync)까지 있어 -- 저널은 fd를 열고, 쓰고, 닫으면 데이터가 IndexedDB에 반영돼.

ready 프로퍼티가 익스포트되어서 초기 프리로드가 완료됐는지 코드가 알 수 있어:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

이 덕분에 VFS 스냅샷이 브라우저에서 페이지 새로고침 후에도 유지돼. 데모를 리로드하면 VFS는 서버를 전혀 거치지 않고 IndexedDB에서 정확히 이전 상태로 복원돼.


node:crypto -- 순수 JS의 SHA-256, HMAC, PBKDF2

Wasm으로 컴파일된 암호 라이브러리를 임포트하는 대신, Fortune은 필요한 프리미티브를 직접 구현했어.

SHA-256은 FIPS 180-4 상수로 스크래치부터 구현됐어:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 more constants */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 rounds of compression
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

SHA-256 위에 HMAC-SHA256과 PBKDF2-HMAC-SHA256이 구축됐어. 이 두 프리미티브는 SSH 핸드셰이크와 내부 인증에서 키 유도에 사용돼.

익스포트되는 API는 Node.js의 것과 비슷해:

// Classic hash
const hash = createHash('sha256').update('data').digest('hex');

// Random bytes (via standard Web Crypto API)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Timing-safe comparison
const ok = timingSafeEqual(a, b);

// scrypt (approximated via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

참고: scryptSync는 PBKDF2를 통해 근사되고, 반복 횟수는 파라미터 N에 맞춰 조정됐어. 진짜 scrypt(다른 메모리 스키마를 사용하는)는 아니지만, 프로젝트 용도로는 충분해.

브라우저에서 합리적으로 에뮬레이션할 수 없는 함수들(generateKeyPairSync, createCipheriv, createDecipheriv, createSign)은 호출되면 명시적 에러를 던져. 정직한 행동이야.


node:os -- 브라우저의 실제 스펙 읽기

고정된 값을 반환하는 대신, 이 polyfill은 브라우저 API를 읽어서 사용자의 실제 머신에 해당하는 정보를 반환해.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB by default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

실제 결과: 브라우저 데모에서 neofetch를 실행하면 표시되는 코어 수와 RAM이 자기 머신에 맞춰져. 사소한 디테일이지만 터미널의 사실감에 엄청나게 기여해.

다른 익스포트들: freemem (전체 메모리의 40%, 합리적 근사), platform → 'browser', type → 'Linux', release → 'web', uptime은 performance.now() 경유, endianness → 'LE' (리틀 엔디언, 모든 일반적인 x86/ARM 프로세서에서 참), loadavg → [0, 0, 0].


node:net -- 깔끔한 TCP 스텁

브라우저는 raw TCP 소켓에 접근할 수 없어 (WebSocket은 다른 애플리케이션 계층 프로토콜이라 카운트 안 돼). 그래서 node:net은 스텁이지만, 잘 쓰여진 스텁이야.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

중요한 점: 이벤트 등록 메서드(on, once, off, emit)는 this를 반환하고 에러를 던지지 않아. 이 덕분에 new net.Socket().on('connect', cb) 같은 코드가 실제로 연결이 이루어지지 않더라도 크래시 없이 동작해. 실제로 연결을 시도하는 메서드만 에러를 던져.

isIP, isIPv4, isIPv6는 (스텁이 아니라) 올바르게 구현됐어. 왜냐하면 가상 네트워크 코드가 소켓을 열지 않고 주소를 검증하는 데 사용하거든.


node:path -- POSIX 경로 연산

POSIX 경로 연산의 완전한 재구현. 컨텍스트에 맞게 조정됐어 (윈도우 백슬래시 없음, 경로는 항상 /로 절대 경로).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

심플하고, 컴팩트하고, 프로젝트 용도에 맞아.


node:url -- 브라우저 API에 위임

이건 단순함 때문에 우아해. URL과 URLSearchParams API는 브라우저에 이미 네이티브로 존재해 -- 다시 익스포트하기만 하면 돼.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

fileURLToPath와 pathToFileURL만 구현이 필요해. Node 고유의 것들이니까:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

타겟 플랫폼(브라우저)이 이미 네이티브 동등 기능을 제공할 때의 이상적인 접근 방식이야.


node:zlib -- 아이덴티티

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

두 줄. 라이브러리는 실제 압축에 fflate를 사용해 (브라우저에서 네이티브로 동작). node:zlib는 브라우저 컨텍스트에서 실행되지 않는 코드 경로에서만 임포트돼 -- 그래서 패스스루로 충분해.

때로는 올바른 구현이 두 줄이면 되는 거야.


node:events -- 최소한의 EventEmitter

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Node.js의 완전한 EventEmitter 구현은 maxListeners, once, prependListener 등을 포함해 약 600줄이야. 여기는 실제로 사용되는 4개 메서드에 대해 12줄. 빌드 도구의 tree-shaking 이전에 멘탈 tree-shaking을 하고 있는 거지.


ssh2와 roxify -- 명시적 스텁

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

SSH 서버는 브라우저에서 돌지 않아 (의미가 없잖아 -- 누가 접속하겠어?). 하지만 SSH에 대해 클라이언트 쪽에서 말하는 코드 -- SSH 패킷을 구성하는 클래스들, 프로토콜 파서들 -- 는 라이브러리 안에 존재해. 이 스텁들은 그런 모든 코드가 에러 없이 번들되도록 하면서, 실제 소켓이 필요한 메서드를 누군가 호출하려 하면 명확한 에러가 발생하도록 보장해.

roxify는 Node 모드에서 VFS 스냅샷에 사용되는 독점 압축 포맷이야. 브라우저에서는 대신 fflate가 사용돼 -- polyfill은 roxify가 직접 호출되면 에러를 던질 뿐이야.


node:worker_threads -- Web Workers 재익스포트

이게 가장 미묘해. Node.js의 node:worker_threads와 브라우저의 Web Workers는 다른 API지만, 개념적으로는 가까워. polyfill이 매핑을 해:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel과 MessagePort는 브라우저에서 직접 재익스포트돼 (같은 API). Worker 자체는 생성자가 다르기 때문에 (Node는 모듈 경로, 브라우저는 URL을 기대) 래퍼가 필요해. isMainThread는 이 컨텍스트에서 브라우저 쪽에서 항상 true야.


개요: 이 640줄이 나타내는 것

Polyfill 줄 수 전략
buffer.js 116 Uint8Array + DataView, 완전한 Buffer API
node:crypto 166 SHA-256/HMAC/PBKDF2를 스크래치부터 + 랜덤용 Web Crypto
node:fs 210 인메모리 Map + 비동기 IndexedDB
node:net 70 체인 가능한 스텁 + 실제 IP 검증
ssh2 74 명시적 스텁
process.js 14 최소 viable process
node:path ~30 POSIX 경로 연산
node:url ~25 브라우저 API에 위임
node:events ~12 4메서드 EventEmitter
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 패스스루
node:worker_threads ~30 Web Workers 재익스포트
roxify.js 8 스텁

640줄. npm 의존성 제로. Wasm 제로. 그리고 1초도 안 되어 시작되고 서버사이드 인프라가 하나도 필요 없는 브라우저 번들이 완성돼.


여기서 배울 점

다음에 Node.js 라이브러리를 브라우저로 포팅하고 싶을 때, Fortune의 접근 방식이 보여주는 것:

  1. 실제로 사용되는 것을 파악해. 코드가 on, emit, removeListener만 쓴다면 EventEmitter 전체를 구현할 필요 없어.

  2. 가능하면 브라우저 API에 위임해. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- 브라우저가 이미 가지고 있으니 활용해.

  3. 비동기 API 앞의 동기 캐시. node:fs의 Map + IndexedDB 해결책은 이 디렉토리 전체에서 가장 재사용 가능한 패턴이야.

  4. 정직한 스텁이 불완전한 구현을 조용히 하는 것보다 나아. 명시적인 throw new Error('not implemented in browser')는 10호출 뒤에 버그가 드러나는 return undefined보다 무한히 더 유용해.

  5. esbuild의 alias + inject는 과소평가됐어. 이런 종류의 포팅에 완벽한 도구야 -- webpack 설정 제로, 플러그인 제로, 단순한 대체 목록.


코드는 리포지토리에 있어: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. 각 파일은 한 페이지에 들어가고, GitHub에서 직접 읽을 수 있어. 비슷한 프로젝트를 하고 있다면 강력히 추천해.

✨ AI Generated Article

Bir Node.js kütüphanesini Wasm olmadan tarayıcıda çalıştırmak --

Fortune'un, konteynerin Wasm olmadan tarayıcıda çalışması için

Bir Node.js kütüphanesini Wasm olmadan tarayıcıda çalıştırmak -- typescript-virtual-container polyfill'leri

Geçenlerde Fortune (Chloé Rolzhausen)'un projesi olan typescript-virtual-container'ın kaynak kodunu incelemeye epey zaman harcadım. Ve beni en çok şaşırtan kısım VFS değil, sanal ağ değil, TypeScript'te yeniden uygulanmış 170 Unix komutu değil. polyfills/ klasörüydü.

Çünkü modül tarayıcıda, Wasm olmadan çalışıyor ve bunun için Fortune, kütüphanenin ihtiyaç duyduğu tüm node:* katmanını elle yeniden uygulamış. Yaklaşık 640 satır el yapımı JavaScript ile node:fs, node:crypto, node:os, node:net ve diğerlerini değiştiriyor.

Bu makale, polyfill polyfill nasıl çalıştığını açıklıyor.


Temel Problem

Bir Node.js kütüphanesi, tarayıcıda var olmayan API'ler kullanır. import { readFileSync } from 'node:fs' yazdığında, bu Node tarafında bir sistem çağrısıdır -- libuv üzerinden gerçek bir disk erişimi. Tarayıcıda node:fs diye bir şey yoktur.

Olağan çözümler şunlardır:

  • Bir Wasm Runtime (Emscripten, WASIp1/WASIp2 gibi) -- Node.js'i Wasm'a derler ve çalıştırırsın. Sonuç: 10-50 MB'lık bundle'lar, belirgin bir yüklenme süresi, önemli bir dağıtım karmaşıklığı.
  • Genel Polyfill'ler (browserify, webpack node: polyfills gibi) -- her Node modülünün yaklaşımlarını sağlayan npm kütüphaneleri. Genelde çok ağır, özel kullanım durumuna uygun değil.
  • Polyfill'leri elle yazmak -- daha çok iş, ama optimal sonuç.

Fortune üçüncü seçeneği tercih etti. Ve sonuç, sadece kütüphanenin kendisi olan, anında başlayan ve hiçbir dış altyapıya bağımlı olmayan bir tarayıcı bundle'ı.


Build Mekaniği

Her şey esbuild ve onun alias seçeneğine dayanıyor. Her node:* import'u yerel bir dosyaya yönlendiriliyor:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

inject seçeneği dikkate değer: process.js ve buffer.js dosyalarını bundle'daki her dosyanın başına enjekte etmeyi sağlar, böylece process ve Buffer hiçbir açık import gerektirmeden global olarak kullanılabilir olur. Tıpkı Node.js'in onları yerel olarak sunması gibi.


buffer.js -- Uint8Array üzerinde Buffer

Bu, enjekte edilen iki globalden biri. Buffer, kodda yoğun olarak kullanılıyor -- her SSH işlemi, her VFS anlık görüntüsü, her ikili okuma/yazma işlemi onun üzerinden geçiyor.

Çözüm: Uint8Array'i genişleten ve Node.js'in tüm Buffer API'sini uygulayan bir BrowserBuffer sınıfı.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

Toplamda uygulananlar:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Tüm yazma metodları: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Tüm karşılık gelen okuma metodları
  • hex, base64, utf8 desteğiyle toString
  • copy, equals, slice, subarray

Bu 116 satır ediyor. Değiştirdiği şeye bakarsak, oldukça kompakt.

Ana numara, çok baytlı erişimler için DataView kullanımı; bu, her tür için bitleri elle manipüle etmek zorunda kalmadan endianness'i doğru şekilde yönetiyor:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- minimal process global'ı

Diğer enjekte edilen global. Küçük ama gerekli -- kod sık sık process.env.NODE_ENV, process.platform, process.nextTick vb. kontrol ediyor.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

nextTick → queueMicrotask eşlemesi buradaki en önemli detay. Node.js'te process.nextTick, olay döngüsünün mevcut aşamasının sonunda, I/O'dan önce bir callback planlar. Tarayıcıda queueMicrotask anlamsal olarak çok benzer bir şey yapar -- bir sonraki render veya olaydan önce yürütülen bir mikro görev planlar. Aynı değil, ancak nextTick kullanan tüm kodun tarayıcıda doğru çalışması için yeterince yakın.


node:fs -- Senkron dosya sistemi olarak IndexedDB

Bu, en karmaşık polyfill ve teknik olarak açık ara en ilginç olanı.

Problem zorlu: node:fs senkron bir API sunar (readFileSync, writeFileSync vb.), ancak tarayıcı depolama API'lerinin tümü asenkrondur (IndexedDB, Cache API vb.). Senkron bir fonksiyonun ortasında await yapamazsın.

Fortune'un çözümü: iki seviyeli bir önbellek.

Seviye 1 -- Bellek içi Map (senkron)
Tüm okumalar, bellekteki bir Map<string, Uint8Array> üzerinden yapılır. Anlık, senkron, API sorunu yok.

Seviye 2 -- IndexedDB (asenkron, arka planda)
Başlangıçta, IndexedDB'nin tüm içeriği Map'e yüklenir. Yazmalar hemen Map'e yapılır ve bloke etmeden IndexedDB'ye asenkron bir yazma başlatır.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Başlangıçta ön yükleme
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// IndexedDB'ye async yazma (bloke etmeyen)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

Dışa aktarılan API eksiksiz: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (recursive seçeneğiyle), mkdirSync (recursive seçeneğiyle), readdirSync, statSync, renameSync.

Hatta VFS'in WAL günlüğünün tarayıcı modunda çalışması için bir dosya tanımlayıcı yönetim katmanı bile var (openSync, writeSync, closeSync) -- günlük bir fd açar, içine yazar, kapatır ve veriler IndexedDB'ye gider.

ready özelliği, kodun ilk ön yüklemenin ne zaman tamamlandığını bilmesini sağlamak için dışa aktarılır:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

Bu sayede VFS anlık görüntüleri tarayıcıda sayfa yenilemelerine dayanır. Demoyu yeniden yüklediğinde, VFS IndexedDB'den, hiçbir sunucu olmadan, tam olarak bıraktığın durumda geri yüklenir.


node:crypto -- Saf JS ile SHA-256, HMAC, PBKDF2

Wasm'da derlenmiş bir kripto kütüphanesi ithal etmek yerine, Fortune gerekli primitifleri doğrudan uygulamış.

SHA-256, FIPS 180-4 sabitleriyle sıfırdan uygulanmış:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 diğer sabit */ 
]);

function sha256(data) {
  // FIPS 180-4 padding
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 sıkıştırma turu
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

SHA-256 üzerine HMAC-SHA256 ve PBKDF2-HMAC-SHA256 inşa edilmiş. Bu iki primitif, SSH el sıkışmalarında ve iç kimlik doğrulamada anahtar türetme için kullanılıyor.

Dışa aktarılan API Node.js'inkine benziyor:

// Klasik Hash
const hash = createHash('sha256').update('data').digest('hex');

// Rastgele baytlar (standart Web Crypto API ile)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Timing-güvenli karşılaştırma
const ok = timingSafeEqual(a, b);

// scrypt (PBKDF2 ile yaklaşık)
const key = scryptSync(password, salt, 32, { N: 16384 });

Not: scryptSync, N parametresine göre ayarlanmış bir iterasyon sayısıyla PBKDF2 üzerinden yaklaşık olarak hesaplanır. Gerçek scrypt değildir (farklı bir bellek şeması kullanır), ancak projenin kullanımları için yeterlidir.

Tarayıcıda makul bir şekilde öykünemeyen fonksiyonlar (generateKeyPairSync, createCipheriv, createDecipheriv, createSign), çağrıldıklarında açık bir hata fırlatır. Dürüst davranış.


node:os -- tarayıcının gerçek özelliklerini okumak

Sabit değerler döndürmek yerine, bu polyfill tarayıcı API'lerini okuyarak kullanıcının gerçek makinesine karşılık gelen bilgileri döndürür.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // varsayılan 2 GB
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Somut sonuç: Tarayıcı demosunda neofetch çalıştırdığında, görüntülenen çekirdek sayısı ve RAM, gerçek makinenle eşleşir. Bu bir detay, ancak terminalin inandırıcılığına büyük katkı sağlar.

Diğer dışa aktarımlar: freemem (toplam belleğin %40'ı, makul bir yaklaşım), platform → 'browser', type → 'Linux', release → 'web', uptime performance.now() ile, endianness → 'LE' (tüm yaygın x86/ARM işlemcilerde geçerli olan little-endian), loadavg → [0, 0, 0].


node:net -- temiz TCP stub'ları

Tarayıcının ham TCP soketlerine erişimi yoktur (WebSocket sayılmaz -- bu farklı bir uygulama katmanı protokolüdür). Bu nedenle node:net bir stub, ama iyi yazılmış bir stub.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // zincirlenebilir
  once() { return this; }  // zincirlenebilir
  pipe() { return this; }  // zincirlenebilir
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

Önemli nokta: olay kayıt metodları (on, once, off, emit) this döndürür ve hata fırlatmaz. Bu, new net.Socket().on('connect', cb) yapan kodun, bağlantı hiç gerçekleşmese bile çökmeden çalışmasını sağlar. Sadece gerçekten bağlantı kurmayı deneyen metodlar hata fırlatır.

isIP, isIPv4, isIPv6 doğru şekilde uygulanmıştır (stub değil), çünkü sanal ağ kodu tarafından hiçbir zaman soket açmadan adresleri doğrulamak için kullanılırlar.


node:path -- POSIX yol işlemleri

POSIX yol işlemlerinin, bağlama uyarlanmış (Windows ters eğik çizgisi yok, yollar her zaman / ile mutlak) tamamen yeniden uygulanması.

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Basit, kompakt, projenin kullanımları için doğru.


node:url -- tarayıcı API'lerine delegasyon

Bu, basitliğiyle zarif. URL ve URLSearchParams API'si tarayıcıda zaten yerel olarak var -- sadece yeniden dışa aktarmak yeterli.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Sadece fileURLToPath ve pathToFileURL bir uygulama gerektirir, çünkü bunlar Node'a özgüdür:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

Hedef platform (tarayıcı) zaten yerel eşdeğerini sağladığında ideal yaklaşım budur.


node:zlib -- kimlik

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

İki satır. Kütüphane gerçek sıkıştırma için fflate kullanır (tarayıcıda yerel olarak çalışır). node:zlib yalnızca tarayıcı bağlamında yürütülmeyen kod yollarında içe aktarılır -- bu nedenle bir passthrough yeterlidir.

Bazen doğru uygulama iki satırdır.


node:events -- Minimal EventEmitter

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Node.js'in tam EventEmitter uygulaması, maxListeners, once, prependListener vb. yönetimiyle ~600 satırdır. Burada gerçekten kullanılan 4 metot için 12 satır. Build aracının tree-shaking'inden önce zihinsel tree-shaking.


ssh2 ve roxify -- açık stub'lar

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

SSH sunucusu tarayıcıda çalışmaz (bunun bir anlamı olmazdı -- kim bağlanacak ki?). Ancak istemci tarafında SSH hakkında konuşan kod -- SSH paketleri oluşturan sınıflar, protokol ayrıştırıcıları -- kütüphanede mevcuttur. Bu stub'lar, tüm bu kodun hatasız bir şekilde bundle edilmesine izin verirken, birisi gerçek bir soket gerektiren bir metodu çağırmaya kalkışırsa net bir hata fırlatılmasını garanti eder.

roxify, Node modunda VFS anlık görüntüleri için kullanılan özel bir sıkıştırma biçimidir. Tarayıcıda bunun yerine fflate kullanılır -- polyfill, roxify doğrudan çağrılırsa sadece bir hata fırlatır.


node:worker_threads -- Web Workers'ları yeniden dışa aktarma

Bu en ince olanı. Node.js'teki node:worker_threads ve tarayıcıdaki Web Workers iki farklı API'dir, ancak kavramsal olarak birbirlerine yakındır. Polyfill eşlemeyi yapar:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel ve MessagePort doğrudan tarayıcıdan yeniden dışa aktarılır (aynı API). Worker'ın kendisi bir wrapper gerektirir çünkü yapıcı farklıdır (Node bir modül yolu bekler, tarayıcı bir URL bekler). isMainThread bu bağlamda tarayıcı tarafında her zaman true'dur.


Genel Bakış: Bu 640 satır neyi temsil ediyor

Polyfill Satır Strateji
buffer.js 116 Uint8Array + DataView, tüm Buffer API'si
node:crypto 166 SHA-256/HMAC/PBKDF2 sıfırdan + Rastgeleler için Web Crypto
node:fs 210 Bellekte Map + IndexedDB async
node:net 70 Zincirlenebilir Stub'lar + gerçek IP doğrulamaları
ssh2 74 Açık Stub'lar
process.js 14 Minimal viable process
node:path ~30 POSIX yol işlemleri
node:url ~25 Tarayıcı API'lerine delegasyon
node:events ~12 EventEmitter 4 metot
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Web Workers'ları yeniden dışa aktarma
roxify.js 8 Stub'lar

640 satır. Hiçbir npm bağımlılığı yok. Hiçbir Wasm yok. Ve bu, bir saniyeden kısa sürede başlayan ve hiçbir sunucu altyapısı gerektirmeyen bir tarayıcı bundle'ı üretiyor.


Bundan çıkarılacak dersler

Bir dahaki sefere bir Node.js kütüphanesini tarayıcıya taşımak istediğinde, Fortune'un yaklaşımı şunları gösteriyor:

  1. Gerçekten neyin kullanıldığını belirle. Kod sadece on, emit ve removeListener kullanıyorsa tüm EventEmitter'ı uygulamaya gerek yok.

  2. Mümkün olduğunda tarayıcı API'lerine devret. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- tarayıcıda zaten var, kullan onları.

  3. Async API'nin önünde senkron önbellek. node:fs için Map + IndexedDB çözümü, tüm klasördeki en yeniden kullanılabilir desen.

  4. Dürüst stub'lar, sessiz eksik uygulamalardan iyidir. Açık bir throw new Error('not implemented in browser'), hatanın 10 çağrı sonra ortaya çıkmasına izin veren bir return undefined'dan sonsuz derecede daha kullanışlıdır.

  5. esbuild alias + inject hafife alınıyor. Bu tür bir taşıma için mükemmel araç -- sıfır webpack yapılandırması, sıfır eklenti, sadece bir değiştirme listesi.


Kod repo'da: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Her dosya tek bir sayfaya sığıyor, doğrudan GitHub'da okunabilir. Benzer bir proje üzerinde çalışıyorsan şiddetle tavsiye edilir.

✨ AI Generated Article

Far funzionare una libreria Node.js nel browser senza Wasm -- i polyfill

Come Fortune ha reimplementato a mano node:fs, node:crypto e una

Far funzionare una libreria Node.js nel browser senza Wasm -- i polyfill di typescript-virtual-container

Di recente ho passato parecchio tempo a spulciare il codice sorgente di typescript-virtual-container, il progetto di Fortune (Chloé Rolzhausen). E la parte che mi ha sorpreso di più non è il VFS, non è la rete virtuale, non sono i 170 comandi Unix reimplementati in TypeScript. È la cartella polyfills/.

Perché il modulo gira nel browser, senza Wasm, e per farlo Fortune ha reimplementato a mano tutto il layer node:* di cui la libreria ha bisogno. Circa 640 righe di JavaScript artigianale che sostituiscono node:fs, node:crypto, node:os, node:net e qualche altro.

Questo articolo spiega come funziona, polyfill per polyfill.


Il problema di base

Una libreria Node.js usa API che non esistono nel browser. Quando scrivi import { readFileSync } from 'node:fs', è una chiamata di sistema lato Node -- un vero accesso al disco via libuv. Nel browser, node:fs non esiste affatto.

Le soluzioni abituali sono:

  • Un runtime Wasm (tipo Emscripten, WASIp1/WASIp2) -- compili Node.js in Wasm e lo esegui. Risultato: bundle da 10-50 MB, un tempo di caricamento notevole, una complessità di deploy significativa.
  • Polyfill generici (tipo browserify, webpack node: polyfills) -- librerie npm che forniscono approssimazioni di ogni modulo Node. Spesso troppo pesanti, mal adattate al caso specifico.
  • Riscrivere i polyfill a mano -- più lavoro, ma risultato ottimale.

Fortune ha scelto la terza opzione. E il risultato è un bundle per browser che è solo la libreria, che si avvia istantaneamente e che non dipende da nessuna infrastruttura esterna.


La meccanica di build

Tutto si basa su esbuild e la sua opzione alias. Ogni import node:* viene reindirizzato a un file locale:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

L'opzione inject merita attenzione: permette di iniettare process.js e buffer.js in testa a ogni file del bundle, rendendo process e Buffer disponibili globalmente senza alcun import esplicito. Esattamente come Node.js li espone nativamente.


buffer.js -- Buffer su Uint8Array

È uno dei due globali iniettati. Buffer è massicciamente usato nel codice -- ogni operazione SSH, ogni snapshot VFS, ogni lettura/scrittura binaria passa di qui.

La soluzione: una classe BrowserBuffer che estende Uint8Array e implementa tutta l'API Buffer di Node.js.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

Quello che è implementato in totale:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Tutti i metodi di scrittura: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Tutti i metodi di lettura corrispondenti
  • toString con supporto hex, base64, utf8
  • copy, equals, slice, subarray

Sono 116 righe. Per quello che sostituisce, è notevolmente compatto.

Il trucco principale è l'uso di DataView per gli accessi multi-byte, che gestisce correttamente l'endianness senza dover manipolare i bit a mano per ogni tipo:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- il globale process minimo

L'altro globale iniettato. Minuscolo ma necessario -- il codice verifica spesso process.env.NODE_ENV, process.platform, process.nextTick, ecc.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

Il mapping nextTick → queueMicrotask è il dettaglio più importante qui. process.nextTick in Node.js pianifica un callback alla fine della fase corrente del ciclo di eventi, prima degli I/O. queueMicrotask nel browser fa qualcosa di semanticamente molto vicino -- pianifica una microtask, che viene eseguita prima del prossimo rendering o evento. Non è identico, ma è abbastanza vicino perché tutto il codice che usa nextTick funzioni correttamente nel browser.


node:fs -- IndexedDB come filesystem sincrono

Questo è il polyfill più sofisticato, e di gran lunga il più interessante tecnicamente.

Il problema è delicato: node:fs espone un'API sincrona (readFileSync, writeFileSync, ecc.), ma le API di storage del browser sono tutte asincrone (IndexedDB, Cache API, ecc.). Non si può fare un await in mezzo a una funzione sincrona.

La soluzione di Fortune: un doppio livello di cache.

Livello 1 -- Map in memoria (sincrono)
Tutte le letture avvengono da un Map<string, Uint8Array> in memoria. Istantaneo, sincrono, nessun problema di API.

Livello 2 -- IndexedDB (asincrono, in background)
All'avvio, tutto il contenuto di IndexedDB viene caricato nella Map. Le scritture avvengono immediatamente nella Map e lanciano una scrittura asincrona verso IndexedDB senza bloccare.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload all'avvio
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Scrittura async verso IndexedDB (non bloccante)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

L'API esposta è completa: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (con opzione recursive), mkdirSync (con opzione recursive), readdirSync, statSync, renameSync.

C'è persino un layer di gestione dei file descriptor (openSync, writeSync, closeSync) per far funzionare il journal WAL del VFS in modalità browser -- il journal apre un fd, scrive al suo interno, lo chiude, e i dati finiscono in IndexedDB.

La proprietà ready viene esportata per permettere al codice di sapere quando il precarico iniziale è terminato:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

È grazie a questo che gli snapshot VFS sopravvivono ai ricaricamenti di pagina nel browser. Quando ricarichi la demo, il VFS viene ripristinato esattamente nello stato in cui lo avevi lasciato, da IndexedDB, senza alcun server coinvolto.


node:crypto -- SHA-256, HMAC, PBKDF2 in JS puro

Invece di importare una libreria crypto compilata in Wasm, Fortune ha implementato le primitive necessarie direttamente.

SHA-256 è implementato from scratch con le costanti FIPS 180-4:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 altre costanti */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 round di compressione
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

Sopra SHA-256, vengono costruiti HMAC-SHA256 e PBKDF2-HMAC-SHA256. Queste due primitive sono usate per la derivazione delle chiavi negli scambi SSH e l'autenticazione interna.

L'API esportata assomiglia a quella di Node.js:

// Hash classico
const hash = createHash('sha256').update('data').digest('hex');

// Byte casuali (via l'API Web Crypto standard)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Confronto timing-safe
const ok = timingSafeEqual(a, b);

// scrypt (approssimato via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Nota: scryptSync è approssimato via PBKDF2 con un numero di iterazioni calibrato sul parametro N. Non è un vero scrypt (che usa uno schema di memoria diverso), ma per gli usi del progetto è sufficiente.

Le funzioni che non possono essere emulate ragionevolmente nel browser (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) lanciano un errore esplicito se vengono chiamate. Comportamento onesto.


node:os -- leggere le vere specifiche del browser

Invece di restituire valori fissi, questo polyfill legge le API del browser per restituire informazioni che corrispondono alla macchina reale dell'utente.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB di default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Risultato concreto: quando lanci neofetch nella demo del browser, il numero di core e la RAM mostrati corrispondono alla tua macchina. È un dettaglio, ma contribuisce enormemente alla verosimiglianza del terminale.

Gli altri export: freemem (40% della memoria totale, approssimazione ragionevole), platform → 'browser', type → 'Linux', release → 'web', uptime via performance.now(), endianness → 'LE' (little-endian, vero su tutti i processori x86/ARM consumer), loadavg → [0, 0, 0].


node:net -- stub TCP puliti

Il browser non ha accesso ai socket TCP grezzi (WebSocket non conta -- è un protocollo applicativo diverso). node:net è quindi uno stub, ma uno stub ben scritto.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // incatenabile
  once() { return this; }  // incatenabile
  pipe() { return this; }  // incatenabile
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

Il punto importante: i metodi di registrazione degli eventi (on, once, off, emit) restituiscono this e non lanciano errori. Questo permette al codice che fa new net.Socket().on('connect', cb) di funzionare senza crash, anche se la connessione non avviene mai. Solo i metodi che tentano effettivamente di connettersi lanciano un errore.

isIP, isIPv4, isIPv6 sono implementati correttamente (non sono stub) perché vengono usati dal codice di rete virtuale per validare indirizzi, senza mai aprire un socket.


node:path -- operazioni di percorso POSIX

Reimplementazione completa delle operazioni di percorso POSIX, adattata al contesto (niente backslash Windows, percorsi sempre assoluti con /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Semplice, compatto, corretto per gli usi del progetto.


node:url -- delega alle API del browser

Questa è elegante per la sua semplicità. L'API URL e URLSearchParams esiste già nativamente nel browser -- basta re-esportarle.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Solo fileURLToPath e pathToFileURL necessitano di implementazione, perché sono specifiche di Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

È l'approccio ideale quando la piattaforma di destinazione (il browser) fornisce già l'equivalente nativo.


node:zlib -- identità

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Due righe. La libreria usa fflate per la compressione reale (che funziona nel browser nativamente). node:zlib viene importato solo in percorsi di codice che non vengono eseguiti nel contesto del browser -- quindi un passthrough è sufficiente.

A volte la buona implementazione sono due righe.


node:events -- EventEmitter minimo

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

L'implementazione completa di EventEmitter di Node.js è di ~600 righe con la gestione di maxListeners, once, prependListener, ecc. Qui sono 12 righe per i 4 metodi effettivamente usati. Tree-shaking mentale prima ancora del tree-shaking dello strumento di build.


ssh2 e roxify -- stub espliciti

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

Il server SSH non gira nel browser (non avrebbe senso -- chi si connetterebbe?). Ma il codice che parla di SSH lato client -- le classi che costruiscono pacchetti SSH, i parser di protocollo -- esiste nella libreria. Questi stub permettono a tutto quel codice di essere bundleato senza errori, garantendo al contempo che venga lanciato un errore chiaro se qualcuno tenta di chiamare un metodo che richiede un socket reale.

roxify è un formato di compressione proprietario usato per gli snapshot VFS in modalità Node. Nel browser, viene usato fflate al suo posto -- il polyfill si limita a lanciare un errore se roxify viene chiamato direttamente.


node:worker_threads -- riesportazione dei Web Worker

Questo è il più sottile. node:worker_threads in Node.js e i Web Worker del browser sono due API diverse, ma concettualmente vicine. Il polyfill fa il mapping:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel e MessagePort vengono riesportati direttamente dal browser (stessa API). Worker stesso necessita di un wrapper perché il costruttore è diverso (Node aspetta un percorso di modulo, il browser aspetta un URL). isMainThread è sempre true lato browser in questo contesto.


Panoramica: cosa rappresentano queste 640 righe

Polyfill Righe Strategia
buffer.js 116 Uint8Array + DataView, tutta l'API Buffer
node:crypto 166 SHA-256/HMAC/PBKDF2 from scratch + Web Crypto per i random
node:fs 210 Map in memoria + IndexedDB async
node:net 70 Stub incatenabili + validazioni IP reali
ssh2 74 Stub espliciti
process.js 14 Minimo vitale process
node:path ~30 Operazioni di percorso POSIX
node:url ~25 Delega alle API del browser
node:events ~12 EventEmitter 4 metodi
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Riesportazione Web Worker
roxify.js 8 Stub

640 righe. Nessuna dipendenza npm. Nessun Wasm. E regala un bundle per browser che si avvia in meno di un secondo e gira senza alcuna infrastruttura lato server.


Cosa possiamo imparare

La prossima volta che vuoi portare una libreria Node.js nel browser, ecco cosa dimostra l'approccio di Fortune:

  1. Identifica ciò che è realmente usato. Non serve implementare EventEmitter per intero se il codice usa solo on, emit e removeListener.

  2. Delega alle API del browser quando possibile. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- il browser le ha già, tanto vale usarle.

  3. Cache sincrona davanti a un'API async. La soluzione Map + IndexedDB per node:fs è il pattern più riutilizzabile di tutta la cartella.

  4. Gli stub onesti sono meglio delle implementazioni incomplete silenziose. Un throw new Error('not implemented in browser') esplicito è infinitamente più utile di un return undefined che lascia che il bug si manifesti 10 chiamate più avanti.

  5. esbuild alias + inject è sottovalutato. È lo strumento perfetto per questo tipo di porting -- zero configurazione webpack, zero plugin, solo una lista di sostituzioni.


Il codice è nel repo: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Ogni file sta in una sola pagina, si legge direttamente su GitHub. Fortemente raccomandato se lavori a un progetto simile.

✨ AI Generated Article

Eine Node.js-Bibliothek ohne Wasm im Browser zum Laufen bringen -- die

Wie Fortune node:fs, node:crypto und ein Dutzend weiterer

Eine Node.js-Bibliothek ohne Wasm im Browser zum Laufen bringen -- die Polyfills von typescript-virtual-container

Ich habe vor kurzem einiges an Zeit damit verbracht, den Quellcode von typescript-virtual-container zu durchforsten, dem Projekt von Fortune (Chloé Rolzhausen). Und der Teil, der mich am meisten überrascht hat, ist nicht das VFS, nicht das virtuelle Netzwerk, nicht die 170 in TypeScript neu implementierten Unix-Befehle. Es ist der Ordner polyfills/.

Weil das Modul im Browser läuft, ohne Wasm, und dafür hat Fortune von Hand die gesamte node:*-Schicht neu implementiert, die die Bibliothek braucht. Etwa 640 Zeilen handgemachtes JavaScript, die node:fs, node:crypto, node:os, node:net und einige andere ersetzen.

Dieser Artikel erklärt, wie das funktioniert, Polyfill für Polyfill.


Das grundlegende Problem

Eine Node.js-Bibliothek verwendet APIs, die es im Browser nicht gibt. Wenn du import { readFileSync } from 'node:fs' schreibst, ist das ein Systemaufruf auf Node-Seite -- ein echter Plattenzugriff via libuv. Im Browser existiert node:fs überhaupt nicht.

Die üblichen Lösungen sind:

  • Ein Wasm-Runtime (wie Emscripten, WASIp1/WASIp2) -- du kompilierst Node.js nach Wasm und führst es aus. Ergebnis: 10-50 MB große Bundles, eine merkliche Ladezeit, eine erhebliche Deployment-Komplexität.
  • Generische Polyfills (wie browserify, webpack node: polyfills) -- npm-Bibliotheken, die Annäherungen an jedes Node-Modul bereitstellen. Oft zu schwer, schlecht an den spezifischen Anwendungsfall angepasst.
  • Polyfills von Hand schreiben -- mehr Arbeit, aber optimales Ergebnis.

Fortune hat sich für die dritte Option entschieden. Und das Ergebnis ist ein Browser-Bundle, das nur aus der Bibliothek besteht, sofort startet und von keiner externen Infrastruktur abhängt.


Die Build-Mechanik

Alles basiert auf esbuild und seiner alias-Option. Jeder node:*-Import wird auf eine lokale Datei umgeleitet:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Die inject-Option ist erwähnenswert: Sie erlaubt es, process.js und buffer.js an den Anfang jeder Datei des Bundles zu injecten, wodurch process und Buffer global verfügbar sind, ohne jeden expliziten Import. Genau wie Node.js sie nativ bereitstellt.


buffer.js -- Buffer auf Uint8Array

Das ist einer der beiden injizierten Globals. Buffer wird im Code massiv genutzt -- jede SSH-Operation, jeder VFS-Snapshot, jedes binäre Lesen/Schreiben läuft darüber.

Die Lösung: eine BrowserBuffer-Klasse, die Uint8Array erweitert und die gesamte Buffer-API von Node.js implementiert.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

Was insgesamt implementiert ist:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Alle Schreibmethoden: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Alle entsprechenden Lesemethoden
  • toString mit Unterstützung für hex, base64, utf8
  • copy, equals, slice, subarray

Das sind 116 Zeilen. Für das, was es ersetzt, ist das bemerkenswert kompakt.

Der Haupttrick ist die Verwendung von DataView für Multi-Byte-Zugriffe, was die Endianness korrekt handhabt, ohne dass man für jeden Typ die Bits manuell manipulieren muss:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- das minimale process-Global

Der andere injizierte Global. Winzig, aber notwendig -- der Code testet oft process.env.NODE_ENV, process.platform, process.nextTick usw.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

Das Mapping nextTick → queueMicrotask ist das wichtigste Detail hier. process.nextTick in Node.js plant einen Callback am Ende der aktuellen Phase der Ereignisschleife, vor I/O. queueMicrotask im Browser macht etwas semantisch sehr Ähnliches -- es plant eine Mikrotask, die vor dem nächsten Rendering oder Ereignis ausgeführt wird. Es ist nicht identisch, aber nah genug, dass der gesamte Code, der nextTick verwendet, im Browser korrekt funktioniert.


node:fs -- IndexedDB als synchrones Dateisystem

Das ist der anspruchsvollste Polyfill und bei weitem der technisch interessanteste.

Das Problem ist knifflig: node:fs bietet eine synchrone API (readFileSync, writeFileSync usw.), aber die Browser-Speicher-APIs sind alle asynchron (IndexedDB, Cache API usw.). Man kann mitten in einer synchronen Funktion kein await machen.

Fortune's Lösung: eine zweistufige Cache-Architektur.

Stufe 1 -- In-Memory-Map (synchron)
Alle Lesevorgänge erfolgen aus einer Map<string, Uint8Array> im Speicher. Sofortig, synchron, kein API-Problem.

Stufe 2 -- IndexedDB (asynchron, im Hintergrund)
Beim Start wird der gesamte Inhalt von IndexedDB in die Map geladen. Schreibvorgänge erfolgen sofort in die Map und starten einen asynchronen Schreibvorgang in IndexedDB, ohne zu blockieren.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload beim Start
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Async-Schreibvorgang nach IndexedDB (nicht-blockierend)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

Die exportierte API ist vollständig: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (mit recursive-Option), mkdirSync (mit recursive-Option), readdirSync, statSync, renameSync.

Es gibt sogar eine Schicht zur Verwaltung von Datei-Deskriptoren (openSync, writeSync, closeSync), damit das WAL-Journal des VFS im Browser-Modus funktioniert -- das Journal öffnet einen fd, schreibt hinein, schließt ihn, und die Daten landen in IndexedDB.

Die Eigenschaft ready wird exportiert, damit der Code weiß, wann der initiale Preload abgeschlossen ist:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

Dadurch überleben VFS-Snapshots Seitenneuladungen im Browser. Wenn du die Demo neu lädst, wird der VFS genau in dem Zustand wiederhergestellt, in dem du ihn verlassen hast, aus IndexedDB, ohne Beteiligung eines Servers.


node:crypto -- SHA-256, HMAC, PBKDF2 in purem JS

Anstatt eine kompilierte Wasm-Kryptobibliothek zu importieren, hat Fortune die benötigten Primitive direkt implementiert.

SHA-256 ist von Grund auf mit den FIPS 180-4-Konstanten implementiert:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 weitere Konstanten */ 
]);

function sha256(data) {
  // FIPS 180-4 Padding
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 Kompressionsrunden
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

Auf SHA-256 aufbauend sind HMAC-SHA256 und PBKDF2-HMAC-SHA256 konstruiert. Diese beiden Primitive werden für die Schlüsselableitung in SSH-Handshakes und der internen Authentifizierung verwendet.

Die exportierte API ähnelt der von Node.js:

// Klassischer Hash
const hash = createHash('sha256').update('data').digest('hex');

// Zufällige Bytes (über die Standard Web Crypto API)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Timing-sicherer Vergleich
const ok = timingSafeEqual(a, b);

// scrypt (angenähert via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Anmerkung: scryptSync wird über PBKDF2 mit einer Iterationszahl angenähert, die auf den Parameter N abgestimmt ist. Das ist kein echtes scrypt (das ein anderes Speicherschema verwendet), aber für die Zwecke des Projekts ist es ausreichend.

Funktionen, die nicht sinnvoll im Browser emuliert werden können (generateKeyPairSync, createCipheriv, createDecipheriv, createSign), werfen einen expliziten Fehler, wenn sie aufgerufen werden. Ehrliches Verhalten.


node:os -- die echten Browser-Spezifikationen auslesen

Anstatt feste Werte zurückzugeben, liest dieser Polyfill die Browser-APIs aus, um Informationen zurückzugeben, die der tatsächlichen Maschine des Benutzers entsprechen.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Konkretes Ergebnis: Wenn du neofetch in der Browser-Demo startest, entsprechen die angezeigte Kernzahl und der RAM deiner tatsächlichen Maschine. Es ist ein Detail, aber es trägt enorm zur Glaubwürdigkeit des Terminals bei.

Die anderen Exports: freemem (40% des gesamten Arbeitsspeichers, eine vernünftige Annäherung), platform → 'browser', type → 'Linux', release → 'web', uptime via performance.now(), endianness → 'LE' (Little-Endian, zutreffend auf allen gängigen x86/ARM-Prozessoren), loadavg → [0, 0, 0].


node:net -- saubere TCP-Stubs

Der Browser hat keinen Zugriff auf rohe TCP-Sockets (WebSocket zählt nicht -- das ist ein anderes Anwendungsprotokoll). node:net ist daher ein Stub, aber ein gut geschriebener Stub.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

Der wichtige Punkt: Die Event-Registrierungsmethoden (on, once, off, emit) geben this zurück und werfen keinen Fehler. Dadurch kann Code, der new net.Socket().on('connect', cb) macht, funktionieren, ohne abzustürzen, auch wenn die Verbindung nie zustande kommt. Nur die Methoden, die tatsächlich versuchen, eine Verbindung herzustellen, werfen einen Fehler.

isIP, isIPv4, isIPv6 sind korrekt implementiert (keine Stubs), da sie vom virtuellen Netzwerkcode zur Validierung von Adressen verwendet werden, ohne jemals einen Socket zu öffnen.


node:path -- POSIX-Pfadoperationen

Vollständige Neuimplementierung der POSIX-Pfadoperationen, an den Kontext angepasst (keine Windows-Backslashes, Pfade immer absolut mit /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Einfach, kompakt, korrekt für die Verwendungszwecke des Projekts.


node:url -- Delegation an Browser-APIs

Dieser ist elegant in seiner Einfachheit. Die URL- und URLSearchParams-API existiert bereits nativ im Browser -- man muss sie nur re-exportieren.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Nur fileURLToPath und pathToFileURL benötigen eine Implementierung, da sie Node-eigen sind:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

Das ist der ideale Ansatz, wenn die Zielplattform (der Browser) bereits das native Äquivalent bereitstellt.


node:zlib -- Identität

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Zwei Zeilen. Die Bibliothek verwendet fflate für die eigentliche Komprimierung (das nativ im Browser funktioniert). node:zlib wird nur in Codepfaden importiert, die im Browser-Kontext nicht ausgeführt werden -- daher reicht ein Passthrough.

Manchmal ist die richtige Implementierung zwei Zeilen lang.


node:events -- Minimaler EventEmitter

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Die vollständige EventEmitter-Implementierung von Node.js umfasst ~600 Zeilen mit der Verwaltung von maxListeners, once, prependListener usw. Hier sind es 12 Zeilen für die 4 tatsächlich verwendeten Methoden. Mentaler Tree-Shaking noch vor dem Tree-Shaking des Build-Tools.


ssh2 und roxify -- explizite Stubs

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

Der SSH-Server läuft nicht im Browser (das wäre sinnlos -- wer sollte sich verbinden?). Aber der Code, der auf der Client-Seite über SSH spricht -- die Klassen, die SSH-Pakete zusammenbauen, die Protokollparser -- existiert in der Bibliothek. Diese Stubs erlauben es, all diesen Code fehlerfrei zu bündeln, während gleichzeitig sichergestellt ist, dass ein klarer Fehler geworfen wird, wenn jemand versucht, eine Methode aufzurufen, die einen echten Socket benötigt.

roxify ist ein proprietäres Komprimierungsformat, das für VFS-Snapshots im Node-Modus verwendet wird. Im Browser wird stattdessen fflate verwendet -- der Polyfill wirft lediglich einen Fehler, wenn roxify direkt aufgerufen wird.


node:worker_threads -- Re-Export der Web Workers

Das ist das Subtilste. node:worker_threads in Node.js und die Web Workers des Browsers sind zwei verschiedene APIs, aber sie sind konzeptionell nahe beieinander. Der Polyfill macht das Mapping:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel und MessagePort werden direkt aus dem Browser re-exportiert (gleiche API). Worker selbst benötigt einen Wrapper, da der Konstruktor anders ist (Node erwartet einen Modulpfad, der Browser erwartet eine URL). isMainThread ist in diesem Kontext im Browser immer true.


Übersicht: Was diese 640 Zeilen darstellen

Polyfill Zeilen Strategie
buffer.js 116 Uint8Array + DataView, gesamte Buffer-API
node:crypto 166 SHA-256/HMAC/PBKDF2 from scratch + Web Crypto für Zufallswerte
node:fs 210 Map im Speicher + IndexedDB async
node:net 70 Chainable Stubs + echte IP-Validierungen
ssh2 74 Explizite Stubs
process.js 14 Minimal viable process
node:path ~30 POSIX-Pfadoperationen
node:url ~25 Delegation an Browser-APIs
node:events ~12 EventEmitter 4 Methoden
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Re-Export Web Workers
roxify.js 8 Stubs

640 Zeilen. Keine npm-Abhängigkeit. Kein Wasm. Und das ergibt ein Browser-Bundle, das in weniger als einer Sekunde startet und ohne jegliche Server-Infrastruktur läuft.


Was man daraus mitnehmen kann

Wenn du das nächste Mal eine Node.js-Bibliothek in den Browser portieren möchtest, zeigt Fortune's Ansatz Folgendes:

  1. Erkenne, was tatsächlich verwendet wird. Kein Grund, den gesamten EventEmitter zu implementieren, wenn der Code nur on, emit und removeListener verwendet.

  2. Delegiere an Browser-APIs, wenn möglich. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- der Browser hat sie bereits, also nutze sie.

  3. Synchroner Cache vor einer async API. Die Lösung Map + IndexedDB für node:fs ist das am meisten wiederverwendbare Pattern im gesamten Ordner.

  4. Ehrliche Stubs sind besser als stille unvollständige Implementierungen. Ein explizites throw new Error('not implemented in browser') ist unendlich nützlicher als ein return undefined, das den Bug erst 10 Aufrufe später manifestieren lässt.

  5. esbuild alias + inject ist unterschätzt. Es ist das perfekte Werkzeug für diese Art von Portierung -- null webpack-Konfiguration, null Plugin, nur eine Liste von Ersetzungen.


Der Code ist im Repo: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Jede Datei passt auf eine einzelne Seite, sie ist direkt auf GitHub lesbar. Sehr empfehlenswert, wenn du an einem ähnlichen Projekt arbeitest.

✨ AI Generated Article

Как заставить Node.js библиотеку работать в браузере без Wasm --

Как Fortune вручную переписала node:fs, node:crypto и дюжину других

Как заставить Node.js библиотеку работать в браузере без Wasm -- полифиллы typescript-virtual-container

Я недавно потратил немало времени, изучая исходный код typescript-virtual-container, проект Fortune (Chloé Rolzhausen). И часть, которая удивила меня больше всего -- это не VFS, не виртуальная сеть, не 170 команд Unix, переписанных на TypeScript. Это папка polyfills/.

Потому что модуль работает в браузере, без Wasm, и для этого Fortune вручную переписала весь слой node:*, который нужен библиотеке. Около 640 строк ручного JavaScript, заменяющих node:fs, node:crypto, node:os, node:net и некоторые другие.

Эта статья объясняет, как это работает, полифилл за полифиллом.


Основная проблема

Node.js библиотека использует API, которых нет в браузере. Когда ты пишешь import { readFileSync } from 'node:fs', это системный вызов на стороне Node -- настоящий доступ к диску через libuv. В браузере node:fs вообще не существует.

Обычные решения:

  • Wasm-рантайм (вроде Emscripten, WASIp1/WASIp2) -- ты компилируешь Node.js в Wasm и запускаешь его. Результат: бандлы по 10-50 МБ, заметное время загрузки, значительная сложность развёртывания.
  • Универсальные полифиллы (вроде browserify, webpack node: polyfills) -- npm-библиотеки, предоставляющие приближения каждого Node-модуля. Часто слишком тяжёлые, плохо подходят под конкретный случай.
  • Написать полифиллы вручную -- больше работы, но оптимальный результат.

Fortune выбрала третий вариант. И результат -- это браузерный бандл, который представляет собой просто библиотеку, запускается мгновенно и не зависит ни от какой внешней инфраструктуры.


Механика сборки

Всё строится вокруг esbuild и его опции alias. Каждый импорт node:* перенаправляется на локальный файл:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Опция inject заслуживает внимания: она позволяет внедрить process.js и buffer.js в начало каждого файла бандла, делая process и Buffer глобально доступными без явного импорта. Именно так Node.js предоставляет их нативно.


buffer.js -- Buffer на основе Uint8Array

Это один из двух глобально внедряемых файлов. Buffer массово используется в коде -- каждая SSH-операция, каждый VFS-снэпшот, каждое чтение/запись бинарных данных проходят через него.

Решение: класс BrowserBuffer, расширяющий Uint8Array и реализующий всё API Buffer из Node.js.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

Что реализовано в сумме:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Все методы записи: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Все соответствующие методы чтения
  • toString с поддержкой hex, base64, utf8
  • copy, equals, slice, subarray

Это 116 строк. Для того, что они заменяют, это поразительно компактно.

Главный трюк -- использование DataView для многобайтового доступа, который корректно обрабатывает порядок байтов без необходимости ручной работы с битами для каждого типа:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- минимальный глобальный process

Другой глобально внедряемый файл. Крошечный, но необходимый -- код часто проверяет process.env.NODE_ENV, process.platform, process.nextTick и т.д.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

Сопоставление nextTick → queueMicrotask -- самая важная деталь. process.nextTick в Node.js планирует callback в конце текущей фазы цикла событий, перед I/O. queueMicrotask в браузере делает нечто семантически очень близкое -- он планирует микротаску, которая выполняется перед следующим рендером или событием. Это не идентично, но достаточно близко, чтобы весь код, использующий nextTick, работал корректно в браузере.


node:fs -- IndexedDB как синхронная файловая система

Это самый сложный полифилл и, безусловно, самый интересный с технической точки зрения.

Проблема деликатная: node:fs предоставляет синхронное API (readFileSync, writeFileSync и т.д.), но все браузерные API для хранения -- асинхронные (IndexedDB, Cache API и т.д.). Нельзя сделать await посреди синхронной функции.

Решение Fortune: двойной уровень кэша.

Уровень 1 -- Map в памяти (синхронный)
Все чтения выполняются из Map<string, Uint8Array> в памяти. Мгновенно, синхронно, никаких проблем с API.

Уровень 2 -- IndexedDB (асинхронный, фоновый)
При запуске всё содержимое IndexedDB загружается в Map. Записи немедленно попадают в Map и запускают асинхронную запись в IndexedDB без блокировки.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload au démarrage
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Async write to IndexedDB (non-blocking)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

Предоставляемое API полноценно: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (с опцией recursive), mkdirSync (с опцией recursive), readdirSync, statSync, renameSync.

Есть даже слой управления файловыми дескрипторами (openSync, writeSync, closeSync), чтобы WAL-журнал VFS работал в браузерном режиме -- журнал открывает fd, пишет в него, закрывает его, и данные оказываются в IndexedDB.

Свойство ready экспортируется, чтобы код мог узнать, когда завершена начальная загрузка:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

Именно благодаря этому VFS-снэпшоты переживают перезагрузки страницы в браузере. Когда ты перезагружаешь демо, VFS восстанавливается точно в том состоянии, в котором ты его оставил, из IndexedDB, без какого-либо сервера.


node:crypto -- SHA-256, HMAC, PBKDF2 на чистом JS

Вместо того чтобы импортировать криптобиблиотеку, скомпилированную в Wasm, Fortune реализовала необходимые примитивы напрямую.

SHA-256 реализован с нуля, с константами FIPS 180-4:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 other constants */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 rounds of compression
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

Поверх SHA-256 построены HMAC-SHA256 и PBKDF2-HMAC-SHA256. Эти два примитива используются для вывода ключей в SSH-обменах и внутренней аутентификации.

Экспортируемое API напоминает API Node.js:

// Classic hash
const hash = createHash('sha256').update('data').digest('hex');

// Random bytes (via standard Web Crypto API)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Timing-safe comparison
const ok = timingSafeEqual(a, b);

// scrypt (approximated via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Примечание: scryptSync аппроксимирован через PBKDF2 с числом итераций, привязанным к параметру N. Это не настоящий scrypt (использующий другую схему памяти), но для нужд проекта этого достаточно.

Функции, которые невозможно разумно эмулировать в браузере (generateKeyPairSync, createCipheriv, createDecipheriv, createSign), при вызове выбрасывают явную ошибку. Честное поведение.


node:os -- чтение реальных характеристик браузера

Вместо возврата фиксированных значений этот полифилл читает браузерные API, чтобы возвращать информацию, соответствующую реальной машине пользователя.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Конкретный результат: когда ты запускаешь neofetch в браузерном демо, отображаемые количество ядер и RAM соответствуют твоей машине. Это деталь, но она огромна для правдоподобия терминала.

Остальные экспорты: freemem (40% от общей памяти, разумная аппроксимация), platform → 'browser', type → 'Linux', release → 'web', uptime через performance.now(), endianness → 'LE' (little-endian, верно для всех массовых x86/ARM процессоров), loadavg → [0, 0, 0].


node:net -- чистые заглушки TCP

У браузера нет доступа к сырым TCP-сокетам (WebSocket не в счёт -- это другой протокол прикладного уровня). Поэтому node:net -- заглушка, но хорошо написанная заглушка.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

Важный момент: методы регистрации событий (on, once, off, emit) возвращают this и не выбрасывают ошибку. Это позволяет коду, делающему new net.Socket().on('connect', cb), работать без падения, даже если соединение никогда не устанавливается. Только методы, которые действительно пытаются подключиться, выбрасывают ошибку.

isIP, isIPv4, isIPv6 реализованы корректно (не заглушки), потому что они используются виртуальным сетевым кодом для валидации адресов, никогда не открывая сокет.


node:path -- операции с POSIX-путями

Полная переработка операций с POSIX-путями, адаптированная под контекст (без обратной косой черты Windows, пути всегда абсолютные с /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Просто, компактно, корректно для нужд проекта.


node:url -- делегирование браузерным API

Этот элегантен своей простотой. API URL и URLSearchParams уже существует нативно в браузере -- достаточно их реэкспортировать.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Только fileURLToPath и pathToFileURL требуют реализации, поскольку они специфичны для Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

Это идеальный подход, когда целевая платформа (браузер) уже предоставляет нативный эквивалент.


node:zlib -- идентичность

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Две строки. Библиотека использует fflate для настоящего сжатия (который работает в браузере нативно). node:zlib импортируется только в тех путях кода, которые не выполняются в браузерном контексте -- поэтому сквозного пропускания достаточно.

Иногда правильная реализация -- это две строки.


node:events -- минимальный EventEmitter

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Полная реализация EventEmitter из Node.js составляет ~600 строк с обработкой maxListeners, once, prependListener и т.д. Здесь 12 строк для 4 методов, которые реально используются. Мысленный tree-shaking до того, как за него взялся инструмент сборки.


ssh2 и roxify -- явные заглушки

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

SSH-сервер не работает в браузере (в этом не было бы смысла -- кто бы подключался?). Но код, который говорит о SSH на стороне клиента -- классы, собирающие SSH-пакеты, парсеры протокола -- присутствует в библиотеке. Эти заглушки позволяют всему этому коду быть собранным без ошибок, гарантируя при этом, что при попытке вызвать метод, требующий настоящий сокет, будет выброшена понятная ошибка.

roxify -- проприетарный формат сжатия, используемый для VFS-снэпшотов в Node-режиме. В браузере вместо него используется fflate -- полифилл просто выбрасывает ошибку, если roxify вызывается напрямую.


node:worker_threads -- реэкспорт Web Workers

Это самый тонкий момент. node:worker_threads в Node.js и Web Workers в браузере -- это разные API, но они концептуально близки. Полифилл делает сопоставление:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel и MessagePort реэкспортируются напрямую из браузера (то же API). Worker сам требует обёртки, потому что конструкторы различаются (Node ожидает путь к модулю, браузер ожидает URL). isMainThread всегда true на стороне браузера в этом контексте.


Обзор: что представляют собой эти 640 строк

Полифилл Строк Стратегия
buffer.js 116 Uint8Array + DataView, весь API Buffer
node:crypto 166 SHA-256/HMAC/PBKDF2 с нуля + Web Crypto для randoms
node:fs 210 Map в памяти + асинхронная IndexedDB
node:net 70 Цепочные заглушки + реальная IP-валидация
ssh2 74 Явные заглушки
process.js 14 Минимальный жизнеспособный process
node:path ~30 Операции с POSIX-путями
node:url ~25 Делегирование браузерным API
node:events ~12 EventEmitter 4 метода
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Сквозное пропускание
node:worker_threads ~30 Реэкспорт Web Workers
roxify.js 8 Заглушки

640 строк. Никаких npm-зависимостей. Никакого Wasm. И это даёт браузерный бандл, который запускается меньше чем за секунду и работает без какой-либо серверной инфраструктуры.


Что можно из этого вынести

В следующий раз, когда ты захочешь портировать Node.js библиотеку в браузер, вот что демонстрирует подход Fortune:

  1. Определи, что действительно используется. Нет нужды реализовывать весь EventEmitter, если код использует только on, emit и removeListener.

  2. Делегируй браузерным API, когда возможно. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- в браузере они уже есть, стоит их использовать.

  3. Синхронный кэш перед асинхронным API. Решение Map + IndexedDB для node:fs -- самый переиспользуемый паттерн во всей папке.

  4. Честные заглушки лучше молчаливых неполных реализаций. Явный throw new Error('not implemented in browser') бесконечно полезнее, чем return undefined, который позволит багу проявиться 10 вызовов спустя.

  5. esbuild alias + inject недооценён. Это идеальный инструмент для такого рода портирования -- ноль конфигурации webpack, ноль плагинов, просто список замен.


Код в репозитории: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Каждый файл умещается на одну страницу, это читабельно прямо на GitHub. Крайне рекомендую, если ты работаешь над похожим проектом.

✨ AI Generated Article

Hacer funcionar una biblioteca Node.js en el navegador sin Wasm -- los

Cómo Fortune reimplementó a mano node:fs, node:crypto y una docena

Hacer funcionar una biblioteca Node.js en el navegador sin Wasm -- los polyfills de typescript-virtual-container

Recientemente pasé bastante tiempo analizando el código fuente de typescript-virtual-container, el proyecto de Fortune (Chloé Rolzhausen). Y la parte que más me sorprendió no es el VFS, no es la red virtual, no son los 170 comandos Unix reimplementados en TypeScript. Es la carpeta polyfills/.

Porque el módulo se ejecuta en el navegador, sin Wasm, y para eso Fortune reimplementó a mano toda la capa node:* que la biblioteca necesita. Unas 640 líneas de JavaScript artesanal que reemplazan node:fs, node:crypto, node:os, node:net y algunos más.

Este artículo explica cómo funciona, polyfill por polyfill.


El problema de base

Una biblioteca Node.js usa APIs que no existen en el navegador. Cuando escribes import { readFileSync } from 'node:fs', es una llamada al sistema del lado de Node -- un acceso real a disco vía libuv. En el navegador, node:fs no existe en absoluto.

Las soluciones habituales son:

  • Un runtime Wasm (tipo Emscripten, WASIp1/WASIp2) -- compilas Node.js en Wasm y lo ejecutas. Resultado: bundles de 10-50 MB, un tiempo de carga notable, una complejidad de despliegue significativa.
  • Polyfills genéricos (tipo browserify, webpack node: polyfills) -- bibliotecas npm que proporcionan aproximaciones de cada módulo Node. A menudo demasiado pesadas, mal adaptadas al caso específico.
  • Reescribir los polyfills a mano -- más trabajo, pero resultado óptimo.

Fortune eligió la tercera opción. Y el resultado es un bundle para navegador que es solo la biblioteca, que arranca instantáneamente y que no depende de ninguna infraestructura externa.


La mecánica de build

Todo se basa en esbuild y su opción alias. Cada import node:* se redirige a un archivo local:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

La opción inject merece atención: permite inyectar process.js y buffer.js al inicio de cada archivo del bundle, haciendo que process y Buffer estén disponibles globalmente sin ningún import explícito. Exactamente como Node.js los expone de forma nativa.


buffer.js -- Buffer sobre Uint8Array

Es uno de los dos globales inyectados. Buffer se usa masivamente en el código -- cada operación SSH, cada snapshot VFS, cada lectura/escritura binaria pasa por ahí.

La solución: una clase BrowserBuffer que extiende Uint8Array e implementa toda la API Buffer de Node.js.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

Lo que está implementado en total:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Todos los métodos de escritura: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Todos los métodos de lectura correspondientes
  • toString con soporte hex, base64, utf8
  • copy, equals, slice, subarray

Son 116 líneas. Para lo que reemplaza, es notablemente compacto.

El truco principal es el uso de DataView para los accesos multi-byte, que maneja correctamente el endianness sin tener que manipular los bits a mano para cada tipo:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- el global process mínimo

El otro global inyectado. Diminuto pero necesario -- el código a menudo verifica process.env.NODE_ENV, process.platform, process.nextTick, etc.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

El mapeo nextTick → queueMicrotask es el detalle más importante aquí. process.nextTick en Node.js programa un callback al final de la fase actual del bucle de eventos, antes de los I/O. queueMicrotask en el navegador hace algo semánticamente muy cercano -- programa una microtarea, que se ejecuta antes del próximo renderizado o evento. No es idéntico, pero es suficientemente cercano para que todo el código que usa nextTick funcione correctamente en el navegador.


node:fs -- IndexedDB como sistema de archivos síncrono

Este es el polyfill más sofisticado, y de lejos el más interesante técnicamente.

El problema es delicado: node:fs expone una API síncrona (readFileSync, writeFileSync, etc.), pero las APIs de almacenamiento del navegador son todas asíncronas (IndexedDB, Cache API, etc.). No se puede hacer un await en medio de una función síncrona.

La solución de Fortune: un doble nivel de caché.

Nivel 1 -- Map en memoria (síncrono)
Todas las lecturas se hacen desde un Map<string, Uint8Array> en memoria. Instantáneo, síncrono, sin problema de API.

Nivel 2 -- IndexedDB (asíncrono, en segundo plano)
Al inicio, todo el contenido de IndexedDB se carga en el Map. Las escrituras se hacen inmediatamente en el Map y lanzan una escritura asíncrona hacia IndexedDB sin bloquear.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload al inicio
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Escritura async hacia IndexedDB (no bloqueante)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

La API expuesta es completa: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (con opción recursive), mkdirSync (con opción recursive), readdirSync, statSync, renameSync.

Incluso hay una capa de gestión de file descriptors (openSync, writeSync, closeSync) para que el journal WAL del VFS funcione en modo navegador -- el journal abre un fd, escribe en él, lo cierra, y los datos terminan en IndexedDB.

La propiedad ready se exporta para permitir que el código sepa cuándo ha terminado la precarga inicial:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

Gracias a esto, los snapshots VFS sobreviven a las recargas de página en el navegador. Cuando recargas la demo, el VFS se restaura exactamente en el estado en que lo dejaste, desde IndexedDB, sin ningún servidor involucrado.


node:crypto -- SHA-256, HMAC, PBKDF2 en JS puro

En lugar de importar una biblioteca crypto compilada en Wasm, Fortune implementó las primitivas necesarias directamente.

SHA-256 está implementado from scratch con las constantes FIPS 180-4:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 constantes más */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 rondas de compresión
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

Sobre SHA-256, se construyen HMAC-SHA256 y PBKDF2-HMAC-SHA256. Estas dos primitivas se usan para la derivación de claves en los intercambios SSH y la autenticación interna.

La API exportada se parece a la de Node.js:

// Hash clásico
const hash = createHash('sha256').update('data').digest('hex');

// Bytes aleatorios (vía la API Web Crypto estándar)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Comparación timing-safe
const ok = timingSafeEqual(a, b);

// scrypt (aproximado vía PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Nota: scryptSync se aproxima vía PBKDF2 con un número de iteraciones ajustado al parámetro N. No es un scrypt real (que usa un esquema de memoria diferente), pero para los usos del proyecto es suficiente.

Las funciones que no pueden emularse razonablemente en el navegador (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) lanzan un error explícito si se llaman. Comportamiento honesto.


node:os -- leer las specs reales del navegador

En lugar de devolver valores fijos, este polyfill lee las APIs del navegador para devolver información que corresponde a la máquina real del usuario.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB por defecto
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Resultado concreto: cuando lanzas neofetch en la demo del navegador, el número de núcleos y la RAM mostrados corresponden a tu máquina. Es un detalle, pero contribuye enormemente a la verosimilitud del terminal.

Los otros exports: freemem (40% de la memoria total, aproximación razonable), platform → 'browser', type → 'Linux', release → 'web', uptime vía performance.now(), endianness → 'LE' (little-endian, cierto en todos los procesadores x86/ARM de consumo), loadavg → [0, 0, 0].


node:net -- stubs TCP limpios

El navegador no tiene acceso a sockets TCP brutos (WebSocket no cuenta -- es un protocolo de aplicación diferente). node:net es por tanto un stub, pero un stub bien escrito.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // encadenable
  once() { return this; }  // encadenable
  pipe() { return this; }  // encadenable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

El punto importante: los métodos de registro de eventos (on, once, off, emit) devuelven this y no lanzan error. Esto permite que el código que hace new net.Socket().on('connect', cb) funcione sin fallar, incluso si la conexión nunca se realiza. Solo los métodos que realmente intentan conectarse lanzan un error.

isIP, isIPv4, isIPv6 están implementados correctamente (no son stubs) porque los usa el código de red virtual para validar direcciones, sin abrir nunca un socket.


node:path -- operaciones de ruta POSIX

Reimplementación completa de las operaciones de ruta POSIX, adaptada al contexto (sin backslash Windows, rutas siempre absolutas con /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Simple, compacto, correcto para los usos del proyecto.


node:url -- delegación a las APIs del navegador

Esta es elegante por su simplicidad. La API URL y URLSearchParams ya existe de forma nativa en el navegador -- solo hay que re-exportarlas.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Solo fileURLToPath y pathToFileURL necesitan implementación, porque son propias de Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

Es el enfoque ideal cuando la plataforma objetivo (el navegador) ya proporciona el equivalente nativo.


node:zlib -- identidad

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Dos líneas. La biblioteca usa fflate para la compresión real (que funciona en el navegador de forma nativa). node:zlib solo se importa en caminos de código que no se ejecutan en el contexto del navegador -- por lo tanto un passthrough es suficiente.

A veces la buena implementación son dos líneas.


node:events -- EventEmitter mínimo

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

La implementación completa de EventEmitter de Node.js tiene ~600 líneas con la gestión de maxListeners, once, prependListener, etc. Aquí son 12 líneas para los 4 métodos realmente usados. Tree-shaking mental antes incluso del tree-shaking de la herramienta de build.


ssh2 y roxify -- stubs explícitos

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

El servidor SSH no se ejecuta en el navegador (no tendría sentido -- ¿quién se conectaría?). Pero el código que habla de SSH del lado cliente -- las clases que construyen paquetes SSH, los analizadores de protocolo -- existe en la biblioteca. Estos stubs permiten que todo ese código se bundlee sin error, garantizando al mismo tiempo que se lanza un error claro si alguien intenta llamar a un método que requiere un socket real.

roxify es un formato de compresión propietario usado para los snapshots VFS en modo Node. En el navegador, se usa fflate en su lugar -- el polyfill se limita a lanzar un error si se llama a roxify directamente.


node:worker_threads -- re-exportación de Web Workers

Este es el más sutil. node:worker_threads en Node.js y los Web Workers del navegador son dos APIs diferentes, pero conceptualmente cercanas. El polyfill hace el mapeo:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel y MessagePort se re-exportan directamente desde el navegador (misma API). Worker en sí mismo necesita un wrapper porque el constructor es diferente (Node espera una ruta de módulo, el navegador espera una URL). isMainThread es siempre true en el lado del navegador en este contexto.


Vista general: qué representan estas 640 líneas

Polyfill Líneas Estrategia
buffer.js 116 Uint8Array + DataView, toda la API Buffer
node:crypto 166 SHA-256/HMAC/PBKDF2 from scratch + Web Crypto para los randoms
node:fs 210 Map en memoria + IndexedDB async
node:net 70 Stubs encadenables + validaciones IP reales
ssh2 74 Stubs explícitos
process.js 14 Mínimo viable process
node:path ~30 Operaciones de ruta POSIX
node:url ~25 Delegación a APIs del navegador
node:events ~12 EventEmitter 4 métodos
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Re-exportación Web Workers
roxify.js 8 Stubs

640 líneas. Ninguna dependencia npm. Ningún Wasm. Y da como resultado un bundle para navegador que arranca en menos de un segundo y se ejecuta sin ninguna infraestructura del lado del servidor.


Qué podemos aprender de esto

La próxima vez que quieras portar una biblioteca Node.js al navegador, esto es lo que demuestra el enfoque de Fortune:

  1. Identifica lo que realmente se usa. No hace falta implementar EventEmitter entero si el código solo usa on, emit y removeListener.

  2. Delega en las APIs del navegador cuando sea posible. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- el navegador ya las tiene, mejor usarlas.

  3. Caché síncrono delante de una API async. La solución Map + IndexedDB para node:fs es el patrón más reutilizable de toda la carpeta.

  4. Los stubs honestos valen más que las implementaciones incompletas silenciosas. Un throw new Error('not implemented in browser') explícito es infinitamente más útil que un return undefined que deja que el bug se manifieste 10 llamadas más adelante.

  5. esbuild alias + inject está infravalorado. Es la herramienta perfecta para este tipo de portabilidad -- cero configuración webpack, cero plugins, solo una lista de reemplazos.


El código está en el repo: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Cada archivo cabe en una sola página, se puede leer directamente en GitHub. Muy recomendado si trabajas en un proyecto similar.

✨ AI Generated Article

Executando uma biblioteca Node.js no navegador sem Wasm -- os polyfills

Como Fortune reimplementou manualmente node:fs, node:crypto e uma

Executando uma biblioteca Node.js no navegador sem Wasm -- os polyfills do typescript-virtual-container

Passei um bom tempo recentemente examinando o código-fonte do typescript-virtual-container, o projeto de Fortune (Chloé Rolzhausen). E a parte que mais me surpreendeu não foi o VFS, não foi a rede virtual, não foram os 170 comandos Unix reimplementados em TypeScript. Foi a pasta polyfills/.

Porque o módulo roda no navegador, sem Wasm, e para isso a Fortune reimplementou manualmente toda a camada node:* que a biblioteca precisa. Cerca de 640 linhas de JavaScript artesanal que substituem node:fs, node:crypto, node:os, node:net, e alguns outros.

Este artigo explica como funciona, polyfill por polyfill.


O problema básico

Uma biblioteca Node.js usa APIs que não existem no navegador. Quando você escreve import { readFileSync } from 'node:fs', é uma chamada de sistema do Node -- um acesso real a disco via libuv. No navegador, node:fs simplesmente não existe.

As soluções comuns são:

  • Um runtime Wasm (tipo Emscripten, WASIp1/WASIp2) -- você compila o Node.js em Wasm e o executa. Resultado: bundles de 10-50 MB, tempo de carregamento notável, complexidade de deploy significativa.
  • Polyfills genéricos (tipo browserify, webpack node: polyfills) -- bibliotecas npm que fornecem aproximações de cada módulo Node. Frequentemente pesadas demais, mal adaptadas ao caso específico.
  • Reescrever os polyfills manualmente -- mais trabalho, mas resultado ideal.

Fortune escolheu a terceira opção. E o resultado é um bundle para navegador que é apenas a biblioteca, que inicia instantaneamente, e que não depende de nenhuma infraestrutura externa.


A mecânica de build

Tudo se baseia no esbuild e sua opção alias. Cada import node:* é redirecionado para um arquivo local:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

A opção inject merece destaque: ela permite injetar process.js e buffer.js no cabeçalho de cada arquivo do bundle, tornando process e Buffer disponíveis globalmente sem nenhum import explícito. Exatamente como o Node.js os expõe nativamente.


buffer.js -- Buffer sobre Uint8Array

É um dos dois globais injetados. Buffer é massivamente usado no código -- cada operação SSH, cada snapshot VFS, cada leitura/escrita binária passa por ele.

A solução: uma classe BrowserBuffer que estende Uint8Array e implementa toda a API Buffer do Node.js.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

O que é implementado no total:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Todos os métodos de escrita: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Todos os métodos de leitura correspondentes
  • toString com suporte a hex, base64, utf8
  • copy, equals, slice, subarray

São 116 linhas. Pelo que substitui, é notavelmente compacto.

O truque principal é o uso de DataView para acessos multibyte, que lida corretamente com endianness sem precisar manipular bits manualmente para cada tipo:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- o global process mínimo

O outro global injetado. Minúsculo mas necessário -- o código frequentemente testa process.env.NODE_ENV, process.platform, process.nextTick, etc.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

O mapeamento nextTick → queueMicrotask é o detalhe mais importante aqui. process.nextTick no Node.js agenda um callback ao final da fase atual do loop de eventos, antes de I/O. queueMicrotask no navegador faz algo semanticamente muito próximo -- ele agenda uma microtask, que é executada antes do próximo render ou evento. Não é idêntico, mas é próximo o suficiente para que todo o código que usa nextTick funcione corretamente no navegador.


node:fs -- IndexedDB como sistema de arquivos síncrono

Este é o polyfill mais sofisticado, e de longe o mais interessante tecnicamente.

O problema é delicado: node:fs expõe uma API síncrona (readFileSync, writeFileSync, etc.), mas as APIs de armazenamento do navegador são todas assíncronas (IndexedDB, Cache API, etc.). Não é possível fazer um await no meio de uma função síncrona.

A solução da Fortune: um duplo nível de cache.

Nível 1 -- Map em memória (síncrono)
Todas as leituras são feitas a partir de um Map<string, Uint8Array> em memória. Instantâneo, síncrono, sem problema de API.

Nível 2 -- IndexedDB (assíncrono, em segundo plano)
Na inicialização, todo o conteúdo do IndexedDB é carregado no Map. As escritas são feitas imediatamente no Map e disparam uma escrita assíncrona para o IndexedDB sem bloquear.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload na inicialização
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Escrita async para IndexedDB (não-bloqueante)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

A API exposta é completa: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (com opção recursive), mkdirSync (com opção recursive), readdirSync, statSync, renameSync.

Há até uma camada de gerenciamento de file descriptors (openSync, writeSync, closeSync) para que o journal WAL do VFS funcione no modo navegador -- o journal abre um fd, escreve nele, fecha, e os dados vão parar no IndexedDB.

A propriedade ready é exportada para permitir que o código saiba quando o preload inicial terminou:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

É graças a isso que os snapshots VFS sobrevivem a recarregamentos de página no navegador. Quando você recarrega a demo, o VFS é restaurado exatamente no estado em que estava, a partir do IndexedDB, sem nenhum servidor envolvido.


node:crypto -- SHA-256, HMAC, PBKDF2 em JS puro

Em vez de importar uma biblioteca crypto compilada em Wasm, a Fortune implementou as primitivas necessárias diretamente.

SHA-256 é implementado do zero com as constantes FIPS 180-4:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... outras 60 constantes */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 rounds de compressão
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

Sobre o SHA-256, HMAC-SHA256 e PBKDF2-HMAC-SHA256 são construídos. Essas duas primitivas são usadas para derivação de chaves nas trocas SSH e autenticação interna.

A API exportada se assemelha à do Node.js:

// Hash clássico
const hash = createHash('sha256').update('data').digest('hex');

// Bytes aleatórios (via Web Crypto API padrão)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Comparação timing-safe
const ok = timingSafeEqual(a, b);

// scrypt (aproximado via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Nota: scryptSync é aproximado via PBKDF2 com um número de iterações baseado no parâmetro N. Não é um scrypt real (que usa um esquema de memória diferente), mas para os usos do projeto é suficiente.

As funções que não podem ser emuladas razoavelmente no navegador (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) lançam um erro explícito se forem chamadas. Comportamento honesto.


node:os -- lendo as specs reais do navegador

Em vez de retornar valores fixos, este polyfill lê as APIs do navegador para retornar informações que correspondem à máquina real do usuário.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB padrão
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Resultado concreto: quando você executa neofetch na demo do navegador, o número de núcleos e a RAM exibidos correspondem à sua máquina. É um detalhe, mas contribui enormemente para a verossimilhança do terminal.

Os outros exports: freemem (40% da memória total, aproximação razoável), platform → 'browser', type → 'Linux', release → 'web', uptime via performance.now(), endianness → 'LE' (little-endian, verdadeiro em todos os processadores x86/ARM de consumo), loadavg → [0, 0, 0].


node:net -- stubs TCP limpos

O navegador não tem acesso a sockets TCP brutos (WebSocket não conta -- é um protocolo de aplicação diferente). node:net é portanto um stub, mas um stub bem escrito.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // encadeável
  once() { return this; }  // encadeável
  pipe() { return this; }  // encadeável
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

O ponto importante: os métodos de registro de eventos (on, once, off, emit) retornam this e não lançam erro. Isso permite que o código que faz new net.Socket().on('connect', cb) funcione sem quebrar, mesmo que a conexão nunca seja estabelecida. Apenas os métodos que tentam realmente se conectar lançam um erro.

isIP, isIPv4, isIPv6 são implementados corretamente (não são stubs) pois são usados pelo código de rede virtual para validar endereços, sem nunca abrir um socket.


node:path -- operações de caminho POSIX

Reimplementação completa das operações de caminho POSIX, adaptada ao contexto (sem barras invertidas do Windows, caminhos sempre absolutos com /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Simples, compacto, correto para os usos do projeto.


node:url -- delegação para APIs do navegador

Esta é elegante pela sua simplicidade. A API URL e URLSearchParams já existe nativamente no navegador -- basta reexportá-las.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Apenas fileURLToPath e pathToFileURL precisam de implementação, pois são próprias do Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

É a abordagem ideal quando a plataforma alvo (o navegador) já fornece o equivalente nativo.


node:zlib -- identidade

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Duas linhas. A biblioteca usa fflate para a compressão real (que funciona nativamente no navegador). node:zlib só é importado em caminhos de código que não são executados no contexto do navegador -- portanto um pass-through é suficiente.

Às vezes a boa implementação são duas linhas.


node:events -- EventEmitter mínimo

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

A implementação completa do EventEmitter do Node.js tem ~600 linhas com gerenciamento de maxListeners, once, prependListener, etc. Aqui são 12 linhas para os 4 métodos realmente usados. Tree-shaking mental antes mesmo do tree-shaking da ferramenta de build.


ssh2 e roxify -- stubs explícitos

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

O servidor SSH não roda no navegador (não faria sentido -- quem se conectaria?). Mas o código que fala de SSH do lado cliente -- as classes que constroem pacotes SSH, os parsers de protocolo -- existe na biblioteca. Esses stubs permitem que todo esse código seja empacotado sem erro, ao mesmo tempo que garantem que um erro claro seja lançado se alguém tentar chamar um método que requer um socket real.

roxify é um formato de compressão proprietário usado para snapshots VFS no modo Node. No navegador, fflate é usado em seu lugar -- o polyfill apenas lança um erro se roxify for chamado diretamente.


node:worker_threads -- reexportação de Web Workers

Este é o mais sutil. node:worker_threads no Node.js e os Web Workers do navegador são duas APIs diferentes, mas são conceitualmente próximas. O polyfill faz o mapeamento:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel e MessagePort são reexportados diretamente do navegador (mesma API). O Worker em si precisa de um wrapper pois o construtor é diferente (Node espera um caminho de módulo, o navegador espera uma URL). isMainThread é sempre true no lado do navegador neste contexto.


Visão geral: o que essas 640 linhas representam

Polyfill Linhas Estratégia
buffer.js 116 Uint8Array + DataView, toda a API Buffer
node:crypto 166 SHA-256/HMAC/PBKDF2 do zero + Web Crypto para aleatórios
node:fs 210 Map em memória + IndexedDB async
node:net 70 Stubs encadeáveis + validações IP reais
ssh2 74 Stubs explícitos
process.js 14 process mínimo viável
node:path ~30 Operações de caminho POSIX
node:url ~25 Delegação para APIs do navegador
node:events ~12 EventEmitter 4 métodos
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Pass-through
node:worker_threads ~30 Reexportação de Web Workers
roxify.js 8 Stubs

640 linhas. Nenhuma dependência npm. Nenhum Wasm. E isso produz um bundle para navegador que inicia em menos de um segundo e roda sem nenhuma infraestrutura do lado do servidor.


O que podemos aprender com isso

Da próxima vez que você quiser portar uma biblioteca Node.js para o navegador, eis o que a abordagem da Fortune demonstra:

  1. Identifique o que é realmente usado. Não precisa implementar o EventEmitter inteiro se o código só usa on, emit, e removeListener.

  2. Delegue para APIs do navegador quando possível. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- o navegador já tem tudo isso, é só usar.

  3. Cache síncrono na frente de uma API async. A solução Map + IndexedDB para node:fs é o padrão mais reutilizável de toda a pasta.

  4. Stubs honestos são melhores que implementações incompletas silenciosas. Um throw new Error('not implemented in browser') explícito é infinitamente mais útil que um return undefined que deixa o bug se manifestar 10 chamadas depois.

  5. esbuild alias + inject é subestimado. É a ferramenta perfeita para esse tipo de portabilidade -- zero configuração webpack, zero plugin, apenas uma lista de substituições.


O código está no repositório: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Cada arquivo cabe em uma única página, é legível diretamente no GitHub. Altamente recomendado se você estiver trabalhando em um projeto similar.

✨ AI Generated Article

Menjalankan Pustaka Node.js di Browser Tanpa Wasm --

Bagaimana Fortune mengimplementasikan ulang node:fs, node:crypto, dan

Menjalankan Pustaka Node.js di Browser Tanpa Wasm -- polyfill typescript-virtual-container

Saya baru saja menghabiskan cukup banyak waktu mempelajari kode sumber typescript-virtual-container, proyek Fortune (Chloé Rolzhausen). Dan bagian yang paling mengejutkan saya bukanlah VFS, bukan jaringan virtual, bukan 170 perintah Unix yang diimplementasikan ulang dalam TypeScript. Melainkan direktori polyfills/.

Karena modul ini berjalan di browser, tanpa Wasm, dan untuk itu Fortune mengimplementasikan ulang secara manual seluruh lapisan node:* yang dibutuhkan pustaka ini. Sekitar 640 baris JavaScript buatan tangan yang menggantikan node:fs, node:crypto, node:os, node:net, dan beberapa lainnya.

Artikel ini menjelaskan cara kerjanya, polyfill demi polyfill.


Masalah dasar

Sebuah pustaka Node.js menggunakan API yang tidak ada di browser. Saat Anda menulis import { readFileSync } from 'node:fs', itu adalah panggilan sistem di sisi Node -- akses disk nyata melalui libuv. Di browser, node:fs tidak ada sama sekali.

Solusi umumnya adalah:

  • Runtime Wasm (seperti Emscripten, WASIp1/WASIp2) -- Anda mengompilasi Node.js ke Wasm dan menjalankannya. Hasilnya: bundle 10-50 MB, waktu muat yang signifikan, kompleksitas deployment yang berarti.
  • Polyfill generik (seperti browserify, webpack node: polyfills) -- pustaka npm yang menyediakan perkiraan untuk setiap modul Node. Seringkali terlalu berat, kurang cocok untuk kasus spesifik.
  • Menulis ulang polyfill secara manual -- lebih banyak kerja, tetapi hasil optimal.

Fortune memilih opsi ketiga. Dan hasilnya adalah bundle browser yang hanya berisi pustaka itu sendiri, langsung menyala, dan tidak bergantung pada infrastruktur eksternal apa pun.


Mekanisme build

Semuanya bergantung pada esbuild dan opsinya alias. Setiap import node:* diarahkan ke file lokal:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Opsi inject perlu diperhatikan: opsi ini menyuntikkan process.js dan buffer.js di awal setiap file bundle, sehingga process dan Buffer tersedia secara global tanpa import eksplisit. Persis seperti yang dilakukan Node.js secara native.


buffer.js -- Buffer di atas Uint8Array

Ini adalah salah satu dari dua global yang disuntikkan. Buffer digunakan secara masif dalam kode -- setiap operasi SSH, setiap snapshot VFS, setiap pembacaan/penulisan biner melaluinya.

Solusinya: kelas BrowserBuffer yang memperluas Uint8Array dan mengimplementasikan seluruh API Buffer Node.js.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

Yang diimplementasikan secara total:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Semua metode penulisan: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Semua metode pembacaan yang sesuai
  • toString dengan dukungan hex, base64, utf8
  • copy, equals, slice, subarray

Itu hanya 116 baris. Untuk apa yang digantikannya, ini luar biasa ringkas.

Trik utamanya adalah penggunaan DataView untuk akses multi-byte, yang menangani endianness dengan benar tanpa perlu memanipulasi bit secara manual untuk setiap tipe:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- global process minimal

Global lain yang disuntikkan. Kecil namun diperlukan -- kode sering menguji process.env.NODE_ENV, process.platform, process.nextTick, dll.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

Pemetaan nextTick → queueMicrotask adalah detail terpenting di sini. process.nextTick di Node.js menjadwalkan callback di akhir fase loop acara saat ini, sebelum I/O. queueMicrotask di browser melakukan sesuatu yang secara semantik sangat mirip -- ia menjadwalkan microtask, yang dijalankan sebelum rendering atau acara berikutnya. Ini tidak identik, tetapi cukup dekat sehingga semua kode yang menggunakan nextTick berfungsi dengan benar di browser.


node:fs -- IndexedDB sebagai sistem file sinkron

Ini adalah polyfill paling canggih, dan sejauh ini yang paling menarik secara teknis.

Masalahnya rumit: node:fs mengekspos API sinkron (readFileSync, writeFileSync, dll.), tetapi API penyimpanan browser semuanya asinkron (IndexedDB, Cache API, dll.). Anda tidak bisa melakukan await di tengah fungsi sinkron.

Solusi Fortune: cache dua tingkat.

Tingkat 1 -- Map di memori (sinkron)
Semua pembacaan dilakukan dari Map<string, Uint8Array> di memori. Instan, sinkron, tanpa masalah API.

Tingkat 2 -- IndexedDB (asinkron, latar belakang)
Saat startup, seluruh konten IndexedDB dimuat ke dalam Map. Penulisan dilakukan segera ke Map dan meluncurkan penulisan asinkron ke IndexedDB tanpa memblokir.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Cache sinkron (path → Uint8Array | null)
const memCache = new Map();

// Preload saat startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Penulisan async ke IndexedDB (non-blocking)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

API yang diekspos lengkap: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (dengan opsi recursive), mkdirSync (dengan opsi recursive), readdirSync, statSync, renameSync.

Bahkan ada lapisan manajemen file descriptor (openSync, writeSync, closeSync) agar jurnal WAL VFS berfungsi dalam mode browser -- jurnal membuka fd, menulis ke dalamnya, menutupnya, dan datanya tersimpan di IndexedDB.

Properti ready diekspor untuk memungkinkan kode mengetahui kapan preload awal selesai:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

Berkat inilah snapshot VFS bertahan dari muat ulang halaman di browser. Saat Anda memuat ulang demo, VFS akan dikembalikan persis seperti saat Anda tinggalkan, dari IndexedDB, tanpa melibatkan server apa pun.


node:crypto -- SHA-256, HMAC, PBKDF2 dalam JS murni

Alih-alih mengimpor pustaka crypto yang dikompilasi ke Wasm, Fortune mengimplementasikan primitif yang diperlukan secara langsung.

SHA-256 diimplementasikan dari awal dengan konstanta FIPS 180-4:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 konstanta lainnya */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 putaran kompresi
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

Di atas SHA-256, HMAC-SHA256 dan PBKDF2-HMAC-SHA256 dibangun. Kedua primitif ini digunakan untuk derivasi kunci dalam pertukaran SSH dan autentikasi internal.

API yang diekspor mirip dengan Node.js:

// Hash klasik
const hash = createHash('sha256').update('data').digest('hex');

// Byte acak (via Web Crypto API standar)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Perbandingan timing-safe
const ok = timingSafeEqual(a, b);

// scrypt (didekati via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Catatan: scryptSync didekati via PBKDF2 dengan jumlah iterasi yang disetel ke parameter N. Ini bukan scrypt asli (yang menggunakan skema memori berbeda), tetapi untuk penggunaan proyek ini sudah cukup.

Fungsi yang tidak dapat diemulasi secara wajar di browser (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) akan melontarkan error eksplisit jika dipanggil. Perilaku yang jujur.


node:os -- membaca spesifikasi nyata browser

Alih-alih mengembalikan nilai tetap, polyfill ini membaca API browser untuk mengembalikan informasi yang sesuai dengan mesin nyata pengguna.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Hasil nyata: saat Anda menjalankan neofetch di demo browser, jumlah core dan RAM yang ditampilkan sesuai dengan mesin Anda. Ini detail kecil, tetapi sangat berkontribusi pada kredibilitas terminal.

Ekspor lainnya: freemem (40% dari total memori, perkiraan wajar), platform → 'browser', type → 'Linux', release → 'web', uptime via performance.now(), endianness → 'LE' (little-endian, benar untuk semua prosesor x86/ARM konsumen), loadavg → [0, 0, 0].


node:net -- stub TCP yang bersih

Browser tidak memiliki akses ke socket TCP mentah (WebSocket tidak dihitung -- itu protokol aplikasi yang berbeda). node:net karenanya adalah stub, tetapi stub yang ditulis dengan baik.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

Poin penting: metode pendaftaran acara (on, once, off, emit) mengembalikan this dan tidak melontarkan error. Ini memungkinkan kode yang melakukan new net.Socket().on('connect', cb) berfungsi tanpa crash, meskipun koneksi tidak pernah terjadi. Hanya metode yang benar-benar mencoba terhubung yang melontarkan error.

isIP, isIPv4, isIPv6 diimplementasikan dengan benar (bukan stub) karena digunakan oleh kode jaringan virtual untuk memvalidasi alamat, tanpa pernah membuka socket.


node:path -- operasi path POSIX

Implementasi ulang lengkap dari operasi path POSIX, disesuaikan dengan konteks (tanpa backslash Windows, path selalu absolut dengan /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Sederhana, ringkas, benar untuk penggunaan proyek.


node:url -- delegasi ke API browser

Yang ini elegan karena kesederhanaannya. API URL dan URLSearchParams sudah ada secara native di browser -- cukup diekspor ulang.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Hanya fileURLToPath dan pathToFileURL yang memerlukan implementasi, karena khusus untuk Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

Ini adalah pendekatan ideal ketika platform target (browser) sudah menyediakan padanan native.


node:zlib -- identitas

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Dua baris. Pustaka menggunakan fflate untuk kompresi sebenarnya (yang berfungsi secara native di browser). node:zlib hanya diimpor di jalur kode yang tidak dijalankan dalam konteks browser -- jadi passthrough sudah cukup.

Terkadang implementasi yang tepat hanya dua baris.


node:events -- EventEmitter minimal

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Implementasi lengkap EventEmitter Node.js sekitar ~600 baris dengan penanganan maxListeners, once, prependListener, dll. Di sini hanya 12 baris untuk 4 metode yang benar-benar digunakan. Tree-shaking mental sebelum tree-shaking alat build.


ssh2 dan roxify -- stub eksplisit

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

Server SSH tidak berjalan di browser (tidak masuk akal -- siapa yang akan terhubung?). Tetapi kode yang berbicara tentang SSH di sisi klien -- kelas yang membangun paket SSH, parser protokol -- ada di pustaka. Stub ini memungkinkan semua kode tersebut dibundle tanpa error, sambil memastikan error yang jelas dilontarkan jika seseorang mencoba memanggil metode yang membutuhkan socket sungguhan.

roxify adalah format kompresi kepemilikan yang digunakan untuk snapshot VFS dalam mode Node. Di browser, fflate digunakan sebagai gantinya -- polyfill hanya melontarkan error jika roxify dipanggil secara langsung.


node:worker_threads -- reekspor Web Workers

Ini yang paling halus. node:worker_threads di Node.js dan Web Workers di browser adalah dua API yang berbeda, tetapi secara konseptual dekat. Polyfill melakukan pemetaan:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel dan MessagePort diekspor ulang langsung dari browser (API yang sama). Worker sendiri memerlukan wrapper karena konstruktornya berbeda (Node menerima path modul, browser menerima URL). isMainThread selalu true di sisi browser dalam konteks ini.


Gambaran umum: apa arti 640 baris ini

Polyfill Baris Strategi
buffer.js 116 Uint8Array + DataView, seluruh API Buffer
node:crypto 166 SHA-256/HMAC/PBKDF2 from scratch + Web Crypto untuk random
node:fs 210 Map di memori + IndexedDB async
node:net 70 Stub chainable + validasi IP nyata
ssh2 74 Stub eksplisit
process.js 14 process minimal yang layak
node:path ~30 Operasi path POSIX
node:url ~25 Delegasi ke API browser
node:events ~12 EventEmitter 4 metode
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Reekspor Web Workers
roxify.js 8 Stub

640 baris. Tanpa dependensi npm. Tanpa Wasm. Dan itu menghasilkan bundle browser yang menyala dalam waktu kurang dari satu detik dan berjalan tanpa infrastruktur sisi server sama sekali.


Yang bisa dipetik

Lain kali Anda ingin memindahkan pustaka Node.js ke browser, inilah yang ditunjukkan oleh pendekatan Fortune:

  1. Identifikasi apa yang benar-benar digunakan. Tidak perlu mengimplementasikan EventEmitter secara keseluruhan jika kode hanya menggunakan on, emit, dan removeListener.

  2. Delegasikan ke API browser jika memungkinkan. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- browser sudah memilikinya, manfaatkan saja.

  3. Cache sinkron di depan API async. Solusi Map + IndexedDB untuk node:fs adalah pola yang paling dapat digunakan kembali dari seluruh direktori.

  4. Stub yang jujur lebih baik daripada implementasi tidak lengkap yang diam. throw new Error('not implemented in browser') yang eksplisit jauh lebih berguna daripada return undefined yang membiarkan bug muncul 10 panggilan kemudian.

  5. esbuild alias + inject diremehkan. Ini adalah alat yang sempurna untuk jenis porting ini -- tanpa konfigurasi webpack, tanpa plugin, hanya daftar penggantian.


Kodenya ada di repo: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Setiap file muat dalam satu halaman, bisa dibaca langsung di GitHub. Sangat direkomendasikan jika Anda mengerjakan proyek serupa.

✨ AI Generated Article

बिना Wasm के ब्राउज़र में Node.js लाइब्रेरी चलाना --

कैसे Fortune ने node:fs, node:crypto और एक दर्जन Node मॉड्यूल को

बिना Wasm के ब्राउज़र में Node.js लाइब्रेरी चलाना -- typescript-virtual-container के polyfills

मैंने हाल ही में typescript-virtual-container के सोर्स कोड को खंगालने में काफी समय बिताया, जो Fortune (Chloé Rolzhausen) का प्रोजेक्ट है। और जिस हिस्से ने मुझे सबसे ज्यादा हैरान किया, वह VFS नहीं है, वह वर्चुअल नेटवर्क नहीं है, वह 170 यूनिक्स कमांड नहीं हैं जो TypeScript में रीइम्प्लीमेंट की गई हैं। वह polyfills/ फ़ोल्डर है।

क्योंकि मॉड्यूल ब्राउज़र में बिना Wasm के चलता है, और इसके लिए Fortune ने हाथ से वह पूरी node:* लेयर रीइम्प्लीमेंट की है जिसकी लाइब्रेरी को ज़रूरत है। लगभग 640 लाइनों की हस्तनिर्मित JavaScript जो node:fs, node:crypto, node:os, node:net, और कुछ अन्य को रिप्लेस करती है।

यह लेख बताता है कि यह कैसे काम करता है, polyfill दर polyfill।


मूल समस्या

एक Node.js लाइब्रेरी ऐसी APIs का उपयोग करती है जो ब्राउज़र में मौजूद नहीं हैं। जब आप import { readFileSync } from 'node:fs' लिखते हैं, तो यह Node की ओर से एक सिस्टम कॉल है -- libuv के ज़रिए एक वास्तविक डिस्क एक्सेस। ब्राउज़र में, node:fs मौजूद ही नहीं है।

सामान्य समाधान हैं:

  • एक Wasm रनटाइम (जैसे Emscripten, WASIp1/WASIp2) -- आप Node.js को Wasm में कंपाइल करके चलाते हैं। परिणाम: 10-50 MB के बंडल, ध्यान देने योग्य लोडिंग समय, महत्वपूर्ण डिप्लॉयमेंट जटिलता।
  • जेनेरिक polyfills (जैसे browserify, webpack node: polyfills) -- npm पैकेज जो हर Node मॉड्यूल का अनुमानित संस्करण प्रदान करते हैं। अक्सर बहुत भारी, विशिष्ट उपयोग के लिए अनुपयुक्त।
  • हाथ से polyfills लिखना -- अधिक काम, लेकिन इष्टतम परिणाम।

Fortune ने तीसरा विकल्प चुना। और परिणाम एक ब्राउज़र बंडल है जो सिर्फ लाइब्रेरी है, तुरंत स्टार्ट होता है, और किसी बाहरी इंफ्रास्ट्रक्चर पर निर्भर नहीं करता।


बिल्ड मैकेनिज्म

सब कुछ esbuild और इसके alias ऑप्शन पर आधारित है। हर node:* इम्पोर्ट एक लोकल फ़ाइल पर रीडायरेक्ट होता है:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

inject ऑप्शन ध्यान देने योग्य है: यह process.js और buffer.js को बंडल की हर फ़ाइल की शुरुआत में इंजेक्ट करता है, जिससे process और Buffer बिना किसी स्पष्ट इम्पोर्ट के वैश्विक रूप से उपलब्ध हो जाते हैं। बिल्कुल वैसे ही जैसे Node.js उन्हें मूल रूप से एक्सपोज़ करता है।


buffer.js -- Buffer ऑन Uint8Array

यह दो इंजेक्टेड ग्लोबल्स में से एक है। Buffer का कोड में बड़े पैमाने पर उपयोग होता है -- हर SSH ऑपरेशन, हर VFS स्नैपशॉट, हर बाइनरी रीड/राइट इसी से होकर गुज़रता है।

समाधान: एक BrowserBuffer क्लास जो Uint8Array को एक्सटेंड करती है और Node.js के पूरे Buffer API को इम्प्लीमेंट करती है।

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

कुल मिलाकर जो इम्प्लीमेंट किया गया है:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • सभी राइट मेथड्स: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • सभी संबंधित रीड मेथड्स
  • toString जिसमें hex, base64, utf8 सपोर्ट है
  • copy, equals, slice, subarray

यह 116 लाइनें हैं। जो चीज़ यह रिप्लेस करता है, उसके लिए यह उल्लेखनीय रूप से कॉम्पैक्ट है।

मुख्य ट्रिक मल्टी-बाइट एक्सेस के लिए DataView का उपयोग है, जो हर टाइप के लिए मैन्युअली बिट्स मैनिपुलेट किए बिना endianness को सही ढंग से संभालता है:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- न्यूनतम process ग्लोबल

दूसरा इंजेक्टेड ग्लोबल। छोटा लेकिन आवश्यक -- कोड अक्सर process.env.NODE_ENV, process.platform, process.nextTick, आदि चेक करता है।

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

nextTick → queueMicrotask मैपिंग यहाँ सबसे महत्वपूर्ण डिटेल है। Node.js में process.nextTick इवेंट लूप के वर्तमान फेज़ के अंत में, I/O से पहले एक कॉलबैक शेड्यूल करता है। ब्राउज़र में queueMicrotask शब्दार्थ की दृष्टि से बहुत समान काम करता है -- यह एक माइक्रोटास्क शेड्यूल करता है, जो अगले रेंडर या इवेंट से पहले निष्पादित होता है। यह एकदम समान नहीं है, लेकिन इतना करीब है कि nextTick का उपयोग करने वाला सारा कोड ब्राउज़र में सही ढंग से काम करता है।


node:fs -- IndexedDB एक सिंक्रोनस फ़ाइल सिस्टम के रूप में

यह सबसे परिष्कृत polyfill है, और अब तक तकनीकी रूप से सबसे दिलचस्प है।

समस्या नाजुक है: node:fs एक सिंक्रोनस API एक्सपोज़ करता है (readFileSync, writeFileSync, आदि), लेकिन ब्राउज़र की स्टोरेज APIs सभी एसिंक्रोनस हैं (IndexedDB, Cache API, आदि)। आप एक सिंक्रोनस फ़ंक्शन के बीच में await नहीं कर सकते।

Fortune का समाधान: कैश का डबल लेवल।

लेवल 1 -- इन-मेमोरी Map (सिंक्रोनस)
सभी रीड्स मेमोरी में एक Map<string, Uint8Array> से होती हैं। तत्काल, सिंक्रोनस, कोई API समस्या नहीं।

लेवल 2 -- IndexedDB (एसिंक्रोनस, बैकग्राउंड में)
स्टार्टअप पर, IndexedDB की पूरी सामग्री Map में लोड हो जाती है। राइट्स तुरंत Map में होती हैं और बिना ब्लॉक किए IndexedDB में एक एसिंक्रोनस राइट शुरू करती हैं।

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload at startup
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Async write to IndexedDB (non-blocking)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

एक्सपोज़्ड API पूर्ण है: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (recursive ऑप्शन के साथ), mkdirSync (recursive ऑप्शन के साथ), readdirSync, statSync, renameSync।

फ़ाइल डिस्क्रिप्टर मैनेजमेंट (openSync, writeSync, closeSync) की एक लेयर भी है ताकि VFS का WAL जर्नल ब्राउज़र मोड में काम करे -- जर्नल एक fd खोलता है, उसमें लिखता है, उसे बंद करता है, और डेटा IndexedDB में पहुँच जाता है।

ready प्रॉपर्टी एक्सपोर्ट की गई है ताकि कोड को पता चले कि शुरुआती प्रीलोड कब पूरा हुआ:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

यही वजह है कि VFS स्नैपशॉट ब्राउज़र में पेज रीलोड होने पर भी बचे रहते हैं। जब आप डेमो को रीलोड करते हैं, तो VFS ठीक उसी स्थिति में बहाल हो जाता है जिसमें आपने इसे छोड़ा था, IndexedDB से, बिना किसी सर्वर के।


node:crypto -- SHA-256, HMAC, PBKDF2 शुद्ध JS में

Wasm में कंपाइल्ड क्रिप्टो लाइब्रेरी इम्पोर्ट करने के बजाय, Fortune ने आवश्यक प्रिमिटिव को सीधे इम्प्लीमेंट किया।

SHA-256 को FIPS 180-4 कॉन्स्टेंट के साथ स्क्रैच से इम्प्लीमेंट किया गया है:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 other constants */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 rounds of compression
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

SHA-256 के ऊपर, HMAC-SHA256 और PBKDF2-HMAC-SHA256 बनाए गए हैं। इन दो प्रिमिटिव का उपयोग SSH एक्सचेंजों और आंतरिक प्रमाणीकरण में की डेरिवेशन के लिए किया जाता है।

एक्सपोर्टेड API Node.js जैसा दिखता है:

// Classic hash
const hash = createHash('sha256').update('data').digest('hex');

// Random bytes (via standard Web Crypto API)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// Timing-safe comparison
const ok = timingSafeEqual(a, b);

// scrypt (approximated via PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

नोट: scryptSync को PBKDF2 के ज़रिए N पैरामीटर पर आधारित इटरेशन काउंट के साथ अनुमानित किया गया है। यह असली scrypt नहीं है (जो एक अलग मेमोरी स्कीम का उपयोग करता है), लेकिन प्रोजेक्ट के उपयोगों के लिए यह पर्याप्त है।

जो फ़ंक्शन ब्राउज़र में उचित रूप से इम्युलेट नहीं किए जा सकते (generateKeyPairSync, createCipheriv, createDecipheriv, createSign), वे कॉल किए जाने पर एक स्पष्ट एरर फेंकते हैं। ईमानदार व्यवहार।


node:os -- ब्राउज़र की वास्तविक स्पेसिफिकेशन पढ़ना

यह polyfill निश्चित मान लौटाने के बजाय, उपयोगकर्ता की वास्तविक मशीन से मेल खाने वाली जानकारी लौटाने के लिए ब्राउज़र APIs पढ़ता है।

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB default
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

ठोस परिणाम: जब आप ब्राउज़र डेमो में neofetch चलाते हैं, तो दिखाए गए कोर और RAM की संख्या आपकी मशीन से मेल खाती है। यह एक छोटी बात है, लेकिन यह टर्मिनल की विश्वसनीयता में बहुत योगदान देता है।

अन्य एक्सपोर्ट्स: freemem (कुल मेमोरी का 40%, उचित अनुमान), platform → 'browser', type → 'Linux', release → 'web', uptime performance.now() के ज़रिए, endianness → 'LE' (लिटिल-एंडियन, सभी मुख्यधारा x86/ARM प्रोसेसर पर सही), loadavg → [0, 0, 0].


node:net -- साफ TCP stubs

ब्राउज़र के पास कच्चे TCP सॉकेट तक पहुँच नहीं है (WebSocket मायने नहीं रखता -- यह एक अलग एप्लिकेशन-लेयर प्रोटोकॉल है)। इसलिए node:net एक stub है, लेकिन एक अच्छी तरह से लिखा गया stub।

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

महत्वपूर्ण बिंदु: इवेंट रजिस्ट्रेशन मेथड्स (on, once, off, emit) this लौटाती हैं और एरर नहीं फेंकतीं। इससे new net.Socket().on('connect', cb) करने वाला कोड बिना क्रैश हुए काम करता है, भले ही कनेक्शन कभी न हो। केवल वे मेथड्स जो वास्तव में कनेक्ट करने का प्रयास करती हैं एरर फेंकती हैं।

isIP, isIPv4, isIPv6 सही ढंग से इम्प्लीमेंट किए गए हैं (stubs नहीं) क्योंकि इनका उपयोग वर्चुअल नेटवर्क कोड द्वारा बिना कोई सॉकेट खोले पतों को मान्य करने के लिए किया जाता है।


node:path -- POSIX पथ ऑपरेशन

POSIX पथ ऑपरेशनों की पूर्ण रीइम्प्लीमेंटेशन, संदर्भ के अनुकूल (कोई विंडोज बैकस्लैश नहीं, पथ हमेशा / से शुरू होते हैं)।

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

सरल, कॉम्पैक्ट, प्रोजेक्ट के उपयोगों के लिए सही।


node:url -- ब्राउज़र APIs को डेलिगेशन

यह अपनी सादगी में सुंदर है। URL और URLSearchParams APIs ब्राउज़र में पहले से ही मूल रूप से मौजूद हैं -- बस उन्हें री-एक्सपोर्ट करना है।

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

केवल fileURLToPath और pathToFileURL को इम्प्लीमेंटेशन की आवश्यकता है, क्योंकि वे Node के लिए विशिष्ट हैं:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

यह आदर्श दृष्टिकोण है जब लक्ष्य प्लेटफ़ॉर्म (ब्राउज़र) पहले से ही मूल समकक्ष प्रदान करता है।


node:zlib -- आइडेंटिटी

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

दो लाइनें। लाइब्रेरी वास्तविक कंप्रेशन के लिए fflate का उपयोग करती है (जो ब्राउज़र में मूल रूप से काम करता है)। node:zlib केवल उन कोड पथों में इम्पोर्ट किया जाता है जो ब्राउज़र संदर्भ में निष्पादित नहीं होते -- इसलिए एक passthrough पर्याप्त है।

कभी-कभी सही इम्प्लीमेंटेशन सिर्फ दो लाइनें होती है।


node:events -- न्यूनतम EventEmitter

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Node.js की पूर्ण EventEmitter इम्प्लीमेंटेशन maxListeners, once, prependListener, आदि के साथ लगभग 600 लाइनों की है। यहाँ वास्तव में उपयोग की जाने वाली 4 मेथड्स के लिए 12 लाइनें हैं। बिल्ड टूल के tree-shaking से पहले ही मेंटल tree-shaking।


ssh2 और roxify -- स्पष्ट stubs

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

SSH सर्वर ब्राउज़र में नहीं चलता (इसका कोई मतलब नहीं होगा -- कौन कनेक्ट होगा?)। लेकिन जो कोड SSH के बारे में क्लाइंट साइड पर बात करता है -- SSH पैकेट बनाने वाली क्लासेस, प्रोटोकॉल पार्सर्स -- वह लाइब्रेरी में मौजूद है। ये stubs उस सारे कोड को बिना एरर के बंडल होने देते हैं, साथ ही यह गारंटी देते हैं कि अगर कोई ऐसी मेथड कॉल करने की कोशिश करता है जिसके लिए वास्तविक सॉकेट की आवश्यकता है, तो एक स्पष्ट एरर उठेगा।

roxify Node मोड में VFS स्नैपशॉट के लिए उपयोग किया जाने वाला एक मालिकाना कंप्रेशन फ़ॉर्मेट है। ब्राउज़र में, इसके बजाय fflate का उपयोग किया जाता है -- polyfill अगर roxify को सीधे कॉल किया जाता है तो एरर फेंक देता है।


node:worker_threads -- वेब वर्कर्स का री-एक्सपोर्ट

यह सबसे सूक्ष्म है। Node.js में node:worker_threads और ब्राउज़र के वेब वर्कर्स दो अलग APIs हैं, लेकिन वे वैचारिक रूप से करीब हैं। polyfill मैपिंग करता है:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel और MessagePort को ब्राउज़र से सीधे री-एक्सपोर्ट किया गया है (एक ही API)। Worker को खुद एक रैपर की आवश्यकता है क्योंकि कंस्ट्रक्टर अलग है (Node एक मॉड्यूल पथ की अपेक्षा करता है, ब्राउज़र एक URL की)। इस संदर्भ में ब्राउज़र साइड पर isMainThread हमेशा true है।


समग्र दृष्टिकोण: ये 640 लाइनें क्या दर्शाती हैं

Polyfill लाइनें रणनीति
buffer.js 116 Uint8Array + DataView, पूरा Buffer API
node:crypto 166 SHA-256/HMAC/PBKDF2 स्क्रैच से + रैंडम के लिए वेब क्रिप्टो
node:fs 210 मेमोरी में Map + एसिंक IndexedDB
node:net 70 चेन करने योग्य stubs + वास्तविक IP वैलिडेशन
ssh2 74 स्पष्ट stubs
process.js 14 न्यूनतम व्यवहार्य process
node:path ~30 POSIX पथ ऑपरेशन
node:url ~25 ब्राउज़र APIs को डेलिगेशन
node:events ~12 EventEmitter 4 मेथड्स
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 वेब वर्कर्स का री-एक्सपोर्ट
roxify.js 8 Stubs

640 लाइनें। कोई npm डिपेंडेंसी नहीं। कोई Wasm नहीं। और यह एक ब्राउज़र बंडल देता है जो एक सेकंड से भी कम में स्टार्ट होता है और बिना किसी सर्वर-साइड इंफ्रास्ट्रक्चर के चलता है।


हम क्या सीख सकते हैं

अगली बार जब आप किसी Node.js लाइब्रेरी को ब्राउज़र में पोर्ट करना चाहें, तो Fortune का दृष्टिकोण यह दर्शाता है:

  1. पहचानें कि वास्तव में क्या उपयोग होता है। पूरा EventEmitter इम्प्लीमेंट करने की ज़रूरत नहीं है अगर कोड केवल on, emit, और removeListener का उपयोग करता है।

  2. जब संभव हो ब्राउज़र APIs को डेलिगेट करें। URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- ब्राउज़र में ये पहले से मौजूद हैं, इनका उपयोग करें।

  3. एसिंक API के सामने सिंक कैश। node:fs के लिए Map + IndexedDB का समाधान पूरे फ़ोल्डर का सबसे पुन: उपयोग योग्य पैटर्न है।

  4. ईमानदार stubs खामोश अधूरे इम्प्लीमेंटेशन से बेहतर हैं। एक स्पष्ट throw new Error('not implemented in browser') एक return undefined से अत्यधिक अधिक उपयोगी है जो बग को 10 कॉल बाद प्रकट होने देता है।

  5. esbuild alias + inject को कम आंका गया है। यह इस तरह के पोर्टिंग के लिए एकदम सही टूल है -- शून्य वेबपैक कॉन्फिगरेशन, शून्य प्लगिन, बस रिप्लेसमेंट की एक सूची।


कोड रिपॉजिटरी में है: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. हर फ़ाइल एक पेज में समा जाती है, GitHub पर सीधे पढ़ने लायक है। अगर आप किसी समान प्रोजेक्ट पर काम कर रहे हैं तो अत्यधिक अनुशंसित।

✨ AI Generated Article

تشغيل مكتبة Node.js في المتصفح بدون Wasm -- polyfills

كيف أعادت Fortune تنفيذ node:fs و node:crypto وعشرات وحدات Node

تشغيل مكتبة Node.js في المتصفح بدون Wasm -- polyfills لـ typescript-virtual-container

قضيت مؤخرًا وقتًا طويلاً في دراسة الكود المصدري لـ typescript-virtual-container، مشروع Fortune (Chloé Rolzhausen). والجزء الذي أدهشني أكثر ليس VFS، ولا الشبكة الافتراضية، ولا أوامر Unix الـ 170 المعاد تنفيذها في TypeScript. بل مجلد polyfills/.

لأن الوحدة تعمل في المتصفح، بدون Wasm، ولتحقيق ذلك أعادت Fortune تنفيذ طبقة node:* التي تحتاجها المكتبة يدويًا بالكامل. حوالي 640 سطرًا من JavaScript الحرفي الذي يحل محل node:fs و node:crypto و node:os و node:net وغيرها.

يشرح هذا المقال كيف يعمل ذلك، polyfill تلو الآخر.


المشكلة الأساسية

مكتبة Node.js تستخدم واجهات برمجة تطبيقات (APIs) غير موجودة في المتصفح. عندما تكتب import { readFileSync } from 'node:fs'، هذا استدعاء نظام من جانب Node -- وصول حقيقي للقرص عبر libuv. في المتصفح، node:fs غير موجودة إطلاقًا.

الحلول المعتادة هي:

  • بيئة تشغيل Wasm (مثل Emscripten، WASIp1/WASIp2) -- تقوم بترجمة Node.js إلى Wasm وتشغيله. النتيجة: حزم بحجم 10-50 MB، وقت تحميل ملحوظ، تعقيد نشر كبير.
  • polyfills عامة (مثل browserify، webpack node: polyfills) -- مكتبات npm توفر تقريبات لكل وحدة Node نمطية. غالبًا ما تكون ثقيلة جدًا، وغير مناسبة للحالة المحددة.
  • إعادة كتابة polyfills يدويًا -- عمل أكثر، لكن بنتيجة مثالية.

اختارت Fortune الخيار الثالث. والنتيجة هي حزمة متصفح هي مجرد المكتبة، تبدأ فورًا، ولا تعتمد على أي بنية تحتية خارجية.


آلية البناء

كل شيء يعتمد على esbuild وخياره alias. كل استيراد node:* يُعاد توجيهه إلى ملف محلي:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

خيار inject يستحق الذكر: يسمح بحقن process.js و buffer.js في رأس كل ملف من الحزمة، مما يجعل process و Buffer متاحين عمومًا دون أي استيراد صريح. تمامًا كما تعرضها Node.js بشكل أصلي.


buffer.js -- Buffer على Uint8Array

هذا أحد المتغيرين العموميين المحقونين. Buffer يُستخدم بشكل مكثف في الكود -- كل عملية SSH، كل لقطة VFS، كل قراءة/كتابة ثنائية تمر عبره.

الحل: كلاس BrowserBuffer يمتد Uint8Array وينفذ كامل واجهة Buffer الخاصة بـ Node.js.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

ما تم تنفيذه إجمالاً:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • جميع طرق الكتابة: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • جميع طرق القراءة المقابلة
  • toString مع دعم hex و base64 و utf8
  • copy, equals, slice, subarray

هذا يمثل 116 سطرًا. مقابل ما يستبدله، إنه مدمج بشكل ملحوظ.

الحيلة الرئيسية هي استخدام DataView للوصول متعدد البايتات، مما يعالج ترتيب البايتات (endianness) بشكل صحيح دون الحاجة للتلاعب بالبتات يدويًا لكل نوع:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- المتغير العمومي process الأدنى

المتغير العمومي الآخر المحقون. صغير جدًا لكنه ضروري -- الكود يختبر غالبًا process.env.NODE_ENV و process.platform و process.nextTick إلخ.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

التعيين nextTick → queueMicrotask هو أهم تفصيل هنا. process.nextTick في Node.js يجدول استدعاءً في نهاية المرحلة الحالية من حلقة الأحداث، قبل الإدخال/الإخراج. queueMicrotask في المتصفح يفعل شيئًا مشابهًا جدًا من الناحية الدلالية -- يجدول مهمة صغرى (microtask)، تُنفذ قبل الرسم التالي أو الحدث التالي. ليس مطابقًا، لكنه قريب كفاية ليعمل كل الكود الذي يستخدم nextTick بشكل صحيح في المتصفح.


node:fs -- IndexedDB كنظام ملفات متزامن

هذا هو polyfill الأكثر تعقيدًا، والأكثر إثارة للاهتمام تقنيًا وبفارق كبير.

المشكلة صعبة: node:fs يعرض واجهة برمجة تطبيقات متزامنة (readFileSync, writeFileSync إلخ)، لكن واجهات تخزين المتصفح كلها غير متزامنة (IndexedDB، Cache API إلخ). لا يمكنك عمل await في منتصف دالة متزامنة.

حل Fortune: مستوى مزدوج من التخزين المؤقت.

المستوى 1 -- Map في الذاكرة (متزامن)
كل القراءات تتم من Map<string, Uint8Array> في الذاكرة. فوري، متزامن، لا مشكلة في واجهة API.

المستوى 2 -- IndexedDB (غير متزامن، في الخلفية)
عند بدء التشغيل، يُحمّل كل محتوى IndexedDB في الـ Map. تتم الكتابة فورًا في الـ Map وتطلق كتابة غير متزامنة نحو IndexedDB دون حظر.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload عند بدء التشغيل
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// كتابة غير متزامنة نحو IndexedDB (غير محظورة)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

واجهة API المعروضة كاملة: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (مع خيار recursive)، mkdirSync (مع خيار recursive)، readdirSync, statSync, renameSync.

يوجد حتى طبقة لإدارة واصفات الملفات (file descriptors) (openSync, writeSync, closeSync) ليعمل سجل WAL الخاص بـ VFS في وضع المتصفح -- يفتح السجل fd، يكتب فيه، يغلقه، وتنتهي البيانات في IndexedDB.

الخاصية ready مُصدّرة للسماح للكود بمعرفة متى ينتهي التحميل المسبق الأولي:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

بفضل هذا تستمر لقطات VFS في النجاة من إعادة تحميل الصفحة في المتصفح. عندما تعيد تحميل العرض التجريبي، يُستعاد VFS بالضبط في الحالة التي تركته فيها، من IndexedDB، دون أي خادم وسيط.


node:crypto -- SHA-256 و HMAC و PBKDF2 في JS خالص

بدلاً من استيراد مكتبة تشفير مُجمّعة في Wasm، نفذت Fortune الأساسيات الضرورية مباشرة.

SHA-256 منفذ من الصفر باستخدام ثوابت FIPS 180-4:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 ثابتًا أخرى */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 جولة ضغط
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

فوق SHA-256، بُني HMAC-SHA256 و PBKDF2-HMAC-SHA256. هاتان البدائيتان تُستخدمان لاشتقاق المفاتيح في تبادلات SSH والمصادقة الداخلية.

واجهة API المُصدّرة تشبه واجهة Node.js:

// Hash عادي
const hash = createHash('sha256').update('data').digest('hex');

// بايتات عشوائية (عبر واجهة Web Crypto القياسية)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// مقارنة آمنة زمنيًا
const ok = timingSafeEqual(a, b);

// scrypt (مقرب عبر PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

ملاحظة: scryptSync مُقرّب عبر PBKDF2 مع عدد تكرارات مضبوط على المعامل N. ليس scrypt حقيقيًا (الذي يستخدم مخطط ذاكرة مختلفًا)، لكنه كافٍ لاستخدامات المشروع.

الدوال التي لا يمكن محاكاتها بشكل معقول في المتصفح (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) تُلقي خطأً صريحًا إذا تم استدعاؤها. سلوك أمين.


node:os -- قراءة مواصفات المتصفح الحقيقية

بدلاً من إرجاع قيم ثابتة، يقرأ هذا polyfill واجهات المتصفح لإرجاع معلومات تطابق جهاز المستخدم الحقيقي.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB افتراضيًا
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

النتيجة الملموسة: عندما تشغل neofetch في العرض التجريبي للمتصفح، عدد الأنوية والذاكرة RAM المعروضة تطابق جهازك. إنها تفصيلة صغيرة، لكنها تساهم بشكل هائل في مصداقية الطرفية.

الصادرات الأخرى: freemem (40% من إجمالي الذاكرة، تقريب معقول)، platform ← 'browser'، type ← 'Linux'، release ← 'web'، uptime عبر performance.now()، endianness ← 'LE' (little-endian، صحيح على جميع معالجات x86/ARM الاستهلاكية)، loadavg ← [0, 0, 0].


node:net -- stubs TCP نظيفة

المتصفح لا يملك وصولاً إلى مقابس TCP الخام (WebSocket لا يُحتسب -- إنه بروتوكول تطبيقي مختلف). لذلك node:net هو stub، لكن stub مكتوب جيدًا.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // قابل للتسلسل
  once() { return this; }  // قابل للتسلسل
  pipe() { return this; }  // قابل للتسلسل
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

النقطة المهمة: طرق تسجيل الأحداث (on, once, off, emit) تُرجع this ولا تُلقي خطأً. هذا يسمح للكود الذي يفعل new net.Socket().on('connect', cb) بالعمل دون تعطل، حتى لو لم يتم الاتصال أبدًا. فقط الطرق التي تحاول فعلاً الاتصال تُلقي خطأً.

isIP, isIPv4, isIPv6 منفذة بشكل صحيح (ليست stubs) لأنها تُستخدم بواسطة كود الشبكة الافتراضية للتحقق من صحة العناوين، دون فتح أي مقبس.


node:path -- عمليات مسار POSIX

إعادة تنفيذ كاملة لعمليات مسار POSIX، مكيفة مع السياق (لا backslash في Windows، المسارات دائمًا مطلقة مع /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

بسيط، مدمج، صحيح لاستخدامات المشروع.


node:url -- تفويض لواجهات المتصفح

هذه أنيقة ببساطتها. واجهة URL و URLSearchParams موجودة أصلاً في المتصفح -- يكفي إعادة تصديرها.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

فقط fileURLToPath و pathToFileURL تحتاجان تنفيذًا، لأنهما خاصتان بـ Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

هذا هو النهج المثالي عندما توفر المنصة الهدف (المتصفح) بالفعل المكافئ الأصلي.


node:zlib -- هوية

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

سطران. المكتبة تستخدم fflate للضغط الفعلي (الذي يعمل في المتصفح أصلاً). node:zlib يُستورد فقط في مسارات كود لا تُنفذ في سياق المتصفح -- لذا فإن التمرير المباشر (passthrough) كافٍ.

أحيانًا أفضل تنفيذ هو سطران.


node:events -- EventEmitter أدنى

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

التنفيذ الكامل لـ EventEmitter في Node.js يبلغ حوالي 600 سطر مع إدارة maxListeners و once و prependListener إلخ. هنا 12 سطرًا لأربع طرق مستخدمة فعلاً. tree-shaking ذهني قبل حتى tree-shaking أداة البناء.


ssh2 و roxify -- stubs صريحة

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

خادم SSH لا يعمل في المتصفح (لن يكون له معنى -- من سيتصل؟). لكن الكود الذي يتحدث عن SSH من جانب العميل -- الكلاسات التي تبني حزم SSH، محللات البروتوكول -- موجود في المكتبة. هذه الـ stubs تسمح بضم كل هذا الكود في الحزمة دون خطأ، مع ضمان رفع خطأ واضح إذا حاول أحد استدعاء طريقة تتطلب مقبسًا حقيقيًا.

roxify هو تنسيق ضغط مملوك يُستخدم للقطات VFS في وضع Node. في المتصفح، يُستخدم fflate بدلاً منه -- يكتفي polyfill برفع خطأ إذا تم استدعاء roxify مباشرة.


node:worker_threads -- إعادة تصدير Web Workers

هذه الأكثر دقة. node:worker_threads في Node.js و Web Workers في المتصفح هما واجهتا API مختلفتان، لكنهما متقاربتان conceptually. polyfill يقوم بالتعيين:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel و MessagePort مُعاد تصديرهما مباشرة من المتصفح (نفس API). Worker نفسه يحتاج غلافًا (wrapper) لأن المُنشئ مختلف (Node يتوقع مسار وحدة، المتصفح يتوقع URL). isMainThread دائمًا true في جانب المتصفح في هذا السياق.


نظرة عامة: ماذا تمثل هذه الـ 640 سطرًا

Polyfill أسطر إستراتيجية
buffer.js 116 Uint8Array + DataView، كل واجهة Buffer
node:crypto 166 SHA-256/HMAC/PBKDF2 من الصفر + Web Crypto للعشوائيات
node:fs 210 Map في الذاكرة + IndexedDB غير متزامن
node:net 70 Stubs قابلة للتسلسل + تحقق IP حقيقي
ssh2 74 Stubs صريحة
process.js 14 process أدنى قابل للحياة
node:path ~30 عمليات مسار POSIX
node:url ~25 تفويض لواجهات المتصفح
node:events ~12 EventEmitter بـ 4 طرق
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 تمرير مباشر (Passthrough)
node:worker_threads ~30 إعادة تصدير Web Workers
roxify.js 8 Stubs

640 سطرًا. لا اعتماديات npm. لا Wasm. وهذا يعطيك حزمة متصفح تبدأ في أقل من ثانية وتعمل دون أي بنية تحتية من جانب الخادم.


ما يمكننا استخلاصه

في المرة القادمة التي تريد فيها نقل مكتبة Node.js إلى المتصفح، هذا ما يثبته نهج Fortune:

  1. حدد ما هو مستخدم فعلاً. لا حاجة لتنفيذ EventEmitter بالكامل إذا كان الكود يستخدم فقط on و emit و removeListener.

  2. فوض لواجهات المتصفح عندما يمكن ذلك. URL و URLSearchParams و MessageChannel و MessagePort و crypto.getRandomValues -- المتصفح يملكها بالفعل، فلنستخدمها.

  3. التخزين المؤقت المتزامن أمام واجهة غير متزامنة. حل Map + IndexedDB لـ node:fs هو النمط الأكثر قابلية لإعادة الاستخدام في المجلد بأكمله.

  4. الـ stubs الصريحة أفضل من التنفيذات غير المكتملة الصامتة. الأمر throw new Error('not implemented in browser') الصريح مفيد بشكل لا نهائي أكثر من return undefined الذي يترك الخلل يظهر بعد 10 استدعاءات.

  5. esbuild alias + inject مُقَلَّم من قيمته. إنها الأداة المثالية لهذا النوع من النقل -- لا إعداد webpack، لا إضافة، مجرد قائمة استبدالات.


الكود موجود في المستودع: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. كل ملف يتسع في صفحة واحدة، قابل للقراءة مباشرة على GitHub. موصى به بشدة إذا كنت تعمل على مشروع مماثل.

✨ AI Generated Article

Chạy thư viện Node.js trong trình duyệt không cần Wasm --

Cách Fortune tự tay tái hiện node:fs, node:crypto và hàng tá

Chạy thư viện Node.js trong trình duyệt không cần Wasm -- các polyfill của typescript-virtual-container

Gần đây tôi đã dành kha khá thời gian để nghiền ngẫm mã nguồn của typescript-virtual-container, dự án của Fortune (Chloé Rolzhausen). Và phần khiến tôi bất ngờ nhất không phải VFS, không phải mạng ảo, không phải 170 lệnh Unix được tái hiện bằng TypeScript. Mà là thư mục polyfills/.

Bởi vì module chạy trong trình duyệt, không cần Wasm, và để làm được điều đó, Fortune đã tự tay tái hiện toàn bộ tầng node:* mà thư viện cần. Khoảng 640 dòng JavaScript thủ công thay thế node:fs, node:crypto, node:os, node:net, và một số module khác.

Bài viết này giải thích cách nó hoạt động, từng polyfill một.


Vấn đề cơ bản

Một thư viện Node.js sử dụng các API không tồn tại trong trình duyệt. Khi bạn viết import { readFileSync } from 'node:fs', đó là một lệnh gọi hệ thống phía Node -- một truy cập đĩa thực qua libuv. Trong trình duyệt, node:fs hoàn toàn không tồn tại.

Các giải pháp thông thường là:

  • Một runtime Wasm (kiểu Emscripten, WASIp1/WASIp2) -- bạn biên dịch Node.js thành Wasm và chạy nó. Kết quả: bundle 10-50 MB, thời gian tải đáng kể, độ phức tạp triển khai lớn.
  • Các polyfill chung chung (kiểu browserify, webpack node: polyfills) -- các thư viện npm cung cấp xấp xỉ của mỗi module Node. Thường quá nặng, không phù hợp với trường hợp cụ thể.
  • Tự viết polyfill bằng tay -- tốn nhiều công hơn, nhưng kết quả tối ưu.

Fortune đã chọn phương án thứ ba. Và kết quả là một bundle trình duyệt chỉ gồm thư viện, khởi động tức thì, và không phụ thuộc vào bất kỳ hạ tầng bên ngoài nào.


Cơ chế build

Tất cả dựa trên esbuild và tùy chọn alias của nó. Mỗi import node:* được chuyển hướng đến một file cục bộ:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

Tùy chọn inject đáng được chú ý: nó cho phép tiêm process.js và buffer.js vào đầu mỗi file trong bundle, giúp process và Buffer có sẵn toàn cục mà không cần import tường minh. Giống hệt cách Node.js expose chúng nguyên bản.


buffer.js -- Buffer trên Uint8Array

Đây là một trong hai global được inject. Buffer được sử dụng rộng rãi trong code -- mọi thao tác SSH, mọi snapshot VFS, mọi đọc/ghi nhị phân đều đi qua nó.

Giải pháp: một lớp BrowserBuffer mở rộng Uint8Array và triển khai toàn bộ API Buffer của Node.js.

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

Tổng cộng những gì được triển khai:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • Tất cả các phương thức ghi: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • Tất cả các phương thức đọc tương ứng
  • toString hỗ trợ hex, base64, utf8
  • copy, equals, slice, subarray

Nó chiếm 116 dòng. So với những gì nó thay thế, nó nhỏ gọn một cách đáng kinh ngạc.

Mẹo chính là sử dụng DataView cho các truy cập đa byte, xử lý endianness chính xác mà không cần thao tác bit thủ công cho từng kiểu:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- global process tối thiểu

Global được inject còn lại. Nhỏ nhưng cần thiết -- code thường kiểm tra process.env.NODE_ENV, process.platform, process.nextTick, v.v.

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

Ánh xạ nextTick → queueMicrotask là chi tiết quan trọng nhất ở đây. process.nextTick trong Node.js lên lịch một callback ở cuối phase hiện tại của event loop, trước I/O. queueMicrotask trong trình duyệt làm điều gì đó rất gần về mặt ngữ nghĩa -- nó lên lịch một microtask, chạy trước lần render hoặc sự kiện tiếp theo. Không hoàn toàn giống hệt, nhưng đủ gần để mọi code sử dụng nextTick hoạt động chính xác trong trình duyệt.


node:fs -- IndexedDB như hệ thống file đồng bộ

Đây là polyfill tinh vi nhất, và xa nhất là thú vị nhất về mặt kỹ thuật.

Vấn đề khá tế nhị: node:fs expose một API đồng bộ (readFileSync, writeFileSync, v.v.), nhưng các API lưu trữ của trình duyệt đều bất đồng bộ (IndexedDB, Cache API, v.v.). Bạn không thể await ở giữa một hàm đồng bộ.

Giải pháp của Fortune: hai tầng cache.

Tầng 1 -- Map trong bộ nhớ (đồng bộ)
Mọi thao tác đọc đều từ một Map<string, Uint8Array> trong bộ nhớ. Tức thì, đồng bộ, không vấn đề về API.

Tầng 2 -- IndexedDB (bất đồng bộ, chạy nền)
Khi khởi động, toàn bộ nội dung từ IndexedDB được tải vào Map. Các thao tác ghi được thực hiện ngay lập tức vào Map và khởi chạy một ghi bất đồng bộ vào IndexedDB mà không chặn.

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload khi khởi động
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// Ghi bất đồng bộ vào IndexedDB (không chặn)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

API được expose đầy đủ: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (với tùy chọn recursive), mkdirSync (với tùy chọn recursive), readdirSync, statSync, renameSync.

Thậm chí còn có một tầng quản lý file descriptors (openSync, writeSync, closeSync) để journal WAL của VFS hoạt động ở chế độ trình duyệt -- journal mở một fd, ghi vào đó, đóng nó, và dữ liệu sẽ nằm trong IndexedDB.

Thuộc tính ready được export để cho phép code biết khi nào quá trình preload ban đầu hoàn tất:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

Nhờ đó mà các snapshot VFS sống sót qua các lần tải lại trang trong trình duyệt. Khi bạn tải lại bản demo, VFS được phục hồi chính xác trạng thái bạn đã để lại, từ IndexedDB, không cần bất kỳ máy chủ nào.


node:crypto -- SHA-256, HMAC, PBKDF2 trong JS thuần

Thay vì import một thư viện crypto được biên dịch thành Wasm, Fortune đã triển khai trực tiếp các primitive cần thiết.

SHA-256 được triển khai từ đầu với các hằng số FIPS 180-4:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 hằng số khác */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 vòng nén
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

Trên nền SHA-256, HMAC-SHA256 và PBKDF2-HMAC-SHA256 được xây dựng. Hai primitive này được sử dụng để dẫn xuất khóa trong trao đổi SSH và xác thực nội bộ.

API được export tương tự như của Node.js:

// Hash thông thường
const hash = createHash('sha256').update('data').digest('hex');

// Bytes ngẫu nhiên (qua Web Crypto API chuẩn)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// So sánh timing-safe
const ok = timingSafeEqual(a, b);

// scrypt (xấp xỉ qua PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

Lưu ý: scryptSync được xấp xỉ qua PBKDF2 với số vòng lặp khớp với tham số N. Đây không phải scrypt thực sự (vốn sử dụng lược đồ bộ nhớ khác), nhưng với các mục đích sử dụng của dự án thì nó đủ dùng.

Các hàm không thể được mô phỏng hợp lý trong trình duyệt (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) sẽ ném ra lỗi tường minh nếu bị gọi. Hành vi trung thực.


node:os -- đọc thông số thực của trình duyệt

Thay vì trả về các giá trị cố định, polyfill này đọc các API của trình duyệt để trả về thông tin tương ứng với máy thực của người dùng.

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB mặc định
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

Kết quả cụ thể: khi bạn chạy neofetch trong bản demo trình duyệt, số nhân và RAM hiển thị tương ứng với máy của bạn. Đó là một chi tiết nhỏ, nhưng nó đóng góp rất lớn vào tính chân thực của terminal.

Các export khác: freemem (40% tổng bộ nhớ, xấp xỉ hợp lý), platform → 'browser', type → 'Linux', release → 'web', uptime qua performance.now(), endianness → 'LE' (little-endian, đúng trên mọi CPU x86/ARM phổ thông), loadavg → [0, 0, 0].


node:net -- các stub TCP sạch sẽ

Trình duyệt không có quyền truy cập vào socket TCP thô (WebSocket không được tính -- đó là một giao thức ứng dụng khác). node:net do đó là một stub, nhưng là một stub được viết tốt.

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // có thể chuỗi
  once() { return this; }  // có thể chuỗi
  pipe() { return this; }  // có thể chuỗi
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

Điểm quan trọng: các phương thức đăng ký sự kiện (on, once, off, emit) trả về this và không ném lỗi. Điều này cho phép code viết new net.Socket().on('connect', cb) hoạt động không crash, ngay cả khi kết nối không bao giờ được thực hiện. Chỉ các phương thức thực sự cố gắng kết nối mới ném lỗi.

isIP, isIPv4, isIPv6 được triển khai chính xác (không phải stub) vì chúng được code mạng ảo sử dụng để xác thực địa chỉ, mà không bao giờ mở socket.


node:path -- thao tác đường dẫn POSIX

Tái hiện đầy đủ các thao tác đường dẫn POSIX, điều chỉnh cho phù hợp với ngữ cảnh (không có backslash Windows, đường dẫn luôn tuyệt đối với /).

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

Đơn giản, gọn nhẹ, chính xác cho các mục đích sử dụng của dự án.


node:url -- ủy quyền cho API trình duyệt

Cái này thanh lịch bởi sự đơn giản của nó. API URL và URLSearchParams đã tồn tại sẵn trong trình duyệt -- chỉ cần tái xuất chúng.

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

Chỉ fileURLToPath và pathToFileURL cần triển khai, vì chúng là riêng của Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

Đây là cách tiếp cận lý tưởng khi nền tảng đích (trình duyệt) đã cung cấp sẵn bản địa tương đương.


node:zlib -- định danh

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

Hai dòng. Thư viện sử dụng fflate cho nén thực tế (hoạt động native trên trình duyệt). node:zlib chỉ được import trong các nhánh code không chạy trong ngữ cảnh trình duyệt -- do đó một passthrough là đủ.

Đôi khi triển khai tốt nhất chỉ là hai dòng.


node:events -- EventEmitter tối thiểu

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

Triển khai đầy đủ EventEmitter của Node.js dài ~600 dòng với quản lý maxListeners, once, prependListener, v.v. Ở đây chỉ 12 dòng cho 4 phương thức thực sự được sử dụng. Tree-shaking tinh thần trước cả tree-shaking của công cụ build.


ssh2 và roxify -- stub tường minh

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

Máy chủ SSH không chạy trong trình duyệt (điều đó chẳng có nghĩa lý gì -- ai sẽ kết nối?). Nhưng code nói về SSH phía client -- các lớp xây dựng gói SSH, trình phân tích giao thức -- vẫn tồn tại trong thư viện. Các stub này cho phép toàn bộ code đó được bundle mà không lỗi, đồng thời đảm bảo một lỗi rõ ràng được ném ra nếu ai đó cố gọi một phương thức yêu cầu socket thực sự.

roxify là một định dạng nén độc quyền được sử dụng cho các snapshot VFS ở chế độ Node. Trong trình duyệt, fflate được sử dụng thay thế -- polyfill chỉ đơn giản ném lỗi nếu roxify được gọi trực tiếp.


node:worker_threads -- tái xuất Web Workers

Đây là cái tinh tế nhất. node:worker_threads trong Node.js và Web Workers của trình duyệt là hai API khác nhau, nhưng chúng gần gũi về mặt khái niệm. Polyfill thực hiện ánh xạ:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel và MessagePort được tái xuất trực tiếp từ trình duyệt (cùng API). Worker bản thân nó cần một wrapper vì constructor khác nhau (Node nhận đường dẫn module, trình duyệt nhận URL). isMainThread luôn là true phía trình duyệt trong ngữ cảnh này.


Tổng quan: 640 dòng này đại diện cho điều gì

Polyfill Số dòng Chiến lược
buffer.js 116 Uint8Array + DataView, toàn bộ API Buffer
node:crypto 166 SHA-256/HMAC/PBKDF2 từ đầu + Web Crypto cho random
node:fs 210 Map trong bộ nhớ + IndexedDB bất đồng bộ
node:net 70 Stubs có thể chuỗi + xác thực IP thực
ssh2 74 Stub tường minh
process.js 14 process tối thiểu khả dụng
node:path ~30 Thao tác đường dẫn POSIX
node:url ~25 Ủy quyền cho API trình duyệt
node:events ~12 EventEmitter 4 phương thức
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Tái xuất Web Workers
roxify.js 8 Stubs

640 dòng. Không phụ thuộc npm. Không Wasm. Và nó tạo ra một bundle trình duyệt khởi động trong chưa đầy một giây và chạy mà không cần bất kỳ hạ tầng máy chủ nào.


Điều có thể rút ra

Lần tới khi bạn muốn port một thư viện Node.js vào trình duyệt, đây là những gì cách tiếp cận của Fortune chứng minh:

  1. Xác định những gì thực sự được sử dụng. Không cần triển khai toàn bộ EventEmitter nếu code chỉ dùng on, emit, và removeListener.

  2. Ủy quyền cho API trình duyệt khi có thể. URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- trình duyệt đã có sẵn, hãy tận dụng chúng.

  3. Cache đồng bộ phía trước API bất đồng bộ. Giải pháp Map + IndexedDB cho node:fs là pattern có thể tái sử dụng nhất trong toàn bộ thư mục.

  4. Stub trung thực tốt hơn triển khai không đầy đủ thầm lặng. Một throw new Error('not implemented in browser') tường minh hữu ích hơn vô cùng so với return undefined để lỗi tự biểu hiện 10 lần gọi sau đó.

  5. esbuild alias + inject bị đánh giá thấp. Đó là công cụ hoàn hảo cho kiểu port này -- zero cấu hình webpack, zero plugin, chỉ một danh sách các thay thế.


Mã nguồn nằm trong repo: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills. Mỗi file chỉ gói gọn trong một trang, có thể đọc trực tiếp trên GitHub. Rất khuyến khích nếu bạn đang làm việc trên một dự án tương tự.

✨ AI Generated Article

ไลบรารี Node.js ทำงานในเบราว์เซอร์โดยไม่ต้องใช้ Wasm --

Fortune ได้สร้าง node:fs, node:crypto และโมดูล Node อีกกว่าสิบโมดูล

ไลบรารี Node.js ทำงานในเบราว์เซอร์โดยไม่ต้องใช้ Wasm -- polyfills ของ typescript-virtual-container

ผมเพิ่งใช้เวลาพอสมควรในการไล่อ่านซอร์สโค้ดของ typescript-virtual-container โปรเจกต์ของ Fortune (Chloé Rolzhausen) และส่วนที่ทำให้ผมประหลาดใจที่สุด ไม่ใช่ VFS ไม่ใช่เครือข่ายเสมือน ไม่ใช่ 170 คำสั่ง Unix ที่ถูกเขียนใหม่ใน TypeScript แต่มันคือโฟลเดอร์ polyfills/

เพราะว่าโมดูลนี้ทำงานในเบราว์เซอร์ โดยไม่ต้องใช้ Wasm และเพื่อให้เป็นเช่นนั้น Fortune ได้เขียนเลเยอร์ node:* ทั้งหมดที่ไลบรารีต้องการขึ้นมาใหม่ด้วยมือ ประมาณ 640 บรรทัดของ JavaScript ที่ทำขึ้นเองเพื่อแทนที่ node:fs, node:crypto, node:os, node:net และอื่น ๆ อีกสองสามโมดูล

บทความนี้จะอธิบายว่ามันทำงานอย่างไร polyfill ทีละตัว


ปัญหาพื้นฐาน

ไลบรารี Node.js ใช้ APIs ที่ไม่มีอยู่ในเบราว์เซอร์ เมื่อคุณเขียน import { readFileSync } from 'node:fs' นั่นคือการเรียกใช้ระบบฝั่ง Node -- การเข้าถึงดิสก์จริงผ่าน libuv ในเบราว์เซอร์ node:fs ไม่มีอยู่เลย

วิธีแก้ปัญหาทั่วไปคือ:

  • Runtime Wasm (แบบ Emscripten, WASIp1/WASIp2) -- คุณคอมไพล์ Node.js เป็น Wasm แล้วรันมัน ผลลัพธ์: bundle ขนาด 10-50 MB, เวลาโหลดที่นาน, ความซับซ้อนในการปรับใช้ที่มาก
  • Polyfills ทั่วไป (แบบ browserify, webpack node: polyfills) -- ไลบรารี npm ที่ให้การประมาณค่าของแต่ละโมดูล Node มักจะหนักเกินไป ไม่เหมาะกับกรณีเฉพาะ
  • เขียน polyfills ด้วยมือ -- ทำงานมากขึ้นแต่ผลลัพธ์ดีที่สุด

Fortune เลือกตัวเลือกที่สาม และผลลัพธ์คือ bundle สำหรับเบราว์เซอร์ที่เป็นแค่ตัวไลบรารี เริ่มต้นทันที และไม่พึ่งพาโครงสร้างพื้นฐานภายนอกใด ๆ


กลไกการ Build

ทุกอย่างขึ้นอยู่กับ esbuild และตัวเลือก alias ของมัน แต่ละ import node:* จะถูกเปลี่ยนเส้นทางไปยังไฟล์ในเครื่อง:

// demo/build.js
esbuild.build({
  entryPoints: ['app.ts'],
  bundle: true,
  platform: 'browser',
  alias: {
    'node:events':         '../polyfills/node_events/index.js',
    'node:path':           '../polyfills/node_path/index.js',
    'node:os':             '../polyfills/node_os/index.js',
    'node:fs':             '../polyfills/node_fs/index.js',
    'node:fs/promises':    '../polyfills/node_fs/promises.js',
    'node:crypto':         '../polyfills/node_crypto/index.js',
    'node:child_process':  '../polyfills/node_child_process/index.js',
    'node:zlib':           '../polyfills/node_zlib/index.js',
    'node:vm':             '../polyfills/node_vm/index.js',
    'node:net':            '../polyfills/node_net/index.js',
    'node:url':            '../polyfills/node_url/index.js',
    'node:worker_threads': '../polyfills/node_worker_threads/index.js',
    'ssh2':                '../polyfills/ssh2/index.js',
    'roxify':              '../polyfills/roxify.js',
  },
  inject: ['../polyfills/process.js', '../polyfills/buffer.js'],
  minify: true,
  treeShaking: true,
});

ตัวเลือก inject น่าสนใจ: มันช่วยให้สามารถแทรก process.js และ buffer.js ไว้ที่หัวของทุกไฟล์ใน bundle ทำให้ process และ Buffer พร้อมใช้งานทั่วโลกโดยไม่ต้อง import อย่างชัดเจน เหมือนกับที่ Node.js ให้มาแบบดั้งเดิม


buffer.js -- Buffer บน Uint8Array

นี่คือหนึ่งในสอง globals ที่ถูกแทรก Buffer ถูกใช้อย่างมหาศาลในโค้ด -- ทุกการดำเนินการ SSH, ทุก VFS snapshot, ทุกการอ่าน/เขียนไบนารี่ล้วนผ่านมัน

วิธีแก้: คลาส BrowserBuffer ที่ extends Uint8Array และ implement API Buffer ทั้งหมดของ Node.js

class BrowserBuffer extends Uint8Array {
  static from(data, encoding) {
    if (typeof data === 'string') {
      if (encoding === 'hex') {
        const arr = new BrowserBuffer(data.length / 2);
        for (let i = 0; i < arr.length; i++)
          arr[i] = parseInt(data.slice(i * 2, i * 2 + 2), 16);
        return arr;
      }
      if (encoding === 'base64') {
        const bin = atob(data);
        const arr = new BrowserBuffer(bin.length);
        for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
        return arr;
      }
      return new BrowserBuffer(new TextEncoder().encode(data));
    }
    if (data instanceof ArrayBuffer) return new BrowserBuffer(data);
    return new BrowserBuffer(data);
  }
  // ...
}
globalThis.Buffer = BrowserBuffer;

สิ่งที่ถูก implement ทั้งหมด:

  • Buffer.from, Buffer.alloc, Buffer.allocUnsafe, Buffer.isBuffer, Buffer.concat, Buffer.byteLength
  • วิธีการเขียนทั้งหมด: writeUInt8/16/32BE/LE, writeInt8/16/32BE/LE, writeBigUInt64BE/LE, writeFloat/DoubleLE/BE
  • วิธีการอ่านที่สอดคล้องกันทั้งหมด
  • toString รองรับ hex, base64, utf8
  • copy, equals, slice, subarray

นั่นคือ 116 บรรทัด เทียบกับสิ่งที่มันแทนที่ได้แล้ว ถือว่ากะทัดรัดอย่างน่าทึ่ง

เคล็ดลับหลักคือการใช้ DataView สำหรับการเข้าถึงแบบหลายไบต์ ซึ่งจัดการ endianness ได้อย่างถูกต้องโดยไม่ต้องจัดการบิตด้วยมือสำหรับแต่ละประเภท:

writeUInt32BE(val, offset = 0) {
  new DataView(this.buffer, this.byteOffset + offset).setUint32(0, val, false);
  return offset + 4;
}

process.js -- global process แบบขั้นต่ำ

global อีกตัวที่ถูกแทรก เล็กมากแต่จำเป็น -- โค้ดมักจะทดสอบ process.env.NODE_ENV, process.platform, process.nextTick ฯลฯ

globalThis.startedat = Date.now();
export const process = {
  env: { NODE_ENV: 'production' },
  version: 'v20.0.0',
  platform: 'browser',
  browser: true,
  argv: [],
  cwd: () => '/',
  exit: () => {},
  nextTick: (fn, ...args) => queueMicrotask(() => fn(...args)),
  memoryUsage: () => ({ rss: 0, heapTotal: 0, heapUsed: 0, external: 0 }),
  uptime: () => (Date.now() - globalThis.startedat) / 1000,
};
globalThis.process = process;

การแมป nextTick → queueMicrotask คือรายละเอียดที่สำคัญที่สุดตรงนี้ process.nextTick ใน Node.js กำหนดการ callback เมื่อสิ้นสุดเฟสปัจจุบันของ event loop ก่อน I/O queueMicrotask ในเบราว์เซอร์ทำสิ่งที่ใกล้เคียงกันในเชิงความหมาย -- มันกำหนดการ microtask ซึ่งจะทำงานก่อนการเรนเดอร์หรือ event ถัดไป มันไม่เหมือนกันทุกประการ แต่มันใกล้เคียงพอที่โค้ดทั้งหมดที่ใช้ nextTick จะทำงานได้อย่างถูกต้องในเบราว์เซอร์


node:fs -- IndexedDB เป็นระบบไฟล์แบบซิงโครนัส

นี่คือ polyfill ที่ซับซ้อนที่สุด และน่าสนใจทางเทคนิคมากที่สุดโดยเทียบเคียง

ปัญหาคือ: node:fs เปิดเผย API แบบซิงโครนัส (readFileSync, writeFileSync ฯลฯ) แต่ APIs พื้นที่จัดเก็บในเบราว์เซอร์ทั้งหมดเป็นแบบอะซิงโครนัส (IndexedDB, Cache API ฯลฯ) คุณไม่สามารถทำ await กลางฟังก์ชันซิงโครนัสได้

วิธีแก้ของ Fortune: แคชสองระดับ

ระดับ 1 -- Map ในหน่วยความจำ (ซิงโครนัส)
การอ่านทั้งหมดทำจาก Map<string, Uint8Array> ในหน่วยความจำ ทันที, ซิงโครนัส, ไม่มีปัญหาเรื่อง API

ระดับ 2 -- IndexedDB (อะซิงโครนัส, ทำงานเบื้องหลัง)
เมื่อเริ่มต้น เนื้อหาทั้งหมดของ IndexedDB จะถูกโหลดเข้า Map การเขียนจะเกิดขึ้นทันทีใน Map และ เริ่มการเขียนแบบอะซิงโครนัสไปยัง IndexedDB โดยไม่บล็อก

const DB_NAME = 'vfs-fs-shim';
const STORE = 'files';
let db = null;

// Sync cache (path → Uint8Array | null)
const memCache = new Map();

// Preload เมื่อเริ่มต้น
openDB().then(db => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return;
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
});

// การเขียน async ไปยัง IndexedDB (ไม่บล็อก)
function idbSet(path, value) {
  openDB().then(db => {
    const tx = db.transaction(STORE, 'readwrite');
    if (value === null) tx.objectStore(STORE).delete(path);
    else tx.objectStore(STORE).put(value, path);
  });
}

API ที่ถูกเปิดเผยมีความครบถ้วน: readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, rmSync (พร้อมตัวเลือก recursive), mkdirSync (พร้อมตัวเลือก recursive), readdirSync, statSync, renameSync

ยังมีเลเยอร์การจัดการ file descriptors (openSync, writeSync, closeSync) เพื่อให้ WAL journal ของ VFS ทำงานในโหมดเบราว์เซอร์ -- journal จะเปิด fd, เขียนลงไป, ปิดมัน, และข้อมูลจะไปอยู่ใน IndexedDB

พร็อพเพอร์ตี้ ready ถูก export เพื่อให้โค้ดรู้ว่าการโหลดเริ่มต้นเสร็จสมบูรณ์เมื่อใด:

export const ready = openDB().then(db => new Promise(resolve => {
  const tx = db.transaction(STORE, 'readonly');
  const req = tx.objectStore(STORE).openCursor();
  req.onsuccess = e => {
    const cursor = e.target.result;
    if (!cursor) return resolve(true);
    memCache.set(cursor.key, cursor.value);
    cursor.continue();
  };
}));
globalThis.__fsReady__ = ready;

นี่คือสาเหตุที่ VFS snapshots อยู่รอดจากการโหลดหน้าเว็บซ้ำในเบราว์เซอร์ เมื่อคุณโหลดเดโม่ซ้ำ VFS จะถูกกู้คืนในสภาพที่คุณทิ้งไว้อย่างแม่นยำ จาก IndexedDB โดยไม่มีเซิร์ฟเวอร์ใดเกี่ยวข้อง


node:crypto -- SHA-256, HMAC, PBKDF2 ใน JS ล้วน

แทนที่จะ import ไลบรารี crypto ที่ถูกคอมไพล์เป็น Wasm Fortune ได้ implement พรีมิทีฟที่จำเป็นโดยตรง

SHA-256 ถูก implement from scratch ด้วยค่าคงที่ FIPS 180-4:

const K = new Uint32Array([
  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, /* ... 60 ค่าคงที่อื่น ๆ */ 
]);

function sha256(data) {
  // padding FIPS 180-4
  const msg = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
  // ...
  // 64 รอบของการบีบอัด
  for (let j = 0; j < 64; j++) {
    const S1  = (e>>>6|e<<26)^(e>>>11|e<<21)^(e>>>25|e<<7);
    const ch  = (e&f)^(~e&g);
    const t1  = (hh + S1 + ch + K[j] + w[j]) | 0;
    const S0  = (a>>>2|a<<30)^(a>>>13|a<<19)^(a>>>22|a<<10);
    const maj = (a&b)^(a&c)^(b&c);
    const t2  = (S0 + maj) | 0;
    // ...
  }
}

บน SHA-256, HMAC-SHA256 และ PBKDF2-HMAC-SHA256 ถูกสร้างขึ้น พรีมิทีฟทั้งสองนี้ใช้สำหรับการ derive คีย์ในการแลกเปลี่ยน SSH และการยืนยันตัวตนภายใน

API ที่ถูก export มีลักษณะคล้ายกับของ Node.js:

// Hash ทั่วไป
const hash = createHash('sha256').update('data').digest('hex');

// ไบต์สุ่ม (ผ่าน Web Crypto API มาตรฐาน)
const bytes = randomBytes(32);

// UUID
const id = randomUUID();

// การเปรียบเทียบแบบ timing-safe
const ok = timingSafeEqual(a, b);

// scrypt (ประมาณผ่าน PBKDF2)
const key = scryptSync(password, salt, 32, { N: 16384 });

หมายเหตุ: scryptSync ถูกประมาณผ่าน PBKDF2 ด้วยจำนวนรอบที่สอดคล้องกับพารามิเตอร์ N มันไม่ใช่ scrypt จริง (ซึ่งใช้โครงสร้างหน่วยความจำที่แตกต่าง) แต่สำหรับการใช้งานของโปรเจกต์มันก็เพียงพอ

ฟังก์ชันที่ไม่สามารถจำลองได้อย่างสมเหตุสมผลในเบราว์เซอร์ (generateKeyPairSync, createCipheriv, createDecipheriv, createSign) จะ throw ข้อผิดพลาดอย่างชัดเจนหากถูกเรียก เป็นพฤติกรรมที่ซื่อสัตย์


node:os -- อ่านสเปกจริงของเบราว์เซอร์

แทนที่จะคืนค่าตายตัว polyfill นี้จะอ่าน APIs ของเบราว์เซอร์เพื่อคืนข้อมูลที่ตรงกับเครื่องจริงของผู้ใช้

export function totalmem() {
  try {
    return navigator?.deviceMemory
      ? navigator.deviceMemory * 1024 * 1024 * 1024
      : 2 * 1024 * 1024 * 1024; // 2 GB โดยค่าเริ่มต้น
  } catch(e) { return 2 * 1024 * 1024 * 1024; }
}

export function cpus() {
  try {
    const n = navigator?.hardwareConcurrency || 2;
    const ua = navigator?.userAgent || '';
    let model = 'Browser CPU';
    const m = ua.match(/\(([^)]+)\)/);
    if (m) model = m[1].split(';').slice(-1)[0].trim() || model;
    return Array.from({ length: n }, () => ({ model, speed: 2400 }));
  } catch(e) { return [{ model: 'Browser CPU', speed: 2400 }]; }
}

export function arch() {
  const ua = navigator?.userAgent || '';
  if (ua.includes('arm64') || ua.includes('aarch64')) return 'aarch64';
  return 'x86_64';
}

ผลลัพธ์ที่ได้: เมื่อคุณรัน neofetch ในเดโม่เบราว์เซอร์ จำนวนคอร์และ RAM ที่แสดงจะตรงกับเครื่องของคุณ มันเป็นรายละเอียดเล็กน้อย แต่มันมีส่วนอย่างมากต่อความสมจริงของเทอร์มินัล

exports อื่น ๆ: freemem (40% ของหน่วยความจำทั้งหมด, การประมาณที่สมเหตุสมผล), platform → 'browser', type → 'Linux', release → 'web', uptime ผ่าน performance.now(), endianness → 'LE' (little-endian, จริงบนโปรเซสเซอร์ x86/ARM ทั่วไปทั้งหมด), loadavg → [0, 0, 0]


node:net -- stubs TCP ที่สะอาด

เบราว์เซอร์ไม่สามารถเข้าถึง raw TCP sockets (WebSocket ไม่นับ -- มันเป็นโปรโตคอลระดับแอปพลิเคชันที่แตกต่าง) ดังนั้น node:net จึงเป็น stub แต่เป็น stub ที่เขียนได้ดี

export class Socket {
  connect() { notImpl('Socket.connect')(); }
  on() { return this; }    // chainable
  once() { return this; }  // chainable
  pipe() { return this; }  // chainable
  setEncoding() { return this; }
  remoteAddress = '127.0.0.1';
  remotePort = 0;
  // ...
}

จุดสำคัญ: เมธอดการลงทะเบียนเหตุการณ์ (on, once, off, emit) คืนค่า this และไม่ throw ข้อผิดพลาด ทำให้โค้ดที่ทำ new net.Socket().on('connect', cb) ทำงานได้โดยไม่พัง แม้ว่าการเชื่อมต่อจะไม่เกิดขึ้นจริง มีเพียงเมธอดที่ พยายามเชื่อมต่อจริง ๆ เท่านั้นที่ throw ข้อผิดพลาด

isIP, isIPv4, isIPv6 ถูก implement อย่างถูกต้อง (ไม่ใช่ stubs) เพราะพวกมันถูกใช้โดยโค้ดเครือข่ายเสมือนเพื่อตรวจสอบที่อยู่ โดยไม่ต้องเปิด socket เลย


node:path -- การดำเนินการเส้นทางแบบ POSIX

การ implement ใหม่ทั้งหมดของการดำเนินการเส้นทางแบบ POSIX ปรับให้เข้ากับบริบท (ไม่มี backslash ของ Windows, เส้นทางเป็น absolute ด้วย / เสมอ)

export const posix = {
  basename(p) {
    const parts = p.split('/').filter(Boolean);
    return parts.length ? parts[parts.length - 1] : '';
  },
  dirname(p) {
    if (!p) return '.';
    const parts = p.split('/').filter(Boolean);
    parts.pop();
    return parts.length ? '/' + parts.join('/') : '/';
  },
  join(...parts) {
    return parts.join('/').replace(/\/+/g, '/');
  },
  normalize(p) {
    const parts = p.split('/');
    const stack = [];
    for (const part of parts) {
      if (part === '..') stack.pop();
      else if (part && part !== '.') stack.push(part);
    }
    return (p.startsWith('/') ? '/' : '') + stack.join('/') || '.';
  }
};

เรียบง่าย กะทัดรัด ถูกต้องสำหรับการใช้งานของโปรเจกต์


node:url -- การมอบหมายให้ APIs ของเบราว์เซอร์

อันนี้สวยงามเพราะความเรียบง่ายของมัน API URL และ URLSearchParams มีอยู่แล้วโดยธรรมชาติในเบราว์เซอร์ -- แค่ต้อง re-export

const _URL = globalThis.URL;
const _URLSearchParams = globalThis.URLSearchParams;
export { _URL as URL, _URLSearchParams as URLSearchParams };

มีเพียง fileURLToPath และ pathToFileURL ที่ต้องการ implement เพราะมันเฉพาะของ Node:

export function fileURLToPath(url) {
  const u = typeof url === 'string' ? new URL(url) : url;
  if (u.protocol !== 'file:') throw new TypeError('...');
  return decodeURIComponent(u.pathname);
}

นี่คือแนวทางที่เหมาะที่สุดเมื่อแพลตฟอร์มเป้าหมาย (เบราว์เซอร์) มีสิ่งที่เทียบเท่าโดยธรรมชาติอยู่แล้ว


node:zlib -- identity

export function gzipSync(buf) { return buf; }
export function gunzipSync(buf) { return buf; }
export default { gzipSync, gunzipSync };

สองบรรทัด ไลบรารีใช้ fflate สำหรับการบีบอัดจริง (ซึ่งทำงานในเบราว์เซอร์โดยธรรมชาติ) node:zlib ถูก import ใน path ของโค้ดที่ไม่ทำงานในบริบทเบราว์เซอร์เท่านั้น -- ดังนั้น passthrough ก็เพียงพอ

บางครั้งการ implement ที่ดีก็แค่สองบรรทัด


node:events -- EventEmitter ขั้นต่ำ

export class EventEmitter {
  constructor() { this._events = Object.create(null); }
  on(ev, fn) { (this._events[ev] ||= []).push(fn); return this; }
  addListener(ev, fn) { return this.on(ev, fn); }
  emit(ev, ...args) {
    const fns = this._events[ev] || [];
    for (const f of fns) try { f(...args); } catch(e) {}
    return fns.length > 0;
  }
  removeListener(ev, fn) {
    if (!this._events[ev]) return;
    this._events[ev] = this._events[ev].filter(x => x !== fn);
  }
}

การ implement EventEmitter เต็มรูปแบบของ Node.js มีประมาณ 600 บรรทัดพร้อมการจัดการ maxListeners, once, prependListener ฯลฯ ที่นี่มันคือ 12 บรรทัดสำหรับ 4 เมธอดที่ใช้งานจริง Tree-shaking ทางความคิดก่อนที่จะถึง tree-shaking ของเครื่องมือ build


ssh2 และ roxify -- stubs ที่ชัดเจน

// polyfills/ssh2/index.js
function notImpl(name) {
  return function() {
    throw new Error(`ssh2: ${name} not implemented in browser`);
  };
}

export class Client {
  connect()  { notImpl('Client.connect')(); }
  end()      { notImpl('Client.end')(); }
  exec()     { notImpl('Client.exec')(); }
  // ...
}

เซิร์ฟเวอร์ SSH ไม่ทำงานในเบราว์เซอร์ (มันไม่สมเหตุสมผล -- ใครจะมาเชื่อมต่อ?) แต่โค้ดที่ พูดถึง SSH ฝั่งไคลเอ็นต์ -- คลาสที่สร้างแพ็กเก็ต SSH, ตัวแยกวิเคราะห์โปรโตคอล -- มีอยู่ในไลบรารี stubs เหล่านี้ทำให้โค้ดทั้งหมดนี้ถูก bundle ได้โดยไม่มีข้อผิดพลาด พร้อมรับประกันว่าข้อผิดพลาดที่ชัดเจนจะถูก throw ถ้ามีคนพยายามเรียกเมธอดที่ต้องการ socket จริง

roxify เป็นรูปแบบการบีบอัดกรรมสิทธิ์ที่ใช้สำหรับ VFS snapshots ในโหมด Node ในเบราว์เซอร์ fflate ถูกใช้แทน -- polyfill แค่ throw ข้อผิดพลาดถ้า roxify ถูกเรียกโดยตรง


node:worker_threads -- การ re-export Web Workers

อันนี้ละเอียดที่สุด node:worker_threads ใน Node.js และ Web Workers ของเบราว์เซอร์เป็นสอง APIs ที่แตกต่างกัน แต่มันใกล้เคียงกันในเชิงแนวคิด polyfill ทำการแมป:

const _MessageChannel = globalThis.MessageChannel;
const _MessagePort = globalThis.MessagePort;
const _Worker = globalThis.Worker;

export { _MessageChannel as MessageChannel, _MessagePort as MessagePort };
export const isMainThread = true;
export const workerData = null;
export const parentPort = null;

MessageChannel และ MessagePort ถูก re-export โดยตรงจากเบราว์เซอร์ (API เดียวกัน) Worker เองต้องการ wrapper เพราะ constructor แตกต่างกัน (Node คาดหวัง path ของโมดูล, เบราว์เซอร์คาดหวัง URL) isMainThread เป็น true เสมอฝั่งเบราว์เซอร์ในบริบทนี้


ภาพรวม: สิ่งที่ 640 บรรทัดนี้เป็นตัวแทน

Polyfill บรรทัด กลยุทธ์
buffer.js 116 Uint8Array + DataView, API Buffer ทั้งหมด
node:crypto 166 SHA-256/HMAC/PBKDF2 from scratch + Web Crypto สำหรับ randoms
node:fs 210 Map ในหน่วยความจำ + IndexedDB async
node:net 70 Stubs ที่ chainable ได้ + การตรวจสอบ IP จริง
ssh2 74 Stubs ที่ชัดเจน
process.js 14 process ขนาดเล็กที่สุดที่ใช้งานได้
node:path ~30 การดำเนินการเส้นทางแบบ POSIX
node:url ~25 มอบหมายให้ browser APIs
node:events ~12 EventEmitter 4 เมธอด
node:os ~25 navigator.deviceMemory / hardwareConcurrency
node:zlib 4 Passthrough
node:worker_threads ~30 Re-export Web Workers
roxify.js 8 Stubs

640 บรรทัด ไม่มีการพึ่งพา npm ไม่มี Wasm และมันให้ bundle สำหรับเบราว์เซอร์ที่เริ่มต้นในเวลาไม่ถึงวินาทีและทำงานโดยไม่มีโครงสร้างพื้นฐานฝั่งเซิร์ฟเวอร์ใด ๆ


สิ่งที่เราเรียนรู้ได้

ครั้งหน้าที่คุณต้องการพอร์ตไลบรารี Node.js ไปยังเบราว์เซอร์ นี่คือสิ่งที่แนวทางของ Fortune แสดงให้เห็น:

  1. ระบุสิ่งที่ใช้งานจริง ไม่จำเป็นต้อง implement EventEmitter ทั้งหมดถ้าโค้ดใช้แค่ on, emit, และ removeListener

  2. มอบหมายให้ browser APIs เมื่อเป็นไปได้ URL, URLSearchParams, MessageChannel, MessagePort, crypto.getRandomValues -- เบราว์เซอร์มีสิ่งเหล่านี้อยู่แล้ว ใช้มันให้เป็นประโยชน์

  3. แคชซิงโครนัสหน้า async API วิธีแก้แบบ Map + IndexedDB สำหรับ node:fs เป็น pattern ที่นำกลับมาใช้ซ้ำได้มากที่สุดในทั้งโฟลเดอร์

  4. stubs ที่ซื่อสัตย์ดีกว่าการ implement ที่ไม่สมบูรณ์อย่างเงียบ ๆ การ throw new Error('not implemented in browser') อย่างชัดเจนมีประโยชน์มากกว่า return undefined ที่ปล่อยให้บั๊กแสดงตัวอีก 10 การเรียกข้างหน้า

  5. esbuild alias + inject ถูกประเมินค่าต่ำเกินไป มันเป็นเครื่องมือที่สมบูรณ์แบบสำหรับการพอร์ตแบบนี้ -- ไม่ต้องกำหนดค่า webpack, ไม่ต้องมีปลั๊กอิน, แค่รายการการแทนที่


โค้ดอยู่ใน repo: github.com/itsrealfortune/typescript-virtual-container/tree/main/polyfills แต่ละไฟล์อยู่ในหน้าเดียว สามารถอ่านได้โดยตรงบน GitHub แนะนำอย่างยิ่งถ้าคุณทำงานบนโปรเจกต์ที่คล้ายกัน

Related Articles