@domstack/sync

A modern, dependency-minimized live-reload dev server. A love letter to browser-sync: ESM-only, Fastify, native WebSocket, and a server-rendered HTMX UI panel.
Install
npm install @domstack/sync
CLI
domstack-sync --server . --files '**/*.css' '**/*.html'
domstack-sync --server . --watch
domstack-sync --files '**/*.css'
dss --server . --files '**/*'
domstack-sync --server . --no-ui
domstack-sync init
domstack-sync reload --port 3000
CLI flags
--server, -s | — | Directory to serve |
--files, -f | — | Glob patterns to watch (repeatable) |
--watch, -w | — | Watch server roots in addition to --files |
--ignore | — | Ignore patterns for file watchers (repeatable) |
--port | 3000 | Port to listen on |
--no-ui | — | Disable the UI panel |
--no-notify | — | Disable the notification overlay |
--no-ghost-mode | — | Disable scroll/click/location/form sync |
--cors | — | Enable CORS headers |
--log-level | info | Pino log level, for example silent, trace, debug, info, warn, error, or fatal |
--log-connections | — | Log browser connection events at info level |
--help, -h | — | Show help text |
--version, -v | — | Show version |
domstack-sync init writes domstack-sync.config.mjs using export default. On startup, the CLI loads the first config file it finds in this order: domstack-sync.config.mjs, .mts, .js, then .ts. TypeScript config files rely on Node's built-in type stripping.
API
import { createServer, parseOptions } from '@domstack/sync'
const sync = await createServer({
server: './public',
files: ['public/**/*.css', 'public/**/*.html'],
port: 3000,
})
console.log(sync.url)
console.log(sync.uiUrl)
sync.reload()
sync.reload(['styles/main.css'])
sync.notify('Build complete')
someReadableStream.pipe(sync.stream())
sync.events.on('client:connect', (info) => console.log('connected', info.ua))
sync.events.on('client:disconnect', (id) => console.log('disconnected', id))
sync.events.on('file:change', (evt) => console.log('changed', evt.path))
sync.pause()
sync.resume()
await sync.exit()
Logging
Standalone usage creates a pretty Pino logger automatically. When embedding @domstack/sync in another tool, pass a raw Pino logger and keep formatting ownership in the parent process:
import pino from 'pino'
import { createLogger, createServer, logAccessUrls } from '@domstack/sync'
const standaloneLogger = createLogger('info')
const ownerLogger = pino({ level: 'info' })
const sync = await createServer({
server: './public',
files: ['public/**/*.css', 'public/**/*.html'],
logger: ownerLogger.child({ component: 'sync', logPrefix: '[domstack-sync]' }),
})
logAccessUrls(standaloneLogger, {
local: sync.url,
ui: sync.uiUrl,
})
logAccessUrls(logger, urls) is exported for callers that want the same access-URL table with their own Pino logger.
Options
port | number | 3000 | Port to listen on (falls back to OS-assigned if taken) |
server | string | boolean | string[] | object | false | Directory or directories to serve statically |
files | string | string[] | object[] | [] | Glob patterns or chokidar watch objects to watch for changes |
ghostMode | boolean | { scroll, clicks, location, forms } | all true | Sync interactions across connected browsers |
logger | pino.Logger | — | Raw Pino logger supplied by an embedding owner; when omitted, sync creates its own standalone pretty logger |
logLevel | Pino log level string | 'info' | Log verbosity used by the standalone logger when logger is omitted |
logConnections | boolean | false | Log browser connection events at info level |
ui | boolean | { port: number } | true | UI panel — false disables, { port } pins the port |
notify | boolean | true | Show notification overlay in connected browsers |
cors | boolean | false | Add CORS headers to all responses |
injectChanges | boolean | true | Inject matching file changes instead of full reload |
injectFileTypes | string[] | ['css', 'png', 'jpg', 'jpeg', 'svg', 'gif', 'webp', 'map'] | Extensions eligible for file injection |
tagNames | Record<string, string> | built-in asset tag map | Element tag names used for non-CSS file injection |
codeSync | boolean | true | Broadcast reload and file-injection messages |
reloadDebounce | number | 500 | Milliseconds to debounce file-change reloads |
reloadDelay | number | 0 | Milliseconds to delay reload after a change |
reloadThrottle | number | 0 | Minimum milliseconds between reload broadcasts |
scrollThrottle | number | 0 | Minimum milliseconds between scroll sync messages |
scrollElements | string[] | [] | CSS selectors eligible for element scroll sync |
scrollElementMapping | string[] | [] | Selector mapping for scroll sync between different layouts |
scrollProportionally | boolean | true | Sync scroll position proportionally instead of raw pixels |
watch | boolean | false | Add server roots and routes to watched files |
ignore | string | string[] | — | Ignore patterns merged into watcher options |
watchOptions | object | {} | Options passed through to chokidar |
watchEvents | string[] | ['change'] | Chokidar event names that trigger reloads |
snippet | boolean | true | Inject the browser client snippet into HTML responses |
snippetOptions | object | {} | Whitelist, blacklist, ignore paths, or custom injection rule |
rewriteRules | object[] | [] | HTML rewrite rules applied before snippet injection |
plugins | array | [] | BrowserSync-compatible plugin entries |
cwd | string | process.cwd() | Working directory for resolving server and files |
TypeScript
All types are exported:
import type { AccessUrls, BsInstance, BsOptions, BsOptionsInput, LoggerOptions, LoggerStreams } from '@domstack/sync'
import type { ServerToClientMessage, ClientToServerMessage } from '@domstack/sync'
How it works
- Script injection — an IIFE
<script> tag is injected immediately after the opening <body> tag (or appended if there is no <body>) in every HTML response. The injected script connects to the WebSocket server.
- File injection — when a watched injectable file changes and
injectChanges: true, matching DOM assets are cache-busted in place without a full page reload. CSS stylesheets, imported stylesheets, images, inline image URLs, and configured asset tags are supported. Falls back to a full reload if no matching asset is found.
- Ghost mode — scroll position, clicks, location changes, and form input changes in one browser are relayed to all other connected browsers via the WebSocket server. Disable with
ghostMode: false or --no-ghost-mode.
- UI panel — a separate Fastify server on an auto-detected port shows connected browsers, file-change history, and server URLs. Disable with
ui: false or --no-ui.
- Port selection — the requested port is tried first. If it is already in use, the OS assigns a free ephemeral port automatically.
License
MIT