@webqit/node-live-response 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: ox-harris # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: webqit # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ otechie: # Replace with a single Otechie username
12
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
@@ -0,0 +1,45 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout repository
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Setup Node.js
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 20
20
+ registry-url: 'https://registry.npmjs.org/'
21
+
22
+ - name: Install dependencies
23
+ run: npm ci
24
+
25
+ - name: Determine npm tag
26
+ id: tag
27
+ run: |
28
+ # Extract tag name without "refs/tags/"
29
+ TAG_REF=${GITHUB_REF#refs/tags/}
30
+ echo "Detected Git tag: $TAG_REF"
31
+
32
+ # Determine npm tag
33
+ if [[ "$TAG_REF" == *-* ]]; then
34
+ # prerelease (contains a hyphen)
35
+ NPM_TAG=$(echo "$TAG_REF" | sed -E 's/^v[0-9]+\.[0-9]+\.[0-9]+-([a-zA-Z0-9]+).*/\1/')
36
+ else
37
+ NPM_TAG="latest"
38
+ fi
39
+ echo "npm publish will use tag: $NPM_TAG"
40
+ echo "tag=$NPM_TAG" >> $GITHUB_OUTPUT
41
+
42
+ - name: Publish to npm
43
+ env:
44
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
45
+ run: npm publish --tag ${{ steps.tag.outputs.tag }}
package/.gitignore ADDED
@@ -0,0 +1,6 @@
1
+ .*
2
+
3
+ !/.github
4
+ !.vitepress
5
+ !/.gitignore
6
+ node_modules
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WebQit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # LiveResponse for Node.js & Express
2
+
3
+ This package brings **LiveResponse** to traditional Node.js and Express backends.
4
+
5
+ LiveResponse is a new response model that extends the HTTP request/response model with interactivity. It allows you to send a response and keep it open for interaction. You want to see the [LiveResponse docs](https://github.com/webqit/fetch-plus?tab=readme-ov-file#section-1-liveresponse) for more details.
6
+
7
+ The definitive way to get full, always‑on interactivity as a core architectural primitive is **[Webflo](https://github.com/webqit/webflo)**. Live responses are native and automatic there. This package exists for cases where you want LiveResponse inside an otherwise conventional Node.js or Express backend.
8
+
9
+ ---
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @webqit/node-live-response
15
+ ```
16
+
17
+ ---
18
+
19
+ ## One-Time Setup
20
+
21
+ The first thing you do is enable live mode against your server instance. This sets up the transport and request bookkeeping required for LiveResponse.
22
+
23
+ ### Node.js HTTP server
24
+
25
+ ```js
26
+ import http from 'http';
27
+ import { enableLive } from '@webqit/node-live-response';
28
+
29
+ const server = http.createServer(handler);
30
+ const liveMode = enableLive(server);
31
+
32
+ server.listen(3000);
33
+ ```
34
+
35
+ ### Express
36
+
37
+ ```js
38
+ import express from 'express';
39
+ import { enableLive } from '@webqit/node-live-response';
40
+
41
+ const app = express();
42
+ const server = app.listen(3000);
43
+
44
+ const liveMode = enableLive(server);
45
+ ```
46
+
47
+ ---
48
+
49
+ ## Usage
50
+
51
+ The returned `liveMode` function is your per-route live mode switch. Call it on a route where you want live mode enabled.
52
+
53
+ This function works as both a direct function and an Express middleware.
54
+
55
+ ### Node.js HTTP server
56
+
57
+ ```js
58
+ async function handler(req, res) {
59
+ liveMode(req, res);
60
+
61
+ const liveRes = new LiveResponse('Hello world');
62
+ await res.send(liveRes); // resolves when live mode is established
63
+
64
+ // ------- interactive phase -------
65
+
66
+ setTimeout(() => {
67
+ res.die();
68
+ }, 5000);
69
+ }
70
+ ```
71
+
72
+ ### Express
73
+
74
+ ```js
75
+ app.get('/counter', liveMode(), async (req, res) => {
76
+ const liveRes = new LiveResponse('Hello world');
77
+ await res.send(liveRes); // resolves when live mode is established
78
+
79
+ // ------- interactive phase -------
80
+
81
+ setTimeout(() => {
82
+ res.die();
83
+ }, 5000);
84
+ });
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Live interaction patterns
90
+
91
+ LiveResponse supports three core interaction patterns.
92
+
93
+ ### 1. Live state projection
94
+
95
+ A live object can be sent as the response body.
96
+
97
+ Mutating that object on the server automatically reflects on the client. (More in the [LiveResponse docs](https://github.com/webqit/fetch-plus#1-live-state-projection-via-mutable-response-bodies))
98
+
99
+ ```js
100
+ import { Observer } from '@webqit/observer';
101
+
102
+ app.get('/counter', liveMode(), async (req, res) => {
103
+ const state = { count: 0 };
104
+
105
+ const liveRes = new LiveResponse(state);
106
+ await res.send(liveRes); // resolves when live mode is established
107
+
108
+ const interval = setInterval(() => {
109
+ Observer.set(state, 'count', state.count + 1);
110
+ }, 1000);
111
+
112
+ setTimeout(() => {
113
+ clearInterval(interval);
114
+ res.die();
115
+ }, 10000);
116
+ });
117
+ ```
118
+
119
+ Then on the client:
120
+
121
+ ```html
122
+ <!doctype html>
123
+ <head>
124
+ <title>Live Counter</title>
125
+ <script src="https://unpkg.com/@webqit/fetch-plus/dist/main.js"></script>
126
+ </head>
127
+ <body>
128
+
129
+ <h1></h1>
130
+
131
+ <script type="module">
132
+ const { LiveResponse, Observer } = window.webqit;
133
+
134
+ const { body: state } = await LiveResponse.from(fetch('/counter')).now();
135
+
136
+ Observer.observe(state, () => {
137
+ document.querySelector('h1').textContent = 'Count: ' + state.count;
138
+ });
139
+ </script>
140
+ </body>
141
+ </html>
142
+ ```
143
+
144
+ ### 2. Response swapping
145
+
146
+ A live response can be replaced with a new one – without opening a new request. It gives you a multi-response architecture. (More in the [LiveResponse docs](https://github.com/webqit/fetch-plus?tab=readme-ov-file#2-a-multi-response-architecture-via-response-swaps))
147
+
148
+ ```js
149
+ app.get('/news', liveMode(), async (req, res) => {
150
+ const liveRes = new LiveResponse({ headline: 'Breaking: Hello World' });
151
+ await res.send(liveRes); // resolves when live mode is established
152
+
153
+ setTimeout(() => {
154
+ liveRes.replaceWith({ headline: 'Update: Still Hello World' });
155
+ }, 3000);
156
+
157
+ setTimeout(() => {
158
+ liveRes.replaceWith({ headline: 'Final: Goodbye' });
159
+ res.die();
160
+ }, 6000);
161
+ });
162
+ ```
163
+
164
+ Then on the client:
165
+
166
+ ```html
167
+ <!doctype html>
168
+ <head>
169
+ <title>Live News</title>
170
+ <script src="https://unpkg.com/@webqit/fetch-plus/dist/main.js"></script>
171
+ </head>
172
+ <body>
173
+ <h1></h1>
174
+ <script type="module">
175
+ const { LiveResponse } = window.webqit;
176
+
177
+ const liveRes = LiveResponse.from(fetch('/news'));
178
+ liveRes.addEventListener('response', (e) => {
179
+ document.querySelector('h1').textContent = e.body.headline;
180
+ });
181
+ </script>
182
+ </body>
183
+ ```
184
+
185
+ ### 3. Bidirectional messaging
186
+
187
+ Live responses can exchange messages bidirectionally. (More in the [LiveResponse docs](https://github.com/webqit/fetch-plus?tab=readme-ov-file#3-bidirectional-messaging-via-message-ports))
188
+
189
+ ```js
190
+ app.get('/chat', liveMode(), async (req, res) => {
191
+ const liveRes = new LiveResponse({ title: 'Chat' });
192
+ await res.send(liveRes); // resolves when live mode is established
193
+
194
+ req.port.addEventListener('message', (e) => {
195
+ req.port.postMessage(e.data);
196
+ });
197
+ });
198
+ ```
199
+
200
+ Then on the client:
201
+
202
+ ```html
203
+ <!doctype html>
204
+ <head>
205
+ <title>Live Chat</title>
206
+ <script src="https://unpkg.com/@webqit/fetch-plus/dist/main.js"></script>
207
+ </head>
208
+ <body>
209
+
210
+ <h1>Chat</h1>
211
+ <ul id="log"></ul>
212
+ <input id="msg" placeholder="Type and press enter" />
213
+
214
+ <script type="module">
215
+ const { LiveResponse } = window.webqit;
216
+
217
+ const liveRes = LiveResponse.from(fetch('/chat'));
218
+ liveRes.port.addEventListener('message', (e) => {
219
+ const li = document.createElement('li');
220
+ li.textContent = e.data;
221
+ log.appendChild(li);
222
+ });
223
+
224
+ const msg = document.querySelector('#msg');
225
+ msg.addEventListener('keydown', (e) => {
226
+ if (e.key === 'Enter') {
227
+ liveRes.port.postMessage(msg.value);
228
+ msg.value = '';
229
+ }
230
+ });
231
+ </script>
232
+ </body>
233
+ ```
234
+
235
+ ---
236
+
237
+ ## What `node-live-response` does
238
+
239
+ The library:
240
+
241
+ * attaches `req.port` and `req.signal` to the request object.
242
+ * patches `res.send()` / `res.end()` to accept `LiveResponse`. Note that calling res.send() with a LiveResponse gives you back a promise that resolves when the connection is live. This promise is to be awaited before interacting with the live response.
243
+ * adds `res.die()` to the response object. This must be called to end interactivity.
244
+
245
+ ---
246
+
247
+ ## Lifecycle contract
248
+
249
+ ### When interactivity starts
250
+
251
+ Interactivity begins **after** the server sends a `LiveResponse`:
252
+
253
+ ```js
254
+ res.send(liveRes);
255
+ ```
256
+
257
+ That is the moment the client learns that the response is interactive and joins the live channel.
258
+
259
+ * Live state projection and `replaceWith()` are only meaningful *after* this moment
260
+ * Messages sent on `req.port` *before* this moment are queued
261
+
262
+ (Contrast this with Webflo, where interaction is implicit and automatic.)
263
+
264
+ ### When interactivity ends
265
+
266
+ Live mode ends only when you explicitly call `res.die()`. This must be called to end interactivity:
267
+
268
+ ```js
269
+ res.die();
270
+ ```
271
+
272
+ Ending the HTTP response does **not** end live interaction. The request lifecycle and the live lifecycle are not coupled.
273
+
274
+ ---
275
+
276
+ ## Learn more
277
+
278
+ * [LiveResponse docs](https://github.com/webqit/fetch-plus#1-live-state-projection-via-mutable-response-bodies)
279
+ * [Webflo](https://github.com/webqit/webflo)
280
+
281
+ ---
282
+
283
+ ## License
284
+
285
+ MIT
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@webqit/node-live-response",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "title": "LiveResponse for Node.js & Express",
7
+ "description": "LiveResponse for Node.js & Express",
8
+ "keywords": [
9
+ "liveresponse",
10
+ "live-response",
11
+ "http",
12
+ "express",
13
+ "node",
14
+ "websocket",
15
+ "realtime",
16
+ "webqit"
17
+ ],
18
+ "version": "0.1.0",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/webqit/node-live-response.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/webqit/node-live-response/issues"
26
+ },
27
+ "type": "module",
28
+ "main": "./src/index.js",
29
+ "exports": {
30
+ ".": "./src/index.js"
31
+ },
32
+ "scripts": {
33
+ "test": "mocha --extension .test.js --recursive --timeout 5000 --exit",
34
+ "preversion": "npm run test",
35
+ "postversion": "git push && git push --tags",
36
+ "version:next": "npm version prerelease --preid=next"
37
+ },
38
+ "dependencies": {
39
+ "@webqit/fetch-plus": "file:../fetch-plus",
40
+ "@webqit/port-plus": "^0.1.12"
41
+ },
42
+ "devDependencies": {
43
+ "chai": "^4.3.4",
44
+ "chai-as-promised": "^7.1.1",
45
+ "esbuild": "^0.20.2",
46
+ "mocha": "^10.3.0"
47
+ },
48
+ "author": "Oxford Harrison <oxharris.dev@gmail.com>",
49
+ "maintainers": [
50
+ "Oxford Harrison <oxharris.dev@gmail.com>"
51
+ ],
52
+ "contributors": []
53
+ }
package/src/index.js ADDED
@@ -0,0 +1,132 @@
1
+ import crypto from 'crypto';
2
+ import { WebSocketServer } from 'ws';
3
+ import { StarPort, WebSocketPort } from '@webqit/port-plus';
4
+ import { LiveResponse, Observer } from '@webqit/fetch-plus';
5
+
6
+ export { LiveResponse, Observer }
7
+
8
+ export function enableLive(server) {
9
+ // One-time WS setup
10
+ handleUpgrade(server);
11
+
12
+ function live(req, res, next) {
13
+ // Node-style usage: live(req, res)
14
+ if (req && res) {
15
+ setupLiveRoute(req, res);
16
+ return;
17
+ }
18
+
19
+ // Express-style usage: live()
20
+ return (req, res, next) => {
21
+ setupLiveRoute(req, res);
22
+ next();
23
+ };
24
+ }
25
+
26
+ return live;
27
+ }
28
+
29
+ export const portRegistry = new Map();
30
+
31
+ export function setupLiveRoute(req, res) {
32
+ const port = new StarPort();
33
+ const portId = crypto.randomUUID();
34
+
35
+ portRegistry.set(portId, port);
36
+
37
+ const abortController = new AbortController();
38
+ let hasLiveResponse = false;
39
+
40
+ const die = () => {
41
+ if (abortController.signal.aborted) return;
42
+ abortController.abort();
43
+ port.close();
44
+ portRegistry.delete(portId);
45
+ };
46
+
47
+ // ---- request-scoped capabilities ----
48
+ req.port = port;
49
+ req.signal = abortController.signal;
50
+
51
+ // ---- response-scoped lifecycle ----
52
+ res.die = die;
53
+
54
+ const originalSend = res.send?.bind(res);
55
+ const originalEnd = res.end.bind(res);
56
+
57
+ async function commitLiveResponse(liveResponse) {
58
+ if (hasLiveResponse) return;
59
+ hasLiveResponse = true;
60
+
61
+ const response = liveResponse.toResponse({
62
+ port,
63
+ signal: abortController.signal,
64
+ });
65
+
66
+ response.headers.set(
67
+ 'X-Message-Port',
68
+ `socket:///?port_id=${portId}`
69
+ );
70
+
71
+ for (const [name, value] of response.headers) {
72
+ res.setHeader(name, value);
73
+ }
74
+
75
+ if (response.body) {
76
+ await response.body.pipeTo(
77
+ new WritableStream({
78
+ write(chunk) {
79
+ res.write(chunk);
80
+ },
81
+ close() {
82
+ res.end();
83
+ },
84
+ abort(err) {
85
+ res.destroy(err);
86
+ },
87
+ })
88
+ );
89
+ } else {
90
+ res.end();
91
+ }
92
+ }
93
+
94
+ // ---- intercept Express-style response exits ----
95
+ if (originalSend) {
96
+ res.send = (value) => {
97
+ if (value instanceof LiveResponse) {
98
+ commitLiveResponse(value);
99
+ return req.port.readyStateChange('open').then(() => res);
100
+ }
101
+ return originalSend(value);
102
+ };
103
+ }
104
+
105
+ res.end = (...args) => {
106
+ // Only end live mode if no LiveResponse was ever committed
107
+ if (!hasLiveResponse) {
108
+ die();
109
+ }
110
+ return originalEnd(...args);
111
+ };
112
+ }
113
+
114
+ export function handleUpgrade(server) {
115
+ const wss = new WebSocketServer({ noServer: true });
116
+
117
+ server.on('upgrade', (req, socket, head) => {
118
+ const url = new URL(req.url, `http://${req.headers.host}`);
119
+ const portId = url.searchParams.get('port_id');
120
+
121
+ if (!portId || !portRegistry.has(portId)) {
122
+ socket.destroy();
123
+ return;
124
+ }
125
+
126
+ wss.handleUpgrade(req, socket, head, (ws) => {
127
+ const wsPort = new WebSocketPort(ws);
128
+ portRegistry.get(portId).addPort(wsPort);
129
+ });
130
+ });
131
+ }
132
+
File without changes
package/test/server.js ADDED
@@ -0,0 +1,10 @@
1
+ import { enableLive } from '../src/index.js';
2
+ import { LiveResponse } from '@webqit/fetch-plus';
3
+
4
+ const server = http.createServer((req, res) => {
5
+ live(req, res);
6
+ res.send(new LiveResponse('Hello world'));
7
+ });
8
+
9
+ const live = enableLive(server);
10
+ server.listen(3000);