retake-dev 0.5.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 CHANGED
@@ -1,7 +1,62 @@
1
1
  # Changelog
2
2
 
3
- ## 0.5.0
3
+ ## 0.5.1
4
4
 
5
+ - Notes on animations work the way web animation does: per element, on the
6
+ animation's own clock. ⌘-click an element and it gets a row on the track with
7
+ its own animations; open one to see its clock (0 after the delay), its
8
+ keyframes and what it moves, and the path it takes on the app. Click to pin a
9
+ note at a point ("at 100ms (20%) of fadeUp"), drag to pin it over a range
10
+ ("200–400ms"), or Shift+drag the timeline first. Numbers typed in the note are
11
+ read on that clock. Copy for agent and `get_note` now give the animation's
12
+ keyframes and timing, the exact point or range with its keyframe segment, the
13
+ values and the box there, and how to change only that part.
14
+ - Each animation note now carries an exact edit: the keyframes again, on plain
15
+ time, with the note's point or range edges as keyframes of their own and the
16
+ exact part of each curve on both sides (written for CSS `@keyframes`,
17
+ `element.animate()` or Motion's `times`/`ease`). Change the marked part and the
18
+ rest of the motion stays as it was, even when the easing is on the whole
19
+ effect. A `@keyframes` that runs on several elements says so, and the edit
20
+ gives the noted element its own copy.
21
+ - Motion keyframes (`x: [0, 120, 120, 240]` with `times`) are read off the
22
+ motion element's props: every keyframe, its times and the ease of each
23
+ segment, so a range over a hold is on one clip and the exact edit is in
24
+ Motion's own terms. A CSS transition's exact edit is its timing as `linear()`
25
+ stops with the range edges marked. Two animations over the same time on one
26
+ element (a transform and an opacity transition) stack on its row, so either
27
+ can be opened.
28
+ - Script-driven motion is recorded: GSAP tweens, Motion's `x`/`y`/`scale` and
29
+ react-spring write inline styles every frame, and now show as clips (kind
30
+ `js`) with their first and last values. Motion and GSAP are named, with
31
+ Motion's `animate`/`transition` props in the note; `element.animate()` clips
32
+ say where they were called. A note after an animation ended is about its end
33
+ state. CSS animations from `<link>` stylesheets (Next, any framework) get
34
+ their `@keyframes` file and line, and source paths are relative to the project.
35
+ - ⌘-click picks what you can see under the pointer: text, media, a control or a
36
+ painted box come first; empty overlays (glows, stretched links, scrims) and
37
+ invisible layers (opacity 0, a closed menu sheet) are a wheel step away, shown
38
+ dimmed with why. The highlight and the note always agree (⌘ pressed with the
39
+ pointer already still, or a build swapping in while ⌘ is held). An svg icon is
40
+ the `<svg>` or its button, not the section around it; shadow DOM and embedded
41
+ iframes are pickable (an embed gets no clicks while paused). A note's clip is
42
+ only ever its own element's (or an ancestor's that moves it). Selectors leave
43
+ out generated ids and classes that came and went during the recording, so they
44
+ find the element on a fresh load. A server component's element gets its own
45
+ source line (`GET /__retake/map`), or "unknown", never its client parent's.
46
+ Next's layout components are left out of component names. A second ⌘-click on
47
+ a pinned spot makes a new note there.
48
+ - `retake mcp`: `get_animation` maps recording times onto an animation's own
49
+ clock (and into CSS %, Motion `times`, GSAP seconds); `get_moment` on a note
50
+ takes the note's word for which animations are its element's, so it can't
51
+ disagree with `get_note`; `list_notes` shows each note's range and animation.
52
+ The server's instructions carry a short guide to editing each kind of animation.
53
+ - Next.js: the timeline on your usual dev URL. Add `proxy.ts` with
54
+ `export { default } from "retake-dev/next"` (Next 16), or `middleware.ts` with the
55
+ Node.js runtime (Next 15), and run `next dev` as always: no second port. Already
56
+ have one? `export default withRetake(yourMiddleware)`. Sessions, notes and
57
+ `retake mcp` work as with the CLI. Dev only: `next build` output has no dock or
58
+ runtime. `npx retake-dev .` still works without installing anything.
59
+ - Large recordings upload gzipped (a Next.js middleware reads 10 MB of a request at most).
5
60
  - The dock is open on a first visit, and folds away: drag its divider down (or
6
61
  press ⌥T) and the timeline becomes a round button, bottom-right at first. The
7
62
  dock follows the pointer all the way down; let go near the bottom and it eases
