Skip to content
Merged
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
67 changes: 39 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ Experimental partial 2-way sync between backend JSDOM and frontend DOM using Web

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

**Compatibility**:
- ESM only
- NodeJS full support
- Bun/Deno do not work
**Compatibility**: NodeJS v22+ with ESM.

## Usage

Expand All @@ -20,6 +17,21 @@ npm i websocket-dom
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.

Create `worker.ts`:

```ts
const btn = document.createElement('button');
btn.innerText = 'Click me';
btn.addEventListener('click', () => {
console.log('hello'); // <-- This will be printed in the server terminal
});
document.body.appendChild(btn);
```

Then set up the server (assuming you're using Express):

```ts
Expand All @@ -32,62 +44,61 @@ const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });

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

// create a new websocket-dom for each connection
wss.on('connection', (ws) => {
// pass the websocket and the initial document
const doc = '<!DOCTYPE html><html><body></body></html>';
const { window } = createWebsocketDom(ws, doc, { url: 'http://localhost:3000' });
const { domImport, terminate } = createWebsocketDom(ws, doc, { url: 'http://localhost:3000' });

const document = window.document;
const btn = document.createElement('button');
btn.innerText = 'Click me';
btn.addEventListener('click', () => {
console.log('hello'); // <-- This will be printed in the server terminal
ws.on('close', () => {
terminate();
});
document.body.appendChild(btn);

// 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'));
});

server.listen(3000, () => {
console.log('Server is running on port 3000');
});
```

To set up the browser client, you just need to import `websocket-dom/client`.

Since this is experimental, only port 3000 is supported until more config options are added.
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:

Assuming you're using Vite, you can do this:

```html
<!DOCTYPE html>
<html>
<body>
<script type="module" src="websocket-dom/client"></script>
</body>
```ts
import { createClient } from "websocket-dom/client";

</html>
export const { ws } = createClient('ws://localhost:3000');
```


## How it works

On the backend, JSDOM classes are patched so that before mutations are applied (createElement, appendChild, etc.), they're intercepted, serialized, and sent to the frontend.
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.

This can only be done under the assumption that the client is only updated from this library (no custom scripts).
To keep the two sides in sync, it's strongly recommended that the only client-side code you load is from this library.

## Open problems / todo
- [ ] Manual flush / reset / sync
- [ ] Full JSDOM api coverage
- [ ] 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.
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
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "websocket-dom",
"module": "src/index.ts",
"type": "module",
"version": "0.1.1",
"version": "0.3.0",
"description": "Experimental 2-way sync between backend JSDOM and frontend DOM using WebSockets",
"repository": "https://github.com/patrickjm/websocket-dom",
"author": "Patrick Moriarty <patrick@moriarty.dev>",
Expand All @@ -21,11 +21,11 @@
],
"scripts": {
"build:client": "vite build",
"build:server": "bun build src/index.tsx --outdir dist --target node --format esm --sourcemap=inline -e jsdom",
"build:server": "bun build src/index.ts --outdir dist --target node --format esm --sourcemap=inline -e jsdom",
"build:types": "tsc",
"clean": "touch ./dist && rm -r ./dist",
"build": "yarn clean && yarn build:server && yarn build:client && yarn build:types",
"start": "vite build && tsx ./src/index.tsx",
"start": "vite build && tsx ./src/index.ts",
"dev": "nodemon --watch src --watch vite.config.ts --watch index.html --watch public --ext ts,html,json,tsx --exec yarn start"
},
"exports": {
Expand Down Expand Up @@ -57,6 +57,7 @@
"express": "^4.19.2",
"jsdom": "^25.0.0",
"vite": "^5.4.3",
"web-worker": "^1.3.0",
"ws": "^8.18.0"
},
"license": "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/client/events.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BaseSerializedEvent, SerializedEvent } from "./types";
import { getXPath } from "../utils";
import { getXPath } from "../shared-utils";

export function serializeEvent(event: Event): SerializedEvent {
if (!event.type.startsWith('key')) {
Expand Down
5 changes: 2 additions & 3 deletions src/client/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { EventMessage, Message } from "../messages";
import type { EventMessage, Message } from "../ws-messages";
import { NodeStash } from "../dom/nodes";
import { debounce } from "../utils";
import { debounce } from "../shared-utils";
import { serializeEvent } from "./events";
// import { applyInstruction } from "./rendering";
import * as Instr from "../dom/instructions";

/**
Expand Down
2 changes: 1 addition & 1 deletion src/client/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { XPath } from "../utils";
import type { XPath } from "../shared-utils";

export interface BaseSerializedEvent {
type: string;
Expand Down
2 changes: 1 addition & 1 deletion src/dom/events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DOMWindow } from 'jsdom';
import { type SerializedChangeEvent, type SerializedClickEvent, type SerializedEvent, type SerializedFocusEvent, type SerializedInputEvent, type SerializedKeyboardEvent, type SerializedMouseEvent, type SerializedSubmitEvent } from '../client/types';
import { type XPath } from "../utils";
import { type XPath } from "../shared-utils";
import type { NodeStash } from './nodes';
import type { DomEmitter } from './instructions';

Expand Down
Loading