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.
@@ -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" }] },