Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'yarn'

- name: Install dependencies
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore

# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/


# Logs

logs
Expand Down Expand Up @@ -119,6 +126,7 @@ out

.nuxt
dist
e2e/dist

# Gatsby files

Expand Down
82 changes: 63 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,51 @@
# websocket-dom

![NPM Version](https://img.shields.io/npm/v/websocket-dom)
![NPM Version](https://img.shields.io/npm/v/websocket-dom) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/patrickjm/websocket-dom/.github%2Fworkflows%2Frun-tests.yml?branch=main&label=Automated%20tests)


Experimental partial 2-way sync between backend JSDOM and frontend DOM using WebSockets.

Fully control the client document and respond to user events from the backend.

**Compatibility**: NodeJS v22+ with ESM.

```mermaid
graph TB
subgraph Frontend
Client[Client DOM]
ClientWS[WebSocket Client]
end

subgraph Backend
ServerWS[WebSocket Server]
subgraph Worker[Web Worker]
JSDOM[JSDOM Instance]
Proto[Patched Prototypes]
end
end

%% Data flow
Client -->|DOM Events| ClientWS
ClientWS -->|Events| ServerWS
ServerWS -->|Events| JSDOM
JSDOM -->|DOM Mutations| Proto
Proto -->|Serialized Instructions| ServerWS
ServerWS -->|Mutation Patches| ClientWS
ClientWS -->|Apply Mutations| Client

%% Styling
classDef worker fill:#f8f0ff,stroke:#8a63d2,stroke-width:2px,color:#4a2b82
classDef frontend fill:#e3f2fd,stroke:#1976d2,stroke-width:2px,color:#0d47a1
classDef backend fill:#f1f8e9,stroke:#689f38,stroke-width:2px,color:#33691e

class Worker worker
class Client,ClientWS frontend
class ServerWS,JSDOM,Proto backend

%% Link styling
linkStyle default stroke:#333,stroke-width:2px,color:#333
```

## Usage

Installation:
Expand All @@ -19,7 +57,7 @@ yarn add websocket-dom

First, create your app code. This will run in a web-worker in the backend, but it feels just like client-side Javascript.

In your build step, you need to make sure the worker.js file is compiled to the `dist` folder separately as its own entrypoint.
In your build step, you need to make sure the resulting `worker.js` file is compiled to the `dist` folder separately as its own entrypoint.

Create `worker.ts`:

Expand All @@ -28,6 +66,7 @@ const btn = document.createElement('button');
btn.innerText = 'Click me';
btn.addEventListener('click', () => {
console.log('hello'); // <-- This will be printed in the server terminal
console.client.log('hello'); // <-- This will be printed in the client terminal
});
document.body.appendChild(btn);
```
Expand All @@ -50,15 +89,21 @@ const __dirname = path.dirname(new URL(import.meta.url).pathname);
wss.on('connection', (ws) => {
// pass the websocket and the initial document
const doc = '<!DOCTYPE html><html><body></body></html>';
const { domImport, terminate } = createWebsocketDom(ws, doc, { url: 'http://localhost:3000' });
const wsDom = new WebsocketDOM({
websocket: ws,
htmlDocument: doc,
url: 'http://localhost:3000'
})

ws.on('close', () => {
terminate();
// This will destroy the backend dom upon disconnect.
// But you can support client reconnection by updating the websocket connection:
// wsDom.setWebsocket(newWs);
wsDom.terminate();
});

// This must be a relative path to the compiled worker.js file in the dist folder,
// NOT the typescript file.
domImport(path.join(__dirname.replace('src', 'dist'), 'worker.js'));
// Import your compiled worker.js file
wsDom.import(path.join(__dirname.replace('src', 'dist'), 'worker.js'));
});

server.listen(3000, () => {
Expand All @@ -69,36 +114,35 @@ server.listen(3000, () => {
Next we need to set up the client code that actually runs in the browser. This will require a bundler. It will automatically create a websocket connection, watch for client-side events, and update the DOM from backend mutations:

```ts
import { createClient } from "websocket-dom/client";
import { createWebsocketDOMClient } from "websocket-dom/client";

export const { ws } = createClient('ws://localhost:3000');
export const ws = new WebSocket('ws://localhost:3000');
createWebsocketDOMClient(ws);
```

## How it works

On the backend, we create an isolated node worker that runs JSDOM. JSDOM classes are patched so that before mutations are applied (createElement, appendChild, etc.), they're intercepted, serialized, and sent to the frontend.

The frontend receives the mutations and applies them to the DOM. User events like clicks, keyboard inputs, etc. are sent back over websocket to the backend where they're dispatched to JSDOM.
The frontend receives the mutations and applies them to the real DOM. User events like clicks, keyboard inputs, etc. are sent back over websocket to the backend where they're dispatched to JSDOM.

To keep the two sides in sync, it's strongly recommended that the only client-side code you load is from this library.

## Limitations

- The backend dom is not a real headless browser. It runs on jsdom, so any limitations of jsdom will apply here too (e.g. no browser history, no contenteditable, etc.)
- Within jsdom, websocket-dom does not have full API coverage yet. There may be some events or DOM mutations that do not sync properly

## Open problems / todo
- [ ] Manual flush / reset / sync
- [ ] Comprehensive JSDOM api coverage
- [ ] Multiple open connections on the same session
- [ ] Event side effects (Input event -> value change -> cursor move)
- [ ] Client reconnection
- [ ] Experiment with client-sided dom mutation intercept
- [ ] Embedding other jsdom documents as elements
- [ ] Accessing element positions and sizes from the backend

## Development

Unfortunately, both bun and node are required to fully build this package at the moment.

But just to develop, only node >= 22 is needed.

## Compatibility

- Why no Bun?
- jsdom depends on node-canvas which is not supported by Bun, see: https://github.com/oven-sh/bun/issues/5835
- Why no Bun or Deno support? Websocket-dom heavily depends on jsdom which is not supported by Deno or Bun
- Bun: jsdom depends on node-canvas which is not supported by Bun, see: https://github.com/oven-sh/bun/issues/5835
Empty file added e2e/fixtures/.gitkeep
Empty file.
9 changes: 9 additions & 0 deletions e2e/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>

<body>
<div id="root"></div>
<script type="module" src="./setup/client.ts"></script>
</body>

</html>
18 changes: 18 additions & 0 deletions e2e/setup/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createWebsocketDOMClient } from "../../src/client";

export const ws = new WebSocket('ws://localhost:3333');
createWebsocketDOMClient(ws);

ws.onopen = () => {
console.log('Connection opened');
};

ws.onerror = (error) => {
console.error('WebSocket error:', error);
};

ws.onclose = () => {
console.log('Connection closed');
};

(window as any).ws = ws;
26 changes: 26 additions & 0 deletions e2e/setup/import-script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Page } from '@playwright/test';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { test } from '@playwright/test';

const __dirname = path.dirname(new URL(import.meta.url).pathname);

export async function importScript(page: Page, code: string) {
const testWorkerPath = path.join(__dirname, `../dist/test-worker-${crypto.randomUUID()}.js`);
const writeCode = [
`// ${test.info().titlePath.join(' > ')}`,
`// ${test.info().file}`,
'',
code
].join('\n');
await fs.promises.writeFile(testWorkerPath, writeCode, { flag: 'w' });

await page.evaluate(({testWorkerPath}) => {
const ws = (window as any).ws;
ws.send(JSON.stringify({
type: 'e2e-import',
path: testWorkerPath
}));
}, {testWorkerPath});
}
67 changes: 67 additions & 0 deletions e2e/setup/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import express from 'express';
import http from 'http';
import { WebSocketServer } from 'ws';
import { WebsocketDOM } from '../../src';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export class TestServer {
private app: express.Application;
private server: http.Server;
private wss: WebSocketServer;
private port: number;

constructor() {
this.app = express();
this.server = http.createServer(this.app);
this.wss = new WebSocketServer({ server: this.server });
this.port = 3333; // Using different port than main app for tests

// Handle websocket connections
this.wss.on('connection', (ws) => {
const doc = '<!DOCTYPE html><html><body></body></html>';
const wsDom = new WebsocketDOM({
websocket: ws,
htmlDocument: doc,
url: `http://localhost:${this.port}`
});

ws.on('message', (data) => {
const message = JSON.parse(data.toString());
if (message.type === 'e2e-import') {
wsDom.import(message.path);
}
});

ws.on('close', () => {
wsDom.terminate();
});
});

// Serve client-side code
this.app.use(express.static(path.join(__dirname, '../dist')));
}

async start() {
return new Promise<void>((resolve) => {
this.server.listen(this.port, () => {
// Wait a short moment to ensure WebSocket server is ready
setTimeout(() => {
console.log(`Test server running at http://localhost:${this.port}`);
resolve();
}, 100);
});
});
}

async stop() {
return new Promise<void>((resolve, reject) => {
this.server.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
}
8 changes: 8 additions & 0 deletions e2e/setup/start-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { TestServer } from './server';

const server = new TestServer();
server.start()
.catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
39 changes: 39 additions & 0 deletions e2e/tests/inner-text.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
import { importScript } from '../setup/import-script';

const script = `
const container = document.createElement('div');
container.id = 'test-container';
container.innerHTML = '<p>Initial <strong>HTML</strong> content</p>';
document.body.appendChild(container);

const textContainer = document.createElement('div');
textContainer.id = 'text-container';
textContainer.innerText = 'Initial text content';
document.body.appendChild(textContainer);

container.addEventListener('click', () => {
container.innerHTML = '<p>Updated <em>HTML</em> after click</p>';
textContainer.innerText = 'Updated text after click';
});
`;

test('should handle innerHTML and innerText updates', async ({ page }) => {
await page.goto('/');
await importScript(page, script);

// Wait for containers to be created
const htmlContainer = await page.waitForSelector('#test-container');
const textContainer = await page.waitForSelector('#text-container');

// Verify initial states
expect(await htmlContainer.innerHTML()).toBe('<p>Initial <strong>HTML</strong> content</p>');
expect(await textContainer.innerText()).toBe('Initial text content');

// Click the HTML container to trigger updates
await htmlContainer.click();

// Verify updated states
await expect(page.getByText('Updated HTML after click')).toBeVisible();
await expect(page.getByText('Updated text after click')).toBeVisible();
});
Loading
Loading