A worker that opens sockets needs a build in which its dgram talks to the same copy of @fkn/lib that relayWorker bridged, under the dev server and in the production build alike. This page covers the Vite settings that get there: one copy of the library, prebundling, a plugin list of the worker’s own, a shim that runs before the hoisted imports and fills process member by member, and the two Node names that stay out of a generic polyfill map.
Uses any subpath of @fkn/lib, since the failures here are in the build and no call reports them
Needs a bundler step
Proven by seven integrations
The broker is the connection your page holds into FKN, and the relay is the server that holds the real socket at the far end of net and dgram. A worker has no broker of its own, so the page awaits relayWorker(worker, { unregisterSignal }) and the worker’s socket calls travel over the page’s broker. What the relay does is on workers, and the two files are on run sockets in a worker.
Every setting below exists because the worker chunk is built apart from the page, and a mismatch between the two reports nothing. The examples are Vite configuration, the bundler all seven integrations build with.
@fkn/lib/net imports events and stream as bare Node names, and @fkn/lib/dgram imports buffer and events. A browser has none of the three, so the bundle has to supply each module, and the globals the stream shim reads, before the library’s module body runs. Which entry needs which shim is on install. vite-plugin-node-polyfills supplies the modules and defines the globals:
Terminal window
npminstall--save-devvite-plugin-node-polyfills
One nodePolyfills() entry under plugins is the whole page-side configuration, as install shows.
relayWorker bridges the copy of the library the page imported, and the worker’s socket calls reach the relay only through that copy. A linked dependency, one you develop beside the app and install by symlink, carries a node_modules of its own, with an @fkn/lib and an osra inside it. A worker that imports dgram from that second copy holds a module that is a different instance from the bridged one, and nothing in the build reports it.
resolve.dedupe makes Vite resolve the listed packages from the project root wherever they are imported:
Vite prebundles the dependencies it finds when the dev server starts. A dependency it meets for the first time later is prebundled then, and the page reloads to pick it up, mid-run. List the library entries the page and the worker import, so the optimizer sees them at start:
Force optimize listed dependencies (must be resolvable import paths,
cannot be globs).
include: ['@fkn/lib', '@fkn/lib/net', '@fkn/lib/dgram'], // prebundled at start, so the dev server does not re-optimise mid-run
},
})
Each subpath is an entry of its own to the optimizer, so list the ones you import rather than the root alone. Do not list the library under optimizeDeps.exclude: ip-address, one of its dependencies, is a CommonJS package, and the prebundle is what converts it for the browser. One integration recorded that excluding the library broke that conversion.
Vite builds a worker as a graph of its own, and the plugins worker.plugins returns are the ones that graph gets. A polyfill listed only under plugins does not reach it, and inside the worker chunk the library’s bare stream import then resolves to nothing the bundler knows. Under the dev server the request comes back as the SPA fallback, an HTML document answering a .js URL, and the worker aborts on it with no message.
Return a fresh instance from worker.plugins, with the same options as the page’s:
Vite plugins that apply to worker bundle. The plugins returned by this function
should be new instances every time it is called, because they are used for each
rolldown worker bundling process.
plugins: () => [
constpolyfills: () =>Plugin<any>[]
polyfills()], // the worker chunk gets the same modules and globals as the page
},
})
worker.plugins is a function because Vite calls it once per worker it bundles and expects new instances each time. format: 'es' builds the worker as an ES module, the format every integration with a worker chunk uses.
ES imports are hoisted. Every import in a module is evaluated before the module’s first statement, in source order, so a process assigned at the top of engine.ts lands after the library has already loaded. The stream shim reads process.env while it loads and calls process.nextTick on the first read or write, with trailing arguments it expects to arrive. Put the shim in a file of its own and import that file on the engine’s first line, because the first import is evaluated before the second:
engine.ts
import'./node-shims'// first, so process exists before the next import evaluates
Adds the listener function to the end of the listeners array for the
event named eventName. No checks are made to see if the listener has
already been added. Multiple calls passing the same combination of eventName
and listener will result in the listener being added, and called, multiple
times.
server.on('connection', (stream) => {
console.log('someone connected!');
});
Returns a reference to the EventEmitter, so that calls can be chained.
By default, event listeners are invoked in the order they are added. The
emitter.prependListener() method can be used as an alternative to add the
event listener to the beginning of the listeners array.
message) // a refused bind lands here, and no listening follows
constsocket:dgram.Socket
socket.
Socket.bind(port?: number |undefined, callback?: (() =>void) |undefined): dgram.Socket (+3 overloads)
For UDP sockets, causes the dgram.Socket to listen for datagram
messages on a named port and optional address. If port is not
specified or is 0, the operating system will attempt to bind to a
random port. If address is not specified, the operating system will
attempt to listen on all addresses. Once binding is complete, a 'listening' event is emitted and the optional callback function is
called.
Specifying both a 'listening' event listener and passing a callback to the socket.bind() method is not harmful but not very
useful.
A bound datagram socket keeps the Node.js process running to receive
datagram messages.
If binding fails, an 'error' event is generated. In rare case (e.g.
attempting to bind with a closed socket), an Error may be thrown.
Example of a UDP server listening on port 41234:
import dgram from'node:dgram';
constserver= dgram.createSocket('udp4');
server.on('error', (err) => {
console.error(`server error:\n${err.stack}`);
server.close();
});
server.on('message', (msg, rinfo) => {
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
Returns an object containing the address information for a socket.
For UDP sockets, this object will contain address, family, and port properties.
This method throws EBADF if called on an unbound socket.
@since ― v0.1.99
address().
AddressInfo.port: number
port// 6881, the relay granted the port and the shim was in place
})
The order of the two imports is the whole point. Four of the seven integrations shim process by hand, with the polyfill plugin in place and without it.
The shim asks whether a member is missing, never whether process is missing. The dependency optimizer’s own shim, another polyfill or your framework may already have put a process on the global, and whatever that object lacks stays missing when a guard skips the whole block. One integration met a process with no nextTick that way, and its engine died mid-start on process.nextTick is not a function. Fill each member with ??=:
node-shims.ts
type
typeShimmable= {
global?:unknown;
process?:Record<string, unknown>;
}
Shimmable= {
global?: unknown
global?:unknown,
process?: Record<string, unknown>|undefined
process?:
typeRecord<Kextendskeyofany, T> = { [PinK]:T; }
Construct a type with a set of properties K of type T
Record<string, unknown> }
const
constroot:Shimmable
root=
moduleglobalThis
globalThisasunknownas
typeShimmable= {
global?:unknown;
process?:Record<string, unknown>;
}
Shimmable
constroot:Shimmable
root.
global?: unknown
global??=
constroot:Shimmable
root// read at module scope by the stream shim
const
constproc:Record<string, unknown>
proc= (
constroot:Shimmable
root.
process?: Record<string, unknown>|undefined
process??= {}) // process exists by the time the library's module body runs, and keeps whatever it already had
constproc:Record<string, unknown>
proc.
unknown
env??= {
typeNODE_DEBUG: string
NODE_DEBUG: '' } // read while the stream shim loads
A polyfill map that covers every Node builtin covers net and dgram too, and answers both with an empty module. That is harmless while every import names @fkn/lib/net, and wrong the moment something imports net by its Node name: an engine written for Node, or your own code under an alias. The map’s entry and the alias both match the name, and the integration that hit this had to take the two names out for the alias to win. Take them out, alias them to the library with an exact match, and the configuration is complete:
Vite plugins that apply to worker bundle. The plugins returned by this function
should be new instances every time it is called, because they are used for each
rolldown worker bundling process.
plugins: () => [
constpolyfills: () =>Plugin<any>[]
polyfills()],
},
})
exclude removes the two entries and leaves the rest of the map in place. The include list on install reaches the same place from the other side, since a name that is not on the map is never stubbed. The aliases are regular expressions because a string alias matches subpaths as well, so net as a string would send net/anything to the library too.
Start the dev server and open the page. The engine’s bind callback runs and socket.address().port answers 6881, the port the relay granted. Then build, serve the output and open it: the same callback runs from the built worker chunk, and the network panel shows the worker’s .js request answered with JavaScript. When that is not what you see:
The worker aborts with no message. The chunk received the SPA fallback. In the network panel a .js request answered with an HTML document is the tell, and the fix is the polyfill plugin under worker.plugins.
process is not defined, or process.nextTick is not a function, in the worker. The shim ran after the library, or an existing process lacked a member. Make import './node-shims' the engine’s first line, and fill every member with ??=.
bind reports a relay refusal on error, with a message that names the reason. That is not a build failure. The refusals are on TCP and UDP sockets.
@fkn/vite-plugin supplies the same three shims and resolves Node’s fs, net, dgram and http to the library with exact-match aliases placed ahead of its own fallback map, so code that keeps its Node imports needs neither the aliases nor the exclude above.