retake-dev 0.4.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/LICENSE +91 -0
- package/README.md +198 -0
- package/bin/retake.js +319 -0
- package/package.json +87 -0
- package/src/code-versions.js +357 -0
- package/src/core.js +265 -0
- package/src/plugin.js +137 -0
- package/src/runtime/00-core.js +308 -0
- package/src/runtime/10-animations.js +362 -0
- package/src/runtime/20-media.js +157 -0
- package/src/runtime/30-recorder.js +315 -0
- package/src/runtime/32-state.js +315 -0
- package/src/runtime/35-network.js +789 -0
- package/src/runtime/36-scripts.js +129 -0
- package/src/runtime/37-next.js +150 -0
- package/src/runtime/38-observers.js +252 -0
- package/src/runtime/40-input.js +795 -0
- package/src/runtime/45-hover.js +78 -0
- package/src/runtime/50-engine.js +341 -0
- package/src/runtime/60-preview.js +437 -0
- package/src/runtime/65-timeline.js +289 -0
- package/src/runtime/66-activity.js +114 -0
- package/src/runtime/67-csssource.js +226 -0
- package/src/runtime/70-boot.js +460 -0
- package/src/server/api.js +285 -0
- package/src/server/child.js +174 -0
- package/src/server/detect.js +167 -0
- package/src/server/front.js +647 -0
- package/src/server/mcp.js +332 -0
- package/src/shell/00-state.js +85 -0
- package/src/shell/05-api.js +136 -0
- package/src/shell/10-dock.js +754 -0
- package/src/shell/12-checkpoint.js +121 -0
- package/src/shell/15-session.js +291 -0
- package/src/shell/20-timeline.js +734 -0
- package/src/shell/25-input.js +537 -0
- package/src/shell/30-notes.js +870 -0
- package/src/shell/40-code.js +80 -0
- package/src/shell/90-handle.js +15 -0
- package/src/shell/shell.css +295 -0
- package/src/shell/shell.html +59 -0
- package/types/client.d.ts +73 -0
- package/types/index.d.ts +111 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// What every way of serving Retake shares: the runtime (as source, or as the
|
|
2
|
+
// script tag a server injects into a frame's page), the dock page, and an HTML
|
|
3
|
+
// stream transform that puts the runtime first in <head>. The Vite plugin, the
|
|
4
|
+
// front server (server/front.js) and the site's deployed build (apps/site) all use these.
|
|
5
|
+
import crypto from "node:crypto"
|
|
6
|
+
import fs from "node:fs"
|
|
7
|
+
import path from "node:path"
|
|
8
|
+
import zlib from "node:zlib"
|
|
9
|
+
import { Transform } from "node:stream"
|
|
10
|
+
import { fileURLToPath } from "node:url"
|
|
11
|
+
|
|
12
|
+
const SRC = path.dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
const read = (...p) => fs.readFileSync(path.join(SRC, ...p), "utf8")
|
|
14
|
+
|
|
15
|
+
// How the runtime is set up for the server it runs behind (`RT` in the
|
|
16
|
+
// runtime). The defaults are the Vite plugin's: a frame marked by `?__wb=app`.
|
|
17
|
+
// marker: "url" (the frame's URL carries `__wb=app`) or "header" (the front
|
|
18
|
+
// server knows the dock's frames by Sec-Fetch-Dest; no URL marker)
|
|
19
|
+
// bootAt: "dcl" (the clock starts at DOMContentLoaded) or "load" (F47)
|
|
20
|
+
// exemptUrls: the dev server's own traffic, as regular expressions (F54)
|
|
21
|
+
// holdScripts: scripts added later are held to their recorded moment (F48)
|
|
22
|
+
// next: the Next.js major version, for its debug channel (F49)
|
|
23
|
+
// docId, docStored: this page's kept copy on the front server, and whether
|
|
24
|
+
// this is it (F56)
|
|
25
|
+
// The front server's setup is frontRuntime() in server/detect.js.
|
|
26
|
+
export const RT_DEFAULTS = Object.freeze({ marker: "url", bootAt: "dcl", exemptUrls: [], holdScripts: false, next: null, docId: null, docStored: false })
|
|
27
|
+
|
|
28
|
+
// The files of a concatenated set ("runtime" or "shell"), in the order they
|
|
29
|
+
// run: later files may use earlier files' top-level bindings. (The type check,
|
|
30
|
+
// scripts/typecheck.mjs, concatenates them the same way.)
|
|
31
|
+
export const setFiles = (set) => fs.readdirSync(path.join(SRC, set)).filter((f) => f.endsWith(".js")).sort()
|
|
32
|
+
|
|
33
|
+
// Read on every page load, so edits to the tool apply on refresh.
|
|
34
|
+
/** @param {import("../types/index.js").RuntimeConfig} [rt] */
|
|
35
|
+
export function runtimeSource(rt = {}) {
|
|
36
|
+
const files = setFiles("runtime")
|
|
37
|
+
const body = files.map((f) => `// ---- ${f}\n${read("runtime", f)}`).join("\n")
|
|
38
|
+
const config = JSON.stringify({ ...RT_DEFAULTS, ...rt })
|
|
39
|
+
return `;(function () {\n"use strict";\nif (window.__retake) return;\nconst RT = Object.freeze(${config});\n${body}\n})();`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {import("../types/index.js").ShellOptions} [options] */
|
|
43
|
+
export function shellHtml(options = {}) {
|
|
44
|
+
const config = {
|
|
45
|
+
codeBranches: !!options.codeBranches,
|
|
46
|
+
features: ["continue", "segments", "serialize"],
|
|
47
|
+
...(options.appPage ? { appPage: options.appPage } : {}),
|
|
48
|
+
...(options.marker && options.marker !== "url" ? { marker: options.marker } : {}),
|
|
49
|
+
// Behind the front server: rebuilds ask for the page as it was recorded (F56).
|
|
50
|
+
...(options.docs ? { docs: true } : {}),
|
|
51
|
+
}
|
|
52
|
+
return read("shell", "shell.html")
|
|
53
|
+
.replace(
|
|
54
|
+
"/*CONFIG*/",
|
|
55
|
+
() => `window.__retakeConfig = ${JSON.stringify(config)};` + (options.token ? `window.__RETAKE_TOKEN = ${JSON.stringify(options.token)};` : ""),
|
|
56
|
+
)
|
|
57
|
+
.replace("/*CSS*/", () => read("shell", "shell.css"))
|
|
58
|
+
.replace("/*JS*/", () => {
|
|
59
|
+
const files = setFiles("shell")
|
|
60
|
+
return `;(function () {\n"use strict";\n${files.map((f) => read("shell", f)).join("\n")}\n})();`
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The script a server injects into a page it can't tell apart from the app's
|
|
65
|
+
// own frames (any iframe document). It takes itself out of the DOM first (so an
|
|
66
|
+
// app hydrating the whole document never sees it), then runs the runtime only
|
|
67
|
+
// in a frame the dock made (`isAppFrame`, or, with the URL marker, a URL with
|
|
68
|
+
// `__wb=app`). Anywhere else `__retake` is `{ inert: true }` and the runtime
|
|
69
|
+
// returns at its first line.
|
|
70
|
+
/** @param {import("../types/index.js").RuntimeConfig} [rt] */
|
|
71
|
+
export function runtimeScript(rt = {}) {
|
|
72
|
+
const marker = rt.marker || RT_DEFAULTS.marker
|
|
73
|
+
const guard =
|
|
74
|
+
`;(function(){try{var d=document.currentScript;d&&d.remove()}catch(e){}` +
|
|
75
|
+
`var ok=false;try{var s=window.parent!==window&&window.parent.__retakeShell;` +
|
|
76
|
+
`ok=!!s&&((typeof s.isAppFrame==="function"&&s.isAppFrame(window.frameElement))` +
|
|
77
|
+
(marker === "url" ? `||/[?&]__wb=app(&|$)/.test(location.search)` : "") +
|
|
78
|
+
`)}catch(e){}if(!ok)window.__retake={inert:true}})();`
|
|
79
|
+
return `${guard}\n${runtimeSource(rt)}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {import("../types/index.js").RuntimeTagOptions} [options] */
|
|
83
|
+
export function runtimeTag({ rt = {}, nonce = null, script = runtimeScript(rt) } = {}) {
|
|
84
|
+
return `<script data-retake${nonce ? ` nonce="${String(nonce).replace(/"/g, """)}"` : ""}>${script}</script>`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// For a CSP without 'unsafe-inline': the hash source that allows this script.
|
|
88
|
+
export const scriptHash = (script) => `'sha256-${crypto.createHash("sha256").update(script, "utf8").digest("base64")}'`
|
|
89
|
+
|
|
90
|
+
const HOLD_MAX = 64 * 1024
|
|
91
|
+
|
|
92
|
+
// Where the tag goes in the HTML seen so far, or -1 to wait for more. `s` is
|
|
93
|
+
// the bytes as latin1 (one char per byte), so indices are byte offsets and a
|
|
94
|
+
// multi-byte character is never split.
|
|
95
|
+
function insertAt(raw, final) {
|
|
96
|
+
// Comments are blanked out (same length) first: a `<head>` written inside one
|
|
97
|
+
// (before the real one) isn't the head. One still open at the end hides the rest.
|
|
98
|
+
const s = raw.replace(/<!--[\s\S]*?(?:-->|$)/g, (c) => " ".repeat(c.length))
|
|
99
|
+
const head = /<head(?=[\s>/])[^>]*>/i.exec(s)
|
|
100
|
+
if (head) {
|
|
101
|
+
let at = head.index + head[0].length
|
|
102
|
+
// A <meta charset> right after it stays first (it must be in the first 1024 bytes).
|
|
103
|
+
const rest = s.slice(at)
|
|
104
|
+
const meta = /<meta\b[^>]*\bcharset\s*=[^>]*>/i.exec(rest)
|
|
105
|
+
if (meta && !/<script\b/i.test(rest.slice(0, meta.index))) at += meta.index + meta[0].length
|
|
106
|
+
return at
|
|
107
|
+
}
|
|
108
|
+
// An opening <head that hasn't closed yet: wait for it.
|
|
109
|
+
if (!final && /<head(?:[\s/][^>]*)?$/i.test(s)) return -1
|
|
110
|
+
const first = s.search(/<(body|script|link|meta|style|title)\b/i)
|
|
111
|
+
if (first >= 0) return first
|
|
112
|
+
if (!final && s.length < HOLD_MAX) return -1
|
|
113
|
+
// Nothing to go by (the hold cap, or the page ended): after the doctype,
|
|
114
|
+
// not before it (that would put the page in quirks mode).
|
|
115
|
+
const doc = /^\s*(?:<!--[\s\S]*?-->\s*)*<!doctype[^>]*>/i.exec(raw)
|
|
116
|
+
return doc ? doc[0].length : 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const decompressor = (encoding) => {
|
|
120
|
+
const e = String(encoding || "").trim().toLowerCase()
|
|
121
|
+
if (!e || e === "identity") return null
|
|
122
|
+
if (e === "gzip" || e === "x-gzip") return zlib.createGunzip()
|
|
123
|
+
if (e === "br") return zlib.createBrotliDecompress()
|
|
124
|
+
if (e === "deflate") return zlib.createInflate()
|
|
125
|
+
if (e === "zstd" && zlib.createZstdDecompress) return zlib.createZstdDecompress()
|
|
126
|
+
throw new Error(`can't decode content-encoding ${encoding}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A byte stream transform that inserts `tag` into an HTML document as it
|
|
131
|
+
* streams: after the `<head …>` opening tag (and a `<meta charset>` right
|
|
132
|
+
* after it), else before the first <body>/<script>/<link>/<meta>. It holds at
|
|
133
|
+
* most 64 KB back; past that, or if the page ends first, the tag goes after
|
|
134
|
+
* the doctype. Everything after the insertion point passes straight through.
|
|
135
|
+
* @param {string} tag
|
|
136
|
+
* @param {{ encoding?: string }} [options] the body's content-encoding (gzip,
|
|
137
|
+
* br, deflate): it's decoded first, and the output is plain.
|
|
138
|
+
*/
|
|
139
|
+
export function injectHtml(tag, { encoding } = {}) {
|
|
140
|
+
const tagBytes = Buffer.from(tag, "utf8")
|
|
141
|
+
let held = []
|
|
142
|
+
let heldLen = 0
|
|
143
|
+
let done = false
|
|
144
|
+
const feed = (chunk, final) => {
|
|
145
|
+
if (done) return chunk
|
|
146
|
+
if (chunk && chunk.length) {
|
|
147
|
+
held.push(chunk)
|
|
148
|
+
heldLen += chunk.length
|
|
149
|
+
}
|
|
150
|
+
const buf = Buffer.concat(held, heldLen)
|
|
151
|
+
const at = insertAt(buf.toString("latin1"), final)
|
|
152
|
+
if (at < 0) {
|
|
153
|
+
held = [buf]
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
done = true
|
|
157
|
+
held = []
|
|
158
|
+
return Buffer.concat([buf.subarray(0, at), tagBytes, buf.subarray(at)])
|
|
159
|
+
}
|
|
160
|
+
const unzip = decompressor(encoding)
|
|
161
|
+
if (!unzip) {
|
|
162
|
+
return new Transform({
|
|
163
|
+
transform(chunk, _, cb) {
|
|
164
|
+
cb(null, feed(chunk, false) || undefined)
|
|
165
|
+
},
|
|
166
|
+
flush(cb) {
|
|
167
|
+
cb(null, feed(null, true) || undefined)
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
/** @type {import("node:stream").TransformCallback | null} */
|
|
172
|
+
let flushed = null
|
|
173
|
+
const t = new Transform({
|
|
174
|
+
transform(chunk, _, cb) {
|
|
175
|
+
unzip.write(chunk) ? cb() : unzip.once("drain", cb)
|
|
176
|
+
},
|
|
177
|
+
flush(cb) {
|
|
178
|
+
flushed = cb
|
|
179
|
+
unzip.end()
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
unzip.on("data", (d) => {
|
|
183
|
+
const out = feed(d, false)
|
|
184
|
+
if (out && out.length) t.push(out)
|
|
185
|
+
})
|
|
186
|
+
unzip.on("end", () => {
|
|
187
|
+
const out = feed(null, true)
|
|
188
|
+
if (out && out.length) t.push(out)
|
|
189
|
+
if (flushed) flushed()
|
|
190
|
+
})
|
|
191
|
+
unzip.on("error", (err) => t.destroy(err))
|
|
192
|
+
return t
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Runs an HTML response that a server writes in its own way (res.writeHead,
|
|
197
|
+
* write, end, or a stream piped into res) through injectHtml. Whether it's
|
|
198
|
+
* HTML is decided at the first write, from its Content-Type; anything else
|
|
199
|
+
* passes through untouched.
|
|
200
|
+
* @param {import("node:http").ServerResponse} res
|
|
201
|
+
* @param {string} tag
|
|
202
|
+
*/
|
|
203
|
+
export function injectResponse(res, tag) {
|
|
204
|
+
const write = res.write.bind(res)
|
|
205
|
+
const end = res.end.bind(res)
|
|
206
|
+
const writeHead = res.writeHead.bind(res)
|
|
207
|
+
/** @type {Transform | null} */
|
|
208
|
+
let t = null
|
|
209
|
+
let decided = false
|
|
210
|
+
const decide = () => {
|
|
211
|
+
if (decided) return
|
|
212
|
+
decided = true
|
|
213
|
+
if (!/^\s*text\/html\b/i.test(String(res.getHeader("content-type") || "")) || res.statusCode === 204 || res.statusCode === 304 || res.req?.method === "HEAD") return
|
|
214
|
+
const encoding = String(res.getHeader("content-encoding") || "")
|
|
215
|
+
for (const h of ["content-length", "content-encoding", "etag"]) res.removeHeader(h)
|
|
216
|
+
res.setHeader("cache-control", "no-store")
|
|
217
|
+
let out
|
|
218
|
+
try {
|
|
219
|
+
out = t = injectHtml(tag, { encoding })
|
|
220
|
+
} catch {
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
out.on("data", (c) => {
|
|
224
|
+
if (!write(c)) {
|
|
225
|
+
out.pause()
|
|
226
|
+
res.once("drain", () => out.resume())
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
out.on("end", () => end())
|
|
230
|
+
out.on("error", () => res.destroy())
|
|
231
|
+
}
|
|
232
|
+
res.writeHead = /** @type {any} */ (function (status, message, headers) {
|
|
233
|
+
if (typeof message !== "string") {
|
|
234
|
+
headers = message
|
|
235
|
+
message = undefined
|
|
236
|
+
}
|
|
237
|
+
// Headers given here are folded in first, so they can be changed.
|
|
238
|
+
if (Array.isArray(headers)) for (let i = 0; i < headers.length; i += 2) res.setHeader(headers[i], headers[i + 1])
|
|
239
|
+
else if (headers) for (const [k, v] of Object.entries(headers)) res.setHeader(k, v)
|
|
240
|
+
res.statusCode = status
|
|
241
|
+
if (message) res.statusMessage = message
|
|
242
|
+
decide()
|
|
243
|
+
return writeHead(res.statusCode)
|
|
244
|
+
})
|
|
245
|
+
const args = (chunk, encoding, cb) => {
|
|
246
|
+
if (typeof chunk === "function") return [null, null, chunk]
|
|
247
|
+
if (typeof encoding === "function") return [chunk, null, encoding]
|
|
248
|
+
return [chunk, encoding, cb]
|
|
249
|
+
}
|
|
250
|
+
res.write = /** @type {any} */ (function (chunk, encoding, cb) {
|
|
251
|
+
decide()
|
|
252
|
+
if (!t) return write(chunk, encoding, cb)
|
|
253
|
+
;[chunk, encoding, cb] = args(chunk, encoding, cb)
|
|
254
|
+
return t.write(typeof chunk === "string" ? Buffer.from(chunk, encoding || "utf8") : chunk, cb)
|
|
255
|
+
})
|
|
256
|
+
res.end = /** @type {any} */ (function (chunk, encoding, cb) {
|
|
257
|
+
decide()
|
|
258
|
+
if (!t) return end(chunk, encoding, cb)
|
|
259
|
+
;[chunk, encoding, cb] = args(chunk, encoding, cb)
|
|
260
|
+
if (chunk != null && chunk.length) t.write(typeof chunk === "string" ? Buffer.from(chunk, encoding || "utf8") : chunk)
|
|
261
|
+
if (cb) res.once("finish", cb)
|
|
262
|
+
t.end()
|
|
263
|
+
return res
|
|
264
|
+
})
|
|
265
|
+
}
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Vite plugin. A page request gets the dock: a small shell page with the
|
|
2
|
+
// timeline docked at the bottom (like DevTools) and the prototype in a frame
|
|
3
|
+
// above it. The frame's own request (`?__wb=app`) gets the real page with the
|
|
4
|
+
// time runtime injected as its first script, before any app code. Dev only.
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
6
|
+
import crypto from "node:crypto"
|
|
7
|
+
import fs from "node:fs"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import { codeVersions } from "./code-versions.js"
|
|
10
|
+
import { injectResponse, runtimeSource, runtimeTag, shellHtml } from "./core.js"
|
|
11
|
+
import { createBus, sessionApi } from "./server/api.js"
|
|
12
|
+
import { frontRuntime } from "./server/detect.js"
|
|
13
|
+
|
|
14
|
+
// The site's deployed build (apps/site/app/retake-dock, retake-runtime) and the tests import these from here.
|
|
15
|
+
export { runtimeSource, shellHtml, runtimeTag, injectHtml } from "./core.js"
|
|
16
|
+
|
|
17
|
+
const NESTED_DEST = new Set(["iframe", "frame", "embed", "object"])
|
|
18
|
+
const pageKind = new AsyncLocalStorage()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {import("../types/index.js").RetakeOptions} [options]
|
|
22
|
+
* `codeBranches`: each timeline keeps its own version of the code; stepping
|
|
23
|
+
* into a timeline checks its code out on disk (see code-versions.js).
|
|
24
|
+
* `root`: where .retake/ goes (default: Vite's root).
|
|
25
|
+
* Public types: types/index.d.ts (keep them in step).
|
|
26
|
+
* @returns {import("vite").Plugin[]}
|
|
27
|
+
*/
|
|
28
|
+
export function retake(options = {}) {
|
|
29
|
+
// Mutating /__retake/ requests must carry this (see CONTRACT.md).
|
|
30
|
+
options = { ...options, token: options.token || crypto.randomBytes(16).toString("hex") }
|
|
31
|
+
const bus = createBus()
|
|
32
|
+
let ssr = false
|
|
33
|
+
// The dock for a top-level page load, the runtime injected into the dock's
|
|
34
|
+
// frame (Sec-Fetch-Dest: iframe; isAppFrame() tells it from an app's own
|
|
35
|
+
// iframe), and anything else left alone. Which one a request is stays known
|
|
36
|
+
// while the framework renders it (pageKind), for frameworks that call
|
|
37
|
+
// server.transformIndexHtml themselves.
|
|
38
|
+
const ssrPages = (req, res, next) => {
|
|
39
|
+
const url = new URL(req.url || "/", "http://x")
|
|
40
|
+
const mode = req.headers["sec-fetch-mode"]
|
|
41
|
+
const dest = req.headers["sec-fetch-dest"]
|
|
42
|
+
const optOut = url.searchParams.get("retake") === "0"
|
|
43
|
+
if (url.pathname.startsWith("/__retake/") || mode !== "navigate" || optOut) return pageKind.run("plain", next)
|
|
44
|
+
if (req.method === "GET" && dest === "document" && req.headers["sec-fetch-site"] !== "cross-site") {
|
|
45
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
|
|
46
|
+
return res.end(shellHtml({ ...options, marker: "header" }))
|
|
47
|
+
}
|
|
48
|
+
if (dest !== "iframe" && dest !== "frame") return pageKind.run("plain", next)
|
|
49
|
+
delete req.headers["accept-encoding"]
|
|
50
|
+
// Set up like the front server's frames (clock at load, scripts held: see frontRuntime).
|
|
51
|
+
injectResponse(res, runtimeTag({ rt: { ...frontRuntime("vite"), marker: "header" } }))
|
|
52
|
+
pageKind.run("frame", next)
|
|
53
|
+
}
|
|
54
|
+
/** @type {import("vite").Plugin} */
|
|
55
|
+
const main = {
|
|
56
|
+
name: "retake",
|
|
57
|
+
apply: "serve",
|
|
58
|
+
// CSS source maps in dev, so cssSourceFor() can point at the original file.
|
|
59
|
+
config(cfg) {
|
|
60
|
+
if (options.enabled === false) return
|
|
61
|
+
if (!cfg.css || cfg.css.devSourcemap === undefined) return { css: { devSourcemap: true } }
|
|
62
|
+
},
|
|
63
|
+
configureServer(server) {
|
|
64
|
+
if (options.enabled === false) return
|
|
65
|
+
// No index.html: a framework renders the pages itself (React Router,
|
|
66
|
+
// SvelteKit, Astro, TanStack Start...), and transformIndexHtml never sees
|
|
67
|
+
// them. Then pages are told apart by their Sec-Fetch-* headers, like the
|
|
68
|
+
// front server does (header marker), and the frame's HTML is injected as
|
|
69
|
+
// it's written. The URL is never touched (a `__wb` would leak into the app).
|
|
70
|
+
ssr = !fs.existsSync(path.join(server.config.root, "index.html"))
|
|
71
|
+
if (ssr) server.middlewares.use(ssrPages)
|
|
72
|
+
// Only a top-level page load gets the dock. An iframe the app itself
|
|
73
|
+
// embeds (or a frame navigating to another page of a multi-page app)
|
|
74
|
+
// gets its plain page. The dock's own frame asks for `?__wb=app`.
|
|
75
|
+
else server.middlewares.use((req, res, next) => {
|
|
76
|
+
const dest = req.headers["sec-fetch-dest"]
|
|
77
|
+
if (dest && NESTED_DEST.has(dest) && req.url && !/[?&]__wb=/.test(req.url)) {
|
|
78
|
+
const add = (u) => u + (u.includes("?") ? "&" : "?") + "__wb=plain"
|
|
79
|
+
req.url = add(req.url)
|
|
80
|
+
if (req.originalUrl) req.originalUrl = add(req.originalUrl)
|
|
81
|
+
}
|
|
82
|
+
next()
|
|
83
|
+
})
|
|
84
|
+
sessionApi(server, { token: options.token, bus, root: options.root })
|
|
85
|
+
if (options.codeBranches) codeVersions(server, { token: options.token, bus })
|
|
86
|
+
const httpServer = server.httpServer
|
|
87
|
+
if (httpServer) {
|
|
88
|
+
httpServer.once("listening", () => {
|
|
89
|
+
const a = httpServer.address()
|
|
90
|
+
const port = a && typeof a === "object" ? a.port : server.config.server.port
|
|
91
|
+
const proto = server.config.server.https ? "https" : "http"
|
|
92
|
+
const base = server.config.base || "/"
|
|
93
|
+
if (options.banner !== false) console.log(`\n \x1b[1mRetake\x1b[0m timeline docked at ${proto}://localhost:${port}${base}${options.codeBranches ? " (code branches on)" : ""}${ssr ? " (server-rendered pages)" : ""}\n`)
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
// With code branches the dock decides when the frame reloads (it replays
|
|
98
|
+
// up to the current moment on the new code), so Vite's HMR stands down.
|
|
99
|
+
handleHotUpdate() {
|
|
100
|
+
if (options.codeBranches) return []
|
|
101
|
+
},
|
|
102
|
+
transformIndexHtml: {
|
|
103
|
+
order: "pre",
|
|
104
|
+
handler(html, ctx) {
|
|
105
|
+
if (options.enabled === false) return
|
|
106
|
+
// A server-rendered page (see ssrPages): the response is injected, if at all.
|
|
107
|
+
if (pageKind.getStore()) return
|
|
108
|
+
const params = new URL(ctx.originalUrl || ctx.path, "http://x").searchParams
|
|
109
|
+
// `?retake=0` opts a page load out entirely.
|
|
110
|
+
if (params.get("retake") === "0") return
|
|
111
|
+
if (params.get("__wb") === "plain") return
|
|
112
|
+
if (params.get("__wb") !== "app") return shellHtml(options)
|
|
113
|
+
return [{ tag: "script", attrs: { "data-retake": "" }, children: runtimeSource(), injectTo: "head-prepend" }]
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
// The dock page must not run Vite's client: a full reload (an edit HMR
|
|
118
|
+
// can't apply) would reload the whole dock and drop the session. Only the
|
|
119
|
+
// app frame keeps the client, so only the app reloads. Other plugins' module
|
|
120
|
+
// scripts go too (plugin-react's refresh preamble imports the client); the
|
|
121
|
+
// dock's own scripts are classic ones.
|
|
122
|
+
/** @type {import("vite").Plugin} */
|
|
123
|
+
const stripClient = {
|
|
124
|
+
name: "retake:shell-without-vite-client",
|
|
125
|
+
apply: "serve",
|
|
126
|
+
transformIndexHtml: {
|
|
127
|
+
order: "post",
|
|
128
|
+
handler(html) {
|
|
129
|
+
if (options.enabled === false || !html.includes('id="wb-dock"')) return
|
|
130
|
+
return html.replace(/<script\b[^>]*\btype=["']?module["']?[^>]*>[\s\S]*?<\/script>\s*/gi, "")
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
return [main, stripClient]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default retake
|