Using npm packages
Install a package and import it, as in any web app. phantom-plugin build bundles what you use into dist/plugin.js. Your plugin's frame is a browser page, so browser packages work as they are. Node packages work if they only need the few Node modules the build has browser versions of.
npm install date-fnsimport { definePlugin } from '@phantomclient/plugin';
import { formatDistanceToNow } from 'date-fns';
export default definePlugin({
setup(ctx) {
ctx.log(`started ${formatDistanceToNow(Date.now() - 60_000)} ago`);
},
});Where your code runs
Every plugin runs in a sandboxed frame inside Phantom: Chromium 87 (Electron 11), with no Node and a strict content security policy. So:
- There's no
require, nofsand no realprocess. script-src 'self': code comes from your own bundle only.evalandnew Functionthrow.- The frame has no network of its own. It can reach its own files, plus
data:andblob:addresses. Everything else goes through Phantom (see the network).
Node modules
The build swaps these modules for browser versions, and only bundles the ones that are used:
| Module | What you get |
|---|---|
buffer | the buffer package, plus Buffer as a global |
events | the events package (EventEmitter) |
path, path/posix | path-browserify |
process | process/browser, plus process as a global; env is {} |
util | inherits, promisify, format, deprecate, types, … |
timers, url, perf_hooks, stream/web | what the browser already has, under those names |
string_decoder | a StringDecoder over TextDecoder |
node: names work too: node:path is path. process.env.NODE_ENV is "production". global is globalThis.
Some modules can't work in a browser. When a package imports one, the build stops and tells you which package it was and what to use instead:
| Module | Use instead |
|---|---|
fs, fs/promises | ctx.storage, and files shipped in dist/ read with fetch |
http, https, net, tls, dns | fetch, which goes through ctx.fetch |
dgram | ctx.phantom().aj.realtime, the live link between players |
crypto | globalThis.crypto (Web Crypto) |
zlib | CompressionStream / DecompressionStream, or fflate |
stream | web streams (ReadableStream), or stream/web |
child_process, worker_threads, os, vm | nothing: a plugin can't start programs or read the computer |
The package some-lib imports node:fs, which a plugin cannot use: plugins run in a browser
frame, with no Node. Instead, keep data with ctx.storage, and ship files in dist/ to load
with fetch.The build respects a package's own browser field in its package.json ("browser": { "fs": false }), like any bundler.
The network
Your frame's fetch and XMLHttpRequest are Phantom's. They send requests through ctx.fetch, so axios, ky, ofetch and other libraries built on them work unchanged. They can only reach the hosts in your manifest's network, once the player has allowed them:
{
"phantom": {
"permissions": { "network": ["api.example.com"] }
}
}import axios from 'axios';
const { data } = await axios.get('https://api.example.com/v1/prices');- HTTPS only, on the usual port, to hosts by name. Phantom sends no cookies, origin or referrer.
- A host you haven't listed is refused before anything is sent. You get a
PhantomError(codeREFUSED) that names the line to add:Add "api.other.com" to "network" in phantom.permissions in package.json. - Answers arrive whole, text or binary (
arrayBuffer()andblob()work), up to 5 MB. - Request bodies must be text: a string, JSON or
URLSearchParams. AFormDataor binary body is refused with a message saying so. - Your own files, such as
fetch('/dist/data.json')or a.wasmfile indist/, are read straight from your plugin. WebSocketandEventSourcethrow aPhantomError. For a live link between players usectx.phantom().aj.realtime. For a server, ask it withfetchfrom time to time.
Newer JavaScript
The build compiles new syntax down to Chromium 87, in your code and your packages. Phantom's runtime adds these newer built-ins for every plugin, so packages don't need their own polyfills:
Array.prototype.at,findLast,findLastIndex,toSorted,toReversed,toSpliced,with, andaton strings and typed arraysObject.hasOwn,Object.groupBy,Map.groupBy,Promise.withResolvers,Array.fromAsyncstructuredClone,AbortSignal.timeout,AbortSignal.any,AbortSignal.abort,crypto.randomUUID,URL.canParse,Response.json- The
Setmethods (union,intersection,difference, …),String.prototype.isWellFormed, and async iteration over aReadableStream
What can't be added stops the build: Temporal, Intl.DurationFormat, Iterator.from, SharedArrayBuffer and Float16Array. A package that checks first (typeof Temporal !== 'undefined') builds fine, since it runs without it. Otherwise, bundle your own polyfill, such as @js-temporal/polyfill.
WebAssembly
WebAssembly works. Chromium 87's content security policy doesn't block it, so WebAssembly.instantiate runs in any plugin frame without a permission. Ship the .wasm in dist/ and load it with fetch, or inline it in your bundle.
Later Chromium versions need 'wasm-unsafe-eval' for it. Phantom will add that when it upgrades, and you won't need to change anything.
Dependency list
Every build writes dist/dependencies.json, listing each npm package in your bundle with its name, version, licence and size in bytes. It's worked out from the modules the build actually bundled, so packages you installed but don't use aren't listed. The Node module stand-ins are listed and marked too.
Players and the store see this list:
- the install and update dialogs show "Includes N npm packages", and what an update adds, removes or moves to a new version
- the plugin's page in Phantom shows it
- the store page and the reviewer show it beside your permissions
phantom-plugin pack refuses a list that doesn't match dist/ exactly, so build with phantom-plugin build and don't edit dist/ by hand. The store checks it again and refuses an upload whose list doesn't match, naming each file that differs, and Phantom won't install or run such a package. A plugin built with an older phantom-plugin has no list; it still installs and runs, and shows "Dependencies not listed".
The build also warns about:
- Packages that use
evalornew Function. Your frame blocks both, so that code throws when it runs. Reviewers see the warning too. - A
dist/plugin.jsover 1.5 MB. The warning names the largest packages. Every frame of your plugin loads the whole bundle, so smaller packages (date-fnsrather thanmoment,kyrather thanaxios) make it start faster.