Skip to content

Bundle an app that uses sockets

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
npm install --save-dev vite-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.config.ts
import {
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
} from 'vite'
export default
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
({
resolve?: AllResolveOptions | undefined
resolve
: {
EnvironmentResolveOptions.dedupe?: string[] | undefined
dedupe
: ['@fkn/lib', 'osra'], // one copy of each in the graph, wherever the import came from
},
})

Both names go on the list. osra is the package the library’s channel runs on, and the integration that met this failure had to list both.

Without the entry the worker’s copy is a copy nobody relayed, and it fails the way the worker nobody relayed fails. bind, listen and connect report @fkn/lib: no broker connection within 8000ms, so a udp socket could not be requested on error after 8,000 ms, while relayWorker on the page resolved without complaint. A call with no deadline of its own never settles.

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:

vite.config.ts
import {
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
} from 'vite'
export default
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
({
UserConfig.optimizeDeps?: DepOptimizationOptions | undefined

Dep optimization options

optimizeDeps
: {
DepOptimizationConfig.include?: string[] | undefined

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.config.ts
import {
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
} from 'vite'
import {
const nodePolyfills: (options?: PolyfillOptions) => Plugin[]

Returns a Vite plugin to polyfill Node's Core Modules for browser environments. Supports node: protocol imports.

@example

// vite.config.ts
import { defineConfig } from 'vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [
nodePolyfills({
// Specific modules that should not be polyfilled.
exclude: [],
// Whether to polyfill specific globals.
globals: {
Buffer: true, // can also be 'build', 'dev', or false
global: true,
process: true,
},
// Whether to polyfill `node:` protocol imports.
protocolImports: true,
}),
],
})

nodePolyfills
} from 'vite-plugin-node-polyfills'
const
const polyfills: () => Plugin<any>[]
polyfills
= () =>
function nodePolyfills(options?: PolyfillOptions): Plugin[]

Returns a Vite plugin to polyfill Node's Core Modules for browser environments. Supports node: protocol imports.

@example

// vite.config.ts
import { defineConfig } from 'vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [
nodePolyfills({
// Specific modules that should not be polyfilled.
exclude: [],
// Whether to polyfill specific globals.
globals: {
Buffer: true, // can also be 'build', 'dev', or false
global: true,
process: true,
},
// Whether to polyfill `node:` protocol imports.
protocolImports: true,
}),
],
})

