retake-dev 0.4.0 → 0.5.1
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/CHANGELOG.md +125 -0
- package/README.md +173 -10
- package/bin/retake.js +14 -1
- package/package.json +7 -1
- package/src/core.js +23 -1
- package/src/mark.svg +1 -0
- package/src/next-dev.js +183 -0
- package/src/next.js +81 -0
- package/src/note-text.js +844 -0
- package/src/plugin.js +7 -3
- package/src/runtime/10-animations.js +9 -0
- package/src/runtime/60-preview.js +3 -1
- package/src/runtime/62-motion.js +106 -0
- package/src/runtime/65-timeline.js +39 -0
- package/src/runtime/67-csssource.js +73 -1
- package/src/runtime/70-boot.js +28 -2
- package/src/server/api.js +73 -1
- package/src/server/front.js +3 -1
- package/src/server/mcp.js +225 -19
- package/src/server/moments.js +317 -0
- package/src/server/sourcemap.js +192 -0
- package/src/shell/00-state.js +7 -0
- package/src/shell/10-dock.js +208 -13
- package/src/shell/15-session.js +66 -10
- package/src/shell/20-timeline.js +47 -16
- package/src/shell/25-input.js +29 -2
- package/src/shell/30-notes.js +709 -113
- package/src/shell/32-anims.js +1353 -0
- package/src/shell/shell.css +79 -8
- package/src/shell/shell.html +10 -3
- package/types/client.d.ts +16 -0
- package/types/index.d.ts +16 -0
- package/types/next.d.ts +50 -0
package/src/plugin.js
CHANGED
|
@@ -12,7 +12,7 @@ import { createBus, sessionApi } from "./server/api.js"
|
|
|
12
12
|
import { frontRuntime } from "./server/detect.js"
|
|
13
13
|
|
|
14
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"
|
|
15
|
+
export { runtimeSource, shellHtml, runtimeTag, injectHtml, markSvg } from "./core.js"
|
|
16
16
|
|
|
17
17
|
const NESTED_DEST = new Set(["iframe", "frame", "embed", "object"])
|
|
18
18
|
const pageKind = new AsyncLocalStorage()
|
|
@@ -30,6 +30,8 @@ export function retake(options = {}) {
|
|
|
30
30
|
options = { ...options, token: options.token || crypto.randomBytes(16).toString("hex") }
|
|
31
31
|
const bus = createBus()
|
|
32
32
|
let ssr = false
|
|
33
|
+
/** @type {string | undefined} */
|
|
34
|
+
let viteRoot
|
|
33
35
|
// The dock for a top-level page load, the runtime injected into the dock's
|
|
34
36
|
// frame (Sec-Fetch-Dest: iframe; isAppFrame() tells it from an app's own
|
|
35
37
|
// iframe), and anything else left alone. Which one a request is stays known
|
|
@@ -43,7 +45,7 @@ export function retake(options = {}) {
|
|
|
43
45
|
if (url.pathname.startsWith("/__retake/") || mode !== "navigate" || optOut) return pageKind.run("plain", next)
|
|
44
46
|
if (req.method === "GET" && dest === "document" && req.headers["sec-fetch-site"] !== "cross-site") {
|
|
45
47
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
|
|
46
|
-
return res.end(shellHtml({ ...options, marker: "header" }))
|
|
48
|
+
return res.end(shellHtml({ root: viteRoot, ...options, marker: "header" }))
|
|
47
49
|
}
|
|
48
50
|
if (dest !== "iframe" && dest !== "frame") return pageKind.run("plain", next)
|
|
49
51
|
delete req.headers["accept-encoding"]
|
|
@@ -62,6 +64,8 @@ export function retake(options = {}) {
|
|
|
62
64
|
},
|
|
63
65
|
configureServer(server) {
|
|
64
66
|
if (options.enabled === false) return
|
|
67
|
+
// Source paths in notes are given relative to the project.
|
|
68
|
+
viteRoot = options.root || server.config.root
|
|
65
69
|
// No index.html: a framework renders the pages itself (React Router,
|
|
66
70
|
// SvelteKit, Astro, TanStack Start...), and transformIndexHtml never sees
|
|
67
71
|
// them. Then pages are told apart by their Sec-Fetch-* headers, like the
|
|
@@ -109,7 +113,7 @@ export function retake(options = {}) {
|
|
|
109
113
|
// `?retake=0` opts a page load out entirely.
|
|
110
114
|
if (params.get("retake") === "0") return
|
|
111
115
|
if (params.get("__wb") === "plain") return
|
|
112
|
-
if (params.get("__wb") !== "app") return shellHtml(options)
|
|
116
|
+
if (params.get("__wb") !== "app") return shellHtml({ root: viteRoot, ...options })
|
|
113
117
|
return [{ tag: "script", attrs: { "data-retake": "" }, children: runtimeSource(), injectTo: "head-prepend" }]
|
|
114
118
|
},
|
|
115
119
|
},
|
|
@@ -186,8 +186,17 @@ function reclaimNative() {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// Where the app (or the library it uses) called element.animate(): the stack,
|
|
190
|
+
// kept per animation so its clip can say where it is defined (65-timeline.js).
|
|
191
|
+
const madeAt = new WeakMap()
|
|
189
192
|
Element.prototype.animate = function (...args) {
|
|
190
193
|
const a = orig.animate.apply(this, args)
|
|
194
|
+
try {
|
|
195
|
+
const limit = Error.stackTraceLimit
|
|
196
|
+
Error.stackTraceLimit = 30
|
|
197
|
+
madeAt.set(a, new Error().stack)
|
|
198
|
+
Error.stackTraceLimit = limit
|
|
199
|
+
} catch {}
|
|
191
200
|
adopt(a, true)
|
|
192
201
|
return a
|
|
193
202
|
}
|
|
@@ -72,7 +72,9 @@ function logMutations(records) {
|
|
|
72
72
|
if (r.type === "attributes" && r.attributeName === HOVER_ATTR) continue // our :hover stand-in
|
|
73
73
|
if (r.type === "attributes") {
|
|
74
74
|
const next = later((x) => x.type === "attributes" && x.target === r.target && x.attributeName === r.attributeName)
|
|
75
|
-
|
|
75
|
+
const now = next ? next.oldValue : r.target.getAttribute(r.attributeName)
|
|
76
|
+
domLog.push({ t, kind: "attr", node: r.target, name: r.attributeName, old: r.oldValue, now })
|
|
77
|
+
if (r.attributeName === "style") noteStyleWrite(r.target, t, r.oldValue, now) // script-driven motion (62-motion.js)
|
|
76
78
|
} else if (r.type === "characterData") {
|
|
77
79
|
const next = later((x) => x.type === "characterData" && x.target === r.target)
|
|
78
80
|
domLog.push({ t, kind: "text", node: r.target, old: r.oldValue, now: next ? next.oldValue : r.target.data })
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Script-driven motion: an element whose inline style a script rewrites frame
|
|
2
|
+
// after frame (GSAP, Motion's x/y/scale, a parallax handler). It never shows
|
|
3
|
+
// up as an Animation, so it's read off the DOM log (logMutations hands every
|
|
4
|
+
// style attribute change here): writes on one element less than MOTION_GAP
|
|
5
|
+
// apart are one clip, kind "js", with the properties that changed and their
|
|
6
|
+
// first and last inline values; once it is a clip, a still stretch of up to
|
|
7
|
+
// MOTION_HOLD is part of it (clip.holds: [from, to] pairs). A clip is recorded from its third write (one
|
|
8
|
+
// style change is a state, not motion). Where it's written comes from one
|
|
9
|
+
// stack per element, taken at its first write through the style setters.
|
|
10
|
+
|
|
11
|
+
const MOTION_GAP = 120
|
|
12
|
+
const MOTION_HOLD = 1000
|
|
13
|
+
const MOTION_MIN_WRITES = 3
|
|
14
|
+
const MOTION_PROPS = /^(transform|translate|scale|rotate|opacity|top|left|right|bottom|width|height|filter|clip-path)$/
|
|
15
|
+
const motionRuns = new WeakMap() // element -> { c, last, writes, from, to }
|
|
16
|
+
const styleStack = new WeakMap() // CSSStyleDeclaration -> stack at its first write
|
|
17
|
+
|
|
18
|
+
function parseInline(s) {
|
|
19
|
+
const out = {}
|
|
20
|
+
for (const part of String(s || "").split(";")) {
|
|
21
|
+
const i = part.indexOf(":")
|
|
22
|
+
if (i > 0) out[part.slice(0, i).trim().toLowerCase()] = part.slice(i + 1).trim()
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The properties that moved (a reset to none that was never set isn't motion).
|
|
28
|
+
const movedProps = (run) => Object.keys(run.from).filter((p) => !((run.from[p] === "" || run.from[p] === "none") && (run.to[p] === "" || run.to[p] === "none")))
|
|
29
|
+
|
|
30
|
+
function noteStyleWrite(el, t, old, now) {
|
|
31
|
+
if (!rec || previewing || hasFuture() || clock.seeking || !el || el.nodeType !== 1) return
|
|
32
|
+
const a = parseInline(old)
|
|
33
|
+
const b = parseInline(now)
|
|
34
|
+
const changed = [...new Set([...Object.keys(a), ...Object.keys(b)])].filter((p) => a[p] !== b[p] && MOTION_PROPS.test(p))
|
|
35
|
+
if (!changed.length) return
|
|
36
|
+
let run = motionRuns.get(el)
|
|
37
|
+
// A pause of up to MOTION_HOLD inside a clip is a hold of the same motion
|
|
38
|
+
// (Motion keyframes with a plateau write nothing while the value stays).
|
|
39
|
+
if (run && run.c && t - run.last > MOTION_GAP && t - run.last <= MOTION_HOLD) (run.c.holds || (run.c.holds = [])).push([run.last, t])
|
|
40
|
+
else if (!run || t - run.last > MOTION_GAP) {
|
|
41
|
+
run = { c: null, start: t, last: t, writes: 0, from: {}, to: {}, first: b }
|
|
42
|
+
motionRuns.set(el, run)
|
|
43
|
+
}
|
|
44
|
+
run.last = t
|
|
45
|
+
run.writes++
|
|
46
|
+
for (const p of changed) {
|
|
47
|
+
if (!(p in run.from)) run.from[p] = a[p] || ""
|
|
48
|
+
run.to[p] = b[p] || ""
|
|
49
|
+
}
|
|
50
|
+
if (!run.c && run.writes >= MOTION_MIN_WRITES) {
|
|
51
|
+
const clips = rec.clips || (rec.clips = [])
|
|
52
|
+
const props = movedProps(run)
|
|
53
|
+
if (!props.length) return
|
|
54
|
+
const pick = (o) => Object.fromEntries(props.map((p) => [p, o[p]]))
|
|
55
|
+
/** @type {Record<string, any>} */
|
|
56
|
+
const c = { id: `c${clips.length + 1}`, kind: "js", start: run.start, end: t, label: props.join(", "), selector: selectorOf(el), component: componentOf(el) || undefined, property: props.join(", "), path: pathOf(el), from: pick(run.from), to: pick(run.to), first: pick(run.first) }
|
|
57
|
+
if (c.component === undefined) delete c.component
|
|
58
|
+
const frames = stackFrames(styleStack.get(el.style))
|
|
59
|
+
if (frames.length) c.stack = frames
|
|
60
|
+
clips.push(c)
|
|
61
|
+
run.c = c
|
|
62
|
+
} else if (run.c) {
|
|
63
|
+
const props = movedProps(run)
|
|
64
|
+
run.c.end = t
|
|
65
|
+
run.c.label = run.c.property = props.join(", ")
|
|
66
|
+
run.c.from = Object.fromEntries(props.map((p) => [p, run.from[p]]))
|
|
67
|
+
run.c.to = Object.fromEntries(props.map((p) => [p, run.to[p]]))
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One stack per element: the first write to one of these through its style.
|
|
72
|
+
;(function wrapStyleSetters() {
|
|
73
|
+
const SP = typeof CSSStyleDeclaration !== "undefined" ? CSSStyleDeclaration.prototype : null
|
|
74
|
+
if (!SP) return
|
|
75
|
+
// The property accessors live on the style object's own interface (CSSStyleProperties in newer browsers).
|
|
76
|
+
const owner = (p) => {
|
|
77
|
+
for (let o = Object.getPrototypeOf(document.documentElement.style); o; o = Object.getPrototypeOf(o)) if (Object.prototype.hasOwnProperty.call(o, p)) return o
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
const keep = (decl) => {
|
|
81
|
+
if (styleStack.has(decl)) return
|
|
82
|
+
try {
|
|
83
|
+
const limit = Error.stackTraceLimit
|
|
84
|
+
Error.stackTraceLimit = 30
|
|
85
|
+
styleStack.set(decl, new Error().stack)
|
|
86
|
+
Error.stackTraceLimit = limit
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
for (const p of ["transform", "translate", "scale", "rotate", "opacity", "top", "left", "width", "height"]) {
|
|
90
|
+
const proto = owner(p)
|
|
91
|
+
const d = proto && Object.getOwnPropertyDescriptor(proto, p)
|
|
92
|
+
if (!d || !d.set || !d.configurable) continue
|
|
93
|
+
Object.defineProperty(proto, p, {
|
|
94
|
+
...d,
|
|
95
|
+
set(v) {
|
|
96
|
+
keep(this)
|
|
97
|
+
d.set.call(this, v)
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
const setProperty = SP.setProperty
|
|
102
|
+
SP.setProperty = function (name, ...rest) {
|
|
103
|
+
if (MOTION_PROPS.test(String(name))) keep(this)
|
|
104
|
+
return setProperty.call(this, name, ...rest)
|
|
105
|
+
}
|
|
106
|
+
})()
|
|
@@ -154,6 +154,30 @@ function recordedStart(a) {
|
|
|
154
154
|
return best.start
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
const KF_SKIP = ["offset", "easing", "composite", "computedOffset"]
|
|
158
|
+
function keyframeValues(k) {
|
|
159
|
+
const out = {}
|
|
160
|
+
let n = 0
|
|
161
|
+
for (const p of Object.keys(k)) {
|
|
162
|
+
if (KF_SKIP.includes(p) || n >= 4 || k[p] == null) continue
|
|
163
|
+
const v = String(k[p])
|
|
164
|
+
if (v.length <= 80) (out[p] = v), n++
|
|
165
|
+
}
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
// The frames of an element.animate() stack that aren't Retake's own (an
|
|
169
|
+
// inline script in this document): [{ url, line, col }], innermost first.
|
|
170
|
+
function stackFrames(stack) {
|
|
171
|
+
const here = location.href.replace(/#.*$/, "")
|
|
172
|
+
const out = []
|
|
173
|
+
for (const line of String(stack || "").split("\n").slice(1)) {
|
|
174
|
+
const m = /\(?((?:https?|file|webpack-internal):\/\/[^\s()]+?):(\d+):(\d+)\)?\s*$/.exec(line)
|
|
175
|
+
if (!m || m[1] === here) continue
|
|
176
|
+
out.push({ url: m[1], line: Number(m[2]), col: Number(m[3]) })
|
|
177
|
+
if (out.length >= 12) break
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
|
157
181
|
function recordClip(e) {
|
|
158
182
|
if (!rec || hasFuture() || clock.seeking) return // replaying: rec.clips already has it
|
|
159
183
|
const clips = rec.clips || (rec.clips = [])
|
|
@@ -162,8 +186,23 @@ function recordClip(e) {
|
|
|
162
186
|
const iters = e.timing && e.timing.iterations
|
|
163
187
|
if (iters === Infinity) c.iterations = "infinite"
|
|
164
188
|
else if (iters > 1) c.iterations = iters
|
|
189
|
+
const timing = e.timing || {}
|
|
190
|
+
if ((iters ?? 1) !== 1 && Number(timing.duration) > 0) c.dur = Math.round(Number(timing.duration))
|
|
191
|
+
if (Number(timing.delay) > 0) c.delay = Math.round(Number(timing.delay))
|
|
192
|
+
// What it animates between (first and last keyframe), so a reader of the
|
|
193
|
+
// recording knows what was visible: { opacity: "0" } → { opacity: "1" }.
|
|
194
|
+
const kf = e.keyframes || []
|
|
195
|
+
if (kf.length >= 2) {
|
|
196
|
+
c.from = keyframeValues(kf[0])
|
|
197
|
+
c.to = keyframeValues(kf[kf.length - 1])
|
|
198
|
+
}
|
|
165
199
|
if (c.property === undefined) delete c.property
|
|
166
200
|
if (c.component === undefined) delete c.component
|
|
201
|
+
const stack = madeAt.get(e.anim)
|
|
202
|
+
if (stack) {
|
|
203
|
+
const frames = stackFrames(stack)
|
|
204
|
+
if (frames.length) c.stack = frames
|
|
205
|
+
}
|
|
167
206
|
clips.push(c)
|
|
168
207
|
clipOfEntry.set(e, c)
|
|
169
208
|
}
|
|
@@ -163,6 +163,73 @@ function locate(hit) {
|
|
|
163
163
|
return out
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
// A rule in a <link rel=stylesheet> (Next, Nuxt, SvelteKit...): its text and
|
|
167
|
+
// source map fetched once per sheet (index maps with sections too, as
|
|
168
|
+
// Turbopack writes them), then the same lookup as locate().
|
|
169
|
+
const linkedCache = new Map() // href -> Promise<{ text, lines, sources, offsets } | null>
|
|
170
|
+
function linkedSheet(href) {
|
|
171
|
+
if (linkedCache.has(href)) return linkedCache.get(href)
|
|
172
|
+
// As a stylesheet: Vite answers a bare fetch of a .css URL with a JS module
|
|
173
|
+
// that wraps the CSS in a string (every rule on one line).
|
|
174
|
+
const p = real
|
|
175
|
+
.fetch(href, { headers: { accept: "text/css,*/*;q=0.1" } })
|
|
176
|
+
.then((r) => (r.ok ? r.text() : null))
|
|
177
|
+
.then(async (text) => {
|
|
178
|
+
if (text == null) return null
|
|
179
|
+
const out = { text, sections: [] }
|
|
180
|
+
const m = /\/\*# sourceMappingURL=([^\s*]+)\s*\*\//.exec(text)
|
|
181
|
+
if (!m) return out
|
|
182
|
+
let map = null
|
|
183
|
+
try {
|
|
184
|
+
if (m[1].startsWith("data:")) map = JSON.parse(new TextDecoder().decode(fromB64(m[1].split(",").pop())))
|
|
185
|
+
else {
|
|
186
|
+
const r = await real.fetch(new URL(m[1], href).href)
|
|
187
|
+
map = r.ok ? await r.json() : null
|
|
188
|
+
}
|
|
189
|
+
} catch {}
|
|
190
|
+
if (!map) return out
|
|
191
|
+
const parts = Array.isArray(map.sections) ? map.sections.map((s) => ({ line: (s.offset && s.offset.line) || 0, map: s.map })) : [{ line: 0, map }]
|
|
192
|
+
for (const part of parts) if (part.map && part.map.mappings != null) out.sections.push({ line: part.line, sources: part.map.sources || [], lines: decodeMappings(part.map.mappings) })
|
|
193
|
+
return out
|
|
194
|
+
})
|
|
195
|
+
.catch(() => null)
|
|
196
|
+
linkedCache.set(href, p)
|
|
197
|
+
return p
|
|
198
|
+
}
|
|
199
|
+
async function locateLinked(hit, out) {
|
|
200
|
+
const sheet = await linkedSheet(hit.sheet.href)
|
|
201
|
+
if (!sheet) return out
|
|
202
|
+
const head = hit.rule instanceof CSSKeyframesRule ? `@keyframes ${hit.rule.name}` : hit.rule.selectorText
|
|
203
|
+
let idx = sheet.text.indexOf(head)
|
|
204
|
+
if (idx < 0) idx = sheet.text.indexOf(head.split(",")[0].trim())
|
|
205
|
+
if (idx < 0) return out
|
|
206
|
+
const before = sheet.text.slice(0, idx)
|
|
207
|
+
const genLine = before.split("\n").length - 1
|
|
208
|
+
const genCol = idx - before.lastIndexOf("\n") - 1
|
|
209
|
+
// Turbopack names each part: /* [project]/app/globals.css [app-client] (css) */
|
|
210
|
+
const named = [...before.matchAll(/\/\* \[project\]\/([^\s*]+)[^*]*\*\//g)].pop()
|
|
211
|
+
if (named) out.file = named[1]
|
|
212
|
+
// No source map and not a bundle: the served file is the source (plain CSS from a dev server).
|
|
213
|
+
else if (!sheet.sections.length) out.line = genLine + 1
|
|
214
|
+
let sec = null
|
|
215
|
+
for (const s of sheet.sections) if (s.line <= genLine) sec = s
|
|
216
|
+
const segs = sec && sec.lines[genLine - sec.line]
|
|
217
|
+
if (!segs || !segs.length) return out
|
|
218
|
+
let seg = null
|
|
219
|
+
for (const sg of segs) if (sg[0] <= genCol) seg = sg
|
|
220
|
+
if (!seg) seg = segs[0]
|
|
221
|
+
out.line = seg[2] + 1
|
|
222
|
+
const srcName = sec.sources[seg[1]]
|
|
223
|
+
if (srcName) {
|
|
224
|
+
try {
|
|
225
|
+
out.file = srcName.startsWith("file://") ? decodeURIComponent(new URL(srcName).pathname) : srcName.replace(/^turbopack:\/\/\/?(\[project\]\/)?/, "").replace(/^webpack:\/\/[^/]*\//, "")
|
|
226
|
+
} catch {
|
|
227
|
+
out.file = srcName
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return out
|
|
231
|
+
}
|
|
232
|
+
|
|
166
233
|
// A Tailwind utility (e.g. .animate-pulse) from Tailwind's generated CSS?
|
|
167
234
|
function tailwindUtility(hit) {
|
|
168
235
|
const sel = hit.selector.replace(/\\/g, "")
|
|
@@ -213,9 +280,14 @@ async function cssSourceFor(x) {
|
|
|
213
280
|
}
|
|
214
281
|
if (!hit) return null
|
|
215
282
|
const out = locate(hit)
|
|
283
|
+
if (out.line == null && hit.sheet.href) await locateLinked(hit, out)
|
|
216
284
|
if (t.kind === "css-animation" && t.name) {
|
|
217
285
|
const kf = findKeyframes(t.name)
|
|
218
|
-
if (kf)
|
|
286
|
+
if (kf) {
|
|
287
|
+
const k = locate(kf)
|
|
288
|
+
if (k.line == null && kf.sheet.href) await locateLinked(kf, k)
|
|
289
|
+
out.keyframes = { name: t.name, ...k }
|
|
290
|
+
}
|
|
219
291
|
}
|
|
220
292
|
const utility = tailwindUtility(hit)
|
|
221
293
|
if (utility) {
|
package/src/runtime/70-boot.js
CHANGED
|
@@ -112,7 +112,7 @@ function record() {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
Object.assign(PT, {
|
|
115
|
-
version: "0.
|
|
115
|
+
version: "0.5.1",
|
|
116
116
|
now: () => clock.now,
|
|
117
117
|
record,
|
|
118
118
|
// Play from here: replays the recorded future, then carries on live.
|
|
@@ -363,6 +363,25 @@ try {
|
|
|
363
363
|
}
|
|
364
364
|
} catch {}
|
|
365
365
|
|
|
366
|
+
// A link or redirect to another site (GitHub, npm, a sign-in page) can't load
|
|
367
|
+
// inside the dock's frame: most sites refuse to be framed. A click the user
|
|
368
|
+
// makes opens it in a new tab, so the timeline stays; anything else (a
|
|
369
|
+
// redirect) goes to the top window, as it would without Retake. Frames being
|
|
370
|
+
// built or replayed never leave.
|
|
371
|
+
try {
|
|
372
|
+
if (W.navigation && shell) {
|
|
373
|
+
W.navigation.addEventListener("navigate", (e) => {
|
|
374
|
+
if (!e.cancelable || e.downloadRequest || !e.destination) return
|
|
375
|
+
const url = new URL(e.destination.url)
|
|
376
|
+
if (url.origin === location.origin || !/^https?:$/.test(url.protocol)) return
|
|
377
|
+
e.preventDefault()
|
|
378
|
+
if (clock.seeking || !onShow()) return
|
|
379
|
+
if (e.userInitiated) W.open(url.href, "_blank", "noopener")
|
|
380
|
+
else W.top.location.assign(url.href)
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
} catch {}
|
|
384
|
+
|
|
366
385
|
// A reload (or navigation) fires beforeunload first; a frame the dock removes
|
|
367
386
|
// doesn't. Only a reload leaves its recording on the shell to resume.
|
|
368
387
|
let unloading = false
|
|
@@ -383,7 +402,14 @@ function boot() {
|
|
|
383
402
|
findSmil(document)
|
|
384
403
|
syncSmil(clock.now, null, segmentAt(clock.now).t)
|
|
385
404
|
if (shell) shell.attach(PT)
|
|
386
|
-
|
|
405
|
+
// This window losing focus lets go of ⌘ (a ⌘-shortcut took it away), but
|
|
406
|
+
// only the frame on show's: one built behind, or one being swapped out,
|
|
407
|
+
// blurs on its own while ⌘ is still held (F99).
|
|
408
|
+
if (shell)
|
|
409
|
+
W.addEventListener("blur", () => {
|
|
410
|
+
if (typeof shell.shows === "function" && !shell.shows(W)) return
|
|
411
|
+
if (shell.meta) shell.meta(false)
|
|
412
|
+
})
|
|
387
413
|
// Let the first render settle on real frames before time starts moving.
|
|
388
414
|
real.raf(() =>
|
|
389
415
|
real.raf(async () => {
|
package/src/server/api.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// GET /__retake/notes/:id
|
|
9
9
|
// PATCH /__retake/notes/:id { status?, reply? }
|
|
10
10
|
// GET /__retake/events SSE: note-updated, code-version, active-changed, session
|
|
11
|
+
// GET /__retake/map?url=&line=&col= a compiled chunk on disk (a server component's) → { file, line }
|
|
11
12
|
//
|
|
12
13
|
// Mutating requests need `x-retake-token`. The token is injected into the
|
|
13
14
|
// shell page and written to <root>/.retake/server.json for local tools (MCP).
|
|
@@ -15,6 +16,8 @@ import fs from "node:fs"
|
|
|
15
16
|
import path from "node:path"
|
|
16
17
|
import zlib from "node:zlib"
|
|
17
18
|
import { promisify } from "node:util"
|
|
19
|
+
import { fileURLToPath } from "node:url"
|
|
20
|
+
import { cleanSource, isLibrary, originalPosition } from "./sourcemap.js"
|
|
18
21
|
|
|
19
22
|
const gzip = promisify(zlib.gzip)
|
|
20
23
|
const gunzip = promisify(zlib.gunzip)
|
|
@@ -110,7 +113,15 @@ function readBody(req) {
|
|
|
110
113
|
req.destroy()
|
|
111
114
|
} else chunks.push(c)
|
|
112
115
|
})
|
|
113
|
-
req.on("end", () =>
|
|
116
|
+
req.on("end", () => {
|
|
117
|
+
const body = Buffer.concat(chunks)
|
|
118
|
+
// The dock gzips large uploads (a Next.js middleware takes 10 MB at most).
|
|
119
|
+
if (!/^\s*gzip\s*$/i.test(String(req.headers["content-encoding"] || ""))) return resolve(body.toString("utf8"))
|
|
120
|
+
gunzip(body, { maxOutputLength: MAX_BODY }).then(
|
|
121
|
+
(b) => resolve(b.toString("utf8")),
|
|
122
|
+
(err) => reject(Object.assign(err, { status: err instanceof RangeError ? 413 : 400 })),
|
|
123
|
+
)
|
|
124
|
+
})
|
|
114
125
|
req.on("error", reject)
|
|
115
126
|
})
|
|
116
127
|
}
|
|
@@ -151,6 +162,66 @@ export function writeServerInfo({ root, url, token, base = "/" }) {
|
|
|
151
162
|
}
|
|
152
163
|
}
|
|
153
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Where a line of a compiled chunk on disk came from (a server component's
|
|
167
|
+
* element: React's stack names `file:///…/.next/server/…chunk.js`, which the
|
|
168
|
+
* dock can't fetch). Only .js files inside the project, and maps next to them
|
|
169
|
+
* or inlined. → { file, line } or { file: null }.
|
|
170
|
+
* @param {string} root
|
|
171
|
+
* @param {URLSearchParams} q
|
|
172
|
+
*/
|
|
173
|
+
export function mapChunk(root, q) {
|
|
174
|
+
const none = { file: null, line: null }
|
|
175
|
+
const line = Number(q.get("line"))
|
|
176
|
+
const col = Number(q.get("col")) || null
|
|
177
|
+
let file
|
|
178
|
+
try {
|
|
179
|
+
const u = String(q.get("url") || "").replace(/^about:\/\/React\/Server\//, "").replace(/[?#].*$/, "")
|
|
180
|
+
file = path.resolve(u.startsWith("file:") ? fileURLToPath(u) : u)
|
|
181
|
+
} catch {
|
|
182
|
+
return none
|
|
183
|
+
}
|
|
184
|
+
const inside = (f) => {
|
|
185
|
+
const real = (() => {
|
|
186
|
+
try {
|
|
187
|
+
return fs.realpathSync(f)
|
|
188
|
+
} catch {
|
|
189
|
+
return f
|
|
190
|
+
}
|
|
191
|
+
})()
|
|
192
|
+
const r = (() => {
|
|
193
|
+
try {
|
|
194
|
+
return fs.realpathSync(root)
|
|
195
|
+
} catch {
|
|
196
|
+
return root
|
|
197
|
+
}
|
|
198
|
+
})()
|
|
199
|
+
return real === r || real.startsWith(r + path.sep)
|
|
200
|
+
}
|
|
201
|
+
if (!(line > 0) || !/\.(m|c)?js$/.test(file) || !inside(file) || !fs.existsSync(file)) return none
|
|
202
|
+
try {
|
|
203
|
+
const code = fs.readFileSync(file, "utf8")
|
|
204
|
+
const refs = [...code.matchAll(/\/\/[#@] sourceMappingURL=(\S+)/g)]
|
|
205
|
+
const ref = refs.length ? refs[refs.length - 1][1] : null
|
|
206
|
+
let json
|
|
207
|
+
if (ref && ref.startsWith("data:")) {
|
|
208
|
+
const comma = ref.indexOf(",")
|
|
209
|
+
const body = ref.slice(comma + 1)
|
|
210
|
+
json = /;base64/.test(ref.slice(0, comma)) ? Buffer.from(body, "base64").toString("utf8") : decodeURIComponent(body)
|
|
211
|
+
} else {
|
|
212
|
+
const mapFile = ref ? path.resolve(path.dirname(file), decodeURIComponent(ref)) : file + ".map"
|
|
213
|
+
if (!inside(mapFile) || !fs.existsSync(mapFile)) return none
|
|
214
|
+
json = fs.readFileSync(mapFile, "utf8")
|
|
215
|
+
}
|
|
216
|
+
const pos = originalPosition(JSON.parse(json), line, col)
|
|
217
|
+
if (!pos) return none
|
|
218
|
+
const src = cleanSource(pos.source, { root, sources: pos.sources })
|
|
219
|
+
return isLibrary(src) ? none : { file: src, line: pos.line }
|
|
220
|
+
} catch {
|
|
221
|
+
return none
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
154
225
|
/**
|
|
155
226
|
* The /__retake/ API as a plain `(req, res, next)` handler, for any Node HTTP
|
|
156
227
|
* server (Vite's middleware stack, the front server, a bare http.Server).
|
|
@@ -198,6 +269,7 @@ export function createSessionHandler({ root, token, bus }) {
|
|
|
198
269
|
}
|
|
199
270
|
return send(res, 200, { config })
|
|
200
271
|
}
|
|
272
|
+
if (route[0] === "map" && route.length === 1 && req.method === "GET") return send(res, 200, mapChunk(root, url.searchParams))
|
|
201
273
|
const known = ["session", "recording", "notes", "events"].includes(route[0])
|
|
202
274
|
if (!known) return next() // e.g. /__retake/version and /checkout (code-versions)
|
|
203
275
|
if (mutating && req.headers["x-retake-token"] !== token) return send(res, 403, { error: "missing or wrong x-retake-token" })
|
package/src/server/front.js
CHANGED
|
@@ -245,6 +245,8 @@ export async function startFront(options) {
|
|
|
245
245
|
headers["X-Forwarded-Host"] = req.headers.host || ""
|
|
246
246
|
headers["X-Forwarded-Proto"] = req.socket.encrypted ? "https" : "http"
|
|
247
247
|
headers["X-Forwarded-Port"] = String(req.socket.localPort)
|
|
248
|
+
// An app with Retake in its own middleware (retake-dev/next) leaves it to us.
|
|
249
|
+
headers["X-Retake-Front"] = "1"
|
|
248
250
|
if (frame) {
|
|
249
251
|
for (const k of Object.keys(headers)) if (/^(accept-encoding|if-none-match|if-modified-since)$/i.test(k)) delete headers[k]
|
|
250
252
|
headers["Accept-Encoding"] = "identity"
|
|
@@ -533,7 +535,7 @@ export async function startFront(options) {
|
|
|
533
535
|
|
|
534
536
|
function dock(res, marker) {
|
|
535
537
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" })
|
|
536
|
-
res.end(shellHtml({ token, marker, docs: true }))
|
|
538
|
+
res.end(shellHtml({ token, marker, docs: true, root }))
|
|
537
539
|
}
|
|
538
540
|
|
|
539
541
|
// ---- WebSockets (HMR): a raw pipe -----------------------------------------
|