@@ -31,6 +86,26 @@
31
86
  - Leaving the page while recording (a new address typed in) no longer loses the
32
87
  seconds since the last save: they're kept in the tab's sessionStorage and the
33
88
  timeline carries on from them.
89
+ - `retake mcp` reads the recording: `get_moment` (around a note's moment, or any
90
+ moment of a timeline) and `get_timeline_events` (a stretch of a timeline) list
91
+ the user's actions, requests, route changes, the animations on or near an
92
+ element with their start, end and what they animate between, and when the
93
+ screen changed most. `get_note` gives the animation's start and end too, and
94
+ points to `get_moment`.
95
+ - A note's source in a bundled app (Next.js on Turbopack or webpack) is the
96
+ app's file and line: the dock reads the chunk's source map, index maps
97
+ included, and skips frames that land in the bundled JSX runtime. With no map
98
+ it says the source isn't known (`retake mcp` tries the map again) instead of
99
+ naming the chunk.
100
+ - "Copy for agent" starts with two lines saying what a Retake note is and how to
101
+ see what happened around its moment.
102
+ - Recorded animations keep their first and last keyframe values (`from`/`to`),
103
+ delay and per-iteration duration.
104
+ - A second skill, `retake-notes`: how a coding agent works through notes.
105
+
106
+ ## 0.5.0
107
+
108
+ The dock folds into an icon you can drag anywhere, notes show only when paused, and timelines are named Timeline 1, Timeline 2. Everything above builds on it.
34
109
 
35
110
  ## 0.4.0
36
111
 
