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 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 `</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.
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 — 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]
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
- // Only files that can carry a script tag.
38
- const INJECTABLE = new Set(['.html', '.htm', '.xhtml', '.svg'])
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(path.extname(file) === '.svg' ? 'image/svg+xml' : 'html')
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>, </svg> or </head>. That page '
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
- 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
+ // 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.0",
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",
@@ -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('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>')
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
+ })