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 +76 -1
- package/README.md +141 -7
- package/bin/retake.js +14 -1
- package/package.json +6 -1
- package/src/core.js +14 -1
- package/src/next-dev.js +183 -0
- package/src/next.js +81 -0
- package/src/note-text.js +844 -0
- package/src/plugin.js +6 -2
- 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 +9 -2
- package/src/server/api.js +73 -1
- package/src/server/front.js +3 -1
- package/src/server/mcp.js +213 -16
- package/src/server/moments.js +317 -0
- package/src/server/sourcemap.js +192 -0
- package/src/shell/10-dock.js +27 -5
- package/src/shell/15-session.js +11 -5
- package/src/shell/20-timeline.js +30 -5
- package/src/shell/25-input.js +25 -2
- package/src/shell/30-notes.js +704 -110
- package/src/shell/32-anims.js +1353 -0
- package/src/shell/shell.css +24 -0
- package/src/shell/shell.html +1 -0
- package/types/client.d.ts +7 -0
- package/types/index.d.ts +2 -0
- package/types/next.d.ts +50 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.5.
|
|
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
|
-
`
|
|
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
|
|
86
|
-
|
|
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
|
|
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.
|
|
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
|
package/src/next-dev.js
ADDED
|
@@ -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" }] },
|