retake-dev 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/README.md +173 -10
- package/bin/retake.js +14 -1
- package/package.json +7 -1
- package/src/core.js +23 -1
- package/src/mark.svg +1 -0
- package/src/next-dev.js +183 -0
- package/src/next.js +81 -0
- package/src/note-text.js +844 -0
- package/src/plugin.js +7 -3
- 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 +28 -2
- package/src/server/api.js +73 -1
- package/src/server/front.js +3 -1
- package/src/server/mcp.js +225 -19
- package/src/server/moments.js +317 -0
- package/src/server/sourcemap.js +192 -0
- package/src/shell/00-state.js +7 -0
- package/src/shell/10-dock.js +208 -13
- package/src/shell/15-session.js +66 -10
- package/src/shell/20-timeline.js +47 -16
- package/src/shell/25-input.js +29 -2
- package/src/shell/30-notes.js +709 -113
- package/src/shell/32-anims.js +1353 -0
- package/src/shell/shell.css +79 -8
- package/src/shell/shell.html +10 -3
- package/types/client.d.ts +16 -0
- package/types/index.d.ts +16 -0
- package/types/next.d.ts +50 -0
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" }] },
|