nodePolyfills
({
globals?: {
Buffer?: BooleanOrBuildTarget;
global?: BooleanOrBuildTarget;
process?: BooleanOrBuildTarget;
} | undefined

Specify whether specific globals should be polyfilled.

@example

nodePolyfills({
globals: {
Buffer: false,
global: true,
process: 'build',
},
})

globals
: {
Buffer?: BooleanOrBuildTarget | undefined
Buffer
: true,
global?: BooleanOrBuildTarget | undefined
global
: true,
process?: BooleanOrBuildTarget | undefined
process
: true } })
export default
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
({
UserConfig.plugins?: PluginOption[] | undefined

Array of vite plugins to use.

plugins
: [
const polyfills: () => Plugin<any>[]
polyfills
()],
UserConfig.worker?: {
format?: "es" | "iife";
plugins?: () => PluginOption[];
rollupOptions?: Omit<RolldownOptions, "plugins" | "input" | "onwarn" | "preserveEntrySignatures">;
rolldownOptions?: Omit<RolldownOptions, "plugins" | "input" | "onwarn" | "preserveEntrySignatures">;
} | undefined

Worker bundle options

worker
: {
format?: "es" | "iife" | undefined

Output format for worker bundle

@default'iife'

format
: 'es',
plugins?: (() => PluginOption[]) | undefined

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
: () => [
const polyfills: () => 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
import * as
import dgram
dgram
from '@fkn/lib/dgram'
const
const socket: dgram.Socket
socket
=
import dgram
dgram
.
function createSocket(options: SocketType | SocketOptions, callback?: (msg: Buffer, rinfo: RemoteInfo) => void): dgram.Socket
export createSocket
createSocket
('udp4')
const socket: dgram.Socket
socket
.
NodeJS.EventEmitter<any>.on<string | symbol>(eventName: string | symbol, listener: (...args: any[]) => void): dgram.Socket

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.

import { EventEmitter } from 'node:events';
const myEE = new EventEmitter();
myEE.on('foo', () => console.log('a'));
myEE.prependListener('foo', () => console.log('b'));
myEE.emit('foo');
// Prints:
// b
// a

@sincev0.1.101

@parameventName The name of the event.

@paramlistener The callback function

on
('error', (
error: Error
error
:
interface Error
Error
) =>
error: Error
error
.
Error.message: string
message
) // a refused bind lands here, and no listening follows
const socket: 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';
const server = 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}`);
});
server.on('listening', () => {
const address = server.address();
console.log(`server listening ${address.address}:${address.port}`);
});
server.bind(41234);
// Prints: server listening 0.0.0.0:41234

bind
(6881, () => {
const socket: dgram.Socket
socket
.
Socket.address(): AddressInfo

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.

@sincev0.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
type Shimmable = {
global?: unknown;
process?: Record<string, unknown>;
}
Shimmable
= {
global?: unknown
global
?: unknown,
process?: Record<string, unknown> | undefined
process
?:
type Record<K extends keyof any, T> = { [P in K]: T; }

Construct a type with a set of properties K of type T

Record
<string, unknown> }
const
const root: Shimmable
root
=
module globalThis
globalThis
as unknown as
type Shimmable = {
global?: unknown;
process?: Record<string, unknown>;
}
Shimmable
const root: Shimmable
root
.
global?: unknown
global
??=
const root: Shimmable
root
// read at module scope by the stream shim
const
const proc: Record<string, unknown>
proc
= (
const root: 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
const proc: Record<string, unknown>
proc
.
unknown
env
??= {
type NODE_DEBUG: string
NODE_DEBUG
: '' } // read while the stream shim loads
const proc: Record<string, unknown>
proc
.
unknown
nextTick
??= (
fn: (...args: unknown[]) => void
fn
: (...
args: unknown[]
args
: unknown[]) => void, ...
args: unknown[]
args
: unknown[]) =>
function queueMicrotask(callback: () => void): void (+1 overload)
queueMicrotask
(() =>
fn: (...args: unknown[]) => void
fn
(...
args: unknown[]
args
)) // the trailing arguments have to arrive
export {}

env and nextTick are the two members the installed stream shim depends on. Add whatever your engine reads the same way, one ??= line each.

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.config.ts
import {
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
} from 'vite'
import {
const nodePolyfills: (options?: PolyfillOptions) => Plugin[]

Returns a Vite plugin to polyfill Node's Core Modules for browser environments. Supports node: protocol imports.

@example

// vite.config.ts
import { defineConfig } from 'vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [
nodePolyfills({
// Specific modules that should not be polyfilled.
exclude: [],
// Whether to polyfill specific globals.
globals: {
Buffer: true, // can also be 'build', 'dev', or false
global: true,
process: true,
},
// Whether to polyfill `node:` protocol imports.
protocolImports: true,
}),
],
})

nodePolyfills
} from 'vite-plugin-node-polyfills'
const
const polyfills: () => Plugin<any>[]
polyfills
= () =>
function nodePolyfills(options?: PolyfillOptions): Plugin[]

Returns a Vite plugin to polyfill Node's Core Modules for browser environments. Supports node: protocol imports.

@example

// vite.config.ts
import { defineConfig } from 'vite'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [
nodePolyfills({
// Specific modules that should not be polyfilled.
exclude: [],
// Whether to polyfill specific globals.
globals: {
Buffer: true, // can also be 'build', 'dev', or false
global: true,
process: true,
},
// Whether to polyfill `node:` protocol imports.
protocolImports: true,
}),
],
})

nodePolyfills
({
exclude?: ModuleNameWithoutNodePrefix[] | undefined

@example

nodePolyfills({
exclude: ['fs', 'path'],
})

exclude
: ['net', 'dgram'], // the two names leave the map, and the FKN entries win
globals?: {
Buffer?: BooleanOrBuildTarget;
global?: BooleanOrBuildTarget;
process?: BooleanOrBuildTarget;
} | undefined

Specify whether specific globals should be polyfilled.

@example

nodePolyfills({
globals: {
Buffer: false,
global: true,
process: 'build',
},
})

globals
: {
Buffer?: BooleanOrBuildTarget | undefined
Buffer
: true,
global?: BooleanOrBuildTarget | undefined
global
: true,
process?: BooleanOrBuildTarget | undefined
process
: true },
})
export default
function defineConfig(config: UserConfig): UserConfig (+5 overloads)

Type helper to make it easier to use vite.config.ts accepts a direct

UserConfig

object, or a function that returns it. The function receives a

ConfigEnv

object.

defineConfig
({
UserConfig.plugins?: PluginOption[] | undefined

Array of vite plugins to use.

plugins
: [
const polyfills: () => Plugin<any>[]
polyfills
()],
resolve?: AllResolveOptions | undefined
resolve
: {
EnvironmentResolveOptions.dedupe?: string[] | undefined
dedupe
: ['@fkn/lib', 'osra'],
alias?: AliasOptions | undefined
alias
: [
{
Alias.find: string | RegExp
find
: /^net$/,
Alias.replacement: string
replacement
: '@fkn/lib/net' }, // exact, so net/anything is left alone
{
Alias.find: string | RegExp
find
: /^dgram$/,
Alias.replacement: string
replacement
: '@fkn/lib/dgram' },
],
},
UserConfig.optimizeDeps?: DepOptimizationOptions | undefined

Dep optimization options

optimizeDeps
: {
DepOptimizationConfig.include?: string[] | undefined

Force optimize listed dependencies (must be resolvable import paths, cannot be globs).

include
: ['@fkn/lib', '@fkn/lib/net', '@fkn/lib/dgram'],
},
UserConfig.worker?: {
format?: "es" | "iife";
plugins?: () => PluginOption[];
rollupOptions?: Omit<RolldownOptions, "plugins" | "input" | "onwarn" | "preserveEntrySignatures">;
rolldownOptions?: Omit<RolldownOptions, "plugins" | "input" | "onwarn" | "preserveEntrySignatures">;
} | undefined

Worker bundle options

worker
: {
format?: "es" | "iife" | undefined

Output format for worker bundle

@default'iife'

format
: 'es',
plugins?: (() => PluginOption[]) | undefined

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
: () => [
const polyfills: () => 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:

  1. error fires with @fkn/lib: no broker connection within 8000ms, so a udp socket could not be requested after 8,000 ms, or a call with no deadline never settles, although relayWorker on the page resolved. The worker holds a second copy of the library. Add resolve.dedupe, and look for a node_modules/@fkn/lib under a linked dependency. The same error with one copy means the page relayed the worker late or not at all, which run sockets in a worker covers.
  2. 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.
  3. 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 ??=.
  4. 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.