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/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "retake-dev",
|
|
3
|
+
"version": "0.4.0",
|
|
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
|
+
"homepage": "https://retake-omega.vercel.app",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/LightningDesigner/retake.git",
|
|
9
|
+
"directory": "packages/retake"
|
|
10
|
+
},
|
|
11
|
+
"bugs": "https://github.com/LightningDesigner/retake/issues",
|
|
12
|
+
"author": "Rushil",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./src/plugin.js",
|
|
15
|
+
"types": "./types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./types/index.d.ts",
|
|
19
|
+
"default": "./src/plugin.js"
|
|
20
|
+
},
|
|
21
|
+
"./client": {
|
|
22
|
+
"types": "./types/client.d.ts"
|
|
23
|
+
},
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"retake": "bin/retake.js",
|
|
28
|
+
"retake-dev": "bin/retake.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"src",
|
|
33
|
+
"!src/shell/mock",
|
|
34
|
+
"types",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"vite": ">=5"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"vite": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@playwright/test": "^1.63.0",
|
|
51
|
+
"@react-spring/web": "^10.1.2",
|
|
52
|
+
"@types/node": "^26.6.4",
|
|
53
|
+
"@vitejs/plugin-react": "^5.2.0",
|
|
54
|
+
"framer-motion": "^12.43.0",
|
|
55
|
+
"gsap": "^3.15.0",
|
|
56
|
+
"lottie-web": "^5.13.0",
|
|
57
|
+
"react": "^19.3.0",
|
|
58
|
+
"react-dom": "^19.3.0",
|
|
59
|
+
"react-router-dom": "^7.18.4",
|
|
60
|
+
"typescript": "^7.0.2",
|
|
61
|
+
"vite": "^8.3.1"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"check": "node test/check-syntax.js",
|
|
65
|
+
"typecheck": "node scripts/typecheck.mjs",
|
|
66
|
+
"test": "node test/check-syntax.js && node scripts/typecheck.mjs && playwright test",
|
|
67
|
+
"test:engine": "playwright test --project=engine",
|
|
68
|
+
"test:shell": "playwright test --project=shell"
|
|
69
|
+
},
|
|
70
|
+
"keywords": [
|
|
71
|
+
"vite",
|
|
72
|
+
"vite-plugin",
|
|
73
|
+
"nextjs",
|
|
74
|
+
"react-router",
|
|
75
|
+
"time-travel",
|
|
76
|
+
"prototype",
|
|
77
|
+
"devtools",
|
|
78
|
+
"replay",
|
|
79
|
+
"remix",
|
|
80
|
+
"astro",
|
|
81
|
+
"sveltekit",
|
|
82
|
+
"nuxt",
|
|
83
|
+
"time-machine",
|
|
84
|
+
"mcp"
|
|
85
|
+
],
|
|
86
|
+
"license": "PolyForm-Shield-1.0.0"
|
|
87
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Code branches (opt-in): every time the project's source changes, snapshot it
|
|
2
|
+
// as a version. The dock gives each timeline a version and checks an older one
|
|
3
|
+
// back out when you step into its timeline.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here may lose work:
|
|
6
|
+
// - Versions live on disk (<root>/.retake/versions + content-addressed blobs),
|
|
7
|
+
// not in memory, so a crash or quit never takes a version with it.
|
|
8
|
+
// - Every checkout first snapshots what's on disk right now, synchronously, and
|
|
9
|
+
// refuses to touch anything if that snapshot can't be made in full.
|
|
10
|
+
// - Only files a snapshot captured are ever written or deleted. A path the
|
|
11
|
+
// target version didn't capture (skipped for size, ignored, never seen) is
|
|
12
|
+
// left alone, never deleted.
|
|
13
|
+
// - Writes are atomic (temp file + rename) and journaled; a checkout that was
|
|
14
|
+
// interrupted is rolled back on the next start.
|
|
15
|
+
// - When the dev server exits, the newest version is put back on disk.
|
|
16
|
+
import crypto from "node:crypto"
|
|
17
|
+
import fs from "node:fs"
|
|
18
|
+
import path from "node:path"
|
|
19
|
+
import { execFileSync } from "node:child_process"
|
|
20
|
+
|
|
21
|
+
const SKIP = new Set(["node_modules", ".git", ".retake", "dist", "build", ".cache", ".vite", ".next", "coverage"])
|
|
22
|
+
const MAX_FILES = 5000
|
|
23
|
+
const MAX_BYTES = 5 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
const sha1 = (buf) => crypto.createHash("sha1").update(buf).digest("hex")
|
|
26
|
+
|
|
27
|
+
export function createStore(root) {
|
|
28
|
+
const dir = path.join(root, ".retake")
|
|
29
|
+
const blobs = path.join(dir, "blobs")
|
|
30
|
+
const versionsDir = path.join(dir, "versions")
|
|
31
|
+
const journalFile = path.join(dir, "journal.json")
|
|
32
|
+
const stateFile = path.join(dir, "code-state.json")
|
|
33
|
+
fs.mkdirSync(blobs, { recursive: true })
|
|
34
|
+
fs.mkdirSync(versionsDir, { recursive: true })
|
|
35
|
+
const ignore = path.join(dir, ".gitignore")
|
|
36
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n")
|
|
37
|
+
|
|
38
|
+
function atomicWrite(file, data) {
|
|
39
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
40
|
+
const tmp = `${file}.retake-${process.pid}-${Date.now()}.tmp`
|
|
41
|
+
fs.writeFileSync(tmp, data)
|
|
42
|
+
fs.renameSync(tmp, file)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const readJson = (file, /** @type {any} */ fallback = null) => {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(fs.readFileSync(file, "utf8"))
|
|
48
|
+
} catch {
|
|
49
|
+
return fallback
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isGit() {
|
|
54
|
+
try {
|
|
55
|
+
return execFileSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim().length > 0
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const git = isGit()
|
|
61
|
+
|
|
62
|
+
// The files a snapshot owns: in a git repo, tracked + untracked-not-ignored;
|
|
63
|
+
// otherwise a walk that skips dot entries and build/dependency folders.
|
|
64
|
+
function candidates() {
|
|
65
|
+
if (git) {
|
|
66
|
+
const out = execFileSync("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--full-name", "."], { maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] })
|
|
67
|
+
const top = execFileSync("git", ["-C", root, "rev-parse", "--show-toplevel"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim()
|
|
68
|
+
const rels = new Set()
|
|
69
|
+
for (const name of out.toString().split("\0")) {
|
|
70
|
+
if (!name) continue
|
|
71
|
+
const rel = path.relative(root, path.join(top, name))
|
|
72
|
+
if (rel.startsWith("..") || rel.split(path.sep).some((p) => SKIP.has(p))) continue
|
|
73
|
+
rels.add(rel)
|
|
74
|
+
}
|
|
75
|
+
return [...rels].sort()
|
|
76
|
+
}
|
|
77
|
+
const rels = []
|
|
78
|
+
const walk = (d) => {
|
|
79
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
80
|
+
if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue
|
|
81
|
+
const full = path.join(d, entry.name)
|
|
82
|
+
if (entry.isDirectory()) walk(full)
|
|
83
|
+
else if (entry.isFile()) rels.push(path.relative(root, full))
|
|
84
|
+
if (rels.length > MAX_FILES) return
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
walk(root)
|
|
88
|
+
return rels.sort()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Snapshot what's on disk now. Returns the version id. Throws if it can't
|
|
92
|
+
// capture the tree completely (too many files, unreadable file): callers
|
|
93
|
+
// must not change anything then.
|
|
94
|
+
function snapshot() {
|
|
95
|
+
const rels = candidates()
|
|
96
|
+
if (rels.length > MAX_FILES) throw new Error(`more than ${MAX_FILES} files; code branches are for small prototypes`)
|
|
97
|
+
const files = {}
|
|
98
|
+
const skipped = []
|
|
99
|
+
for (const rel of rels) {
|
|
100
|
+
const full = path.join(root, rel)
|
|
101
|
+
let st
|
|
102
|
+
try {
|
|
103
|
+
st = fs.lstatSync(full)
|
|
104
|
+
} catch {
|
|
105
|
+
continue // listed but gone (e.g. a tracked file deleted on disk): not part of this version
|
|
106
|
+
}
|
|
107
|
+
if (!st.isFile()) continue
|
|
108
|
+
if (st.size > MAX_BYTES) {
|
|
109
|
+
skipped.push(rel)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const buf = fs.readFileSync(full)
|
|
113
|
+
const h = sha1(buf)
|
|
114
|
+
const blob = path.join(blobs, h)
|
|
115
|
+
if (!fs.existsSync(blob)) atomicWrite(blob, buf)
|
|
116
|
+
files[rel] = h
|
|
117
|
+
}
|
|
118
|
+
const manifest = { files, skipped: skipped.sort() }
|
|
119
|
+
const id = sha1(JSON.stringify(manifest)).slice(0, 10)
|
|
120
|
+
const file = path.join(versionsDir, `${id}.json`)
|
|
121
|
+
if (!fs.existsSync(file)) atomicWrite(file, JSON.stringify({ id, at: Date.now(), ...manifest }))
|
|
122
|
+
return id
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const load = (id) => (id && /^[0-9a-f]{10}$/.test(id) ? readJson(path.join(versionsDir, `${id}.json`)) : null)
|
|
126
|
+
|
|
127
|
+
// What turning version `from` (what's on disk, just snapshotted) into `to`
|
|
128
|
+
// means: writes for paths `to` captured that differ, deletes for paths `from`
|
|
129
|
+
// captured that `to` positively didn't have. Nothing else is touched.
|
|
130
|
+
function plan(from, to) {
|
|
131
|
+
const writes = []
|
|
132
|
+
const deletes = []
|
|
133
|
+
for (const [rel, h] of Object.entries(to.files)) if (from.files[rel] !== h) writes.push([rel, h])
|
|
134
|
+
const toSkipped = new Set(to.skipped)
|
|
135
|
+
for (const rel of Object.keys(from.files)) {
|
|
136
|
+
if (rel in to.files || toSkipped.has(rel)) continue
|
|
137
|
+
deletes.push(rel)
|
|
138
|
+
}
|
|
139
|
+
return { writes, deletes }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function apply(p, onFile) {
|
|
143
|
+
for (const [rel, h] of p.writes) {
|
|
144
|
+
const full = path.join(root, rel)
|
|
145
|
+
atomicWrite(full, fs.readFileSync(path.join(blobs, h)))
|
|
146
|
+
onFile && onFile(full, h)
|
|
147
|
+
}
|
|
148
|
+
for (const rel of p.deletes) {
|
|
149
|
+
const full = path.join(root, rel)
|
|
150
|
+
try {
|
|
151
|
+
fs.unlinkSync(full)
|
|
152
|
+
} catch {}
|
|
153
|
+
onFile && onFile(full, null)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Put version `id` on disk. Snapshots first; returns { ok, from, error }.
|
|
158
|
+
function checkout(id, onFile) {
|
|
159
|
+
const to = load(id)
|
|
160
|
+
if (!to) return { ok: false, error: `unknown version ${id}` }
|
|
161
|
+
let fromId
|
|
162
|
+
try {
|
|
163
|
+
fromId = snapshot()
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return { ok: false, error: `refusing to switch code: couldn't snapshot the current files first (${err.message})` }
|
|
166
|
+
}
|
|
167
|
+
if (fromId === id) return { ok: true, from: fromId }
|
|
168
|
+
const from = load(fromId)
|
|
169
|
+
atomicWrite(journalFile, JSON.stringify({ from: fromId, to: id, at: Date.now() }))
|
|
170
|
+
apply(plan(from, to), onFile)
|
|
171
|
+
fs.rmSync(journalFile, { force: true })
|
|
172
|
+
return { ok: true, from: fromId }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// An interrupted checkout leaves a journal: put the `from` version back.
|
|
176
|
+
function recover(onFile) {
|
|
177
|
+
const j = readJson(journalFile)
|
|
178
|
+
if (!j) return null
|
|
179
|
+
const from = load(j.from)
|
|
180
|
+
if (!from) {
|
|
181
|
+
fs.rmSync(journalFile, { force: true })
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
let nowId
|
|
185
|
+
try {
|
|
186
|
+
nowId = snapshot() // the half-switched state is kept as a version too
|
|
187
|
+
} catch {
|
|
188
|
+
return { error: "journal found but the tree can't be snapshotted; leaving files as they are" }
|
|
189
|
+
}
|
|
190
|
+
apply(plan(load(nowId), from), onFile)
|
|
191
|
+
fs.rmSync(journalFile, { force: true })
|
|
192
|
+
return { restored: j.from }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const state = () => readJson(stateFile, {})
|
|
196
|
+
const saveState = (s) => atomicWrite(stateFile, JSON.stringify(s))
|
|
197
|
+
|
|
198
|
+
return { dir, snapshot, load, checkout, recover, state, saveState, plan }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {import("vite").ViteDevServer} server
|
|
203
|
+
* @param {{ token?: string, bus?: ReturnType<typeof import("./server/api.js").createBus> }} [options]
|
|
204
|
+
*/
|
|
205
|
+
export function codeVersions(server, { token, bus } = {}) {
|
|
206
|
+
const announce = () => bus && bus.emit("code-version", { version: current, newest })
|
|
207
|
+
const root = server.config.root
|
|
208
|
+
const store = createStore(root)
|
|
209
|
+
const log = (msg) => server.config.logger.info(` retake: ${msg}`, { timestamp: true })
|
|
210
|
+
const warn = (msg) => server.config.logger.warn(` retake: ${msg}`, { timestamp: true })
|
|
211
|
+
|
|
212
|
+
// Files this process wrote or deleted during a checkout, so the watcher can
|
|
213
|
+
// tell our own writes from the user's edits.
|
|
214
|
+
const selfWrites = new Map() // full path -> hash (null for a delete)
|
|
215
|
+
function invalidate(full) {
|
|
216
|
+
/** @type {any[]} Vite's module graph and each environment's (Vite 6+) */
|
|
217
|
+
const graphs = [server.moduleGraph, ...Object.values(server.environments || {}).map((e) => e.moduleGraph)]
|
|
218
|
+
for (const graph of graphs) {
|
|
219
|
+
for (const mod of (graph && graph.getModulesByFile && graph.getModulesByFile(full)) || []) graph.invalidateModule(mod)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const onFile = (full, h) => {
|
|
223
|
+
selfWrites.set(full, h)
|
|
224
|
+
invalidate(full)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const rec = store.recover(onFile)
|
|
228
|
+
// True while the files on disk are what this server put back at startup
|
|
229
|
+
// (a rolled-back checkout, or the newest code after a crash), until the next
|
|
230
|
+
// edit or checkout. The dock then checks out its active timeline's own code
|
|
231
|
+
// instead of adopting what it finds on disk.
|
|
232
|
+
let restoredAtStart = false
|
|
233
|
+
if (rec && rec.restored) {
|
|
234
|
+
warn(`an interrupted code checkout was rolled back to version ${rec.restored}`)
|
|
235
|
+
restoredAtStart = true
|
|
236
|
+
}
|
|
237
|
+
if (rec && rec.error) warn(rec.error)
|
|
238
|
+
|
|
239
|
+
let current
|
|
240
|
+
let newest
|
|
241
|
+
try {
|
|
242
|
+
current = store.snapshot()
|
|
243
|
+
} catch (err) {
|
|
244
|
+
warn(`code branches are off: ${err.message}`)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
// Last run ended on an older timeline's code without getting to restore
|
|
248
|
+
// (a crash)? If nothing changed since, put the newest version back.
|
|
249
|
+
const prev = store.state()
|
|
250
|
+
if (prev.newest && prev.current && prev.current !== prev.newest && prev.current === current && store.load(prev.newest)) {
|
|
251
|
+
const r = store.checkout(prev.newest, onFile)
|
|
252
|
+
if (r.ok) {
|
|
253
|
+
log(`restored the newest code (version ${prev.newest}) left behind by the last run`)
|
|
254
|
+
restoredAtStart = true
|
|
255
|
+
current = prev.newest
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
newest = current
|
|
259
|
+
const persist = () => store.saveState({ current, newest })
|
|
260
|
+
persist()
|
|
261
|
+
|
|
262
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
263
|
+
let pending
|
|
264
|
+
server.watcher.on("all", (event, file) => {
|
|
265
|
+
const rel = path.relative(root, file)
|
|
266
|
+
if (rel.startsWith("..") || rel.split(path.sep).some((p) => SKIP.has(p))) return
|
|
267
|
+
if (selfWrites.has(file)) {
|
|
268
|
+
const expected = selfWrites.get(file)
|
|
269
|
+
/** @type {string | null} */
|
|
270
|
+
let actual = null
|
|
271
|
+
try {
|
|
272
|
+
actual = sha1(fs.readFileSync(file))
|
|
273
|
+
} catch {}
|
|
274
|
+
if (actual === expected) return // our own checkout write
|
|
275
|
+
selfWrites.delete(file)
|
|
276
|
+
}
|
|
277
|
+
clearTimeout(pending)
|
|
278
|
+
pending = setTimeout(() => {
|
|
279
|
+
try {
|
|
280
|
+
const id = store.snapshot()
|
|
281
|
+
const changed = id !== current
|
|
282
|
+
if (changed) restoredAtStart = false
|
|
283
|
+
current = newest = id
|
|
284
|
+
persist()
|
|
285
|
+
if (changed) announce()
|
|
286
|
+
} catch (err) {
|
|
287
|
+
warn(`couldn't snapshot: ${err.message}`)
|
|
288
|
+
}
|
|
289
|
+
}, 120)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
function checkout(id) {
|
|
293
|
+
clearTimeout(pending)
|
|
294
|
+
const r = store.checkout(id, onFile)
|
|
295
|
+
if (r.ok) {
|
|
296
|
+
restoredAtStart = false
|
|
297
|
+
// Anything the snapshot-before-checkout captured that we hadn't seen yet
|
|
298
|
+
// is the user's latest work.
|
|
299
|
+
if (r.from !== current && r.from !== id) newest = r.from
|
|
300
|
+
current = id
|
|
301
|
+
persist()
|
|
302
|
+
announce()
|
|
303
|
+
} else warn(r.error)
|
|
304
|
+
return r
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Leaving on an older timeline's code? Put the newest back first.
|
|
308
|
+
let restored = false
|
|
309
|
+
function restoreNewest() {
|
|
310
|
+
if (restored) return
|
|
311
|
+
restored = true
|
|
312
|
+
if (current === newest) return
|
|
313
|
+
const r = store.checkout(newest, onFile)
|
|
314
|
+
if (r.ok) {
|
|
315
|
+
current = newest
|
|
316
|
+
persist()
|
|
317
|
+
console.log(`\n retake: restored the newest code (version ${newest}) on exit`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
process.once("exit", restoreNewest)
|
|
321
|
+
// A plain SIGINT/SIGHUP kills the process without an "exit" event, so catch
|
|
322
|
+
// them, restore, then leave the way the signal would have.
|
|
323
|
+
for (const [sig, code] of /** @type {[NodeJS.Signals, number][]} */ ([["SIGINT", 130], ["SIGHUP", 129], ["SIGTERM", 143]])) {
|
|
324
|
+
process.once(sig, () => {
|
|
325
|
+
restoreNewest()
|
|
326
|
+
process.exit(code)
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
server.httpServer && server.httpServer.once("close", restoreNewest)
|
|
330
|
+
|
|
331
|
+
const json = (res, code, body) => {
|
|
332
|
+
res.statusCode = code
|
|
333
|
+
res.setHeader("Content-Type", "application/json")
|
|
334
|
+
res.end(JSON.stringify(body))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
server.middlewares.use((req, res, next) => {
|
|
338
|
+
const url = new URL(req.url || "/", "http://x")
|
|
339
|
+
if (url.pathname === "/__retake/version") return json(res, 200, { version: current, newest, restored: restoredAtStart })
|
|
340
|
+
if (url.pathname === "/__retake/checkout") {
|
|
341
|
+
// POST + token. A GET is accepted only from the dock's own origin
|
|
342
|
+
// (Sec-Fetch-Site can't be forged by other sites) while the dock moves over.
|
|
343
|
+
const tokenOk = token && req.headers["x-retake-token"] === token
|
|
344
|
+
const sameOrigin = req.headers["sec-fetch-site"] === "same-origin"
|
|
345
|
+
if (!(req.method === "POST" && tokenOk) && !(req.method === "GET" && sameOrigin)) {
|
|
346
|
+
return json(res, 403, { ok: false, error: "checkout needs POST with x-retake-token" })
|
|
347
|
+
}
|
|
348
|
+
const r = checkout(url.searchParams.get("v"))
|
|
349
|
+
// left: the snapshot taken just before switching, i.e. the real code of
|
|
350
|
+
// the timeline being left (edits the version poll hadn't seen yet included).
|
|
351
|
+
return json(res, r.ok ? 200 : 409, { ok: r.ok, version: current, left: r.from || null, error: r.error })
|
|
352
|
+
}
|
|
353
|
+
next()
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
return { store, checkout, get current() { return current }, get newest() { return newest } }
|
|
357
|
+
}
|