mikser-io-live 6.1.0 → 6.1.2
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 +3 -1
- package/index.js +21 -9
- package/lib/client.js +9 -3
- package/lib/paths.js +15 -0
- package/package.json +1 -1
- package/test/inject.test.js +61 -5
package/README.md
CHANGED
|
@@ -58,7 +58,9 @@ any of those.
|
|
|
58
58
|
|
|
59
59
|
## How the page gets the script
|
|
60
60
|
|
|
61
|
-
A small snippet is injected before `</body>`, falling back to `</
|
|
61
|
+
A small snippet is injected before `</body>`, falling back to `</head>` for a document with no closing body tag. A page with neither is warned about once, because a page that silently never reloads looks exactly like a reload that is broken.
|
|
62
|
+
|
|
63
|
+
**`.html` and `.htm` only.** Nothing else is touched — in particular not SVG. An SVG served as `image/svg+xml` is parsed as XML, where the snippet's `&&` starts an entity reference and `<` starts a tag, so injecting there is not a no-op but a fatal parse error: the browser draws nothing. [alive-server](https://www.npmjs.com/package/alive-server) does inject into SVG and gets away with it by wrapping its script in `<![CDATA[ ]]>`; this one does not, and excluding the format outright is the version that cannot be got subtly wrong later.
|
|
62
64
|
|
|
63
65
|
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.
|
|
64
66
|
|
package/index.js
CHANGED
|
@@ -27,15 +27,27 @@ import { existsSync, statSync } from 'node:fs'
|
|
|
27
27
|
import { readFile } from 'node:fs/promises'
|
|
28
28
|
|
|
29
29
|
import { clientScript } from './lib/client.js'
|
|
30
|
+
export { samePage } from './lib/paths.js'
|
|
30
31
|
|
|
31
|
-
// Where the snippet goes, in preference order
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
// closing body tag at all.
|
|
35
|
-
const INJECT_BEFORE = [/<\/body>/i, /<\/svg>/i, /<\/head>/i]
|
|
32
|
+
// Where the snippet goes, in preference order. `</head>` is the fallback for
|
|
33
|
+
// a document with no closing body tag.
|
|
34
|
+
const INJECT_BEFORE = [/<\/body>/i, /<\/head>/i]
|
|
36
35
|
|
|
37
|
-
//
|
|
38
|
-
|
|
36
|
+
// HTML only, and this is a correctness limit rather than a scope decision.
|
|
37
|
+
//
|
|
38
|
+
// `.svg` and `.xhtml` were here, taken from alive-server, which does inject
|
|
39
|
+
// into SVG — and gets away with it because its snippet is wrapped in
|
|
40
|
+
// `// <![CDATA[ ... // ]]>`. Mine is not, and an SVG served as image/svg+xml
|
|
41
|
+
// is parsed as XML: `&&` starts an entity reference and `<` starts a tag, so
|
|
42
|
+
// the script is not merely ignored, it is a FATAL parse error and the browser
|
|
43
|
+
// renders nothing at all.
|
|
44
|
+
//
|
|
45
|
+
// Wrapping in CDATA would make SVG work again. Not doing it: a standalone SVG
|
|
46
|
+
// page that live-reloads is a rare thing to want, breaking one outright is
|
|
47
|
+
// not, and a rule that reads "html and htm" cannot be got subtly wrong the way
|
|
48
|
+
// a rule that reads "html, and also XML dialects if the payload is escaped
|
|
49
|
+
// correctly" can.
|
|
50
|
+
const INJECTABLE = new Set(['.html', '.htm'])
|
|
39
51
|
|
|
40
52
|
// Where the snippet goes in a given document, or null if there is nowhere.
|
|
41
53
|
//
|
|
@@ -171,7 +183,7 @@ export function live(options = {}) {
|
|
|
171
183
|
return next()
|
|
172
184
|
}
|
|
173
185
|
const body = injectInto(html, clientScript(streamPath))
|
|
174
|
-
res.type(
|
|
186
|
+
res.type('html')
|
|
175
187
|
res.set('Cache-Control', 'no-store')
|
|
176
188
|
res.send(body)
|
|
177
189
|
}).catch(() => next())
|
|
@@ -233,6 +245,6 @@ function warnOnce(logger, file) {
|
|
|
233
245
|
if (warned.has(file)) return
|
|
234
246
|
warned.add(file)
|
|
235
247
|
logger?.warn?.({ code: 'live-no-inject-point' },
|
|
236
|
-
'Could not inject the live-reload snippet into %s — it has no </body
|
|
248
|
+
'Could not inject the live-reload snippet into %s — it has no </body> or </head>. That page '
|
|
237
249
|
+ 'will not reload on its own; every other page still will.', file)
|
|
238
250
|
}
|
package/lib/client.js
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
// EventSource rather than a WebSocket. It reconnects on its own, survives a
|
|
8
8
|
// mikser restart without any retry logic here, needs no upgrade handshake, and
|
|
9
9
|
// mikser already has a `streaming: true` route kind for exactly this.
|
|
10
|
+
import { samePage } from './paths.js'
|
|
11
|
+
|
|
10
12
|
export function clientScript(path) {
|
|
11
13
|
return `<script data-mikser-live>(() => {
|
|
14
|
+
const samePage = ${samePage.toString()}
|
|
12
15
|
const source = new EventSource(${JSON.stringify(path)})
|
|
13
16
|
let boot = null
|
|
14
17
|
|
|
@@ -49,9 +52,12 @@ export function clientScript(path) {
|
|
|
49
52
|
// Anything else: reload, but only if this page is among what changed. A
|
|
50
53
|
// build that rerendered forty other pages is not a reason to interrupt
|
|
51
54
|
// the one being looked at.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
//
|
|
56
|
+
// Compared after normalising both sides, because one page has three URLs.
|
|
57
|
+
// /about, /about/ and /about/index.html are all served, and the watcher
|
|
58
|
+
// only ever reports the third — so a plain comparison reloaded two of them
|
|
59
|
+
// and silently never reloaded the one people actually type or link to.
|
|
60
|
+
if (paths.some((p) => samePage(p, location.pathname))) location.reload()
|
|
55
61
|
})
|
|
56
62
|
})()</script>`
|
|
57
63
|
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Is a changed path the page a browser is looking at?
|
|
2
|
+
//
|
|
3
|
+
// One page has three URLs. /about, /about/ and /about/index.html are all
|
|
4
|
+
// served, and the watcher only ever reports the third — so comparing them
|
|
5
|
+
// literally reloads two of them and silently never reloads the one people
|
|
6
|
+
// actually type or link to.
|
|
7
|
+
//
|
|
8
|
+
// Lives here, alone, because it is needed on both sides: the browser decides
|
|
9
|
+
// with it, and the tests check it. The snippet inlines THIS function's source
|
|
10
|
+
// rather than carrying its own copy, so the two cannot drift into disagreeing
|
|
11
|
+
// about which page you are on.
|
|
12
|
+
export function samePage(a, b) {
|
|
13
|
+
const norm = (p) => String(p).replace(/\/index\.html$/, '/').replace(/(.)\/$/, '$1')
|
|
14
|
+
return norm(a) === norm(b)
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-live",
|
|
3
|
-
"version": "6.1.
|
|
3
|
+
"version": "6.1.2",
|
|
4
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",
|
package/test/inject.test.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { describe, it } from 'node:test'
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
5
5
|
|
|
6
|
-
import { injectionPoint, injectInto } from '../index.js'
|
|
6
|
+
import { injectionPoint, injectInto, samePage } from '../index.js'
|
|
7
7
|
import { clientScript } from '../lib/client.js'
|
|
8
8
|
|
|
9
9
|
describe('finding somewhere to put the snippet', () => {
|
|
@@ -11,10 +11,13 @@ describe('finding somewhere to put the snippet', () => {
|
|
|
11
11
|
assert.equal(injectionPoint('<html><head></head><body>hi</body></html>'), '</body>')
|
|
12
12
|
})
|
|
13
13
|
|
|
14
|
-
it('
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
14
|
+
it('does NOT treat a closing svg as an injection point', () => {
|
|
15
|
+
// An SVG served as image/svg+xml is parsed as XML, where the script's
|
|
16
|
+
// `&&` starts an entity reference and `<` starts a tag. The snippet is
|
|
17
|
+
// not ignored there — it is a fatal parse error and the browser draws
|
|
18
|
+
// nothing. alive-server injects into SVG and gets away with it because
|
|
19
|
+
// its snippet is CDATA-wrapped; this one is not.
|
|
20
|
+
assert.equal(injectionPoint('<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'), null)
|
|
18
21
|
})
|
|
19
22
|
|
|
20
23
|
it('falls back to the head for a document with no closing body', () => {
|
|
@@ -75,3 +78,56 @@ describe('the client snippet', () => {
|
|
|
75
78
|
assert.match(clientScript('/x'), /boot !== id/)
|
|
76
79
|
})
|
|
77
80
|
})
|
|
81
|
+
|
|
82
|
+
describe('what it will and will not touch', () => {
|
|
83
|
+
// The rule is html and htm. Anything XML-parsed is excluded outright
|
|
84
|
+
// rather than escaped into working, because a rule that says "HTML" cannot
|
|
85
|
+
// be got subtly wrong the way "HTML, plus XML dialects if the payload is
|
|
86
|
+
// escaped correctly" can.
|
|
87
|
+
it('leaves an svg document alone entirely', () => {
|
|
88
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
|
89
|
+
assert.equal(injectInto(svg, '<script>a && b</script>'), svg)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('keeps the payload that would break XML, so the exclusion is load-bearing', () => {
|
|
93
|
+
// If the snippet ever became XML-safe this test says so, and the
|
|
94
|
+
// exclusion could be revisited deliberately rather than by accident.
|
|
95
|
+
assert.match(clientScript('/live'), /&&/)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('deciding whether a change is THIS page', () => {
|
|
100
|
+
// One page, three URLs. The watcher reports the file it saw —
|
|
101
|
+
// /about/index.html — and a browser may be sitting on any of the three.
|
|
102
|
+
it('treats every spelling of a page as the same page', () => {
|
|
103
|
+
for (const here of ['/about', '/about/', '/about/index.html']) {
|
|
104
|
+
assert.equal(samePage(here, '/about/index.html'), true, here)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('treats the root the same way', () => {
|
|
109
|
+
for (const here of ['/', '/index.html']) {
|
|
110
|
+
assert.equal(samePage(here, '/index.html'), true, here)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('still says no to a different page', () => {
|
|
115
|
+
// Over-matching would reload every browser on every build, which is
|
|
116
|
+
// the behaviour this whole comparison exists to avoid.
|
|
117
|
+
assert.equal(samePage('/contact/', '/about/index.html'), false)
|
|
118
|
+
assert.equal(samePage('/', '/about/index.html'), false)
|
|
119
|
+
assert.equal(samePage('/aboutus/', '/about/index.html'), false)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('the browser and the tests agree', () => {
|
|
124
|
+
it('the snippet uses the same comparison, not a copy of it', () => {
|
|
125
|
+
// Two implementations of "is this my page" is the shape that produced
|
|
126
|
+
// the bug: the tests would keep passing while the browser used
|
|
127
|
+
// something subtly different. The snippet inlines this function's
|
|
128
|
+
// own source.
|
|
129
|
+
const script = clientScript('/live')
|
|
130
|
+
assert.ok(script.includes(samePage.toString()),
|
|
131
|
+
'the client must carry THIS function, not a second version of it')
|
|
132
|
+
})
|
|
133
|
+
})
|