@brett_lamy/docstream 1.20.1 → 1.22.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/README.md +5 -0
- package/dist/doc-env.d.ts +5 -3
- package/dist/doc-env.d.ts.map +1 -1
- package/dist/doc-env.js +2 -2
- package/dist/doc-env.js.map +1 -1
- package/dist/env-keychain.d.ts +49 -0
- package/dist/env-keychain.d.ts.map +1 -0
- package/dist/env-keychain.js +201 -0
- package/dist/env-keychain.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/replay/ReplayDevtools.d.ts +18 -0
- package/dist/replay/ReplayDevtools.d.ts.map +1 -0
- package/dist/replay/ReplayDevtools.js +137 -0
- package/dist/replay/ReplayDevtools.js.map +1 -0
- package/dist/replay/ReplayPlayer.d.ts +10 -2
- package/dist/replay/ReplayPlayer.d.ts.map +1 -1
- package/dist/replay/ReplayPlayer.js +16 -6
- package/dist/replay/ReplayPlayer.js.map +1 -1
- package/dist/replay/demo/index.d.ts.map +1 -1
- package/dist/replay/demo/index.js +15 -4
- package/dist/replay/demo/index.js.map +1 -1
- package/dist/replay/devtools.d.ts +70 -0
- package/dist/replay/devtools.d.ts.map +1 -0
- package/dist/replay/devtools.js +201 -0
- package/dist/replay/devtools.js.map +1 -0
- package/dist/replay/index.d.ts +3 -0
- package/dist/replay/index.d.ts.map +1 -1
- package/dist/replay/index.js +1 -0
- package/dist/replay/index.js.map +1 -1
- package/dist/styles.css +547 -0
- package/package.json +1 -1
- package/src/doc-env.tsx +7 -5
- package/src/env-keychain.ts +264 -0
- package/src/index.ts +2 -0
- package/src/replay/ReplayDevtools.tsx +348 -0
- package/src/replay/ReplayPlayer.tsx +36 -6
- package/src/replay/demo/index.ts +15 -4
- package/src/replay/devtools.ts +251 -0
- package/src/replay/index.ts +3 -0
- package/src/styles.css +547 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/* A passkey keychain for a page's environment variables (frontmatter `env`).
|
|
2
|
+
|
|
3
|
+
The values are encrypted with AES-GCM under a key derived (HKDF) from the passkey's PRF output, so reading
|
|
4
|
+
them takes the reader's fingerprint, face or security key; the ciphertext lives in localStorage. Which
|
|
5
|
+
variables it holds is kept in the clear (names are not secrets), so a page can say "unlock" rather than
|
|
6
|
+
"sign in" before asking for biometrics. There is no recovery key: tokens can always be fetched again, so
|
|
7
|
+
losing the passkey only means signing in again (`reset()` starts over).
|
|
8
|
+
|
|
9
|
+
const keychain = createEnvKeychain({ id: "my-docs", rpName: "My docs" })
|
|
10
|
+
const envHost = keychainEnvHost(keychain, {
|
|
11
|
+
obtain: async (names) => ({ GITHUB_TOKEN: await signInWithGitHub() }), // for what it doesn't hold
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
`keychainEnvHost` unlocks once (a biometric prompt) and then answers with *everything* the keychain holds,
|
|
15
|
+
so one press opens every example on the page whose variables are already saved. Runs in the browser only;
|
|
16
|
+
PRF needs a passkey provider that supports it (iCloud Keychain, Google Password Manager, Windows Hello,
|
|
17
|
+
recent security keys). Without it, values are kept for the page only. */
|
|
18
|
+
import type { DocstreamEnvHost } from "./doc-env"
|
|
19
|
+
|
|
20
|
+
export type EnvKeychainStatus = "unsupported" | "empty" | "locked" | "unlocked"
|
|
21
|
+
|
|
22
|
+
export interface EnvKeychain {
|
|
23
|
+
readonly status: EnvKeychainStatus
|
|
24
|
+
/** The variables it holds (readable while locked). */
|
|
25
|
+
readonly names: readonly string[]
|
|
26
|
+
/** Creates the passkey (a biometric prompt; may ask twice on authenticators that only return PRF on sign-in). */
|
|
27
|
+
enroll(): Promise<void>
|
|
28
|
+
/** A biometric prompt; then `values` can be read. */
|
|
29
|
+
unlock(): Promise<void>
|
|
30
|
+
lock(): void
|
|
31
|
+
/** Everything it holds. Unlocked only. */
|
|
32
|
+
values(): Promise<Record<string, string>>
|
|
33
|
+
/** Adds or replaces values. Unlocked only. */
|
|
34
|
+
set(values: Record<string, string>): Promise<void>
|
|
35
|
+
/** Forgets one variable, or (no argument) everything including the passkey's record. */
|
|
36
|
+
reset(name?: string): Promise<void>
|
|
37
|
+
subscribe(listener: () => void): () => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EnvKeychainOptions {
|
|
41
|
+
/** Names the keychain in storage; different ids are different keychains. */
|
|
42
|
+
id: string
|
|
43
|
+
/** Shown by the passkey prompt. */
|
|
44
|
+
rpName: string
|
|
45
|
+
/** Account name the passkey is listed under. Default "Environment variables". */
|
|
46
|
+
userName?: string
|
|
47
|
+
/** Default `localStorage`. */
|
|
48
|
+
storage?: Pick<Storage, "getItem" | "setItem" | "removeItem">
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Stored {
|
|
52
|
+
v: 1
|
|
53
|
+
credentialId: string
|
|
54
|
+
salt: string
|
|
55
|
+
names: string[]
|
|
56
|
+
iv?: string
|
|
57
|
+
data?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const b64 = (bytes: ArrayBuffer | Uint8Array) => {
|
|
61
|
+
const u = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
|
|
62
|
+
let s = ""
|
|
63
|
+
for (const b of u) s += String.fromCharCode(b)
|
|
64
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
65
|
+
}
|
|
66
|
+
const unb64 = (text: string) => {
|
|
67
|
+
const s = atob(text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4))
|
|
68
|
+
return Uint8Array.from(s, (c) => c.charCodeAt(0))
|
|
69
|
+
}
|
|
70
|
+
const random = (n: number) => crypto.getRandomValues(new Uint8Array(n))
|
|
71
|
+
|
|
72
|
+
type PrfResults = { prf?: { enabled?: boolean; results?: { first?: ArrayBuffer | ArrayBufferView } } }
|
|
73
|
+
const prfFirst = (credential: PublicKeyCredential): Uint8Array<ArrayBuffer> | null => {
|
|
74
|
+
const first = (credential.getClientExtensionResults() as PrfResults).prf?.results?.first
|
|
75
|
+
if (!first) return null
|
|
76
|
+
const view = ArrayBuffer.isView(first) ? new Uint8Array(first.buffer, first.byteOffset, first.byteLength) : new Uint8Array(first)
|
|
77
|
+
const copy = new Uint8Array(view.byteLength)
|
|
78
|
+
copy.set(view)
|
|
79
|
+
return copy
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function aesKey(prf: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
|
|
83
|
+
const base = await crypto.subtle.importKey("raw", prf, "HKDF", false, ["deriveKey"])
|
|
84
|
+
return crypto.subtle.deriveKey(
|
|
85
|
+
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("docstream env keychain v1") },
|
|
86
|
+
base,
|
|
87
|
+
{ name: "AES-GCM", length: 256 },
|
|
88
|
+
false,
|
|
89
|
+
["encrypt", "decrypt"],
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createEnvKeychain(options: EnvKeychainOptions): EnvKeychain {
|
|
94
|
+
const key = `docstream-env-keychain:${options.id}`
|
|
95
|
+
const storage = options.storage ?? (typeof localStorage !== "undefined" ? localStorage : null)
|
|
96
|
+
const listeners = new Set<() => void>()
|
|
97
|
+
let aes: CryptoKey | null = null
|
|
98
|
+
let cache: Record<string, string> | null = null
|
|
99
|
+
|
|
100
|
+
const read = (): Stored | null => {
|
|
101
|
+
try {
|
|
102
|
+
const raw = storage?.getItem(key)
|
|
103
|
+
const s = raw ? (JSON.parse(raw) as Stored) : null
|
|
104
|
+
return s?.v === 1 && typeof s.credentialId === "string" && typeof s.salt === "string" ? { ...s, names: Array.isArray(s.names) ? s.names : [] } : null
|
|
105
|
+
} catch {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const write = (s: Stored | null) => {
|
|
110
|
+
if (s) storage?.setItem(key, JSON.stringify(s))
|
|
111
|
+
else storage?.removeItem(key)
|
|
112
|
+
listeners.forEach((l) => l())
|
|
113
|
+
}
|
|
114
|
+
const supported = () =>
|
|
115
|
+
typeof window !== "undefined" && window.isSecureContext && typeof PublicKeyCredential !== "undefined" && !!navigator.credentials && !!storage && !!crypto?.subtle
|
|
116
|
+
|
|
117
|
+
/** A sign-in with the stored passkey, returning its PRF output. */
|
|
118
|
+
async function assert(stored: Stored): Promise<Uint8Array<ArrayBuffer>> {
|
|
119
|
+
const credential = (await navigator.credentials.get({
|
|
120
|
+
publicKey: {
|
|
121
|
+
challenge: random(32),
|
|
122
|
+
allowCredentials: [{ type: "public-key", id: unb64(stored.credentialId) }],
|
|
123
|
+
userVerification: "required",
|
|
124
|
+
extensions: { prf: { eval: { first: unb64(stored.salt) } } } as AuthenticationExtensionsClientInputs,
|
|
125
|
+
},
|
|
126
|
+
})) as PublicKeyCredential | null
|
|
127
|
+
const first = credential && prfFirst(credential)
|
|
128
|
+
if (!first) throw new Error("This passkey can't unlock the keychain (no PRF support).")
|
|
129
|
+
return first
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function seal(values: Record<string, string>, stored: Stored) {
|
|
133
|
+
const iv = random(12)
|
|
134
|
+
const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aes!, new TextEncoder().encode(JSON.stringify(values)))
|
|
135
|
+
cache = { ...values }
|
|
136
|
+
write({ ...stored, names: Object.keys(values).sort(), iv: b64(iv), data: b64(data) })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const keychain: EnvKeychain = {
|
|
140
|
+
get status(): EnvKeychainStatus {
|
|
141
|
+
if (!supported()) return "unsupported"
|
|
142
|
+
if (!read()) return "empty"
|
|
143
|
+
return aes ? "unlocked" : "locked"
|
|
144
|
+
},
|
|
145
|
+
get names() {
|
|
146
|
+
return read()?.names ?? []
|
|
147
|
+
},
|
|
148
|
+
async enroll() {
|
|
149
|
+
if (!supported()) throw new Error("This browser can't keep a passkey keychain.")
|
|
150
|
+
const salt = random(32)
|
|
151
|
+
const created = (await navigator.credentials.create({
|
|
152
|
+
publicKey: {
|
|
153
|
+
rp: { name: options.rpName },
|
|
154
|
+
user: { id: random(16), name: options.userName ?? "Environment variables", displayName: options.userName ?? "Environment variables" },
|
|
155
|
+
challenge: random(32),
|
|
156
|
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
|
|
157
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
|
|
158
|
+
extensions: { prf: { eval: { first: salt } } } as AuthenticationExtensionsClientInputs,
|
|
159
|
+
},
|
|
160
|
+
})) as PublicKeyCredential | null
|
|
161
|
+
if (!created) throw new Error("No passkey was created.")
|
|
162
|
+
const ext = (created.getClientExtensionResults() as PrfResults).prf
|
|
163
|
+
if (!ext?.enabled && !ext?.results?.first) throw new Error("This passkey can't encrypt (no PRF support). Try another passkey provider.")
|
|
164
|
+
const stored: Stored = { v: 1, credentialId: b64(created.rawId), salt: b64(salt), names: [] }
|
|
165
|
+
// Some authenticators only evaluate PRF on sign-in: then this asks once more.
|
|
166
|
+
aes = await aesKey(prfFirst(created) ?? (await assert(stored)))
|
|
167
|
+
await seal({}, stored)
|
|
168
|
+
},
|
|
169
|
+
async unlock() {
|
|
170
|
+
const stored = read()
|
|
171
|
+
if (!stored) throw new Error("There is no keychain yet.")
|
|
172
|
+
if (aes) return
|
|
173
|
+
aes = await aesKey(await assert(stored))
|
|
174
|
+
cache = null
|
|
175
|
+
listeners.forEach((l) => l())
|
|
176
|
+
},
|
|
177
|
+
lock() {
|
|
178
|
+
aes = null
|
|
179
|
+
cache = null
|
|
180
|
+
listeners.forEach((l) => l())
|
|
181
|
+
},
|
|
182
|
+
async values() {
|
|
183
|
+
if (!aes) throw new Error("The keychain is locked.")
|
|
184
|
+
if (cache) return { ...cache }
|
|
185
|
+
const stored = read()
|
|
186
|
+
if (!stored?.data || !stored.iv) return (cache = {})
|
|
187
|
+
try {
|
|
188
|
+
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(stored.iv) }, aes, unb64(stored.data))
|
|
189
|
+
cache = JSON.parse(new TextDecoder().decode(plain)) as Record<string, string>
|
|
190
|
+
} catch {
|
|
191
|
+
aes = null
|
|
192
|
+
throw new Error("That passkey doesn't open this keychain.")
|
|
193
|
+
}
|
|
194
|
+
return { ...cache }
|
|
195
|
+
},
|
|
196
|
+
async set(values) {
|
|
197
|
+
const stored = read()
|
|
198
|
+
if (!stored || !aes) throw new Error("The keychain is locked.")
|
|
199
|
+
await seal({ ...(await keychain.values()), ...values }, stored)
|
|
200
|
+
},
|
|
201
|
+
async reset(name) {
|
|
202
|
+
if (name === undefined) {
|
|
203
|
+
aes = null
|
|
204
|
+
cache = null
|
|
205
|
+
write(null)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
const stored = read()
|
|
209
|
+
if (!stored) return
|
|
210
|
+
if (aes) {
|
|
211
|
+
const { [name]: _, ...rest } = await keychain.values()
|
|
212
|
+
await seal(rest, stored)
|
|
213
|
+
} else write({ ...stored, names: stored.names.filter((n) => n !== name) })
|
|
214
|
+
},
|
|
215
|
+
subscribe(listener) {
|
|
216
|
+
listeners.add(listener)
|
|
217
|
+
return () => listeners.delete(listener)
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
return keychain
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface KeychainEnvHostOptions {
|
|
224
|
+
/** Gets values the keychain doesn't hold — a sign-in, a prompt. Resolve with what you got; it is saved. */
|
|
225
|
+
obtain?: (names: readonly string[]) => Promise<Record<string, string>>
|
|
226
|
+
/** The gate's button when a sign-in is needed. Default "Sign In". */
|
|
227
|
+
signInLabel?: string | ((names: readonly string[]) => string)
|
|
228
|
+
/** The gate's button when the keychain already holds everything the example needs. Default "Unlock with Passkey". */
|
|
229
|
+
unlockLabel?: string
|
|
230
|
+
/** A hint beside a variable the keychain doesn't hold. */
|
|
231
|
+
describe?: (name: string) => string | undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A `DocstreamEnvHost` over a keychain. Pressing a gate's button:
|
|
236
|
+
* 1. creates the keychain (first time; a passkey prompt) or unlocks it (a biometric prompt);
|
|
237
|
+
* 2. asks `obtain` for the variables it doesn't hold, and saves them;
|
|
238
|
+
* 3. answers with everything the keychain holds, so every example those values cover opens.
|
|
239
|
+
* Without passkey support (or if the reader declines) values are kept for the page only.
|
|
240
|
+
*/
|
|
241
|
+
export function keychainEnvHost(keychain: EnvKeychain, options: KeychainEnvHostOptions = {}): DocstreamEnvHost {
|
|
242
|
+
const held = (names: readonly string[]) => names.every((n) => keychain.names.includes(n))
|
|
243
|
+
return {
|
|
244
|
+
label: (names) =>
|
|
245
|
+
held(names) ? options.unlockLabel ?? "Unlock with Passkey"
|
|
246
|
+
: typeof options.signInLabel === "function" ? options.signInLabel(names) : options.signInLabel ?? "Sign In",
|
|
247
|
+
describe: (name) => (keychain.names.includes(name) ? "In your keychain" : options.describe?.(name)),
|
|
248
|
+
async request(names) {
|
|
249
|
+
// The press's user activation is still live here: spend it on the passkey prompt first.
|
|
250
|
+
let usable = keychain.status === "unlocked"
|
|
251
|
+
if (keychain.status === "locked") {
|
|
252
|
+
await keychain.unlock()
|
|
253
|
+
usable = true
|
|
254
|
+
} else if (keychain.status === "empty") {
|
|
255
|
+
usable = await keychain.enroll().then(() => true, () => false)
|
|
256
|
+
}
|
|
257
|
+
const saved = usable ? await keychain.values() : {}
|
|
258
|
+
const missing = names.filter((n) => saved[n] === undefined)
|
|
259
|
+
const got = missing.length && options.obtain ? await options.obtain(missing) : {}
|
|
260
|
+
if (usable && Object.keys(got).length) await keychain.set(got)
|
|
261
|
+
return { ...saved, ...got }
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type { TaskCheckboxProps, TaskStateChangeHandler } from "./docs/TaskCheck
|
|
|
8
8
|
export { TASK_STATES, getTaskState, taskMarker, taskStateFromMarker } from "./gitbook/tasks"
|
|
9
9
|
export { DocstreamEnvProvider, EnvGate, envNamesFromProperties, useDocstreamEnv, useEnvGate, usedEnvNames } from "./doc-env"
|
|
10
10
|
export type { DocstreamEnv, DocstreamEnvHost, DocstreamEnvProviderProps, DocstreamEnvValues } from "./doc-env"
|
|
11
|
+
export { createEnvKeychain, keychainEnvHost } from "./env-keychain"
|
|
12
|
+
export type { EnvKeychain, EnvKeychainOptions, EnvKeychainStatus, KeychainEnvHostOptions } from "./env-keychain"
|
|
11
13
|
export { DocumentProperties } from "./docs/DocumentProperties"
|
|
12
14
|
export type { DocumentPropertiesProps } from "./docs/DocumentProperties"
|
|
13
15
|
export { readDocumentProperties, removeDocumentProperty, setDocumentProperty } from "./gitbook/properties"
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/* The player's DevTools: a Console and a Network button in the top bar, with what had happened by the playhead —
|
|
2
|
+
requests sent and, in red, how many had failed; console errors in red — and, behind them, a panel over the
|
|
3
|
+
page in the manner of Chrome's DevTools. The Console lists what had been logged, warnings and errors on their
|
|
4
|
+
tinted rows with their stack traces; the Network tab lists the requests that had been sent (a request still
|
|
5
|
+
waiting at the playhead shows as pending), and a request's row opens its headers, payload and response.
|
|
6
|
+
|
|
7
|
+
Everything is a function of the playhead: scrub back and the lists shrink to what DevTools showed then. A row's
|
|
8
|
+
time jumps the player there. Esc closes the panel. Plain React + plain CSS, like the player. */
|
|
9
|
+
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from 'react'
|
|
10
|
+
import { ChevronRight, CircleX, Info, Network, SquareTerminal, TriangleAlert, X } from 'lucide-react'
|
|
11
|
+
import type { ReplayConsoleEntry, ReplayDevtoolsState, ReplayRequest } from './devtools'
|
|
12
|
+
|
|
13
|
+
export type ReplayDevtoolsTab = 'console' | 'network'
|
|
14
|
+
|
|
15
|
+
const time = (ms: number) => {
|
|
16
|
+
const s = Math.max(0, ms) / 1000
|
|
17
|
+
return `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`
|
|
18
|
+
}
|
|
19
|
+
const duration = (ms?: number) => (ms === undefined ? '—' : ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`)
|
|
20
|
+
|
|
21
|
+
/** The two buttons for the top bar (or the controls row): icon, request count and, in red, what failed. */
|
|
22
|
+
export function DevtoolsButtons({ state, open, onOpen }: {
|
|
23
|
+
state: ReplayDevtoolsState; open: ReplayDevtoolsTab | null; onOpen: (tab: ReplayDevtoolsTab) => void
|
|
24
|
+
}) {
|
|
25
|
+
const { requests, failed, errors, warnings } = state.counts
|
|
26
|
+
return (
|
|
27
|
+
<div data-slot="replay-devtools-buttons">
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
data-slot="replay-devtools-button"
|
|
31
|
+
data-tab="console"
|
|
32
|
+
aria-pressed={open === 'console'}
|
|
33
|
+
aria-label={`Console: ${errors} ${errors === 1 ? 'error' : 'errors'}, ${warnings} ${warnings === 1 ? 'warning' : 'warnings'}`}
|
|
34
|
+
title="Console"
|
|
35
|
+
onClick={() => onOpen('console')}
|
|
36
|
+
>
|
|
37
|
+
<SquareTerminal size={14} strokeWidth={2} />
|
|
38
|
+
{errors ? <span data-part="failed">{errors}</span> : null}
|
|
39
|
+
{warnings ? <span data-part="warn">{warnings}</span> : null}
|
|
40
|
+
</button>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
data-slot="replay-devtools-button"
|
|
44
|
+
data-tab="network"
|
|
45
|
+
aria-pressed={open === 'network'}
|
|
46
|
+
aria-label={`Network: ${requests} ${requests === 1 ? 'request' : 'requests'}, ${failed} failed`}
|
|
47
|
+
title="Network"
|
|
48
|
+
onClick={() => onOpen('network')}
|
|
49
|
+
>
|
|
50
|
+
<Network size={14} strokeWidth={2} />
|
|
51
|
+
<span data-part="count">{requests}</span>
|
|
52
|
+
{failed ? <span data-part="failed">{failed}</span> : null}
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The panel over the page: Console and Network, cut at the playhead. */
|
|
59
|
+
export function DevtoolsPanel({ state, at, tab, onTab, onClose, onSeek }: {
|
|
60
|
+
state: ReplayDevtoolsState; at: number; tab: ReplayDevtoolsTab; onTab: (tab: ReplayDevtoolsTab) => void
|
|
61
|
+
onClose: () => void; onSeek: (ms: number) => void
|
|
62
|
+
}) {
|
|
63
|
+
const panel = useRef<HTMLDivElement>(null)
|
|
64
|
+
const [height, setHeight] = useState(58)
|
|
65
|
+
useEffect(() => { panel.current?.focus({ preventScroll: true }) }, [])
|
|
66
|
+
|
|
67
|
+
// Drag the top edge to resize, as a fraction of the stage.
|
|
68
|
+
const resize = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
69
|
+
const stage = panel.current?.parentElement
|
|
70
|
+
if (!stage) return
|
|
71
|
+
e.preventDefault()
|
|
72
|
+
const el = e.currentTarget
|
|
73
|
+
el.setPointerCapture(e.pointerId)
|
|
74
|
+
const box = stage.getBoundingClientRect()
|
|
75
|
+
const move = (ev: PointerEvent) => setHeight(Math.min(92, Math.max(22, ((box.bottom - ev.clientY) / box.height) * 100)))
|
|
76
|
+
const up = () => { el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up) }
|
|
77
|
+
el.addEventListener('pointermove', move)
|
|
78
|
+
el.addEventListener('pointerup', up)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const onKeyDown = (e: ReactKeyboardEvent) => {
|
|
82
|
+
if (e.key === 'Escape') { e.stopPropagation(); onClose() }
|
|
83
|
+
// The player's own keys (space, arrows, J/K/L) stay out of the panel.
|
|
84
|
+
else if (!(e.target as HTMLElement).closest('input')) e.stopPropagation()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const tabs: { id: ReplayDevtoolsTab; label: string }[] = [{ id: 'console', label: 'Console' }, { id: 'network', label: 'Network' }]
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={panel}
|
|
91
|
+
data-slot="replay-devtools"
|
|
92
|
+
role="dialog"
|
|
93
|
+
aria-label="DevTools"
|
|
94
|
+
tabIndex={-1}
|
|
95
|
+
onKeyDown={onKeyDown}
|
|
96
|
+
style={{ height: `${height}%` }}
|
|
97
|
+
>
|
|
98
|
+
<div data-slot="replay-devtools-resize" aria-hidden="true" onPointerDown={resize} />
|
|
99
|
+
<div data-slot="replay-devtools-tabs" role="tablist" aria-label="DevTools panels">
|
|
100
|
+
{tabs.map((t) => (
|
|
101
|
+
<button
|
|
102
|
+
key={t.id}
|
|
103
|
+
type="button"
|
|
104
|
+
role="tab"
|
|
105
|
+
aria-selected={tab === t.id}
|
|
106
|
+
onClick={() => onTab(t.id)}
|
|
107
|
+
onKeyDown={(e) => {
|
|
108
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); onTab(t.id === 'console' ? 'network' : 'console') }
|
|
109
|
+
}}
|
|
110
|
+
tabIndex={tab === t.id ? 0 : -1}
|
|
111
|
+
>
|
|
112
|
+
{t.label}
|
|
113
|
+
{t.id === 'console' && state.counts.errors ? <span data-part="failed">{state.counts.errors}</span> : null}
|
|
114
|
+
{t.id === 'network' && state.counts.failed ? <span data-part="failed">{state.counts.failed}</span> : null}
|
|
115
|
+
</button>
|
|
116
|
+
))}
|
|
117
|
+
<span data-part="at">As of {time(at)}</span>
|
|
118
|
+
<button type="button" data-slot="replay-devtools-close" aria-label="Close DevTools" onClick={onClose}><X size={14} /></button>
|
|
119
|
+
</div>
|
|
120
|
+
{tab === 'console' ? <ConsolePane state={state} onSeek={onSeek} /> : <NetworkPane state={state} onSeek={onSeek} />}
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ── Console ─────────────────────────────────────────────────────────────────────── */
|
|
126
|
+
|
|
127
|
+
type LevelFilter = 'all' | 'error' | 'warn' | 'info'
|
|
128
|
+
const LEVEL_ICON = { error: CircleX, warn: TriangleAlert, info: Info } as const
|
|
129
|
+
|
|
130
|
+
function ConsolePane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (ms: number) => void }) {
|
|
131
|
+
const [query, setQuery] = useState('')
|
|
132
|
+
const [level, setLevel] = useState<LevelFilter>('all')
|
|
133
|
+
const list = useRef<HTMLDivElement>(null)
|
|
134
|
+
const rows = useMemo(() => {
|
|
135
|
+
const q = query.trim().toLowerCase()
|
|
136
|
+
return state.console.filter((e) =>
|
|
137
|
+
(level === 'all' || (level === 'info' ? e.level === 'info' || e.level === 'log' || e.level === 'debug' : e.level === level)) &&
|
|
138
|
+
(!q || e.message.toLowerCase().includes(q)))
|
|
139
|
+
}, [state.console, query, level])
|
|
140
|
+
// Follow new messages as they're logged, unless you've scrolled up to read.
|
|
141
|
+
const pinned = useRef(true)
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const el = list.current
|
|
144
|
+
if (el && pinned.current) el.scrollTop = el.scrollHeight
|
|
145
|
+
}, [rows.length])
|
|
146
|
+
return (
|
|
147
|
+
<div data-slot="replay-devtools-pane" data-pane="console">
|
|
148
|
+
<div data-slot="replay-devtools-toolbar">
|
|
149
|
+
<input type="search" aria-label="Filter console" placeholder="Filter" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
|
|
150
|
+
<select aria-label="Log levels" value={level} onChange={(e) => setLevel(e.currentTarget.value as LevelFilter)}>
|
|
151
|
+
<option value="all">All levels</option>
|
|
152
|
+
<option value="error">Errors</option>
|
|
153
|
+
<option value="warn">Warnings</option>
|
|
154
|
+
<option value="info">Info</option>
|
|
155
|
+
</select>
|
|
156
|
+
<span data-part="summary">
|
|
157
|
+
{state.counts.errors ? <span data-part="failed">{state.counts.errors} {state.counts.errors === 1 ? 'error' : 'errors'}</span> : null}
|
|
158
|
+
{state.counts.warnings ? <span data-part="warn">{state.counts.warnings} {state.counts.warnings === 1 ? 'warning' : 'warnings'}</span> : null}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
<div
|
|
162
|
+
ref={list}
|
|
163
|
+
data-slot="replay-devtools-list"
|
|
164
|
+
role="log"
|
|
165
|
+
aria-label="Console messages"
|
|
166
|
+
onScroll={(e) => { const el = e.currentTarget; pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 8 }}
|
|
167
|
+
>
|
|
168
|
+
{rows.map((entry) => <ConsoleRow key={entry.id} entry={entry} onSeek={onSeek} />)}
|
|
169
|
+
{!rows.length ? <p data-slot="replay-devtools-empty">{state.console.length ? 'No messages match the filter' : 'Nothing logged yet'}</p> : null}
|
|
170
|
+
{state.counts.laterMessages ? <p data-slot="replay-devtools-later">{state.counts.laterMessages} more later in the recording</p> : null}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ConsoleRow({ entry, onSeek }: { entry: ReplayConsoleEntry; onSeek: (ms: number) => void }) {
|
|
177
|
+
const Glyph = entry.level === 'error' || entry.level === 'warn' || entry.level === 'info' ? LEVEL_ICON[entry.level] : null
|
|
178
|
+
const body = (
|
|
179
|
+
<>
|
|
180
|
+
<span data-part="icon" aria-hidden="true">{Glyph ? <Glyph size={12} strokeWidth={2.2} /> : null}</span>
|
|
181
|
+
<span data-part="message">{entry.message || ' '}</span>
|
|
182
|
+
</>
|
|
183
|
+
)
|
|
184
|
+
return (
|
|
185
|
+
<div data-slot="replay-devtools-message" data-level={entry.level}>
|
|
186
|
+
{entry.trace?.length ? (
|
|
187
|
+
<details>
|
|
188
|
+
<summary>{body}</summary>
|
|
189
|
+
<ol data-part="trace">{entry.trace.map((frame, i) => <li key={i}>{frame}</li>)}</ol>
|
|
190
|
+
</details>
|
|
191
|
+
) : <div data-part="line">{body}</div>}
|
|
192
|
+
<button type="button" data-part="time" aria-label={`Go to ${time(entry.time)}`} onClick={() => onSeek(entry.time)}>{time(entry.time)}</button>
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── Network ─────────────────────────────────────────────────────────────────────── */
|
|
198
|
+
|
|
199
|
+
type RequestFilter = 'all' | 'fetch' | 'failed'
|
|
200
|
+
type Row = ReplayRequest & { pending: boolean }
|
|
201
|
+
|
|
202
|
+
const statusText = (r: Row) => (r.pending ? '(pending)' : r.status === undefined ? (r.failed ? '(failed)' : '—') : r.status === 0 ? '(failed)' : String(r.status))
|
|
203
|
+
|
|
204
|
+
function NetworkPane({ state, onSeek }: { state: ReplayDevtoolsState; onSeek: (ms: number) => void }) {
|
|
205
|
+
const [query, setQuery] = useState('')
|
|
206
|
+
const [filter, setFilter] = useState<RequestFilter>('all')
|
|
207
|
+
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
208
|
+
const rows = useMemo(() => {
|
|
209
|
+
const q = query.trim().toLowerCase()
|
|
210
|
+
return state.network.filter((r) =>
|
|
211
|
+
(filter === 'all' || (filter === 'failed' ? r.failed && !r.pending : r.type === 'fetch' || r.type === 'xhr')) &&
|
|
212
|
+
(!q || r.url.toLowerCase().includes(q) || r.method.toLowerCase() === q))
|
|
213
|
+
}, [state.network, query, filter])
|
|
214
|
+
const selected = state.network.find((r) => r.id === selectedId) ?? null
|
|
215
|
+
// The waterfall spans from the first request to the last answer seen by the playhead.
|
|
216
|
+
const span = useMemo(() => {
|
|
217
|
+
const first = state.network[0]?.time ?? 0
|
|
218
|
+
let last = first + 1
|
|
219
|
+
for (const r of state.network) last = Math.max(last, r.time + (r.duration ?? 0))
|
|
220
|
+
return { first, length: last - first }
|
|
221
|
+
}, [state.network])
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div data-slot="replay-devtools-pane" data-pane="network" data-detail={selected ? '' : undefined}>
|
|
225
|
+
<div data-slot="replay-devtools-toolbar">
|
|
226
|
+
<input type="search" aria-label="Filter requests" placeholder="Filter" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
|
|
227
|
+
<div data-slot="replay-devtools-chips" role="radiogroup" aria-label="Request types">
|
|
228
|
+
{(['all', 'fetch', 'failed'] as const).map((f) => (
|
|
229
|
+
<button key={f} type="button" role="radio" aria-checked={filter === f} onClick={() => setFilter(f)}>
|
|
230
|
+
{f === 'all' ? 'All' : f === 'fetch' ? 'Fetch/XHR' : 'Failed'}
|
|
231
|
+
</button>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div data-slot="replay-devtools-network">
|
|
236
|
+
<div data-slot="replay-devtools-table-wrap">
|
|
237
|
+
<table data-slot="replay-devtools-table">
|
|
238
|
+
<thead>
|
|
239
|
+
<tr>
|
|
240
|
+
<th scope="col">Name</th>
|
|
241
|
+
<th scope="col">Status</th>
|
|
242
|
+
{selected ? null : <><th scope="col">Method</th><th scope="col">Type</th><th scope="col">Time</th><th scope="col" data-col="waterfall">Waterfall</th></>}
|
|
243
|
+
</tr>
|
|
244
|
+
</thead>
|
|
245
|
+
<tbody>
|
|
246
|
+
{rows.map((r) => (
|
|
247
|
+
<tr
|
|
248
|
+
key={r.id}
|
|
249
|
+
data-failed={(r.failed && !r.pending) || undefined}
|
|
250
|
+
data-pending={r.pending || undefined}
|
|
251
|
+
aria-selected={r.id === selectedId}
|
|
252
|
+
tabIndex={0}
|
|
253
|
+
onClick={() => setSelectedId(r.id === selectedId ? null : r.id)}
|
|
254
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(r.id === selectedId ? null : r.id) } }}
|
|
255
|
+
>
|
|
256
|
+
<td title={r.url}><span data-part="name">{r.name}</span></td>
|
|
257
|
+
<td>{statusText(r)}</td>
|
|
258
|
+
{selected ? null : (
|
|
259
|
+
<>
|
|
260
|
+
<td>{r.method}</td>
|
|
261
|
+
<td>{r.type}</td>
|
|
262
|
+
<td>{r.pending ? 'Pending' : duration(r.duration)}</td>
|
|
263
|
+
<td data-col="waterfall">
|
|
264
|
+
<span
|
|
265
|
+
data-part="bar"
|
|
266
|
+
style={{
|
|
267
|
+
left: `${((r.time - span.first) / span.length) * 100}%`,
|
|
268
|
+
width: `max(2px, ${(((r.duration ?? 0) / span.length) * 100).toFixed(2)}%)`,
|
|
269
|
+
}}
|
|
270
|
+
/>
|
|
271
|
+
</td>
|
|
272
|
+
</>
|
|
273
|
+
)}
|
|
274
|
+
</tr>
|
|
275
|
+
))}
|
|
276
|
+
</tbody>
|
|
277
|
+
</table>
|
|
278
|
+
{!rows.length ? <p data-slot="replay-devtools-empty">{state.network.length ? 'No requests match the filter' : 'No requests yet'}</p> : null}
|
|
279
|
+
</div>
|
|
280
|
+
{selected ? <RequestDetail request={selected} onClose={() => setSelectedId(null)} onSeek={onSeek} /> : null}
|
|
281
|
+
</div>
|
|
282
|
+
<div data-slot="replay-devtools-status">
|
|
283
|
+
<span>{state.counts.requests} {state.counts.requests === 1 ? 'request' : 'requests'}</span>
|
|
284
|
+
{state.counts.failed ? <span data-part="failed">{state.counts.failed} failed</span> : null}
|
|
285
|
+
{state.counts.laterRequests ? <span>{state.counts.laterRequests} more later in the recording</span> : null}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
type DetailTab = 'headers' | 'payload' | 'response'
|
|
292
|
+
|
|
293
|
+
function RequestDetail({ request, onClose, onSeek }: { request: Row; onClose: () => void; onSeek: (ms: number) => void }) {
|
|
294
|
+
const [tab, setTab] = useState<DetailTab>('headers')
|
|
295
|
+
const pretty = (body?: string) => {
|
|
296
|
+
if (body === undefined || body === '') return undefined
|
|
297
|
+
try { return JSON.stringify(JSON.parse(body), null, 2) } catch { return body }
|
|
298
|
+
}
|
|
299
|
+
const general: [string, string][] = [
|
|
300
|
+
['Request URL', request.url],
|
|
301
|
+
['Request Method', request.method],
|
|
302
|
+
['Status Code', request.pending ? 'Pending' : request.status !== undefined ? `${request.status}${request.statusText ? ` ${request.statusText}` : ''}` : request.failed ? 'Failed' : 'Unknown'],
|
|
303
|
+
...(request.error ? [['Error', request.error] as [string, string]] : []),
|
|
304
|
+
['Sent at', time(request.time)],
|
|
305
|
+
['Duration', duration(request.duration)],
|
|
306
|
+
]
|
|
307
|
+
return (
|
|
308
|
+
<div data-slot="replay-devtools-detail">
|
|
309
|
+
<div data-slot="replay-devtools-detail-tabs" role="tablist" aria-label={`${request.name} details`}>
|
|
310
|
+
<button type="button" aria-label="Close request details" data-slot="replay-devtools-close" onClick={onClose}><X size={13} /></button>
|
|
311
|
+
{(['headers', 'payload', 'response'] as const).map((t) => (
|
|
312
|
+
<button key={t} type="button" role="tab" aria-selected={tab === t} onClick={() => setTab(t)}>
|
|
313
|
+
{t[0]!.toUpperCase() + t.slice(1)}
|
|
314
|
+
</button>
|
|
315
|
+
))}
|
|
316
|
+
</div>
|
|
317
|
+
<div data-slot="replay-devtools-detail-body">
|
|
318
|
+
{tab === 'headers' ? (
|
|
319
|
+
<>
|
|
320
|
+
<Section title="General" rows={general} />
|
|
321
|
+
{request.responseHeaders ? <Section title="Response Headers" rows={Object.entries(request.responseHeaders)} /> : null}
|
|
322
|
+
{request.requestHeaders ? <Section title="Request Headers" rows={Object.entries(request.requestHeaders)} /> : null}
|
|
323
|
+
<button type="button" data-slot="replay-devtools-jump" onClick={() => onSeek(request.time)}>Go to {time(request.time)} in the recording</button>
|
|
324
|
+
</>
|
|
325
|
+
) : (
|
|
326
|
+
<Body text={pretty(tab === 'payload' ? request.requestBody : request.responseBody)} empty={tab === 'payload' ? 'This request has no recorded payload' : request.pending ? 'Waiting for the response' : 'The recording has no response body for this request'} />
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function Section({ title, rows }: { title: string; rows: [string, string][] }) {
|
|
334
|
+
return (
|
|
335
|
+
<details open data-slot="replay-devtools-section">
|
|
336
|
+
<summary><ChevronRight size={12} data-part="chevron" />{title}</summary>
|
|
337
|
+
<dl>
|
|
338
|
+
{rows.map(([k, v]) => (
|
|
339
|
+
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
|
|
340
|
+
))}
|
|
341
|
+
</dl>
|
|
342
|
+
</details>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function Body({ text, empty }: { text?: string; empty: string }) {
|
|
347
|
+
return text !== undefined ? <pre data-slot="replay-devtools-body">{text}</pre> : <p data-slot="replay-devtools-empty">{empty}</p>
|
|
348
|
+
}
|