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 +34 -13
- package/index.js +227 -8
- package/lib/client.js +57 -0
- package/package.json +6 -7
- package/test/inject.test.js +77 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# mikser-io-live
|
|
2
2
|
|
|
3
|
-
Live
|
|
3
|
+
Live reload for [mikser](https://github.com/almero-digital-marketing/mikser-io), mounted on mikser's own HTTP server.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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": "
|
|
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.
|
|
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
|
+
})
|