retake-dev 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +91 -0
- package/README.md +198 -0
- package/bin/retake.js +319 -0
- package/package.json +87 -0
- package/src/code-versions.js +357 -0
- package/src/core.js +265 -0
- package/src/plugin.js +137 -0
- package/src/runtime/00-core.js +308 -0
- package/src/runtime/10-animations.js +362 -0
- package/src/runtime/20-media.js +157 -0
- package/src/runtime/30-recorder.js +315 -0
- package/src/runtime/32-state.js +315 -0
- package/src/runtime/35-network.js +789 -0
- package/src/runtime/36-scripts.js +129 -0
- package/src/runtime/37-next.js +150 -0
- package/src/runtime/38-observers.js +252 -0
- package/src/runtime/40-input.js +795 -0
- package/src/runtime/45-hover.js +78 -0
- package/src/runtime/50-engine.js +341 -0
- package/src/runtime/60-preview.js +437 -0
- package/src/runtime/65-timeline.js +289 -0
- package/src/runtime/66-activity.js +114 -0
- package/src/runtime/67-csssource.js +226 -0
- package/src/runtime/70-boot.js +460 -0
- package/src/server/api.js +285 -0
- package/src/server/child.js +174 -0
- package/src/server/detect.js +167 -0
- package/src/server/front.js +647 -0
- package/src/server/mcp.js +332 -0
- package/src/shell/00-state.js +85 -0
- package/src/shell/05-api.js +136 -0
- package/src/shell/10-dock.js +754 -0
- package/src/shell/12-checkpoint.js +121 -0
- package/src/shell/15-session.js +291 -0
- package/src/shell/20-timeline.js +734 -0
- package/src/shell/25-input.js +537 -0
- package/src/shell/30-notes.js +870 -0
- package/src/shell/40-code.js +80 -0
- package/src/shell/90-handle.js +15 -0
- package/src/shell/shell.css +295 -0
- package/src/shell/shell.html +59 -0
- package/types/client.d.ts +73 -0
- package/types/index.d.ts +111 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Required Notice: Copyright (c) 2026 Rushil (https://github.com/LightningDesigner/retake)
|
|
2
|
+
|
|
3
|
+
# PolyForm Shield License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/shield/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncompete
|
|
34
|
+
|
|
35
|
+
Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
|
|
36
|
+
|
|
37
|
+
## Competition
|
|
38
|
+
|
|
39
|
+
Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
|
|
40
|
+
|
|
41
|
+
## New Products
|
|
42
|
+
|
|
43
|
+
If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
|
|
44
|
+
|
|
45
|
+
## Discontinued Products
|
|
46
|
+
|
|
47
|
+
You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
|
|
48
|
+
|
|
49
|
+
> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
|
|
50
|
+
|
|
51
|
+
## Sales of Business
|
|
52
|
+
|
|
53
|
+
If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
|
|
54
|
+
|
|
55
|
+
## Fair Use
|
|
56
|
+
|
|
57
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
58
|
+
|
|
59
|
+
## No Other Rights
|
|
60
|
+
|
|
61
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
62
|
+
|
|
63
|
+
## Patent Defense
|
|
64
|
+
|
|
65
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
66
|
+
|
|
67
|
+
## Violations
|
|
68
|
+
|
|
69
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
70
|
+
|
|
71
|
+
## No Liability
|
|
72
|
+
|
|
73
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
74
|
+
|
|
75
|
+
## Definitions
|
|
76
|
+
|
|
77
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
78
|
+
|
|
79
|
+
A **product** can be a good or service, or a combination of them.
|
|
80
|
+
|
|
81
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
82
|
+
|
|
83
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
|
|
84
|
+
|
|
85
|
+
**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
|
|
86
|
+
|
|
87
|
+
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
88
|
+
|
|
89
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
90
|
+
|
|
91
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Retake
|
|
2
|
+
|
|
3
|
+
A time machine for your dev server: Vite apps, Next.js, React Router and other
|
|
4
|
+
frameworks. A timeline docks over the bottom of your app and records everything from page load. Drag it back and the app is at that
|
|
5
|
+
moment. Ctrl-click the timeline to start a new take from there; the old one
|
|
6
|
+
stays as a lane you can click back into. Dev only: nothing ships in builds.
|
|
7
|
+
|
|
8
|
+
## Getting started
|
|
9
|
+
|
|
10
|
+
### Requirements
|
|
11
|
+
- Node 18 or newer
|
|
12
|
+
- A Vite 5+ app served from an `index.html` (React, Vue, Svelte, plain JS), or anything
|
|
13
|
+
else with a dev server that serves HTML (Next.js, React Router, Remix, Nuxt, SvelteKit, Astro...):
|
|
14
|
+
see [Frameworks](#frameworks).
|
|
15
|
+
- Chrome, Edge or another Chromium browser (that's what it's tested on)
|
|
16
|
+
|
|
17
|
+
### Try it without installing
|
|
18
|
+
From your app's folder:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npx retake-dev . # npm
|
|
22
|
+
pnpm dlx retake-dev . # pnpm
|
|
23
|
+
yarn dlx retake-dev . # yarn (berry)
|
|
24
|
+
bunx retake-dev . # bun
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Open the URL it prints (default http://localhost:3014). Your files aren't
|
|
28
|
+
touched: on a Vite app it runs your own `vite.config` through a wrapper kept in your temp
|
|
29
|
+
folder; on a framework it runs your `dev` script and sits in front of it.
|
|
30
|
+
Retake keeps its session in a `.retake/` folder in the app (it git-ignores itself).
|
|
31
|
+
|
|
32
|
+
> Use the full name `retake-dev` with `npx`/`dlx`. The short `retake` command only
|
|
33
|
+
> exists after you install `retake-dev` in the project (an unrelated npm package
|
|
34
|
+
> is called `retake`).
|
|
35
|
+
|
|
36
|
+
### Install it in the project
|
|
37
|
+
```sh
|
|
38
|
+
npm i -D retake-dev # or: pnpm add -D retake-dev / yarn add -D retake-dev / bun add -d retake-dev
|
|
39
|
+
npx retake . # same as above, now from node_modules
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or keep your usual `npm run dev` and add the plugin to `vite.config`:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
import { retake } from "retake-dev"
|
|
46
|
+
|
|
47
|
+
export default defineConfig({
|
|
48
|
+
plugins: [retake(), /* ...your plugins */],
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`retake()` only runs in `vite dev`; `vite build` output has no Retake code in it.
|
|
53
|
+
|
|
54
|
+
### Connect your coding agent (MCP)
|
|
55
|
+
Notes you leave in the dock can go straight to your coding agent. Register the
|
|
56
|
+
MCP server once, from the app folder. With Claude Code:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
claude mcp add retake -- npx -y retake-dev mcp
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Any other MCP client (Cursor, Codex, Windsurf...) takes the same command,
|
|
63
|
+
`npx -y retake-dev mcp`, in its MCP settings.
|
|
64
|
+
|
|
65
|
+
With the dev server running, the agent can `list_notes`, `get_note`,
|
|
66
|
+
`get_active_timeline`, `acknowledge`, `resolve`, `reply` and `watch_notes`.
|
|
67
|
+
It finds the server through `.retake/server.json` (or pass `--url http://localhost:3014`).
|
|
68
|
+
Acknowledging and resolving show up on the note in the dock right away.
|
|
69
|
+
|
|
70
|
+
### The first 60 seconds
|
|
71
|
+
1. **Use your app** for a few seconds: it's being recorded already.
|
|
72
|
+
2. **Pause** with space (or ⌥P, or the play button). The app goes view-only:
|
|
73
|
+
you can scroll, not click.
|
|
74
|
+
3. **Rewind**: drag the playhead back. Let go and the moment is rebuilt for real.
|
|
75
|
+
⌘-scroll on the timeline zooms in; ←/→ step frame by frame; F fits everything.
|
|
76
|
+
4. **Branch**: Ctrl-click the timeline at a moment to start a new take there.
|
|
77
|
+
It starts paused: press play and do something different. Click the other
|
|
78
|
+
lane to go back to the first take.
|
|
79
|
+
5. **Note**: hold ⌘ and click an element (or one of its animation layers),
|
|
80
|
+
write what should change, press Enter. "Copy for agent" copies a prompt with
|
|
81
|
+
the element, its React component, source file:line and CSS; or let your
|
|
82
|
+
agent pick it up over MCP.
|
|
83
|
+
|
|
84
|
+
### Uninstall
|
|
85
|
+
```sh
|
|
86
|
+
npm uninstall retake-dev # or pnpm remove / yarn remove / bun remove
|
|
87
|
+
rm -rf .retake # Retake's session and recordings
|
|
88
|
+
claude mcp remove retake # if you added the MCP server (or remove it in your client's MCP settings)
|
|
89
|
+
```
|
|
90
|
+
Remove `retake()` from `vite.config` if you added it.
|
|
91
|
+
|
|
92
|
+
## Commands
|
|
93
|
+
```sh
|
|
94
|
+
retake <project> # run the project's dev server with the timeline
|
|
95
|
+
retake <project> --port 4000
|
|
96
|
+
retake <project> --code-branches # each timeline keeps its own version of the code (Vite apps; rewrites files!)
|
|
97
|
+
retake <project> -- --host # anything after -- goes to the dev server
|
|
98
|
+
retake -- <dev command> # run that command with the timeline in front (retake -- next dev)
|
|
99
|
+
retake http://localhost:3000 # put the timeline in front of a dev server that's already running
|
|
100
|
+
retake init # print the vite.config lines
|
|
101
|
+
retake mcp # the MCP server your coding agent runs
|
|
102
|
+
```
|
|
103
|
+
`--root <dir>` puts `.retake/` somewhere else; `--verbose` logs every request the
|
|
104
|
+
front server handles to `.retake/front.log`. Opt out for one page load with `?retake=0`.
|
|
105
|
+
|
|
106
|
+
Recordings hold what you typed and what your API answered. Retake's front server
|
|
107
|
+
only answers on localhost; with the plugin and `vite --host`, anyone on your
|
|
108
|
+
network can read the session.
|
|
109
|
+
|
|
110
|
+
## Frameworks
|
|
111
|
+
|
|
112
|
+
Retake needs to put a small script first in the page the dock frames. A Vite
|
|
113
|
+
single-page app gets it from the Vite plugin. Anything that renders its own HTML
|
|
114
|
+
gets it from Retake's **front server**: Retake listens on its port (3014), serves
|
|
115
|
+
the dock there, and passes everything else through to your dev server, adding the
|
|
116
|
+
script to the frame's page as it streams. Assets, data fetches, server actions,
|
|
117
|
+
API routes and the HMR socket go through untouched. Your config isn't changed.
|
|
118
|
+
|
|
119
|
+
`retake .` works out which one you have. A project that depends on Next, Nuxt,
|
|
120
|
+
React Router (framework mode), Remix, SvelteKit, Astro, TanStack Start, SolidStart,
|
|
121
|
+
Vike, Waku or Analog gets the front server, in front of its `dev` script (run with
|
|
122
|
+
the package manager its lockfile names):
|
|
123
|
+
|
|
124
|
+
| Framework | Run | Notes |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| Vite SPA (React, Vue, Svelte, plain) | `npx retake-dev .` | or `plugins: [retake()]` in `vite.config` |
|
|
127
|
+
| Next.js | `npx retake-dev .` | tested on 16.3 (app and pages router, server actions) and 15.5 (app router), Turbopack |
|
|
128
|
+
| React Router 7 framework mode | `npx retake-dev .` | or `plugins: [retake(), reactRouter()]` and your usual `npm run dev`; tested on 7.18 |
|
|
129
|
+
| Remix 2 (Vite) | `npx retake-dev .` | tested on 2.17 |
|
|
130
|
+
| Astro | `npx retake-dev .` | tested on 7.3 with React islands and `<ClientRouter />` |
|
|
131
|
+
| SvelteKit | `npx retake-dev .` | tested on 3.0 (Svelte 5) |
|
|
132
|
+
| Nuxt | `npx retake-dev .` | tested on 4.5; runs `nuxt dev` behind Retake (it ignores `PORT`: `npx retake-dev . -- --port 3001` picks its port) |
|
|
133
|
+
| TanStack Start, SolidStart, Vike... | `npx retake-dev .` | untested; with Vite you can also add `retake()` to its Vite plugins |
|
|
134
|
+
| Anything else that serves HTML | `npx retake-dev -- <your dev command>` | e.g. `npx retake-dev -- npm run dev` |
|
|
135
|
+
| A dev server that's already running | `npx retake-dev http://localhost:3000` | `.retake/` goes in the current folder (or `--root`) |
|
|
136
|
+
|
|
137
|
+
Tested means: the app hydrates in the dock with no warning, and recording, scrubbing back, the rebuilt moment, Play, hot
|
|
138
|
+
updates and reloads all work, started either way (`retake .` or `retake http://localhost:…`). The untested ones go
|
|
139
|
+
through the same front server and should work; say so if one doesn't.
|
|
140
|
+
|
|
141
|
+
- **Which port?** Retake sets `PORT` to a free port for the dev command (or keeps
|
|
142
|
+
yours), and otherwise uses the first `http://localhost:…` the command prints, so
|
|
143
|
+
tools that ignore `PORT` work too. Ctrl-C stops the dev server with it.
|
|
144
|
+
- **The plugin in a Vite-based framework.** With `retake()` in `vite.config` and
|
|
145
|
+
no `index.html` in the Vite root, the plugin docks into the pages your framework
|
|
146
|
+
renders, on your usual dev server port: no second server.
|
|
147
|
+
- **Signing in.** Sign-in pages (OAuth) refuse to load inside a frame, so sign in
|
|
148
|
+
at your app's own port first (cookies on `localhost` are shared across ports),
|
|
149
|
+
then open Retake's. A sign-in redirect that comes back from another site gets the
|
|
150
|
+
plain page so it completes; reload to get the dock back.
|
|
151
|
+
- **Next 16.** Next 16 sends React debug data for every request over its HMR
|
|
152
|
+
socket (`experimental.reactDebugChannel`). Retake keeps it with the recording and
|
|
153
|
+
hands it back on replay, so nothing needs changing. Other Next versions with a
|
|
154
|
+
debug channel aren't known yet: if replayed navigations or server actions don't
|
|
155
|
+
show, Retake's warning says to set `experimental: { reactDebugChannel: false }`.
|
|
156
|
+
- **Exact replays on server-rendered pages.** Behind the front server the clock
|
|
157
|
+
starts once the page has loaded and nothing more is loading (so an app that
|
|
158
|
+
imports itself after load, like Nuxt's, has mounted), scripts and stylesheets added later (lazily
|
|
159
|
+
loaded chunks) run at their recorded moment, the dev server's own traffic (HMR)
|
|
160
|
+
is left out of the recording, and a rebuilt moment gets the page's HTML as it
|
|
161
|
+
was recorded (kept in `.retake/docs/`), not rendered again. Native `import()`
|
|
162
|
+
(Vite's lazy routes, Astro islands) can't be held to its moment.
|
|
163
|
+
- **Bottom of the app hidden by the dock?** The dock floats over the bottom of
|
|
164
|
+
the app (a cookie banner's buttons, Next's dev badge). Drag the dock's divider
|
|
165
|
+
down, or open the app with `?retake=0`.
|
|
166
|
+
- **Not rewound.** Retake rewinds the browser, not your server: database writes,
|
|
167
|
+
server sessions and server-action side effects stay as they are (replays answer
|
|
168
|
+
from the recording). Service workers are off while Retake is in front, and
|
|
169
|
+
`--code-branches` is Vite-only.
|
|
170
|
+
|
|
171
|
+
**Code per timeline** (`--code-branches`): when the source changes, the timeline
|
|
172
|
+
you're on takes the new code; the others keep theirs. Stepping into a timeline
|
|
173
|
+
checks its code out on disk (snapshots are kept in `.retake/`, and the newest
|
|
174
|
+
code is put back when the server stops), but use it on prototypes, not shared repos.
|
|
175
|
+
|
|
176
|
+
## How going back works
|
|
177
|
+
|
|
178
|
+
It doesn't snapshot the DOM. It records every input (pointer, keys, typing,
|
|
179
|
+
scrolling, back/forward), runs time on a virtual clock (timers, rAF, `Date`,
|
|
180
|
+
idle callbacks, CSS and Web Animations) and seeds randomness (`Math.random`,
|
|
181
|
+
`crypto`). Going back reloads the app and replays those inputs at full speed up
|
|
182
|
+
to the chosen moment, with the app's own code rebuilding the screen.
|
|
183
|
+
|
|
184
|
+
Server traffic is answered from the recording: `fetch` (streamed replies too,
|
|
185
|
+
chunk by chunk at the pace they arrived), `XMLHttpRequest`, `EventSource` and
|
|
186
|
+
`WebSocket`. So are observer callbacks and worker messages. Web storage,
|
|
187
|
+
cookies and IndexedDB go back to how they were when the recording began.
|
|
188
|
+
|
|
189
|
+
It rewinds the browser, not your server, so it suits prototypes whose backends
|
|
190
|
+
don't remember state. Not covered: the Cache API / service workers, and
|
|
191
|
+
cross-origin iframes.
|
|
192
|
+
|
|
193
|
+
Requires Node 18+, and Vite 5 or newer for Vite apps and the plugin.
|
|
194
|
+
|
|
195
|
+
## License
|
|
196
|
+
|
|
197
|
+
[PolyForm Shield 1.0.0](LICENSE). Use it, change it and share it for anything,
|
|
198
|
+
except building a product that competes with Retake.
|
package/bin/retake.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Retake CLI.
|
|
3
|
+
// retake [dev] <project> [--port 3014] [--code-branches] [-- ...dev server args]
|
|
4
|
+
// A Vite single-page app: runs the project's own Vite with the timeline
|
|
5
|
+
// added, from a wrapper config kept outside the project. A framework with
|
|
6
|
+
// its own dev server (Next, Nuxt, React Router, Remix, SvelteKit, Astro...):
|
|
7
|
+
// runs its dev command and puts Retake in front of it (src/server/front.js).
|
|
8
|
+
// Nothing in the project is touched (except with --code-branches, Vite
|
|
9
|
+
// only, which checks timelines' code out on disk).
|
|
10
|
+
// retake http://localhost:3000
|
|
11
|
+
// Puts Retake in front of a dev server that's already running.
|
|
12
|
+
// retake -- <dev command>
|
|
13
|
+
// Runs that command (e.g. `retake -- next dev`) and puts Retake in front.
|
|
14
|
+
// retake init
|
|
15
|
+
// Prints the lines that add Retake to a project's vite.config instead.
|
|
16
|
+
// retake mcp
|
|
17
|
+
// MCP server (stdio) for coding agents, talking to a running dev server.
|
|
18
|
+
import fs from "node:fs"
|
|
19
|
+
import os from "node:os"
|
|
20
|
+
import path from "node:path"
|
|
21
|
+
import crypto from "node:crypto"
|
|
22
|
+
import { spawn } from "node:child_process"
|
|
23
|
+
import { createRequire } from "node:module"
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
25
|
+
import { detectProject, nextDebugChannelWarning, shellQuote, withArgs } from "../src/server/detect.js"
|
|
26
|
+
import { runDevCommand } from "../src/server/child.js"
|
|
27
|
+
|
|
28
|
+
const HOME = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
29
|
+
const PKG = JSON.parse(fs.readFileSync(path.join(HOME, "package.json"), "utf8"))
|
|
30
|
+
const CONFIGS = ["vite.config.ts", "vite.config.mts", "vite.config.cts", "vite.config.js", "vite.config.mjs", "vite.config.cjs"]
|
|
31
|
+
const USAGE = `Retake ${PKG.version}: a time machine for your dev server
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
retake <project> run the project's dev server with the timeline
|
|
35
|
+
(Vite apps, Next, Nuxt, React Router, Remix,
|
|
36
|
+
SvelteKit, Astro...)
|
|
37
|
+
retake dev <project> [options] same thing
|
|
38
|
+
retake http://localhost:3000 put the timeline in front of a running dev server
|
|
39
|
+
retake -- <dev command> run that command with the timeline in front
|
|
40
|
+
(e.g. retake -- next dev, retake -- pnpm dev)
|
|
41
|
+
retake init print the vite.config lines instead
|
|
42
|
+
retake mcp [--url http://localhost:3014]
|
|
43
|
+
MCP server for coding agents (stdio)
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--port <n> port to serve on (default 3014)
|
|
47
|
+
--root <dir> where .retake/ (sessions, recordings) goes
|
|
48
|
+
(default: the project, or this folder)
|
|
49
|
+
--code-branches each timeline keeps its own version of the code
|
|
50
|
+
(Vite apps only; rewrites files on disk)
|
|
51
|
+
--verbose log every request to .retake/front.log (front server)
|
|
52
|
+
-- after a project: arguments for its dev server
|
|
53
|
+
(e.g. retake . -- --host); without one: the dev command
|
|
54
|
+
-v, --version print the version
|
|
55
|
+
-h, --help show this help`
|
|
56
|
+
|
|
57
|
+
/** @returns {never} */
|
|
58
|
+
function fail(msg, hint) {
|
|
59
|
+
console.error(`retake: ${msg}`)
|
|
60
|
+
if (hint) console.error(` ${hint}`)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Minimal argv parsing with validation. After `--`: with no project, the dev
|
|
65
|
+
// command to run (`retake -- next dev`); otherwise arguments for the dev server.
|
|
66
|
+
export function parseArgs(argv) {
|
|
67
|
+
const dash = argv.indexOf("--")
|
|
68
|
+
const own = dash < 0 ? argv : argv.slice(0, dash)
|
|
69
|
+
let passthrough = dash < 0 ? [] : argv.slice(dash + 1)
|
|
70
|
+
/** @type {{ cmd: string | null, project: string | null, upstream: string | null, command: string[] | null, root: string | null, verbose: boolean,
|
|
71
|
+
* port: number, portSet?: boolean, codeBranches: boolean, url: string | null, passthrough: string[], help: boolean, version: boolean }} */
|
|
72
|
+
const out = { cmd: null, project: null, upstream: null, command: null, root: null, verbose: false, port: 3014, codeBranches: false, url: null, passthrough, help: false, version: false }
|
|
73
|
+
const positional = []
|
|
74
|
+
const value = (a, i, what) => {
|
|
75
|
+
const v = a.includes("=") ? a.slice(a.indexOf("=") + 1) : own[i]
|
|
76
|
+
if (v == null || v === "") throw new Error(`${a.split("=")[0]} needs ${what}`)
|
|
77
|
+
return v
|
|
78
|
+
}
|
|
79
|
+
for (let i = 0; i < own.length; i++) {
|
|
80
|
+
const a = own[i]
|
|
81
|
+
if (a === "-h" || a === "--help") out.help = true
|
|
82
|
+
else if (a === "-v" || a === "--version") out.version = true
|
|
83
|
+
else if (a === "--code-branches") out.codeBranches = true
|
|
84
|
+
else if (a === "--verbose") out.verbose = true
|
|
85
|
+
else if (a === "--port" || a.startsWith("--port=")) {
|
|
86
|
+
const v = a.includes("=") ? a.split("=")[1] : own[++i]
|
|
87
|
+
const n = Number(v)
|
|
88
|
+
if (v == null || v === "" || !Number.isInteger(n) || n < 1 || n > 65535) throw new Error(`--port needs a number between 1 and 65535 (got ${v == null ? "nothing" : JSON.stringify(v)})`)
|
|
89
|
+
out.port = n
|
|
90
|
+
out.portSet = true
|
|
91
|
+
} else if (a === "--url" || a.startsWith("--url=")) {
|
|
92
|
+
out.url = a.includes("=") ? a.split("=")[1] : own[++i]
|
|
93
|
+
if (!out.url) throw new Error("--url needs a value, like http://localhost:3014")
|
|
94
|
+
} else if (a === "--root" || a.startsWith("--root=")) {
|
|
95
|
+
out.root = value(a, a.includes("=") ? i : ++i, "a folder")
|
|
96
|
+
} else if (a.startsWith("-")) throw new Error(`unknown option ${a} (pass dev server options after --, e.g. retake . -- ${a})`)
|
|
97
|
+
else positional.push(a)
|
|
98
|
+
}
|
|
99
|
+
if (["dev", "init", "mcp", "help"].includes(positional[0])) out.cmd = positional.shift()
|
|
100
|
+
else if (positional.length) out.cmd = "dev" // a bare path (or URL) means dev
|
|
101
|
+
if (out.cmd === "help") out.help = true
|
|
102
|
+
const target = positional.shift() ?? null
|
|
103
|
+
if (target && /^https?:\/\//i.test(target)) {
|
|
104
|
+
try {
|
|
105
|
+
out.upstream = new URL(target).href
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`${JSON.stringify(target)} isn't a URL`)
|
|
108
|
+
}
|
|
109
|
+
} else out.project = target
|
|
110
|
+
if (positional.length) throw new Error(`unexpected argument ${JSON.stringify(positional[0])}`)
|
|
111
|
+
// `retake -- next dev`: no project, and the first word isn't an option.
|
|
112
|
+
if (!out.project && !out.upstream && passthrough.length && !passthrough[0].startsWith("-") && (out.cmd === null || out.cmd === "dev")) {
|
|
113
|
+
out.command = passthrough
|
|
114
|
+
out.passthrough = passthrough = []
|
|
115
|
+
out.cmd = "dev"
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The project's own Vite (so its plugins match), found the way Node would find
|
|
121
|
+
// it from the project, walking up to a workspace root. Falls back to ours.
|
|
122
|
+
export function resolveVite(project) {
|
|
123
|
+
const ours = path.join(HOME, "package.json")
|
|
124
|
+
for (const from of [path.join(project, "package.json"), ours]) {
|
|
125
|
+
try {
|
|
126
|
+
const pkgPath = createRequire(from).resolve("vite/package.json")
|
|
127
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
|
128
|
+
const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin && pkg.bin.vite
|
|
129
|
+
return { bin: path.join(path.dirname(pkgPath), bin || "bin/vite.js"), version: pkg.version, own: from === ours }
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Wrapper config and Vite's dep cache live outside the project, keyed by the
|
|
136
|
+
// project's absolute path so two projects with the same folder name don't clash.
|
|
137
|
+
export function workDir(project) {
|
|
138
|
+
const hash = crypto.createHash("sha1").update(project).digest("hex").slice(0, 10)
|
|
139
|
+
const base = process.env.RETAKE_CACHE_DIR || path.join(os.tmpdir(), "retake")
|
|
140
|
+
return path.join(base, `${path.basename(project)}-${hash}`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function dev(opts) {
|
|
144
|
+
if (opts.upstream || opts.command) {
|
|
145
|
+
if (opts.codeBranches) fail("--code-branches only works on Vite apps (retake <project>)")
|
|
146
|
+
const root = path.resolve(opts.root || ".")
|
|
147
|
+
// `retake -- "next dev --turbo"` (one word) is a command line as it is.
|
|
148
|
+
const command = opts.command && (opts.command.length === 1 ? opts.command[0] : opts.command.map(shellQuote).join(" "))
|
|
149
|
+
// Run from a framework's folder, it's that framework (it tunes the runtime).
|
|
150
|
+
const here = opts.command ? detectProject(process.cwd()) : null
|
|
151
|
+
const framework = here && here.mode === "front" ? here.framework : null
|
|
152
|
+
return front({ ...opts, root, upstream: opts.upstream, command, cwd: process.cwd(), framework, dir: framework ? process.cwd() : null })
|
|
153
|
+
}
|
|
154
|
+
const project = path.resolve(opts.project || ".")
|
|
155
|
+
if (!fs.existsSync(project)) fail(`${project} doesn't exist`)
|
|
156
|
+
if (!fs.existsSync(path.join(project, "package.json"))) fail(`no package.json in ${project}`, "point retake at your app's folder, e.g. retake ./my-app, or run retake -- <your dev command>")
|
|
157
|
+
const found = detectProject(project)
|
|
158
|
+
if (found.mode === "front") {
|
|
159
|
+
if (opts.codeBranches) fail(`--code-branches only works on Vite apps, not ${found.framework}`, "drop --code-branches; everything else works the same")
|
|
160
|
+
if (found.framework === "next") {
|
|
161
|
+
const warning = nextDebugChannelWarning(project)
|
|
162
|
+
if (warning) console.warn(`retake: ${warning}`)
|
|
163
|
+
}
|
|
164
|
+
return front({ ...opts, root: path.resolve(opts.root || project), command: withArgs(found.command, opts.passthrough, found.pm), cwd: project, framework: found.framework, dir: project })
|
|
165
|
+
}
|
|
166
|
+
if (found.mode !== "vite") {
|
|
167
|
+
fail(`${found.reason}`, `run your dev server through Retake instead: retake -- <your dev command> (e.g. retake -- npm run dev), or retake http://localhost:<port> if it's already running`)
|
|
168
|
+
}
|
|
169
|
+
viteDev(project, opts)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A Vite single-page app: the project's own Vite, with the plugin added from a
|
|
173
|
+
// wrapper config outside the project.
|
|
174
|
+
function viteDev(project, opts) {
|
|
175
|
+
const vite = resolveVite(project)
|
|
176
|
+
if (!vite) fail(`vite isn't installed for ${project}`, "run your package manager's install there first")
|
|
177
|
+
if (vite.own) console.warn(`retake: vite not found from ${project}; using retake's own vite ${vite.version}`)
|
|
178
|
+
const config = CONFIGS.map((f) => path.join(project, f)).find((f) => fs.existsSync(f))
|
|
179
|
+
const work = workDir(project)
|
|
180
|
+
fs.mkdirSync(work, { recursive: true })
|
|
181
|
+
// One wrapper (and dep cache) per port: Vite watches its config, so two runs
|
|
182
|
+
// on one project sharing a wrapper would restart each other on the wrong port.
|
|
183
|
+
const wrapper = path.join(work, `vite.config.${opts.port}.mjs`)
|
|
184
|
+
const url = (p) => JSON.stringify(pathToFileURL(p).href)
|
|
185
|
+
fs.writeFileSync(
|
|
186
|
+
wrapper,
|
|
187
|
+
`// Generated by retake; regenerated on every run.
|
|
188
|
+
import { retake } from ${url(path.join(HOME, "src", "plugin.js"))}
|
|
189
|
+
${config ? `import base from ${url(config)}` : "const base = {}"}
|
|
190
|
+
|
|
191
|
+
export default async (env) => {
|
|
192
|
+
const cfg = (typeof base === "function" ? await base(env) : await base) || {}
|
|
193
|
+
return {
|
|
194
|
+
...cfg,
|
|
195
|
+
root: cfg.root ? cfg.root : ${JSON.stringify(project)},
|
|
196
|
+
// Our own dep cache, so the project's node_modules/.vite is left alone.
|
|
197
|
+
cacheDir: ${JSON.stringify(path.join(work, `vite-${opts.port}`))},
|
|
198
|
+
plugins: [retake({ codeBranches: ${opts.codeBranches}, banner: true${opts.root ? `, root: ${JSON.stringify(path.resolve(opts.root))}` : ""} }), ...(cfg.plugins || [])],
|
|
199
|
+
server: { ...cfg.server, port: ${opts.port}, strictPort: true },
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
`,
|
|
203
|
+
)
|
|
204
|
+
const child = spawn(process.execPath, [vite.bin, "--config", wrapper, ...opts.passthrough], {
|
|
205
|
+
cwd: project,
|
|
206
|
+
stdio: "inherit",
|
|
207
|
+
env: { ...process.env, VITE_CONFIG_NATIVE_IGNORE_WARNING: "true", RETAKE_PROJECT: project },
|
|
208
|
+
})
|
|
209
|
+
child.on("exit", (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
|
|
210
|
+
for (const sig of /** @type {NodeJS.Signals[]} */ (["SIGINT", "SIGTERM", "SIGHUP"])) process.on(sig, () => child.kill(sig))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Front-server mode: Retake on --port, in front of a dev server that's
|
|
214
|
+
// running (`upstream`) or that `command` starts.
|
|
215
|
+
async function front(opts) {
|
|
216
|
+
const { startFront } = await import(pathToFileURL(path.join(HOME, "src", "server", "front.js")).href)
|
|
217
|
+
// Where the dev command listens isn't known until it says (or answers).
|
|
218
|
+
let found = /** @type {{ resolve: (url: string) => void, reject: (err: any) => void } | null} */ (null)
|
|
219
|
+
const upstream = opts.upstream || new Promise((resolve, reject) => (found = { resolve, reject }))
|
|
220
|
+
let label = opts.upstream ? new URL(opts.upstream).host : opts.command
|
|
221
|
+
let server
|
|
222
|
+
try {
|
|
223
|
+
server = await startFront({
|
|
224
|
+
upstream,
|
|
225
|
+
port: opts.port,
|
|
226
|
+
root: opts.root,
|
|
227
|
+
verbose: opts.verbose,
|
|
228
|
+
framework: opts.framework,
|
|
229
|
+
dir: opts.dir,
|
|
230
|
+
watch: opts.dir || (opts.command ? opts.cwd : null), // whose edits retire kept pages
|
|
231
|
+
get label() {
|
|
232
|
+
return label // the waiting page's "Waiting for next dev on :3015…"
|
|
233
|
+
},
|
|
234
|
+
})
|
|
235
|
+
} catch (err) {
|
|
236
|
+
if (err.code === "EADDRINUSE") fail(`port ${opts.port} is in use`, `pick another with --port, e.g. --port ${opts.port === 65535 ? 3014 : opts.port + 1}`)
|
|
237
|
+
throw err
|
|
238
|
+
}
|
|
239
|
+
// "Retake timeline docked at http://localhost:3014 (in front of next dev on :3015)"
|
|
240
|
+
const banner = (url, what) => {
|
|
241
|
+
console.log(`\n \x1b[1mRetake\x1b[0m timeline docked at ${server.url} (in front of ${what})`)
|
|
242
|
+
console.log(` Using sign-in? Sign in at ${new URL(url).origin} first.\n`)
|
|
243
|
+
}
|
|
244
|
+
const stop = async (code) => {
|
|
245
|
+
await server.close().catch(() => {})
|
|
246
|
+
process.exit(code)
|
|
247
|
+
}
|
|
248
|
+
if (opts.upstream) {
|
|
249
|
+
banner(opts.upstream, new URL(opts.upstream).origin)
|
|
250
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => stop(0))
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
// PORT is the dev server's, unless it's the one Retake took.
|
|
254
|
+
const env = { ...process.env }
|
|
255
|
+
if (Number(env.PORT) === server.port) delete env.PORT
|
|
256
|
+
const dev = await runDevCommand(opts.command, { cwd: opts.cwd, env })
|
|
257
|
+
if (dev.portTaken) console.warn(`retake: PORT ${dev.portTaken} is already in use; the dev command gets PORT=${dev.port}`)
|
|
258
|
+
label = `${opts.command} on :${dev.port}`
|
|
259
|
+
for (const sig of /** @type {NodeJS.Signals[]} */ (["SIGINT", "SIGTERM", "SIGHUP"])) {
|
|
260
|
+
process.on(sig, () => {
|
|
261
|
+
dev.stop(sig)
|
|
262
|
+
// A dev server that ignores the signal gets SIGKILL after 5 s.
|
|
263
|
+
setTimeout(() => dev.stop("SIGKILL"), 5000).unref()
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
// Exit with the dev command's exit code, once what it started has stopped
|
|
267
|
+
// too (a wrapper can exit on Ctrl-C before its server does: that one gets
|
|
268
|
+
// SIGKILL 5 s on, not left running on its own).
|
|
269
|
+
dev.exited.then(async (code) => {
|
|
270
|
+
await dev.reap(5000)
|
|
271
|
+
stop(code)
|
|
272
|
+
})
|
|
273
|
+
try {
|
|
274
|
+
const url = await dev.url
|
|
275
|
+
found?.resolve(url)
|
|
276
|
+
label = `${opts.command} on :${new URL(url).port}`
|
|
277
|
+
banner(url, label)
|
|
278
|
+
} catch (err) {
|
|
279
|
+
found?.reject(err)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function init() {
|
|
283
|
+
console.log(`Add Retake to vite.config (dev only; it does nothing in builds):
|
|
284
|
+
|
|
285
|
+
import { retake } from "retake-dev"
|
|
286
|
+
|
|
287
|
+
export default defineConfig({
|
|
288
|
+
plugins: [retake(), /* ...your plugins */],
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
Options: retake({ codeBranches: true }) gives each timeline its own version of the code.
|
|
292
|
+
Or leave the project untouched and run: npx retake-dev .
|
|
293
|
+
(Next, Nuxt, React Router, SvelteKit, Astro... too: it puts Retake in front of your dev server.)`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function main() {
|
|
297
|
+
let opts
|
|
298
|
+
try {
|
|
299
|
+
opts = parseArgs(process.argv.slice(2))
|
|
300
|
+
} catch (err) {
|
|
301
|
+
fail(err.message, "run retake --help for usage")
|
|
302
|
+
}
|
|
303
|
+
if (opts.version) return console.log(PKG.version)
|
|
304
|
+
if (opts.help || !opts.cmd) {
|
|
305
|
+
console.log(USAGE)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
if (opts.cmd === "dev") return dev(opts).catch((err) => fail(err.message))
|
|
309
|
+
if (opts.cmd === "init") return init()
|
|
310
|
+
if (opts.cmd === "mcp") {
|
|
311
|
+
const { runMcp } = await import(pathToFileURL(path.join(HOME, "src", "server", "mcp.js")).href)
|
|
312
|
+
// Without --url/--port it finds the server from <cwd>/.retake/server.json.
|
|
313
|
+
return runMcp({ url: opts.url || process.env.RETAKE_URL || (opts.portSet ? `http://localhost:${opts.port}` : null) })
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Only run when executed, not when imported by tests.
|
|
318
|
+
const invoked = process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
319
|
+
if (invoked) main()
|