mikser-io-live 5.0.0 → 6.0.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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # mikser-io-live
2
2
 
3
- Live-reload dev server for [Mikser](https://github.com/almero-digital-marketing/mikser-io). Wraps [alive-server](https://www.npmjs.com/package/alive-server) and serves the configured `outputFolder`, auto-refreshing browsers as Mikser regenerates files.
3
+ Live reload for [mikser](https://github.com/almero-digital-marketing/mikser-io), mounted on mikser's own HTTP server.
4
4
 
5
- The lighter alternative to `mikser --server` when all you want is "save a file → browser refreshes." Pair with `mikser --watch` for the classic SSG dev loop. If you also need API endpoints, an admin UI, or other plugins that need a shared Express app, use `mikser --server` instead.
5
+ Save a file, the browser updates. Edit a stylesheet and it swaps in place — no reload, so scroll position, open menus and form state survive.
6
6
 
7
7
  ## Install
8
8
 
@@ -17,22 +17,43 @@ npm install mikser-io-live
17
17
  import { live } from 'mikser-io-live'
18
18
 
19
19
  export default {
20
- plugins: [
21
- live({
22
- port: 8080,
23
- open: true
24
- })
25
- ]
20
+ plugins: [ /* … */, live() ],
26
21
  }
27
22
  ```
28
23
 
29
- The options object is passed straight through to `alive-server.start(...)`. Defaults applied by the plugin:
24
+ ```bash
25
+ mikser --server --watch
26
+ ```
27
+
28
+ That's the whole setup. `--watch` rebuilds on change, `--server` serves the output, and this plugin connects the two to the browser.
29
+
30
+ | option | default | |
31
+ | --- | --- | --- |
32
+ | `path` | `/__mikser_live` | where the event stream is mounted |
33
+
34
+ ## What it does
35
+
36
+ Watches the output folder and tells connected browsers what changed. That folder **is** the bytes the browser gets — a page arrives there by being rendered, a stylesheet by being copied, an image by being symlinked — so watching it catches all three. Deriving the list from what the engine rendered was tried and misses everything that was not rendered, which includes the most common reason to want live reload.
37
+
38
+ What the engine is asked for is the timing and the verdict: changes are flushed at the end of a build, so one build produces one reload rather than forty, and a build that **failed** produces none. A failed render leaves the previous good output on disk, so reloading would show the same page and hide the failure — you get a console warning instead.
39
+
40
+ Three behaviours worth knowing:
41
+
42
+ - **Stylesheets swap, pages reload.** If everything that changed was CSS, the `<link>` href is refreshed and nothing else happens.
43
+ - **Only the page you are looking at reloads.** A build that rerendered forty other pages does not interrupt this one.
44
+ - **A mikser restart reloads.** The client notices the server it was talking to has been replaced and reloads rather than trusting a page the previous process rendered.
45
+
46
+ ## Requires `--server`
47
+
48
+ There is no second server and no second port. Without a shared Express app there is nothing to mount on, and the plugin says so rather than starting one of its own.
49
+
50
+ This is a change from 5.x, which wrapped [alive-server](https://www.npmjs.com/package/alive-server) and ran its own server on its own port. If you were using it as the lighter alternative to `mikser --server`, you now want `mikser --server --watch` instead — one port, one process, and it composes with every other plugin that mounts on the same app.
51
+
52
+ ## How the page gets the script
30
53
 
31
- - `wait: 1000`
32
- - `root: runtime.options.outputFolder`
33
- - `open: false`
54
+ A small snippet is injected before `</body>`, falling back to `</svg>` then `</head>` — the same order [alive-server](https://www.npmjs.com/package/alive-server) uses, and right for the same reasons: a standalone SVG served as a page has no body, and some documents have no closing body tag at all. A page with none of the three is warned about once, because a page that silently never reloads looks exactly like a reload that is broken.
34
55
 
35
- Pair with Mikser's watch mode to get a live-reloading dev loop.
56
+ Requests carrying an `Origin` header are left alone. Those are `fetch`/XHR — script asking for the same HTML as data — and injecting there corrupts what the caller parses.
36
57
 
37
58
  ## License
38
59
 
package/index.js CHANGED
@@ -1,16 +1,235 @@
1
- import liveServer from 'alive-server'
1
+ // Live reload on mikser's own server.
2
+ //
3
+ // This used to start a second HTTP server on a second port (alive-server),
4
+ // which watched the output folder and guessed, from a one-second debounce,
5
+ // that a build had probably finished. That works, and it costs a port, a
6
+ // separate process lifecycle, and a dependency — and it cannot answer the one
7
+ // question that matters, which is what actually changed.
8
+ //
9
+ // Mounted on the shared Express app instead. It still watches the output
10
+ // folder, because that folder IS the bytes the browser gets: a page arrives
11
+ // there by being rendered, a stylesheet by being copied, an image by being
12
+ // symlinked, and only one of those three produces anything in the build
13
+ // report. Deriving the served paths from what the engine rendered was tried
14
+ // and is wrong twice over — it misses every file that was not rendered, and
15
+ // it turns one observation into three inferences.
16
+ //
17
+ // What the engine IS asked for is the timing and the verdict: flush at the end
18
+ // of a cycle so a build produces one reload rather than forty, and do not
19
+ // reload at all when the build failed.
20
+ //
21
+ // Requires `mikser --server`. Without a shared app there is nothing to mount
22
+ // on, and the plugin says so rather than starting a server of its own — one
23
+ // port and one lifecycle is the point.
24
+
25
+ import path from 'node:path'
26
+ import { existsSync, statSync } from 'node:fs'
27
+ import { readFile } from 'node:fs/promises'
28
+
29
+ import { clientScript } from './lib/client.js'
30
+
31
+ // Where the snippet goes, in preference order — borrowed from alive-server,
32
+ // which is right about this: `</svg>` matters because a standalone SVG served
33
+ // as a page has no body, and `</head>` is the fallback for a document with no
34
+ // closing body tag at all.
35
+ const INJECT_BEFORE = [/<\/body>/i, /<\/svg>/i, /<\/head>/i]
36
+
37
+ // Only files that can carry a script tag.
38
+ const INJECTABLE = new Set(['.html', '.htm', '.xhtml', '.svg'])
39
+
40
+ // Where the snippet goes in a given document, or null if there is nowhere.
41
+ //
42
+ // Exported because it is the only part of the injection with a decision in it,
43
+ // and a closure inside an express handler is not a thing a test can reach.
44
+ export function injectionPoint(html) {
45
+ for (const candidate of INJECT_BEFORE) {
46
+ const match = candidate.exec(html)
47
+ if (match) return match[0]
48
+ }
49
+ return null
50
+ }
51
+
52
+ // Before the closing tag, never after: a script appended past </body> is
53
+ // outside the document and browsers put it back inside anyway, which makes the
54
+ // bytes on the wire disagree with the DOM.
55
+ export function injectInto(html, script) {
56
+ const tag = injectionPoint(html)
57
+ if (!tag) return html
58
+ return html.replace(tag, script + tag)
59
+ }
2
60
 
3
61
  export function live(options = {}) {
4
- return ({ onLoaded, runtime, useLogger }) => {
62
+ const streamPath = options.path ?? '/__mikser_live'
63
+ // Changes every start. A client that reconnects and sees a different one
64
+ // knows mikser restarted — new code, possibly a different site — and
65
+ // reloads rather than trusting a page the previous process rendered.
66
+ const boot = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
67
+
68
+ return (core) => {
69
+ const {
70
+ runtime, onLoaded, onFinalized, useLogger,
71
+ registerRoute, requestReport, buildReport, watchFolder,
72
+ } = core
73
+
74
+ // Per-cycle recording is opt-in, and this plugin is a reader — see
75
+ // report.js requestReport. Asked for at registration, before any cycle
76
+ // has run, or the first build reports nothing to reload from.
77
+ requestReport()
78
+
79
+ const clients = new Set()
80
+
81
+ function broadcast(event, data) {
82
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
83
+ for (const res of clients) {
84
+ try { res.write(frame) } catch { clients.delete(res) }
85
+ }
86
+ }
87
+
88
+ // Resolve a request path the way express.static will, so the middleware
89
+ // injects into exactly the file that is about to be served.
90
+ function resolveFile(pathname) {
91
+ const root = runtime.options.outputFolder
92
+ if (!root) return null
93
+ const decoded = decodeURIComponent(pathname)
94
+ let file = path.join(root, decoded)
95
+ // Containment: a decoded `..` must not reach outside the folder
96
+ // being served. express.static guards its own reads; this one is
97
+ // ours and needs its own.
98
+ if (path.relative(root, file).startsWith('..')) return null
99
+ if (existsSync(file) && statSync(file).isDirectory()) file = path.join(file, 'index.html')
100
+ if (!existsSync(file)) return null
101
+ return INJECTABLE.has(path.extname(file).toLowerCase()) ? file : null
102
+ }
103
+
5
104
  onLoaded(async () => {
6
105
  const logger = useLogger()
7
- logger.info('Starting live server')
8
- liveServer.start({
9
- wait: 1000,
10
- root: runtime.options.outputFolder,
11
- open: false,
12
- ...options,
106
+ const app = runtime.options.app
107
+ if (!app) {
108
+ logger.warn({ code: 'live-no-server' },
109
+ 'live() needs the shared HTTP server and there is none — run `mikser --server`. Nothing is '
110
+ + 'mounted, so pages will not reload; the build itself is unaffected.')
111
+ return
112
+ }
113
+
114
+ // The event stream. `streaming` so a reverse-proxy generator knows
115
+ // never to buffer it — a buffered SSE stream delivers every event
116
+ // at once when the connection closes, which is indistinguishable
117
+ // from live reload not working.
118
+ registerRoute({
119
+ path: streamPath,
120
+ plugin: 'live',
121
+ reachability: 'public',
122
+ streaming: true,
123
+ label: 'Live reload',
124
+ detail: 'reload events for pages served from the output folder',
125
+ })
126
+
127
+ app.get(streamPath, (req, res) => {
128
+ res.writeHead(200, {
129
+ 'Content-Type': 'text/event-stream',
130
+ 'Cache-Control': 'no-cache, no-transform',
131
+ Connection: 'keep-alive',
132
+ // Nginx buffers proxied responses by default and that is
133
+ // fatal to an event stream; this is the header it honours.
134
+ 'X-Accel-Buffering': 'no',
135
+ })
136
+ res.write(`event: hello\ndata: ${JSON.stringify({ boot })}\n\n`)
137
+ clients.add(res)
138
+ req.on('close', () => clients.delete(res))
139
+ })
140
+
141
+ // Injection, ahead of the static mount.
142
+ //
143
+ // The engine registers its static handler from an inner onLoaded
144
+ // so it runs last, after every plugin has had its turn. That is
145
+ // what lets this sit in front of it and hand back an HTML document
146
+ // with the snippet in it; anything this does not answer falls
147
+ // through and is served normally.
148
+ app.use((req, res, next) => {
149
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next()
150
+ // No Origin means a top-level navigation. A request WITH one
151
+ // is fetch/XHR — script asking for the same HTML as data — and
152
+ // injecting there corrupts what the caller parses. Borrowed
153
+ // from alive-server, and it is the subtle half of doing this
154
+ // correctly.
155
+ if (req.headers.origin) return next()
156
+
157
+ const file = resolveFile(req.path)
158
+ if (!file) return next()
159
+
160
+ readFile(file, 'utf8').then((html) => {
161
+ const tag = injectionPoint(html)
162
+ if (!tag) {
163
+ // Said out loud, once per file. A page that silently
164
+ // never receives the snippet looks exactly like a page
165
+ // whose reload is broken, and the difference is a
166
+ // missing closing tag nobody would think to look for.
167
+ warnOnce(logger, file)
168
+ return next()
169
+ }
170
+ const body = injectInto(html, clientScript(streamPath))
171
+ res.type(path.extname(file) === '.svg' ? 'image/svg+xml' : 'html')
172
+ res.set('Cache-Control', 'no-store')
173
+ res.send(body)
174
+ }).catch(() => next())
175
+ })
176
+
177
+ logger.info('Live reload mounted: %s', streamPath)
178
+ })
179
+
180
+ // Served paths that changed, straight from the folder they are served
181
+ // from. Accumulated rather than sent per event: one build writes many
182
+ // files, and a reload per file is a browser that never settles.
183
+ const pending = new Set()
184
+ let flushTimer = null
185
+
186
+ function flush() {
187
+ clearTimeout(flushTimer)
188
+ flushTimer = null
189
+ if (!pending.size || !clients.size) return pending.clear()
190
+ const paths = [...pending]
191
+ pending.clear()
192
+ // The one thing the folder cannot tell us: whether the build that
193
+ // wrote it succeeded. A failed render leaves the previous good
194
+ // bytes in place, so the page would reload to look identical and
195
+ // the failure would pass unnoticed.
196
+ const errors = buildReport().summary?.errors ?? 0
197
+ broadcast('build', { errors, paths })
198
+ }
199
+
200
+ onLoaded(async () => {
201
+ const outputFolder = runtime.options.outputFolder
202
+ if (!runtime.options.app || !outputFolder) return
203
+
204
+ // The engine's own watcher primitive, not a second chokidar.
205
+ // It carries mikser's junk filter and — load-bearing here — it
206
+ // follows symlinks: the files plugin serves a file by linking it
207
+ // from the source folder into the output folder, so a watcher that
208
+ // stopped at the link would see it created once and never hear
209
+ // about a stylesheet edit again.
210
+ watchFolder(outputFolder, (_event, fullPath) => {
211
+ const rel = path.relative(outputFolder, fullPath)
212
+ if (!rel || rel.startsWith('..')) return
213
+ pending.add('/' + rel.split(path.sep).join('/'))
214
+ // Fallback only. A change that arrives outside a build —
215
+ // something else writing into the folder — still reaches the
216
+ // browser, just without a verdict to go with it.
217
+ clearTimeout(flushTimer)
218
+ flushTimer = setTimeout(flush, 250)
13
219
  })
14
220
  })
221
+
222
+ // The primary flush. One event per build, with an accurate error
223
+ // count, rather than whenever the debounce happens to expire.
224
+ onFinalized(async () => flush())
15
225
  }
16
226
  }
227
+
228
+ const warned = new Set()
229
+ function warnOnce(logger, file) {
230
+ if (warned.has(file)) return
231
+ warned.add(file)
232
+ logger?.warn?.({ code: 'live-no-inject-point' },
233
+ 'Could not inject the live-reload snippet into %s — it has no </body>, </svg> or </head>. That page '
234
+ + 'will not reload on its own; every other page still will.', file)
235
+ }
package/lib/client.js ADDED
@@ -0,0 +1,57 @@
1
+ // The snippet injected into every served HTML page.
2
+ //
3
+ // Deliberately dependency-free and tiny: it is added to every page in dev, so
4
+ // anything it drags in is something the developer is looking at that will not
5
+ // be there in production.
6
+ //
7
+ // EventSource rather than a WebSocket. It reconnects on its own, survives a
8
+ // mikser restart without any retry logic here, needs no upgrade handshake, and
9
+ // mikser already has a `streaming: true` route kind for exactly this.
10
+ export function clientScript(path) {
11
+ return `<script data-mikser-live>(() => {
12
+ const source = new EventSource(${JSON.stringify(path)})
13
+ let boot = null
14
+
15
+ // A different boot id means mikser restarted — new code, new config,
16
+ // possibly a different site. Reload rather than trusting a page rendered by
17
+ // the process that just went away.
18
+ source.addEventListener('hello', (event) => {
19
+ const id = JSON.parse(event.data).boot
20
+ if (boot !== null && boot !== id) return location.reload()
21
+ boot = id
22
+ })
23
+
24
+ source.addEventListener('build', (event) => {
25
+ const { paths = [], errors = 0 } = JSON.parse(event.data)
26
+
27
+ // A failed build leaves the previous good output on disk, so reloading
28
+ // shows the same page and hides the failure. Say so instead.
29
+ if (errors) return console.warn('[mikser] build failed —', errors, 'render error(s); page not reloaded')
30
+ if (!paths.length) return
31
+
32
+ // Stylesheets swap in place. Reloading would work and would also throw
33
+ // away scroll position, open menus, form state and whatever you were
34
+ // looking at — which for a CSS edit is the entire cost of the change.
35
+ const css = paths.filter((p) => p.endsWith('.css'))
36
+ if (css.length === paths.length) {
37
+ for (const link of document.querySelectorAll('link[rel=stylesheet]')) {
38
+ const url = new URL(link.href, location.href)
39
+ if (!css.includes(url.pathname)) continue
40
+ // set(), not append: repeated edits must not accumulate a query
41
+ // string, which is what makes the browser treat it as a new resource
42
+ // every time and never reuse the connection.
43
+ url.searchParams.set('mikser', Date.now())
44
+ link.href = url.href
45
+ }
46
+ return
47
+ }
48
+
49
+ // Anything else: reload, but only if this page is among what changed. A
50
+ // build that rerendered forty other pages is not a reason to interrupt
51
+ // the one being looked at.
52
+ const here = location.pathname
53
+ const asIndex = here.endsWith('/') ? here + 'index.html' : here
54
+ if (paths.includes(here) || paths.includes(asIndex)) location.reload()
55
+ })
56
+ })()</script>`
57
+ }
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "mikser-io-live",
3
- "version": "5.0.0",
4
- "description": "",
3
+ "version": "6.0.0",
4
+ "description": "Live reload for mikser-io, mounted on mikser's own HTTP server. Reloads from the build report rather than by watching files, so a CSS edit swaps the stylesheet in place and a build that touched other pages does not interrupt the one you are looking at.",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
- "scripts": {},
7
+ "scripts": {
8
+ "test": "node --test --test-reporter=spec 'test/**/*.test.js'"
9
+ },
8
10
  "repository": {
9
11
  "type": "git",
10
12
  "url": "git+https://github.com/almero-digital-marketing/mikser-io-live.git"
@@ -16,9 +18,6 @@
16
18
  },
17
19
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-live#readme",
18
20
  "peerDependencies": {
19
- "mikser-io": "^9.0.0"
20
- },
21
- "dependencies": {
22
- "alive-server": "^1.3.0"
21
+ "mikser-io": "^9.62.0"
23
22
  }
24
23
  }
@@ -0,0 +1,77 @@
1
+ // Where the live-reload snippet goes, and what the browser does with it.
2
+
3
+ import { describe, it } from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+
6
+ import { injectionPoint, injectInto } from '../index.js'
7
+ import { clientScript } from '../lib/client.js'
8
+
9
+ describe('finding somewhere to put the snippet', () => {
10
+ it('prefers the end of the body', () => {
11
+ assert.equal(injectionPoint('<html><head></head><body>hi</body></html>'), '</body>')
12
+ })
13
+
14
+ it('falls back to a closing svg', () => {
15
+ // A standalone SVG served as a page has no body at all, and is a thing
16
+ // mikser renders. Borrowed from alive-server, which is right about it.
17
+ assert.equal(injectionPoint('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'), '</svg>')
18
+ })
19
+
20
+ it('falls back to the head for a document with no closing body', () => {
21
+ assert.equal(injectionPoint('<html><head><title>t</title></head><p>loose'), '</head>')
22
+ })
23
+
24
+ it('reports nowhere rather than guessing', () => {
25
+ // A fragment with no closing tag gets no snippet, and the plugin warns
26
+ // — a page that silently never reloads looks exactly like a page whose
27
+ // reload is broken.
28
+ assert.equal(injectionPoint('<p>just a fragment</p>'), null)
29
+ })
30
+
31
+ it('matches case-insensitively, because HTML does', () => {
32
+ assert.equal(injectionPoint('<BODY>hi</BODY>'), '</BODY>')
33
+ })
34
+ })
35
+
36
+ describe('injecting', () => {
37
+ it('puts the script BEFORE the closing tag, not after', () => {
38
+ // Past </body> is outside the document; browsers move it back inside,
39
+ // so the bytes on the wire stop agreeing with the DOM.
40
+ const out = injectInto('<body>hi</body>', '<script>x</script>')
41
+ assert.match(out, /<script>x<\/script><\/body>/)
42
+ })
43
+
44
+ it('leaves a document it cannot inject into byte-identical', () => {
45
+ const html = '<p>fragment</p>'
46
+ assert.equal(injectInto(html, '<script>x</script>'), html)
47
+ })
48
+
49
+ it('injects once, at the first candidate only', () => {
50
+ const out = injectInto('<body>a</body><body>b</body>', '<script>x</script>')
51
+ assert.equal(out.split('<script>x</script>').length - 1, 1)
52
+ })
53
+ })
54
+
55
+ describe('the client snippet', () => {
56
+ it('connects to the path it was mounted at', () => {
57
+ assert.match(clientScript('/__custom'), /EventSource\("\/__custom"\)/)
58
+ })
59
+
60
+ it('is marked, so a reader can tell what put it there', () => {
61
+ assert.match(clientScript('/x'), /data-mikser-live/)
62
+ })
63
+
64
+ it('does not reload on a failed build', () => {
65
+ // The previous good output is still on disk, so a reload shows the
66
+ // same page and hides the failure.
67
+ assert.match(clientScript('/x'), /if \(errors\) return console\.warn/)
68
+ })
69
+
70
+ it('swaps stylesheets instead of reloading', () => {
71
+ assert.match(clientScript('/x'), /link\[rel=stylesheet\]/)
72
+ })
73
+
74
+ it('reloads when mikser restarts under it', () => {
75
+ assert.match(clientScript('/x'), /boot !== id/)
76
+ })
77
+ })