Getting started
By the end of this guide you will know what a Platform holds, how to get one in Node, in a page
and in a test, how to start a process, how to use the helpers, and what errors to expect. Your code takes the platform as
a parameter and never reads a global, so a test can hand it doubles and get the same result every
time.
There are four packages. @wtf/platform has the Platform type, the helpers and a test double
for each member. @wtf/platform-node builds Node’s platform, @wtf/platform-browser builds a
page’s, and @wtf/platform-memory builds one in memory, for a test or a dry run, each in one call.
Know the standard members
Where a web standard defines the object, the member is that standard’s global, with its name and
its signature, cut down to the parts the helpers call. So a browser’s window and Node’s own
objects fit as they are, and each double behaves as the standard says, errors included.
@wtf/platform writes the types itself, as the standards have them, so Node code without lib.dom
can use them:
| Member | Standard | Cut down to | In Node | Its double |
|---|---|---|---|---|
setTimeout, clearTimeout | HTML (WHATWG) | a handler and a timeout; an id | node:timers | fakeTimers() |
performance | High Resolution Time (W3C) | now(), timeOrigin | node:perf_hooks | fakeTimers().performance |
crypto | Web Cryptography (W3C) | getRandomValues, randomUUID, subtle.digest | webcrypto from node:crypto | seededCrypto(seed), its softwareSubtle |
console | Console (WHATWG) | debug, log, info, warn, error | node:console | memoryConsole() |
navigator.locks | Web Locks (W3C) | request, query | locks from node:worker_threads (nodeLocks) | memoryLocks() |
navigator.storage | Storage, File System (WHATWG) | getDirectory() | passed in by the entry point (nodeStorage) | directoryStorage(directory), noStorage |
BroadcastChannel | HTML (WHATWG) | name, postMessage, close, events | node:worker_threads | memoryBroadcastChannel() |
MessageChannel | HTML (WHATWG) | port1, port2: postMessage, start, close | node:worker_threads | MemoryMessageChannel |
Worker | HTML (WHATWG) | postMessage, terminate, events | a node:worker_threads thread (NodeWorker) | memoryWorker(scripts) |
fetch | Fetch (WHATWG) | fetch(input, init) | passed in by the entry point | memoryFetch(handler), offlineFetch |
In a page, every member is the window’s own, through browserPlatform({ window }). In Node,
fetch exists only as a global, which nothing here reads, so your entry point passes it in. It
passes the root of the file system too, such as nodeStorage() from @wtf/platform-fs-node/storage:
nodePlatform({ fetch, storage }). Where Node differs from the standard, @wtf/platform-node
adapts it.
nodeLocks releases the lock when a callback throws, which Node 24 keeps held. NodeWorker makes
a thread behave like HTML’s Worker: an EventTarget whose script is a URL.
The values and constructors that the WinterTC Minimum Common Web API defines, such as Web Streams,
URL, TextEncoder, AbortController, DOMException, Event and structuredClone, are not
members. Every package uses them as they are.
Know the other members
Where no standard exists, Platform adds a member of its own, named as Effect’s platform names it:
import type { WebPlatform } from '@wtf/platform'import type { Dirs } from '@wtf/platform/dirs'import type { Env } from '@wtf/platform/env'import type { Open } from '@wtf/platform/open'import type { Path } from '@wtf/platform/path'import type { Terminal } from '@wtf/platform/terminal'
type Platform = WebPlatform & { env: Env // Readonly<Record<string, string | undefined>> path: Path // node:path's calls, and fromFileUrl, toFileUrl dirs: Dirs // os, arch, home, temp, cwd, and app(name): data, config, cache, state terminal: Terminal // isTTY, columns, rows, colorDepth, display(text), readLine() open: Open // (url) => Promise<void>}dirs.cwd is the folder the program works in, as an absolute path. In Node it is process.cwd(),
read each time, so a chdir shows. In a page it is /, the root of the page’s file system. In a
test it is /, or the folder you give testDirs({ cwd }). path resolves a relative path against
it.
In Node, open is nodeOpen({ env }), which reads the platform’s env. It starts the program that
BROWSER names, with the URL as its one argument, as CLIs do. BROWSER=none opens nothing.
Otherwise it starts the platform’s own app (open, rundll32 or xdg-open). In a page, open
opens a window. In a test, it keeps each URL so you can check it.
Start a process
A browser starts no process, so Platform has no member for it. A platform that does is a
ProcessPlatform, a Platform with spawn. No web standard starts a process, so spawn is built
from standard parts, as Deno’s Command is: it starts a command with its arguments, with no shell,
and gives you its stdin as a WritableStream, its stdout and stderr as ReadableStreams,
and its status as a promise. exec from @wtf/platform/process runs one to its end and gives
you what it wrote, as text, and its exit code:
import type { ProcessPlatform } from '@wtf/platform'import { exec } from '@wtf/platform/process'
declare const platform: ProcessPlatform
const { stdout, stderr, code } = await exec(platform.spawn, 'git', ['status', '--porcelain'], { cwd: platform.dirs.cwd})if (code !== 0) platform.console.error(stderr)A process gets the env you give it, whole, or else the platform’s, and works in the cwd you
give it, or else the platform’s working folder. Pass input to exec to write to its stdin, and a
signal to end it: an aborted signal ends the process with SIGTERM, and exec rejects with the
signal’s reason. An exit code other than 0 is not an error; exec gives it to you to decide. A
command that is not there rejects with a SystemError whose reason is NotFound.
To stream, call spawn yourself:
import type { ProcessPlatform } from '@wtf/platform'
declare const platform: ProcessPlatform
const child = platform.spawn('sort', [])const writer = child.stdin.getWriter()await writer.write(new TextEncoder().encode('b\na\n'))await writer.close()const sorted = await new Response(child.stdout).text()await child.stderr.cancel()const { code } = await child.statusRead or cancel both of its outputs, or a process that writes a lot may wait on a full pipe. In
Node, spawn is nodeSpawn({ env }) from @wtf/platform-node/process, over
node:child_process. In memory, a script of yours answers each process, and each run is recorded:
see Test a program.
Use the file system
navigator.storage.getDirectory(), from the Storage and File System standards, returns the root of
the platform’s file system: a FileSystemDirectoryHandle, typed by @wtf/platform/fs. In a
page it is the origin private file system. In Node it is the storage your entry point passes,
such as nodeStorage() from @wtf/platform-fs-node/storage, whose root is /. In a test it is
the folder you give. To reach a file, resolve its path against dirs.cwd and walk down from the
root, as at from @wtf/platform-fs/path does:
import type { Platform } from '@wtf/platform'import { at } from '@wtf/platform-fs/path'
declare const platform: Platform
const root = await platform.navigator.storage.getDirectory()const file = await at(root, platform.path.resolve(platform.dirs.cwd, 'notes/today.md'), { kind: 'file', create: true})readText, writeText and the other helpers of @wtf/platform-fs/files do both steps for you:
Files and folders shows them.
In a test, give a folder in memory: testPlatform({ directory: memoryDirectory() }), or
memoryPlatform({ directory: () => memoryDirectory() }), with memoryDirectory from
@wtf/platform-fs-memory/handles. Without one, testPlatform() has no file system:
getDirectory() rejects with a SecurityError, as the Storage Standard says a page without
storage does, and memoryPlatform()’s rejects with a NotSupportedError.
Use the helpers
Each helper asks only for the members it uses, so you can pass the whole platform or just those members:
import type { Platform } from '@wtf/platform'import { randomId, randomInt } from '@wtf/platform/random'import { sleep, wallTime } from '@wtf/platform/timers'
declare const platform: Platformdeclare const signal: AbortSignal
await sleep(1000, platform, signal) // of setTimeout and clearTimeout, aborted by the signalwallTime(platform) // performance.timeOrigin + performance.now()randomInt(6, platform) // of crypto.getRandomValues, without biasrandomId(platform) // 21 characters a URL holds as they arerandomBytes(length, platform) fills more bytes than one getRandomValues call allows, and
randomFloat(platform) returns a number below 1, as Math.random does.
randomInt asks only for crypto, so the whole platform fits, and so does a double. A page’s
crypto gives other rolls each time; seededCrypto(1) gives the same rolls each run, as a test
needs:
import { randomInt, seededCrypto, type RandomSource } from '@wtf/platform/random'
declare const platform: RandomSource
const roll = (source: RandomSource) => Array.from({ length: 5 }, () => randomInt(6, source) + 1)
roll(platform) // other dice each timeroll({ crypto: seededCrypto(1) }) // the same dice each runRun a program in Node
import { sleep } from '@wtf/platform/timers'import { runMain } from '@wtf/platform-node/runtime'import { nodeStorage } from '@wtf/platform-fs-node/storage'
await runMain( async ({ platform, signal }) => { platform.console.log('Waiting') await sleep(1000, platform, signal) }, { fetch, storage: nodeStorage() })runMain runs your program with nodePlatform({ fetch, storage }), or with the platform you
give it. It aborts the program’s signal at the first SIGINT or SIGTERM, then sets the process’s
exit code, through processExitCode, from how the program ended. A success, or an interruption on
its own, exits with 0. A failure exits with the exitCode of what the program threw, or 1, and the
failure is written to the console. A program that sets its own exit code sets it through
processExitCode too. To replace one of Node’s members with your own, pass it in:
nodePlatform({ fetch, storage, ...members }).
Run a program in a page
import type { Platform } from '@wtf/platform'import { randomId } from '@wtf/platform/random'import { runMain, type Program } from '@wtf/platform/runtime'import { sleep } from '@wtf/platform/timers'import type { BrowserWindow, PageEvents } from '@wtf/platform-browser'import { browserPlatform } from '@wtf/platform-browser/platform'import { pageInterrupts } from '@wtf/platform-browser/runtime'
declare const window: BrowserWindow & PageEvents // the page's own
const program: Program<Platform, void> = async ({ platform, signal }) => { platform.console.log('Running on %s, in %s', platform.dirs.os, platform.dirs.cwd) await sleep(500, platform, signal) platform.console.info('Your id is', randomId(platform))}
const exitCode = await runMain(program, { platform: browserPlatform({ window }), interrupts: pageInterrupts({ window, hidden: true })})runMain gives you the exit code: 0 when the program ends, or 1, with the error in the console,
when it throws. pageInterrupts stops the program when the user leaves the page (pagehide). With
hidden: true, it also stops it when the page is hidden, since a phone may end a background tab
without a pagehide.
Test a program
import type { Platform } from '@wtf/platform'import { manualInterrupts, runMain, type Program } from '@wtf/platform/runtime'import { testPlatform } from '@wtf/platform/test'
declare const program: Program<Platform, void>
const platform = testPlatform({ env: { API_URL: 'https://example.com' } })const { interrupts, interrupt } = manualInterrupts()const running = runMain(program, { platform, interrupts })await platform.timers.advance(1000)interrupt()await runningplatform.console.records // each line it loggedplatform.open.opened // each URL it openedtestPlatform gives you fake timers and a matching performance, which move only when you call
advance; a crypto seeded with 0; locks, channels, ports and workers that run in this realm; the
directory you give as the root of its file system; a fetch with no network; and doubles of the
other members, working in /. Any member you pass replaces its double.
When your program starts processes, or you want to run it for real, without its side effects,
use memoryPlatform from @wtf/platform-memory/platform. It is a whole ProcessPlatform in
memory: a script of yours answers each process, a handler of yours each request, and it records
each process the program ran. Its timers and crypto are the real ones, so it suits a dry run as
well as a test:
import type { ProcessPlatform } from '@wtf/platform'import { exec } from '@wtf/platform/process'import { memoryPlatform } from '@wtf/platform-memory/platform'
const release = async (platform: ProcessPlatform) => { const { stdout } = await exec(platform.spawn, 'git', ['describe', '--tags']) await exec(platform.spawn, 'npm', ['publish', '--tag', stdout.trim()])}
const platform = memoryPlatform({ spawn: (command) => command === 'git' ? { stdout: 'v1.2.3\n' } : command === 'npm' ? {} : undefined})await release(platform)platform.spawn.runs.map(({ command, args }) => [command, ...args].join(' '))// ['git describe --tags', 'npm publish --tag v1.2.3']A script that gives undefined says the command is not there, so a run of a command you did not
expect fails as it would on a real platform.
Handle errors
A member that no standard defines throws a PlatformError from @wtf/platform/errors, as
Effect’s does. It is a BadArgument when the caller passed something wrong, or a SystemError when the
platform failed, with a reason: AlreadyExists, BadResource, Busy, InvalidData,
NotFound, PermissionDenied, TimedOut, UnexpectedEof, Unknown, WouldBlock or
WriteZero. Each names its module and method, and a SystemError also names the path or
descriptor it failed on, pathOrDescriptor. Match on _tag and reason, never on the message.
A member from a web standard throws what the standard says: a DOMException named
InvalidStateError, DataCloneError, NotSupportedError, QuotaExceededError or AbortError,
a TypeError, or the reason of an aborted signal.
Check conformance
Each standard’s Web Platform Tests run against the doubles and against Node’s own objects. Each
package’s test/wpt.ts pins them and lists them: console/, hr-time/, the getRandomValues,
randomUUID and digest tests in WebCryptoAPI/, webmessaging/broadcastchannel/,
web-locks/, the MessageChannel and MessagePort tests in webmessaging/, and those that start
a Worker from a page. Each package lists a file’s known failures in its test/wpt/, each with
its reason. They fall into three groups: what a node:vm context lacks (a second context,
FileReader, isSecureContext), what a double does on purpose (fake time starts at 0 and moves
only when told), and where Node differs from the standard (a port started by its listener, a port’s
close event, a channel message without an origin).
The environment has a guide of its own: env.md.