package/README.md CHANGED
@@ -51,6 +51,60 @@ export default defineConfig({
51
51
 
52
52
  `retake()` only runs in `vite dev`; `vite build` output has no Retake code in it.
53
53
 
54
+ ### Next.js: on your usual dev URL
55
+ Install `retake-dev` (above) and add one file in the project root (in `src/` if
56
+ your app lives in `src/app`). Then run `next dev` / `npm run dev` as always: the
57
+ timeline shows on your normal dev URL (http://localhost:3000), no second port.
58
+
59
+ ```ts
60
+ // proxy.ts (Next 16)
61
+ export { default } from "retake-dev/next"
62
+ ```
63
+
64
+ ```ts
65
+ // middleware.ts (Next 15.5)
66
+ import retake from "retake-dev/next"
67
+ export default retake
68
+ export const config = { runtime: "nodejs" }
69
+ ```
70
+
71
+ Install it from the registry (or a tarball, `npm i ../retake-dev-0.5.1.tgz`): a
72
+ `file:` or linked install is a symlink, which Turbopack doesn't follow out of the
73
+ project, and `retake-dev/next` isn't found.
74
+
75
+ Next 15 needs the Node.js runtime (Retake keeps `.retake/` on disk). It only runs in
76
+ `next dev`: in `next build` / `next start` every request goes straight on, and the
77
+ build has no dock or runtime in it.
78
+
79
+ Already have a proxy or middleware? Wrap yours:
80
+
81
+ ```ts
82
+ import { withRetake } from "retake-dev/next"
83
+
84
+ export default withRetake(async (req) => {
85
+ // ...your middleware, as before
86
+ })
87
+ ```
88
+
89
+ Next reads `config` from your file as written (it can't be re-exported). Without one
90
+ the proxy runs for every request and passes everything but page loads on. With a
91
+ `matcher` of your own, add Retake's two entries to it:
92
+
93
+ ```ts
94
+ export const config = {
95
+ matcher: [
96
+ "/__retake/:path*",
97
+ { source: "/((?!_next/static|_next/image).*)", has: [{ type: "header", key: "sec-fetch-mode", value: "navigate" }] },
98
+ // ...your entries
99
+ ],
100
+ }
101
+ ```
102
+
103
+ `withRetake` hands every request it doesn't answer to your function, so with a
104
+ merged matcher your function also sees page loads it didn't match before.
105
+ No install at all: `npx retake-dev .` runs `next dev` behind Retake's own port
106
+ (3014) instead.
107
+
54
108
  ### Connect your coding agent (MCP)
55
109
  Notes you leave in the dock can go straight to your coding agent. Register the
56
110
  MCP server once, from the app folder. With Claude Code:
@@ -63,10 +117,26 @@ Any other MCP client (Cursor, Codex, Windsurf...) takes the same command,
63
117
  `npx -y retake-dev mcp`, in its MCP settings.
64
118
 
65
119
  With the dev server running, the agent can `list_notes`, `get_note`,
66
- `get_active_timeline`, `acknowledge`, `resolve`, `reply` and `watch_notes`.
120
+ `get_moment`, `get_animation`, `get_timeline_events`, `get_active_timeline`,
121
+ `acknowledge`, `resolve`, `reply` and `watch_notes`.
67
122
  It finds the server through `.retake/server.json` (or pass `--url http://localhost:3014`).
68
123
  Acknowledging and resolving show up on the note in the dock right away.
69
124
 
125
+ A note is pinned to a moment of a recording. `get_moment` (a note id, or a
126
+ timeline and a time like `00:10.91`) reads that recording around it: the user's
127
+ clicks, keys, typing and route changes, the requests, the animations on or near
128
+ the element (start, end, duration, what they animate between, how far along at
129
+ the moment) and when the screen changed most. `get_timeline_events` lists the
130
+ same for any stretch of a timeline. A note whose source is only a line of a
131
+ compiled bundle (a Next.js chunk) is mapped back to the app's file and line
132
+ through the bundle's source map.
133
+
134
+ A note on an animation says exactly which part of it you mean (see
135
+ [Notes on animations](#notes-on-animations)). `get_animation` gives that
136
+ animation's timing and every keyframe, and maps any recording time onto its own
137
+ clock, in CSS %, Motion `times` and GSAP seconds. `get_note`, `get_moment` and
138
+ Copy for agent describe the element and its animation with the same words.
139
+
70
140
  ### The first 60 seconds
71
141
  1. **Use your app** for a few seconds: it's being recorded already.
72
142
  2. **Pause** with space (or ⌥P, or the play button). The app goes view-only:
@@ -81,9 +151,71 @@ Acknowledging and resolving show up on the note in the dock right away.
81
151
  the element, its React component, source file:line and CSS; or let your
82
152
  agent pick it up over MCP.
83
153
 
154
+ ### Picking an element
155
+ Hold ⌘ over the paused app: a chip by the pointer lists what's under it, and the
156
+ pick is what you can see there: text, an image or icon, a control, a painted box.
157
+ Empty overlays (a glow, a stretched card link, a scrim) and invisible layers
158
+ (opacity 0, a closed menu) stay in the list, dimmed with why, one wheel step or
159
+ Tab away. On an icon you get the `<svg>` (or the button it's the icon of), with
160
+ the shape inside it a step away; inside an open shadow root, the element itself;
161
+ an embedded iframe is one element (and gets no clicks while paused). A second
162
+ ⌘-click on a pinned spot makes another note there.
163
+
164
+ The note's selector finds the element again on a fresh load: no generated ids
165
+ (`:r1:`, `radix-…`), no classes that came and went during the recording (`in`,
166
+ `is-open`, `opacity-100`), test ids, labels and hrefs where they're unique. Its
167
+ source is where the element itself was written, a server component's line too
168
+ (the dev server reads the chunk's source map), never just the parent it was
169
+ passed into; Next's own layout components aren't listed as yours.
170
+
171
+ ### Notes on animations
172
+ Web animations aren't edited on a global timeline: each one belongs to an
173
+ element, runs on its own clock (0 is the end of its delay), and moves the
174
+ element between keyframes. Notes work the same way.
175
+
176
+ - **⌘-click an element** and it gets a row on the track under the timelines, with
177
+ its own animations (and an ancestor's that moves it). Nothing moving on it?
178
+ The row says so and lists what animates inside it; click one to switch. Two
179
+ animations at once (a transform and an opacity transition) stack in thinner
180
+ rows; hover one to see its name.
181
+ - **Click an animation** on that row to open it: a ruler on its own clock (the
182
+ delay hatched before 0), a diamond where each keyframe is reached, and small
183
+ lines of what it moves (x, y, scale, rotation, opacity, size). The hint reads
184
+ where the playhead is on it: `fadeUp · 100ms of 500 · 20% · seg 0→40% ease-out ·
185
+ x 248 y 568 · 320×64`. On the app, dashed boxes show where the element starts and
186
+ ends, and a dotted line the path it takes.
187
+ - **A point**: click on the open animation (or step with ←/→ a frame at a time,
188
+ Shift+←/→ keyframe to keyframe). The note is pinned there: "at 100ms (20%) of
189
+ fadeUp on `<h1.title>`".
190
+ - **A range**: drag across the open animation ("200–400ms · 40–80% of fadeUp").
191
+ It snaps to keyframes and 10ms steps (hold ⌥ to drag freely); drag past the
192
+ end for "from here to the end". Shift+drag on the timeline selects a range of
193
+ the recording first, then ⌘-click any element.
194
+ - **Typed numbers** ("at 100ms", "between 200 and 400 ms", "after 40%") are read
195
+ on the open animation's own clock; a chip under the note says how, and a click
196
+ switches it to recording time.
197
+ - Esc closes the open animation, then the note.
198
+
199
+ The note carries, for that animation: its kind and where it's defined, timing,
200
+ every keyframe, the point or both range edges on its own clock (local ms,
201
+ progress, eased progress, the keyframe segment), the values and the element's
202
+ box there and one frame either side, samples across a range, and what to change
203
+ for its kind (CSS keyframes, transitions, `element.animate()`, Motion, GSAP,
204
+ scroll-driven). It ends with an exact edit: the keyframes again on plain time,
205
+ the point or range edges as keyframes of their own, each piece keeping its part
206
+ of the curve, so an agent changes only the marked part and the rest moves as
207
+ before. A `@keyframes` shared with other elements is flagged, and the edit gives
208
+ the noted element its own copy. Motion written as inline styles every frame
209
+ (GSAP, Motion's `x`, react-spring) is recorded too, with its first and last
210
+ values and the library named; Motion keyframes come with every keyframe, their
211
+ `times` and each segment's ease, read off the component's props. A CSS
212
+ transition's exact edit is its timing as `linear()` stops.
213
+
84
214
  ### The dock
85
- - **Keys**: space or ⌥P plays and pauses, ←/→ step, F fits everything, + starts
86
- a new timeline at the playhead, M drops a bookmark, ⌥T folds the dock away.
215
+ - **Keys**: space or ⌥P plays and pauses, ←/→ step (on an open animation: its
216
+ frames; Shift: its keyframes), F fits everything, + starts a new timeline at
217
+ the playhead, M drops a bookmark, ⌥T folds the dock away, Esc closes an open
218
+ animation, a selected range, then the note.
87
219
  - **Resize** by dragging the divider at its top. Drag it all the way down and
88
220
  the timeline folds into a round button (bottom-right at first): the whole
89
221
  window is the app's, and recording carries on. Drag the button anywhere; it
@@ -98,7 +230,7 @@ npm uninstall retake-dev # or pnpm remove / yarn remove / bun remove
98
230
  rm -rf .retake # Retake's session and recordings
99
231
  claude mcp remove retake # if you added the MCP server (or remove it in your client's MCP settings)
100
232
  ```
101
- Remove `retake()` from `vite.config` if you added it.
233
+ Remove `retake()` from `vite.config`, or Retake's `proxy.ts` / `middleware.ts` (or `withRetake`), if you added it.
102
234
 
103
235
  ## Commands
104
236
  ```sh
@@ -108,7 +240,7 @@ retake <project> --code-branches # each timeline keeps its own version of th
108
240
  retake <project> -- --host # anything after -- goes to the dev server
109
241
  retake -- <dev command> # run that command with the timeline in front (retake -- next dev)
110
242
  retake http://localhost:3000 # put the timeline in front of a dev server that's already running
111
- retake init # print the vite.config lines
243
+ retake init # print the vite.config / proxy.ts lines
112
244
  retake mcp # the MCP server your coding agent runs
113
245
  ```
114
246
  `--root <dir>` puts `.retake/` somewhere else; `--verbose` logs every request the
@@ -135,7 +267,7 @@ the package manager its lockfile names):
135
267
  | Framework | Run | Notes |
136
268
  |---|---|---|
137
269
  | Vite SPA (React, Vue, Svelte, plain) | `npx retake-dev .` | or `plugins: [retake()]` in `vite.config` |
138
- | Next.js | `npx retake-dev .` | tested on 16.3 (app and pages router, server actions) and 15.5 (app router), Turbopack |
270
+ | Next.js | `proxy.ts` / `middleware.ts` ([above](#nextjs-on-your-usual-dev-url)) and your usual `next dev`, or `npx retake-dev .` | tested on 16.3 (app and pages router, server actions) and 15.5 (app router), Turbopack and webpack |
139
271
  | React Router 7 framework mode | `npx retake-dev .` | or `plugins: [retake(), reactRouter()]` and your usual `npm run dev`; tested on 7.18 |
140
272
  | Remix 2 (Vite) | `npx retake-dev .` | tested on 2.17 |
141
273
  | Astro | `npx retake-dev .` | tested on 7.3 with React islands and `<ClientRouter />` |
@@ -170,7 +302,9 @@ through the same front server and should work; open an issue if one doesn't.
170
302
  loaded chunks) run at their recorded moment, the dev server's own traffic (HMR)
171
303
  is left out of the recording, and a rebuilt moment gets the page's HTML as it
172
304
  was recorded (kept in `.retake/docs/`), not rendered again. Native `import()`
173
- (Vite's lazy routes, Astro islands) can't be held to its moment.
305
+ (Vite's lazy routes, Astro islands) can't be held to its moment. With Next's
306
+ `proxy.ts` the same holds, except that a rebuilt moment's page is rendered again
307
+ (what the server renders differently each time, like the time, can differ).
174
308
  - **The dock covers the bottom of the app.** It floats over the bottom of
175
309
  the app (a cookie banner's buttons, Next's dev badge). Drag the dock's divider
176
310
  down (all the way down folds it into a button in the corner, as does ⌥T), or
package/bin/retake.js CHANGED
@@ -38,7 +38,8 @@ Usage:
38
38
  retake http://localhost:3000 put the timeline in front of a running dev server
39
39
  retake -- <dev command> run that command with the timeline in front
40
40
  (e.g. retake -- next dev, retake -- pnpm dev)
41
- retake init print the vite.config lines instead
41
+ retake init print the lines for vite.config or Next's
42
+ proxy.ts instead
42
43
  retake mcp [--url http://localhost:3014]
43
44
  MCP server for coding agents (stdio)
44
45
 
@@ -289,6 +290,18 @@ function init() {
289
290
  })
290
291
 
291
292
  Options: retake({ codeBranches: true }) gives each timeline its own version of the code.
293
+
294
+ Next.js: one file in the project root (src/ if your app is in src/app), dev only; the timeline
295
+ shows on your usual dev URL:
296
+
297
+ // proxy.ts (Next 16)
298
+ export { default } from "retake-dev/next"
299
+
300
+ // middleware.ts (Next 15)
301
+ import retake from "retake-dev/next"
302
+ export default retake
303
+ export const config = { runtime: "nodejs" }
304
+
292
305
  Or leave the project untouched and run: npx retake-dev .
293
306
  (Next, Nuxt, React Router, SvelteKit, Astro... too: it puts Retake in front of your dev server.)`)
294
307
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-dev",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Retake: a time machine for your dev server (Vite, Next.js, React Router, Remix, Astro, SvelteKit, Nuxt). Drag back on a timeline and your app is at that moment; branch a new take from there.",
5
5
  "homepage": "https://retake-omega.vercel.app",
6
6
  "repository": {
@@ -21,6 +21,11 @@
21
21
  "./client": {
22
22
  "types": "./types/client.d.ts"
23
23
  },
24
+ "./next": {
25
+ "types": "./types/next.d.ts",
26
+ "default": "./src/next.js"
27
+ },
28
+ "./next-dev": "./src/next-dev.js",
24
29
  "./package.json": "./package.json"
25
30
  },
26
31
  "bin": {
package/src/core.js CHANGED
@@ -56,6 +56,8 @@ export function shellHtml(options = {}) {
56
56
  // No Retake server behind it (a static or serverless deploy): the dock
57
57
  // never asks /__retake/ for anything and keeps the session in memory.
58
58
  ...(options.server === false ? { server: false } : {}),
59
+ // The project folder: source paths in notes are given relative to it.
60
+ ...(options.root ? { root: options.root } : {}),
59
61
  }
60
62
  return read("shell", "shell.html")
61
63
  .replace(
@@ -66,10 +68,21 @@ export function shellHtml(options = {}) {
66
68
  .replace("/*CSS*/", () => read("shell", "shell.css"))
67
69
  .replace("/*JS*/", () => {
68
70
  const files = setFiles("shell")
69
- return `;(function () {\n"use strict";\n${files.map((f) => read("shell", f)).join("\n")}\n})();`
71
+ return `;(function () {\n"use strict";\n${noteTextSource()}\n${files.map((f) => read("shell", f)).join("\n")}\n})();`
70
72
  })
71
73
  }
72
74
 
75
+ // src/note-text.js (the note as text for an agent, shared with `retake mcp`)
76
+ // for the dock: its exports as one const, `NT`.
77
+ export function noteTextSource() {
78
+ const src = read("note-text.js")
79
+ const names = new Set()
80
+ const body = src
81
+ .replace(/^export (?:async )?(function|const|let) (\w+)/gm, (_, kind, name) => (names.add(name), `${kind} ${name}`))
82
+ .replace(/^export \{([^}]*)\}\s*$/gm, (_, list) => (list.split(",").forEach((n) => n.trim() && names.add(n.trim())), ""))
83
+ return `const NT = (function () {\n${body}\nreturn { ${[...names].join(", ")} }\n})();`
84
+ }
85
+
73
86
  // The script a server injects into a page it can't tell apart from the app's
74
87
  // own frames (any iframe document). It takes itself out of the DOM first (so an
75
88
  // app hydrating the whole document never sees it), then runs the runtime only
@@ -0,0 +1,183 @@
1
+ // The dev side of `retake-dev/next` (next.js): Retake from a Next.js app's
2
+ // own middleware (Next 16's proxy.ts, Next 15's middleware.ts with the
3
+ // Node.js runtime), on the app's own dev server. Loaded by next.js at run time
4
+ // in `next dev` only. Per request, like the front server (server/front.js):
5
+ //
6
+ // x-retake-internal / x-retake-front passed on (our own fetch below, or
7
+ // Retake's front server in front)
8
+ // /__retake/* the session API (api.js), in <cwd>/.retake
9
+ // Service-Worker: script 404 (a worker would take the dock's origin over)
10
+ // a top-level page load the dock (header marker)
11
+ // the dock's frame (Sec-Fetch-Dest: iframe)
12
+ // the page, fetched from this server with
13
+ // x-retake-internal, the runtime injected
14
+ // as its first script
15
+ // anything else passed on
16
+ //
17
+ // What a middleware module holds is lost when Next compiles it again, so the
18
+ // token and the session API live on globalThis for the life of the server.
19
+ import crypto from "node:crypto"
20
+ import { Readable } from "node:stream"
21
+ import { injectHtml, runtimeScript, runtimeTag, shellHtml } from "./core.js"
22
+ import { createBus, createSessionHandler, writeServerInfo } from "./server/api.js"
23
+ import { frontRuntime } from "./server/detect.js"
24
+ import { adaptCsp, hostAllowed } from "./server/front.js"
25
+
26
+ const INTERNAL = "x-retake-internal"
27
+ const FRONT = "x-retake-front"
28
+ const HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer", "proxy-authenticate", "proxy-authorization", "host", "content-length"])
29
+ const NESTED = new Set(["iframe", "frame"])
30
+ const VARY = "Sec-Fetch-Dest, Sec-Fetch-Mode"
31
+ const KEY = Symbol.for("retake-dev/next")
32
+
33
+ /**
34
+ * @typedef {{ root: string, token: string, api: ReturnType<typeof createSessionHandler>, rt: import("../types/index.js").RuntimeConfig, url: string | null }} State
35
+ */
36
+ /** @returns {State} */
37
+ function state() {
38
+ const g = /** @type {any} */ (globalThis)
39
+ if (g[KEY]) return g[KEY]
40
+ const root = process.env.RETAKE_ROOT || process.cwd()
41
+ const token = crypto.randomBytes(16).toString("hex")
42
+ const bus = createBus()
43
+ return (g[KEY] = { root, token, api: createSessionHandler({ root, token, bus }), rt: { ...frontRuntime("next", root), marker: /** @type {"header"} */ ("header") }, url: null })
44
+ }
45
+
46
+ // .retake/server.json, for `retake mcp`: the dev server's URL as the browser uses it.
47
+ function announce(s, req) {
48
+ const url = new URL(req.url).origin
49
+ if (s.url === url) return
50
+ const first = !s.url
51
+ s.url = url
52
+ writeServerInfo({ root: s.root, url, token: s.token })
53
+ if (first) console.log(`\n \x1b[1mRetake\x1b[0m timeline docked at ${url}\n`)
54
+ }
55
+
56
+ /**
57
+ * Retake's answer to a request in `next dev`, or null to pass it on.
58
+ * @param {Request} req
59
+ * @returns {Promise<Response | null>}
60
+ */
61
+ export async function handle(req) {
62
+ const h = req.headers
63
+ if (h.get(INTERNAL) === "1" || h.get(FRONT) === "1") return null
64
+ const url = new URL(req.url)
65
+ const p = url.pathname
66
+ const allowed = hostAllowed(h.get("host") ?? url.host)
67
+ if (p.startsWith("/__retake/")) {
68
+ if (!allowed) return new Response("Blocked request: this host is not allowed.", { status: 403 })
69
+ const s = state()
70
+ announce(s, req)
71
+ return api(s, req, url)
72
+ }
73
+ if (h.get("service-worker") === "script") return new Response("retake: service workers are off while Retake is docked", { status: 404 })
74
+ const mode = h.get("sec-fetch-mode")
75
+ const dest = h.get("sec-fetch-dest")
76
+ if (mode !== "navigate" || !allowed || url.searchParams.get("retake") === "0") return null
77
+ // The dock: a top-level page load. A cross-site one (an OAuth callback) gets the plain page.
78
+ if (req.method === "GET" && dest === "document" && h.get("sec-fetch-site") !== "cross-site") {
79
+ const s = state()
80
+ announce(s, req)
81
+ return new Response(shellHtml({ token: s.token, marker: "header", root: s.root }), {
82
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", vary: VARY },
83
+ })
84
+ }
85
+ if (dest && NESTED.has(dest)) return frame(state(), req, url)
86
+ return null
87
+ }
88
+
89
+ // The dock's frame: the page as this server renders it, with the runtime first in <head>.
90
+ async function frame(s, req, url) {
91
+ const headers = new Headers()
92
+ for (const [k, v] of req.headers) if (!HOP.has(k) && !/^(accept-encoding|if-none-match|if-modified-since)$/.test(k)) headers.set(k, v)
93
+ headers.set(INTERNAL, "1")
94
+ headers.set("accept-encoding", "identity")
95
+ const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer()
96
+ const r = await fetch(url, { method: req.method, headers, body, redirect: "manual", cache: "no-store" })
97
+ const out = new Headers(r.headers)
98
+ const type = out.get("content-type") || ""
99
+ const bodyless = req.method === "HEAD" || r.status === 204 || r.status === 304 || !r.body
100
+ if (!/^\s*text\/html\b/i.test(type) || bodyless) return new Response(r.body, { status: r.status, statusText: r.statusText, headers: out })
101
+ const script = runtimeScript(s.rt)
102
+ /** @type {string | null} */
103
+ let nonce = null
104
+ const csp = out.get("content-security-policy")
105
+ if (csp) {
106
+ const a = adaptCsp(csp, script)
107
+ nonce = a.nonce
108
+ if (a.csp) out.set("content-security-policy", a.csp)
109
+ else out.delete("content-security-policy")
110
+ }
111
+ // Framing: SAMEORIGIN already lets the dock (same origin) frame it.
112
+ const xfo = (out.get("x-frame-options") || "").toLowerCase()
113
+ if (xfo && xfo !== "sameorigin") out.delete("x-frame-options")
114
+ // (fetch has decoded the body already.)
115
+ for (const k of ["content-encoding", "content-length", "etag"]) out.delete(k)
116
+ out.set("cache-control", "no-store")
117
+ out.set("vary", VARY)
118
+ if (!/charset=/i.test(type)) out.set("content-type", `${type.trim()}; charset=utf-8`)
119
+ const t = injectHtml(runtimeTag({ script, nonce }))
120
+ const src = Readable.fromWeb(/** @type {any} */ (r.body))
121
+ src.on("error", (err) => t.destroy(err))
122
+ src.pipe(t)
123
+ return new Response(/** @type {any} */ (Readable.toWeb(t)), { status: r.status, statusText: r.statusText, headers: out })
124
+ }
125
+
126
+ // The session API (api.js's Node handler) for a web Request: a Node-shaped
127
+ // request and response around it. Event streams stay open until the browser
128
+ // goes (the response body is cancelled).
129
+ function api(s, req, url) {
130
+ return new Promise((resolve) => {
131
+ const nodeReq = /** @type {any} */ (req.body ? Readable.fromWeb(/** @type {any} */ (req.body)) : Readable.from([]))
132
+ nodeReq.method = req.method
133
+ nodeReq.url = url.pathname + url.search
134
+ nodeReq.headers = Object.fromEntries(req.headers)
135
+ /** @type {ReadableStreamDefaultController<Uint8Array> | null} */
136
+ let ctl = null
137
+ let closed = false
138
+ const stream = new ReadableStream({
139
+ start(c) {
140
+ ctl = c
141
+ },
142
+ cancel() {
143
+ closed = true
144
+ nodeReq.emit("close")
145
+ },
146
+ })
147
+ const head = new Headers()
148
+ let sent = false
149
+ const res = {
150
+ statusCode: 200,
151
+ setHeader: (k, v) => head.set(k, String(v)),
152
+ getHeader: (k) => head.get(k),
153
+ writeHead(code, h) {
154
+ res.statusCode = code
155
+ for (const [k, v] of Object.entries(h || {})) head.set(k, String(v))
156
+ start()
157
+ return res
158
+ },
159
+ write(chunk) {
160
+ start()
161
+ if (!closed && chunk != null) ctl?.enqueue(typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk))
162
+ return true
163
+ },
164
+ end(chunk) {
165
+ if (chunk != null) res.write(chunk)
166
+ else start()
167
+ if (!closed) {
168
+ closed = true
169
+ ctl?.close()
170
+ }
171
+ return res
172
+ },
173
+ }
174
+ function start() {
175
+ if (sent) return
176
+ sent = true
177
+ resolve(new Response(stream, { status: res.statusCode, headers: head }))
178
+ }
179
+ Promise.resolve(s.api(nodeReq, res)).catch((err) => {
180
+ if (!sent) resolve(new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "content-type": "application/json" } }))
181
+ })
182
+ })
183
+ }
package/src/next.js ADDED
@@ -0,0 +1,81 @@
1
+ // Retake on a Next.js app's own dev server, from its middleware: Next 16's
2
+ // `proxy.ts` or Next 15's `middleware.ts`.
3
+ //
4
+ // export { default } from "retake-dev/next"
5
+ //
6
+ // Next 15's middleware.ts needs the Node.js runtime (Retake reads its files and
7
+ // keeps .retake/ on disk), and imports it (Next 15.5's Turbopack puts a
8
+ // re-exported default on the Edge runtime):
9
+ //
10
+ // import retake from "retake-dev/next"
11
+ // export default retake
12
+ // export const config = { runtime: "nodejs" }
13
+ //
14
+ // With a middleware of your own:
15
+ //
16
+ // import { withRetake } from "retake-dev/next"
17
+ // export default withRetake(yourMiddleware)
18
+ //
19
+ // In `next dev` it does what the front server (server/front.js) does on a port
20
+ // of its own: a top-level page load gets the dock, the dock's frame gets the
21
+ // page with the runtime as its first script, /__retake/* is the session API,
22
+ // and everything else passes through. Anywhere else (next build, next start)
23
+ // it does nothing: it hands every request on and never loads the rest of
24
+ // Retake.
25
+ //
26
+ // This file stays free of Node imports, so it bundles into any middleware. The
27
+ // work is in next-dev.js, loaded at run time in dev only, from node_modules as
28
+ // it is: never bundled (it reads the runtime and the dock from its own files).
29
+
30
+ const DEV = process.env.NODE_ENV === "development"
31
+
32
+ /** @type {Promise<typeof import("./next-dev.js")> | null} */
33
+ let loading = null
34
+ let warned = false
35
+ const devHandler = () => (loading ||= import(/* webpackIgnore: true */ /* turbopackIgnore: true */ "retake-dev/next-dev"))
36
+
37
+ /**
38
+ * Retake's answer to this request, or undefined to let it through.
39
+ * @param {Request} req
40
+ * @returns {Promise<Response | undefined>}
41
+ */
42
+ export async function retakeResponse(req) {
43
+ if (!DEV) return undefined
44
+ if (typeof (/** @type {any} */ (globalThis).EdgeRuntime) === "string") {
45
+ if (!warned) console.warn('[retake] the timeline needs the Node.js runtime: add `export const config = { runtime: "nodejs" }` to middleware.ts')
46
+ warned = true
47
+ return undefined
48
+ }
49
+ try {
50
+ return (await (await devHandler()).handle(req)) || undefined
51
+ } catch (err) {
52
+ console.error("[retake]", err instanceof Error ? err.message : err)
53
+ return undefined
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Wraps a middleware of your own: Retake answers first (in dev, for page loads
59
+ * and /__retake/*), everything else goes to yours.
60
+ * @template {(...args: any[]) => any} M
61
+ * @param {M} [middleware]
62
+ * @returns {(req: Request, event?: any) => Promise<any>}
63
+ */
64
+ export function withRetake(middleware) {
65
+ return async function retakeMiddleware(req, event) {
66
+ if (DEV) {
67
+ const res = await retakeResponse(req)
68
+ if (res) return res
69
+ }
70
+ return middleware ? middleware(req, event) : undefined
71
+ }
72
+ }
73
+
74
+ export default withRetake()
75
+
76
+ // Next reads a middleware's `config` from the file itself (it can't be
77
+ // re-exported), so there's none here. Without one Next runs it for every
78
+ // request, and it hands everything but page loads and /__retake/* straight on.
79
+ // A matcher of your own needs these two entries, as written:
80
+ // "/__retake/:path*",
81
+ // { source: "/((?!_next/static|_next/image).*)", has: [{ type: "header", key: "sec-fetch-mode", value: "navigate" }] },