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/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
- domLog.push({ t, kind: "attr", node: r.target, name: r.attributeName, old: r.oldValue, now: next ? next.oldValue : r.target.getAttribute(r.attributeName) })
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) out.keyframes = { name: t.name, ...locate(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) {
@@ -112,7 +112,7 @@ function record() {
112
112
  }
113
113
 
114
114
  Object.assign(PT, {
115
- version: "0.4.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
- if (shell) W.addEventListener("blur", () => shell.meta && shell.meta(false))
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", () => resolve(Buffer.concat(chunks).toString("utf8")))
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" })
@@ -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 -----------------------------------------