@c9up/ream 0.2.29 → 0.2.30

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.
Files changed (220) hide show
  1. package/README.md +5 -6
  2. package/dist/Codemods.d.ts +8 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +29 -1
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/HttpKernel.d.ts.map +1 -1
  7. package/dist/HttpKernel.js +17 -5
  8. package/dist/HttpKernel.js.map +1 -1
  9. package/dist/Ignitor.d.ts +5 -0
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +12 -1
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/bodyparser/BodyParserMiddleware.d.ts +4 -4
  14. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  15. package/dist/bodyparser/BodyParserMiddleware.js +38 -11
  16. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  17. package/dist/bodyparser/MultipartFile.d.ts +21 -15
  18. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  19. package/dist/bodyparser/MultipartFile.js +40 -58
  20. package/dist/bodyparser/MultipartFile.js.map +1 -1
  21. package/dist/bodyparser/parseSize.d.ts +7 -4
  22. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  23. package/dist/bodyparser/parseSize.js +12 -17
  24. package/dist/bodyparser/parseSize.js.map +1 -1
  25. package/dist/bodyparser/qsParse.d.ts +10 -0
  26. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  27. package/dist/bodyparser/qsParse.js +27 -12
  28. package/dist/bodyparser/qsParse.js.map +1 -1
  29. package/dist/console/prompts.d.ts.map +1 -1
  30. package/dist/console/prompts.js +3 -1
  31. package/dist/console/prompts.js.map +1 -1
  32. package/dist/container/Container.d.ts.map +1 -1
  33. package/dist/container/Container.js +61 -71
  34. package/dist/container/Container.js.map +1 -1
  35. package/dist/events/Emitter.d.ts +12 -0
  36. package/dist/events/Emitter.d.ts.map +1 -1
  37. package/dist/events/Emitter.js +36 -9
  38. package/dist/events/Emitter.js.map +1 -1
  39. package/dist/events/helix/helpers.d.ts.map +1 -1
  40. package/dist/events/helix/helpers.js +19 -9
  41. package/dist/events/helix/helpers.js.map +1 -1
  42. package/dist/graphql/GraphQLEngine.d.ts +8 -0
  43. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  44. package/dist/graphql/GraphQLEngine.js +77 -11
  45. package/dist/graphql/GraphQLEngine.js.map +1 -1
  46. package/dist/helpers/MessageBuilder.d.ts +4 -2
  47. package/dist/helpers/MessageBuilder.d.ts.map +1 -1
  48. package/dist/helpers/MessageBuilder.js +4 -2
  49. package/dist/helpers/MessageBuilder.js.map +1 -1
  50. package/dist/helpers/errors.d.ts +11 -0
  51. package/dist/helpers/errors.d.ts.map +1 -0
  52. package/dist/helpers/errors.js +17 -0
  53. package/dist/helpers/errors.js.map +1 -0
  54. package/dist/helpers/napi-loader.d.ts.map +1 -1
  55. package/dist/helpers/napi-loader.js +3 -2
  56. package/dist/helpers/napi-loader.js.map +1 -1
  57. package/dist/http/HttpContext.d.ts +20 -2
  58. package/dist/http/HttpContext.d.ts.map +1 -1
  59. package/dist/http/HttpContext.js +10 -4
  60. package/dist/http/HttpContext.js.map +1 -1
  61. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  62. package/dist/http/RedirectBuilder.js +4 -0
  63. package/dist/http/RedirectBuilder.js.map +1 -1
  64. package/dist/http/Request.d.ts.map +1 -1
  65. package/dist/http/Request.js +15 -29
  66. package/dist/http/Request.js.map +1 -1
  67. package/dist/http/Response.d.ts +7 -4
  68. package/dist/http/Response.d.ts.map +1 -1
  69. package/dist/http/Response.js +14 -6
  70. package/dist/http/Response.js.map +1 -1
  71. package/dist/http/permissions.d.ts +14 -8
  72. package/dist/http/permissions.d.ts.map +1 -1
  73. package/dist/http/permissions.js +26 -16
  74. package/dist/http/permissions.js.map +1 -1
  75. package/dist/index.d.ts +2 -2
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/middleware/Pipeline.js +1 -1
  80. package/dist/middleware/Pipeline.js.map +1 -1
  81. package/dist/native/generated.d.ts +16 -30
  82. package/dist/native/generated.d.ts.map +1 -1
  83. package/dist/profiler/Profiler.d.ts.map +1 -1
  84. package/dist/profiler/Profiler.js +4 -1
  85. package/dist/profiler/Profiler.js.map +1 -1
  86. package/dist/profiler/client.generated.d.ts +5 -0
  87. package/dist/profiler/client.generated.d.ts.map +1 -0
  88. package/dist/profiler/client.generated.js +6 -0
  89. package/dist/profiler/client.generated.js.map +1 -0
  90. package/dist/profiler/collectors.d.ts +92 -13
  91. package/dist/profiler/collectors.d.ts.map +1 -1
  92. package/dist/profiler/collectors.js +106 -19
  93. package/dist/profiler/collectors.js.map +1 -1
  94. package/dist/profiler/endpoint.d.ts +2 -1
  95. package/dist/profiler/endpoint.d.ts.map +1 -1
  96. package/dist/profiler/endpoint.js +25 -1
  97. package/dist/profiler/endpoint.js.map +1 -1
  98. package/dist/profiler/index.d.ts +8 -4
  99. package/dist/profiler/index.d.ts.map +1 -1
  100. package/dist/profiler/index.js +11 -4
  101. package/dist/profiler/index.js.map +1 -1
  102. package/dist/profiler/middleware.d.ts.map +1 -1
  103. package/dist/profiler/middleware.js +76 -10
  104. package/dist/profiler/middleware.js.map +1 -1
  105. package/dist/profiler/preview.d.ts +17 -0
  106. package/dist/profiler/preview.d.ts.map +1 -0
  107. package/dist/profiler/preview.js +100 -0
  108. package/dist/profiler/preview.js.map +1 -0
  109. package/dist/profiler/toolbar.d.ts +15 -23
  110. package/dist/profiler/toolbar.d.ts.map +1 -1
  111. package/dist/profiler/toolbar.js +25 -91
  112. package/dist/profiler/toolbar.js.map +1 -1
  113. package/dist/profiler/types.d.ts +6 -1
  114. package/dist/profiler/types.d.ts.map +1 -1
  115. package/dist/router/Router.d.ts +6 -0
  116. package/dist/router/Router.d.ts.map +1 -1
  117. package/dist/router/Router.js +115 -12
  118. package/dist/router/Router.js.map +1 -1
  119. package/dist/rpc/RpcRouter.js +3 -3
  120. package/dist/rpc/RpcRouter.js.map +1 -1
  121. package/dist/scheduler/Scheduler.js +1 -1
  122. package/dist/scheduler/Scheduler.js.map +1 -1
  123. package/dist/security/CookieSigner.d.ts.map +1 -1
  124. package/dist/security/CookieSigner.js +15 -5
  125. package/dist/security/CookieSigner.js.map +1 -1
  126. package/dist/security/SignedUrl.d.ts.map +1 -1
  127. package/dist/security/SignedUrl.js +10 -18
  128. package/dist/security/SignedUrl.js.map +1 -1
  129. package/dist/server/Server.d.ts +4 -1
  130. package/dist/server/Server.d.ts.map +1 -1
  131. package/dist/server/Server.js.map +1 -1
  132. package/dist/server/nativeLimits.d.ts +9 -1
  133. package/dist/server/nativeLimits.d.ts.map +1 -1
  134. package/dist/server/nativeLimits.js +8 -0
  135. package/dist/server/nativeLimits.js.map +1 -1
  136. package/dist/session/Session.d.ts.map +1 -1
  137. package/dist/session/Session.js +13 -4
  138. package/dist/session/Session.js.map +1 -1
  139. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  140. package/dist/session/drivers/MemoryDriver.js +14 -2
  141. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  142. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  143. package/dist/storage/StaticMiddleware.js +20 -9
  144. package/dist/storage/StaticMiddleware.js.map +1 -1
  145. package/dist/testing/ApiResponse.d.ts.map +1 -1
  146. package/dist/testing/ApiResponse.js +7 -1
  147. package/dist/testing/ApiResponse.js.map +1 -1
  148. package/dist/testing/TestClient.d.ts.map +1 -1
  149. package/dist/testing/TestClient.js +16 -4
  150. package/dist/testing/TestClient.js.map +1 -1
  151. package/dist/utils/Macroable.d.ts +1 -1
  152. package/dist/utils/Macroable.js +2 -2
  153. package/dist/utils/Macroable.js.map +1 -1
  154. package/events.darwin-arm64.node +0 -0
  155. package/events.darwin-x64.node +0 -0
  156. package/events.linux-arm64-gnu.node +0 -0
  157. package/events.linux-x64-gnu.node +0 -0
  158. package/events.win32-x64-msvc.node +0 -0
  159. package/index.darwin-arm64.node +0 -0
  160. package/index.darwin-x64.node +0 -0
  161. package/index.linux-arm64-gnu.node +0 -0
  162. package/index.linux-x64-gnu.node +0 -0
  163. package/index.win32-x64-msvc.node +0 -0
  164. package/package.json +1 -2
  165. package/scheduler.darwin-arm64.node +0 -0
  166. package/scheduler.darwin-x64.node +0 -0
  167. package/scheduler.linux-arm64-gnu.node +0 -0
  168. package/scheduler.linux-x64-gnu.node +0 -0
  169. package/scheduler.win32-x64-msvc.node +0 -0
  170. package/src/Codemods.ts +49 -3
  171. package/src/HttpKernel.ts +18 -7
  172. package/src/Ignitor.ts +21 -4
  173. package/src/bodyparser/BodyParserMiddleware.ts +42 -16
  174. package/src/bodyparser/MultipartFile.ts +46 -67
  175. package/src/bodyparser/parseSize.ts +17 -17
  176. package/src/bodyparser/qsParse.ts +28 -13
  177. package/src/console/prompts.ts +3 -1
  178. package/src/container/Container.ts +97 -81
  179. package/src/events/Emitter.ts +37 -10
  180. package/src/events/helix/helpers.ts +24 -19
  181. package/src/graphql/GraphQLEngine.ts +89 -13
  182. package/src/helpers/MessageBuilder.ts +4 -2
  183. package/src/helpers/errors.ts +16 -0
  184. package/src/helpers/napi-loader.ts +3 -2
  185. package/src/http/HttpContext.ts +20 -4
  186. package/src/http/RedirectBuilder.ts +3 -0
  187. package/src/http/Request.ts +15 -29
  188. package/src/http/Response.ts +15 -7
  189. package/src/http/permissions.ts +31 -15
  190. package/src/index.ts +2 -1
  191. package/src/middleware/Pipeline.ts +1 -1
  192. package/src/native/generated.ts +18 -42
  193. package/src/profiler/Profiler.ts +4 -1
  194. package/src/profiler/client/toolbar.css +595 -0
  195. package/src/profiler/client/toolbar.js +1299 -0
  196. package/src/profiler/client.generated.ts +7 -0
  197. package/src/profiler/collectors.ts +156 -19
  198. package/src/profiler/endpoint.ts +27 -1
  199. package/src/profiler/index.ts +17 -3
  200. package/src/profiler/middleware.ts +74 -9
  201. package/src/profiler/preview.ts +104 -0
  202. package/src/profiler/toolbar.ts +28 -91
  203. package/src/profiler/types.ts +6 -1
  204. package/src/router/Router.ts +136 -13
  205. package/src/rpc/RpcRouter.ts +4 -4
  206. package/src/scheduler/Scheduler.ts +1 -1
  207. package/src/security/CookieSigner.ts +13 -5
  208. package/src/security/SignedUrl.ts +12 -16
  209. package/src/server/Server.ts +4 -1
  210. package/src/server/nativeLimits.ts +18 -1
  211. package/src/session/Session.ts +13 -4
  212. package/src/session/drivers/MemoryDriver.ts +15 -2
  213. package/src/storage/StaticMiddleware.ts +18 -9
  214. package/src/testing/ApiResponse.ts +7 -1
  215. package/src/testing/TestClient.ts +14 -5
  216. package/src/utils/Macroable.ts +2 -2
  217. package/scripts/build-napi-types.mjs +0 -69
  218. package/scripts/copy-napi.mjs +0 -121
  219. package/scripts/generate-mime.mjs +0 -119
  220. package/scripts/generate-napi-types.mjs +0 -187
@@ -0,0 +1,1299 @@
1
+ /*
2
+ * The debug toolbar, as it runs in the browser.
3
+ *
4
+ * Served from /__ream/profiler/_assets/toolbar.js and loaded by a <script src>
5
+ * that carries the request's profile token in data attributes: no inline code,
6
+ * so a `script-src 'self'` policy runs it, and a nonce-only one runs it through
7
+ * the nonce the server stamps on the tag.
8
+ *
9
+ * Built with the DOM API and `textContent` only. Everything it shows came from
10
+ * a request — a URL, SQL, a log message — and none of it is ever parsed as
11
+ * markup.
12
+ *
13
+ * Plain readable JS, on purpose: someone will open the inspector and land
14
+ * here.
15
+ */
16
+ ;(() => {
17
+ const script = document.currentScript
18
+ if (!script || window.__reamProfiler) return
19
+ const base = script.dataset.path || '/__ream/profiler'
20
+ const pageToken = script.dataset.token || ''
21
+ const nonce = script.nonce || script.getAttribute('nonce') || ''
22
+ const TOKEN = /^[A-Za-z0-9_-]{1,64}$/
23
+ const STORE = 'ream/profiler/toolbar'
24
+ const BAR = 34
25
+
26
+ // ── State ───────────────────────────────────────────────
27
+
28
+ const saved = read()
29
+ const state = {
30
+ closed: saved.closed === true,
31
+ open: saved.open === true,
32
+ tab: typeof saved.tab === 'string' ? saved.tab : 'request',
33
+ height: clampHeight(typeof saved.height === 'number' ? saved.height : 320),
34
+ /** Every request this page knows: itself first, then each fetch/XHR. */
35
+ requests: [],
36
+ /** The one the bar shows. */
37
+ current: null,
38
+ filters: {
39
+ query: '',
40
+ onlyDuplicates: false,
41
+ sortBySlowest: false,
42
+ levels: {},
43
+ logText: '',
44
+ eventText: '',
45
+ },
46
+ }
47
+ window.__reamProfiler = { state }
48
+
49
+ function read() {
50
+ try {
51
+ const value = JSON.parse(localStorage.getItem(STORE) || '{}')
52
+ return value && typeof value === 'object' ? value : {}
53
+ } catch {
54
+ return {}
55
+ }
56
+ }
57
+
58
+ function persist() {
59
+ try {
60
+ localStorage.setItem(
61
+ STORE,
62
+ JSON.stringify({
63
+ closed: state.closed,
64
+ open: state.open,
65
+ tab: state.tab,
66
+ height: state.height,
67
+ }),
68
+ )
69
+ } catch {
70
+ // Private mode or storage disabled: the bar still works, it just forgets.
71
+ }
72
+ }
73
+
74
+ function clampHeight(value) {
75
+ const max = Math.max(160, window.innerHeight - BAR - 60)
76
+ return Math.min(Math.max(value, 120), max)
77
+ }
78
+
79
+ // ── Requests made by the page after it loaded ───────────
80
+
81
+ function track(entry) {
82
+ state.requests.push(entry)
83
+ if (state.requests.length > 100) state.requests.splice(1, 1)
84
+ if (entry.token) load(entry)
85
+ else render()
86
+ }
87
+
88
+ function own(url) {
89
+ try {
90
+ return new URL(url, location.href).pathname.startsWith(base)
91
+ } catch {
92
+ return false
93
+ }
94
+ }
95
+
96
+ const originalFetch = window.fetch
97
+ if (typeof originalFetch === 'function') {
98
+ window.fetch = function (...args) {
99
+ const [input, init] = args
100
+ const url = typeof input === 'string' ? input : input?.url || String(input)
101
+ const method = (init?.method || input?.method || 'GET').toUpperCase()
102
+ const started = performance.now()
103
+ const promise = originalFetch.apply(this, args)
104
+ if (own(url)) return promise
105
+ promise.then(
106
+ (response) =>
107
+ track({
108
+ kind: 'fetch',
109
+ method,
110
+ url,
111
+ status: response.status,
112
+ duration: performance.now() - started,
113
+ token: response.headers.get('x-debug-token') || '',
114
+ }),
115
+ () =>
116
+ track({
117
+ kind: 'fetch',
118
+ method,
119
+ url,
120
+ status: 0,
121
+ duration: performance.now() - started,
122
+ token: '',
123
+ }),
124
+ )
125
+ return promise
126
+ }
127
+ }
128
+
129
+ const xhrOpen = XMLHttpRequest.prototype.open
130
+ XMLHttpRequest.prototype.open = function (...args) {
131
+ const [method, url] = args
132
+ if (!own(String(url))) {
133
+ const started = performance.now()
134
+ this.addEventListener('loadend', () => {
135
+ // Only read a header the response actually exposes: asking for one it
136
+ // does not prints "Refused to get unsafe header" in the console.
137
+ const has = /^x-debug-token:/im.test(this.getAllResponseHeaders())
138
+ track({
139
+ kind: 'xhr',
140
+ method: String(method).toUpperCase(),
141
+ url: String(url),
142
+ status: this.status,
143
+ duration: performance.now() - started,
144
+ token: has ? this.getResponseHeader('x-debug-token') || '' : '',
145
+ })
146
+ })
147
+ }
148
+ return xhrOpen.apply(this, args)
149
+ }
150
+
151
+ /** Fetch one profile; the server may still be saving it, so retry a little. */
152
+ function load(entry, attempt = 0) {
153
+ if (!TOKEN.test(entry.token)) return render()
154
+ originalFetch
155
+ .call(window, `${base}/${entry.token}.json`, { headers: { accept: 'application/json' } })
156
+ .then((response) => {
157
+ if (response.status === 404 && attempt < 5) {
158
+ setTimeout(() => load(entry, attempt + 1), 120 * (attempt + 1))
159
+ return null
160
+ }
161
+ return response.ok ? response.json() : null
162
+ })
163
+ .then((profile) => {
164
+ if (profile) {
165
+ entry.profile = profile
166
+ entry.status = profile.status
167
+ // The page's own load is timed by the server, not by an interceptor.
168
+ if (entry.kind === 'page') entry.duration = profile.duration
169
+ if (state.current === null && entry.kind === 'page') state.current = entry
170
+ }
171
+ render()
172
+ })
173
+ .catch(() => render())
174
+ }
175
+
176
+ // ── DOM helpers ─────────────────────────────────────────
177
+
178
+ function h(tag, props, ...children) {
179
+ const node = document.createElement(tag)
180
+ for (const [key, value] of Object.entries(props || {})) {
181
+ if (value === undefined || value === null || value === false) continue
182
+ if (key === 'text') node.textContent = String(value)
183
+ else if (key === 'class') node.className = value
184
+ // Through the CSSOM: a `style` attribute is inline style, which a
185
+ // `style-src` policy without 'unsafe-inline' silently drops.
186
+ else if (key === 'style') node.style.cssText = value
187
+ else if (key.startsWith('on')) node.addEventListener(key.slice(2), value)
188
+ else if (key === 'dataset') Object.assign(node.dataset, value)
189
+ else node.setAttribute(key, value === true ? '' : String(value))
190
+ }
191
+ for (const child of children.flat(Infinity)) {
192
+ if (child === null || child === undefined || child === false) continue
193
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)))
194
+ }
195
+ return node
196
+ }
197
+
198
+ function ms(value) {
199
+ const n = Number(value) || 0
200
+ if (n < 1) return `${n.toFixed(2)} ms`
201
+ if (n < 100) return `${n.toFixed(1)} ms`
202
+ return `${Math.round(n)} ms`
203
+ }
204
+
205
+ function mb(value) {
206
+ return `${(Number(value) || 0).toFixed(1)} MB`
207
+ }
208
+
209
+ function json(value) {
210
+ try {
211
+ return JSON.stringify(value, null, 2)
212
+ } catch {
213
+ return String(value)
214
+ }
215
+ }
216
+
217
+ function statusTone(status) {
218
+ if (!status || status >= 500) return 'bad'
219
+ if (status >= 400) return 'warn'
220
+ if (status >= 300) return 'warn'
221
+ return 'ok'
222
+ }
223
+
224
+ function row(label, value) {
225
+ if (value === undefined || value === null || value === '') return null
226
+ return h('div', { class: 'row' }, h('b', { text: label }), h('span', { text: String(value) }))
227
+ }
228
+
229
+ function copy(text, button) {
230
+ const done = () => {
231
+ button.textContent = 'copied'
232
+ setTimeout(() => {
233
+ button.textContent = 'copy'
234
+ }, 1500)
235
+ }
236
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(done, () => {})
237
+ }
238
+
239
+ // ── What each panel derives from a profile ──────────────
240
+
241
+ function panelOf(profile, name) {
242
+ const value = profile?.data?.[name]
243
+ return value && typeof value === 'object' ? value : {}
244
+ }
245
+
246
+ function queriesOf(profile) {
247
+ const db = panelOf(profile, 'database')
248
+ const queries = Array.isArray(db.queries) ? db.queries : []
249
+ const counts = new Map()
250
+ for (const query of queries) {
251
+ const key = `${query.sql}\u0000${JSON.stringify(query.bindings)}\u0000${query.connection || ''}`
252
+ query.key = key
253
+ counts.set(key, (counts.get(key) || 0) + 1)
254
+ }
255
+ let duplicates = 0
256
+ for (const query of queries) {
257
+ query.copies = counts.get(query.key) || 1
258
+ if (query.copies > 1) duplicates += 1
259
+ }
260
+ return {
261
+ total: Number(db.total) || 0,
262
+ failed: Number(db.failed) || 0,
263
+ duration: Number(db.duration) || 0,
264
+ capped: db.capped === true,
265
+ queries,
266
+ duplicates,
267
+ unique: counts.size,
268
+ slowest: queries.reduce((max, query) => Math.max(max, Number(query.duration) || 0), 0),
269
+ }
270
+ }
271
+
272
+ function eventsOf(profile) {
273
+ const panel = panelOf(profile, 'events')
274
+ return Array.isArray(panel.events) ? panel.events : []
275
+ }
276
+
277
+ function cacheOf(profile) {
278
+ const ops = eventsOf(profile).filter((event) => event.name.startsWith('cache:'))
279
+ const count = (suffix) => ops.filter((event) => event.name === `cache:${suffix}`).length
280
+ return { ops, hits: count('hit'), misses: count('miss'), writes: count('written') }
281
+ }
282
+
283
+ function mailsOf(profile) {
284
+ return eventsOf(profile).filter((event) => /^mail[:.]/.test(event.name))
285
+ }
286
+
287
+ function logsOf(profile) {
288
+ const panel = panelOf(profile, 'logs')
289
+ return {
290
+ total: Number(panel.total) || 0,
291
+ errors: Number(panel.errors) || 0,
292
+ warnings: Number(panel.warnings) || 0,
293
+ lines: Array.isArray(panel.lines) ? panel.lines : [],
294
+ }
295
+ }
296
+
297
+ // ── The shell ───────────────────────────────────────────
298
+
299
+ const host = h('div', { 'data-ream-profiler': '' })
300
+ host.style.cssText = 'all:initial'
301
+ const root = host.attachShadow({ mode: 'open' })
302
+ root.append(
303
+ h('link', {
304
+ rel: 'stylesheet',
305
+ href: `${base}/_assets/toolbar.css`,
306
+ nonce: nonce || undefined,
307
+ }),
308
+ )
309
+ const shell = h('div', { class: 'rp', role: 'region', 'aria-label': 'Debug toolbar' })
310
+ const panel = h('div', { class: 'panel' })
311
+ const grip = h('div', { class: 'grip', title: 'Drag to resize' })
312
+ const tabs = h('nav', { class: 'tabs' })
313
+ const body = h('div', { class: 'body' })
314
+ const bar = h('div', { class: 'bar', role: 'toolbar' })
315
+ const restore = h('button', {
316
+ class: 'restore',
317
+ text: 'R',
318
+ title: 'Show the debug toolbar',
319
+ onclick: () => {
320
+ state.closed = false
321
+ persist()
322
+ render()
323
+ },
324
+ })
325
+ panel.append(grip, tabs, body)
326
+ shell.append(panel, bar, restore)
327
+ root.append(shell)
328
+
329
+ // ── Keeping the page visible ────────────────────────────
330
+
331
+ // The page keeps its own bottom padding; the bar adds its height to it, so
332
+ // nothing at the bottom of the page ends up underneath.
333
+ let originalPadding = null
334
+ function offsetPage() {
335
+ if (!document.body) return
336
+ if (originalPadding === null) {
337
+ originalPadding = Number.parseFloat(getComputedStyle(document.body).paddingBottom) || 0
338
+ }
339
+ const covered = state.closed ? 0 : shell.getBoundingClientRect().height
340
+ document.body.style.paddingBottom = covered > 0 ? `${originalPadding + covered}px` : ''
341
+ }
342
+
343
+ // ── Resizing ────────────────────────────────────────────
344
+
345
+ grip.addEventListener('mousedown', (start) => {
346
+ start.preventDefault()
347
+ const startY = start.clientY
348
+ const startHeight = state.height
349
+ const move = (event) => {
350
+ state.height = clampHeight(startHeight + (startY - event.clientY))
351
+ panel.style.height = `${state.height}px`
352
+ offsetPage()
353
+ }
354
+ const up = () => {
355
+ document.removeEventListener('mousemove', move)
356
+ document.removeEventListener('mouseup', up)
357
+ persist()
358
+ }
359
+ document.addEventListener('mousemove', move)
360
+ document.addEventListener('mouseup', up)
361
+ })
362
+
363
+ document.addEventListener('keydown', (event) => {
364
+ if (event.key === 'Escape' && state.open) {
365
+ state.open = false
366
+ persist()
367
+ render()
368
+ }
369
+ })
370
+
371
+ function openTab(name) {
372
+ if (state.open && state.tab === name) state.open = false
373
+ else {
374
+ state.open = true
375
+ state.tab = name
376
+ }
377
+ persist()
378
+ render()
379
+ }
380
+
381
+ // ── The bar ─────────────────────────────────────────────
382
+
383
+ function item(opts) {
384
+ return h(
385
+ 'div',
386
+ {
387
+ class: 'item',
388
+ tabindex: '0',
389
+ role: 'button',
390
+ dataset: {
391
+ tone: opts.tone || '',
392
+ ...(state.open && state.tab === opts.tab ? { active: '' } : {}),
393
+ },
394
+ onclick: () => opts.tab && openTab(opts.tab),
395
+ onkeydown: (event) => {
396
+ if (event.key === 'Enter' || event.key === ' ') {
397
+ event.preventDefault()
398
+ if (opts.tab) openTab(opts.tab)
399
+ }
400
+ },
401
+ },
402
+ opts.lead || null,
403
+ opts.label ? h('span', { class: 'label', text: opts.label }) : null,
404
+ h('span', { class: 'value', text: opts.value }),
405
+ opts.info
406
+ ? h('div', { class: 'info', onclick: (event) => event.stopPropagation() }, opts.info)
407
+ : null,
408
+ )
409
+ }
410
+
411
+ function renderBar(profile, entry) {
412
+ const items = []
413
+ const request = panelOf(profile, 'request')
414
+ const status = Number(profile.status) || 0
415
+ items.push(
416
+ item({
417
+ tab: 'request',
418
+ lead: h('span', {
419
+ class: 'status',
420
+ dataset: { tone: statusTone(status) },
421
+ text: String(status),
422
+ }),
423
+ value: `${profile.method} ${request.route || request.path || profile.url}`,
424
+ info: [
425
+ row('Status', status),
426
+ row('Method', profile.method),
427
+ row('URL', profile.url),
428
+ row('Route', request.route),
429
+ row('Pattern', request.pattern),
430
+ row('Controller', request.controller),
431
+ row('User', request.userId),
432
+ row('Session', request.session ? 'yes' : 'no'),
433
+ row('Content type', request.response?.contentType),
434
+ ],
435
+ }),
436
+ )
437
+
438
+ const duration = Number(profile.duration) || 0
439
+ const q = queriesOf(profile)
440
+ items.push(
441
+ item({
442
+ tab: 'timeline',
443
+ label: 'time',
444
+ value: ms(duration),
445
+ tone: duration > 1000 ? 'warn' : '',
446
+ info: [
447
+ row('Total time', ms(duration)),
448
+ row(
449
+ 'In SQL',
450
+ `${ms(q.duration)} (${duration > 0 ? Math.round((q.duration / duration) * 100) : 0}%)`,
451
+ ),
452
+ row('Profile', profile.token),
453
+ ],
454
+ }),
455
+ )
456
+
457
+ const memory = panelOf(profile, 'memory')
458
+ items.push(
459
+ item({
460
+ tab: 'timeline',
461
+ label: 'mem',
462
+ value: mb(memory.usedMb),
463
+ tone: memory.usedMb > 512 ? 'warn' : '',
464
+ info: [
465
+ row('Heap used', mb(memory.usedMb)),
466
+ row('Heap total', mb(memory.totalMb)),
467
+ row('Resident', mb(memory.rssMb)),
468
+ row('During request', `${memory.deltaMb >= 0 ? '+' : ''}${mb(memory.deltaMb)}`),
469
+ ],
470
+ }),
471
+ )
472
+
473
+ items.push(
474
+ item({
475
+ tab: 'queries',
476
+ label: 'db',
477
+ value: `${q.total} · ${ms(q.duration)}`,
478
+ tone: q.failed > 0 ? 'bad' : q.duplicates > 0 || q.total >= 50 ? 'warn' : '',
479
+ info: [
480
+ row('Statements', q.total),
481
+ row('Duplicated', q.duplicates > 0 ? `${q.duplicates} (${q.unique} unique)` : 'none'),
482
+ row('Failed', q.failed || 'none'),
483
+ row('Total time', ms(q.duration)),
484
+ row('Slowest', q.total > 0 ? ms(q.slowest) : ''),
485
+ ],
486
+ }),
487
+ )
488
+
489
+ const logs = logsOf(profile)
490
+ if (logs.total > 0) {
491
+ items.push(
492
+ item({
493
+ tab: 'logs',
494
+ label: 'logs',
495
+ value: String(logs.errors || logs.warnings || logs.total),
496
+ tone: logs.errors > 0 ? 'bad' : logs.warnings > 0 ? 'warn' : '',
497
+ info: [
498
+ row('Errors', logs.errors),
499
+ row('Warnings', logs.warnings),
500
+ row('Lines', logs.total),
501
+ ],
502
+ }),
503
+ )
504
+ }
505
+
506
+ const events = eventsOf(profile)
507
+ if (events.length > 0) {
508
+ const names = new Map()
509
+ for (const event of events) names.set(event.name, (names.get(event.name) || 0) + 1)
510
+ items.push(
511
+ item({
512
+ tab: 'events',
513
+ label: 'events',
514
+ value: String(panelOf(profile, 'events').total || events.length),
515
+ info: [...names.entries()].slice(0, 12).map(([name, count]) => row(name, `× ${count}`)),
516
+ }),
517
+ )
518
+ }
519
+
520
+ const cache = cacheOf(profile)
521
+ if (cache.ops.length > 0) {
522
+ const reads = cache.hits + cache.misses
523
+ items.push(
524
+ item({
525
+ tab: 'cache',
526
+ label: 'cache',
527
+ value: String(cache.ops.length),
528
+ info: [
529
+ row('Operations', cache.ops.length),
530
+ row(
531
+ 'Hits',
532
+ reads > 0
533
+ ? `${cache.hits} / ${reads} (${Math.round((cache.hits / reads) * 100)}%)`
534
+ : 0,
535
+ ),
536
+ row('Misses', cache.misses),
537
+ row('Writes', cache.writes),
538
+ ],
539
+ }),
540
+ )
541
+ }
542
+
543
+ const mails = mailsOf(profile)
544
+ if (mails.length > 0) {
545
+ items.push(item({ tab: 'mail', label: 'mail', value: String(mails.length) }))
546
+ }
547
+
548
+ const exception = panelOf(profile, 'exception')
549
+ if (typeof exception.message === 'string') {
550
+ items.push(
551
+ item({
552
+ tab: 'exception',
553
+ tone: 'bad',
554
+ value: `${exception.name || 'Error'}`,
555
+ info: [row('Message', exception.message), row('Code', exception.code)],
556
+ }),
557
+ )
558
+ }
559
+
560
+ const ajax = state.requests.filter((r) => r.kind !== 'page')
561
+ if (ajax.length > 0) {
562
+ const failed = ajax.slice(-4).some((r) => !r.status || r.status >= 400)
563
+ items.push(
564
+ item({
565
+ tab: 'ajax',
566
+ label: 'ajax',
567
+ value: String(ajax.length),
568
+ tone: failed ? 'bad' : '',
569
+ info: ajax
570
+ .slice(-8)
571
+ .reverse()
572
+ .map((r) => row(`${r.method} ${r.status || '—'}`, `${r.url} · ${ms(r.duration)}`)),
573
+ }),
574
+ )
575
+ }
576
+
577
+ const spacer = h('div', { class: 'spacer' }, profile.url || '')
578
+ if (entry && entry.kind !== 'page') {
579
+ spacer.append(
580
+ h('button', {
581
+ class: 'viewing',
582
+ text: `viewing ${entry.kind} · back to page`,
583
+ onclick: () => {
584
+ state.current = state.requests[0] || null
585
+ render()
586
+ },
587
+ }),
588
+ )
589
+ }
590
+
591
+ const app = panelOf(profile, 'app')
592
+ const appItem = item({
593
+ tab: 'app',
594
+ value: app.ream ? `ream ${app.ream}` : 'ream',
595
+ info: [
596
+ row('ream', app.ream),
597
+ row('Node.js', app.node),
598
+ row('Environment', app.environment),
599
+ row('Platform', app.platform),
600
+ row('Process', app.pid),
601
+ row('Uptime', app.uptime !== undefined ? `${app.uptime} s` : ''),
602
+ ],
603
+ })
604
+
605
+ const full = h('a', {
606
+ class: 'tool',
607
+ href: TOKEN.test(profile.token) ? `${base}/${profile.token}` : base,
608
+ target: '_blank',
609
+ rel: 'noopener',
610
+ title: 'Open the full profile',
611
+ text: '↗',
612
+ })
613
+ const toggle = h('button', {
614
+ class: 'tool',
615
+ title: state.open ? 'Collapse the panel' : 'Expand the panel',
616
+ text: state.open ? '▾' : '▴',
617
+ onclick: () => openTab(state.tab),
618
+ })
619
+ const close = h('button', {
620
+ class: 'tool',
621
+ title: 'Hide the toolbar',
622
+ text: '✕',
623
+ onclick: () => {
624
+ state.closed = true
625
+ state.open = false
626
+ persist()
627
+ render()
628
+ },
629
+ })
630
+
631
+ bar.replaceChildren(...items, spacer, appItem, full, toggle, close)
632
+ }
633
+
634
+ // ── The panel ───────────────────────────────────────────
635
+
636
+ const TABS = [
637
+ { name: 'request', label: 'Request' },
638
+ { name: 'queries', label: 'Queries', count: (p) => queriesOf(p).total, tone: (p) => dbTone(p) },
639
+ { name: 'logs', label: 'Logs', count: (p) => logsOf(p).total, tone: (p) => logTone(p) },
640
+ { name: 'events', label: 'Events', count: (p) => eventsOf(p).length },
641
+ { name: 'timeline', label: 'Timeline' },
642
+ { name: 'cache', label: 'Cache', count: (p) => cacheOf(p).ops.length, hideEmpty: true },
643
+ { name: 'mail', label: 'Mail', count: (p) => mailsOf(p).length, hideEmpty: true },
644
+ {
645
+ name: 'exception',
646
+ label: 'Exception',
647
+ count: (p) => (typeof panelOf(p, 'exception').message === 'string' ? 1 : 0),
648
+ tone: () => 'bad',
649
+ hideEmpty: true,
650
+ },
651
+ { name: 'ajax', label: 'AJAX', count: () => state.requests.length - 1, hideEmpty: true },
652
+ { name: 'app', label: 'App' },
653
+ ]
654
+
655
+ function dbTone(profile) {
656
+ const q = queriesOf(profile)
657
+ return q.failed > 0 ? 'bad' : q.duplicates > 0 ? 'warn' : ''
658
+ }
659
+
660
+ function logTone(profile) {
661
+ const logs = logsOf(profile)
662
+ return logs.errors > 0 ? 'bad' : logs.warnings > 0 ? 'warn' : ''
663
+ }
664
+
665
+ function renderPanel(profile) {
666
+ panel.style.height = `${state.height}px`
667
+ const visible = TABS.filter((tab) => !tab.hideEmpty || (tab.count?.(profile) || 0) > 0)
668
+ if (!visible.some((tab) => tab.name === state.tab)) state.tab = 'request'
669
+ tabs.replaceChildren(
670
+ ...visible.map((tab) => {
671
+ const count = tab.count ? tab.count(profile) : undefined
672
+ return h(
673
+ 'button',
674
+ {
675
+ class: 'tab',
676
+ dataset: state.tab === tab.name ? { active: '' } : {},
677
+ onclick: () => {
678
+ state.tab = tab.name
679
+ persist()
680
+ render()
681
+ },
682
+ },
683
+ tab.label,
684
+ count
685
+ ? h('span', {
686
+ class: 'count',
687
+ dataset: { tone: tab.tone?.(profile) || '' },
688
+ text: count,
689
+ })
690
+ : null,
691
+ )
692
+ }),
693
+ )
694
+ const view = VIEWS[state.tab] || VIEWS.request
695
+ // replaceChildren would print a null as the text "null".
696
+ body.replaceChildren(
697
+ ...[view(profile)].flat(Infinity).filter((node) => node !== null && node !== undefined),
698
+ )
699
+ }
700
+
701
+ function table(rows) {
702
+ const present = rows.filter(
703
+ ([, value]) => value !== undefined && value !== null && value !== '',
704
+ )
705
+ if (present.length === 0) return h('div', { class: 'empty', text: 'Nothing.' })
706
+ return h(
707
+ 'table',
708
+ null,
709
+ present.map(([key, value]) =>
710
+ h(
711
+ 'tr',
712
+ null,
713
+ h('td', { class: 'key', text: key }),
714
+ h(
715
+ 'td',
716
+ null,
717
+ value instanceof Node
718
+ ? value
719
+ : typeof value === 'object'
720
+ ? h('pre', { text: json(value) })
721
+ : h('span', { text: String(value) }),
722
+ ),
723
+ ),
724
+ ),
725
+ )
726
+ }
727
+
728
+ function objectTable(value) {
729
+ if (!value || typeof value !== 'object' || Object.keys(value).length === 0) {
730
+ return h('div', { class: 'empty', text: 'Empty.' })
731
+ }
732
+ return table(Object.entries(value))
733
+ }
734
+
735
+ function searchBox(value, onInput, placeholder) {
736
+ const input = h('input', { type: 'search', placeholder, value })
737
+ input.addEventListener('input', () => {
738
+ onInput(input.value)
739
+ const at = input.selectionStart
740
+ render()
741
+ const again = body.querySelector('input[type=search]')
742
+ if (again) {
743
+ again.focus()
744
+ again.setSelectionRange(at, at)
745
+ }
746
+ })
747
+ return input
748
+ }
749
+
750
+ const VIEWS = {
751
+ request(profile) {
752
+ const r = panelOf(profile, 'request')
753
+ return [
754
+ h('h3', { text: 'Route' }),
755
+ table([
756
+ ['Name', r.route],
757
+ ['Pattern', r.pattern],
758
+ ['Controller', r.controller],
759
+ ['Params', r.params && Object.keys(r.params).length ? r.params : undefined],
760
+ ]),
761
+ h('h3', { text: 'Request' }),
762
+ table([
763
+ ['Method', profile.method],
764
+ ['URL', profile.url],
765
+ ['Path', r.path],
766
+ ['IP', r.ip],
767
+ ['Request id', r.requestId],
768
+ ['AJAX', r.ajax ? 'yes' : undefined],
769
+ ['Time', new Date(profile.time).toLocaleString()],
770
+ ]),
771
+ h('h3', { text: 'Query string' }),
772
+ objectTable(r.query),
773
+ h('h3', { text: 'Body' }),
774
+ r.body === undefined
775
+ ? h('div', { class: 'empty', text: 'No body.' })
776
+ : h('pre', { text: json(r.body) }),
777
+ h('h3', { text: 'Headers' }),
778
+ objectTable(r.headers),
779
+ h('h3', { text: 'Cookies' }),
780
+ r.cookies?.length
781
+ ? h(
782
+ 'div',
783
+ null,
784
+ r.cookies.map((name) => [h('span', { class: 'chip', text: name }), ' ']),
785
+ )
786
+ : h('div', { class: 'empty', text: 'None.' }),
787
+ h('h3', { text: 'Session' }),
788
+ r.session === undefined
789
+ ? h('div', { class: 'empty', text: 'No session.' })
790
+ : objectTable(r.session),
791
+ h('h3', { text: 'User' }),
792
+ r.user === undefined
793
+ ? h('div', { class: 'empty', text: 'Guest.' })
794
+ : h('pre', { text: json(r.user) }),
795
+ h('h3', { text: 'Response' }),
796
+ table([
797
+ ['Status', profile.status],
798
+ ['Content type', r.response?.contentType],
799
+ ['Duration', ms(profile.duration)],
800
+ ]),
801
+ r.response ? objectTable(r.response.headers) : null,
802
+ ]
803
+ },
804
+
805
+ queries(profile) {
806
+ const q = queriesOf(profile)
807
+ const f = state.filters
808
+ if (q.total === 0) {
809
+ return h('div', {
810
+ class: 'empty',
811
+ text: 'No query reached the profiler for this request. A data layer reports statements as `db:query` on the application emitter.',
812
+ })
813
+ }
814
+ let rows = q.queries.slice()
815
+ if (f.onlyDuplicates) rows = rows.filter((query) => query.copies > 1)
816
+ if (f.query) {
817
+ const needle = f.query.toLowerCase()
818
+ rows = rows.filter((query) =>
819
+ `${query.sql} ${JSON.stringify(query.bindings)}`.toLowerCase().includes(needle),
820
+ )
821
+ }
822
+ if (f.sortBySlowest) rows.sort((a, b) => b.duration - a.duration)
823
+ const max = Math.max(q.slowest, 0.0001)
824
+ return [
825
+ h(
826
+ 'div',
827
+ { class: 'toolbar' },
828
+ h('span', {
829
+ text: `${q.total} statements · ${q.unique} unique · ${q.duplicates} duplicated · ${q.failed} failed · ${ms(q.duration)}`,
830
+ }),
831
+ q.capped
832
+ ? h('span', {
833
+ class: 'chip',
834
+ dataset: { tone: 'warn' },
835
+ text: `listing stopped at ${q.queries.length}`,
836
+ })
837
+ : null,
838
+ searchBox(
839
+ f.query,
840
+ (value) => {
841
+ f.query = value
842
+ },
843
+ 'Filter SQL',
844
+ ),
845
+ h('button', {
846
+ class: 'btn',
847
+ dataset: f.onlyDuplicates ? { on: '' } : {},
848
+ text: 'Only duplicates',
849
+ onclick: () => {
850
+ f.onlyDuplicates = !f.onlyDuplicates
851
+ render()
852
+ },
853
+ }),
854
+ h('button', {
855
+ class: 'btn',
856
+ dataset: f.sortBySlowest ? { on: '' } : {},
857
+ text: 'Slowest first',
858
+ onclick: () => {
859
+ f.sortBySlowest = !f.sortBySlowest
860
+ render()
861
+ },
862
+ }),
863
+ ),
864
+ h(
865
+ 'div',
866
+ { class: 'list' },
867
+ rows.map((query) => {
868
+ const copyButton = h('button', {
869
+ class: 'btn',
870
+ text: 'copy',
871
+ onclick: (event) => {
872
+ event.stopPropagation()
873
+ copy(query.sql, copyButton)
874
+ },
875
+ })
876
+ return h(
877
+ 'div',
878
+ {
879
+ class: 'entry',
880
+ dataset: { tone: query.error ? 'bad' : query.copies > 1 ? 'warn' : '' },
881
+ onclick: (event) => {
882
+ if (getSelection()?.type === 'Range') return
883
+ event.currentTarget.toggleAttribute('data-expanded')
884
+ },
885
+ },
886
+ h(
887
+ 'div',
888
+ { class: 'num' },
889
+ ms(query.duration),
890
+ h('div', {
891
+ class: 'meter',
892
+ style: `width:${Math.max(4, (query.duration / max) * 100)}%`,
893
+ }),
894
+ ),
895
+ h(
896
+ 'div',
897
+ null,
898
+ h('div', { class: 'sql', text: query.sql }),
899
+ query.error ? h('div', { class: 'bad', text: query.error }) : null,
900
+ h(
901
+ 'div',
902
+ { class: 'meta' },
903
+ query.bindings?.length
904
+ ? h('span', { class: 'mono', text: JSON.stringify(query.bindings) })
905
+ : null,
906
+ ),
907
+ ),
908
+ h(
909
+ 'div',
910
+ null,
911
+ query.copies > 1
912
+ ? [
913
+ h('span', {
914
+ class: 'chip',
915
+ dataset: { tone: 'warn' },
916
+ text: `× ${query.copies}`,
917
+ }),
918
+ ' ',
919
+ ]
920
+ : null,
921
+ query.model ? [h('span', { class: 'chip', text: query.model }), ' '] : null,
922
+ query.method ? [h('span', { class: 'chip', text: query.method }), ' '] : null,
923
+ query.inTransaction ? [h('span', { class: 'chip', text: 'trx' }), ' '] : null,
924
+ query.connection
925
+ ? [h('span', { class: 'chip', text: query.connection }), ' ']
926
+ : null,
927
+ copyButton,
928
+ ),
929
+ h(
930
+ 'div',
931
+ { class: 'extra' },
932
+ table([
933
+ ['Started at', `+${ms(query.at)}`],
934
+ ['Bindings', query.bindings],
935
+ ['Copies', query.copies > 1 ? query.copies : undefined],
936
+ ]),
937
+ ),
938
+ )
939
+ }),
940
+ ),
941
+ ]
942
+ },
943
+
944
+ logs(profile) {
945
+ const logs = logsOf(profile)
946
+ const f = state.filters
947
+ if (logs.total === 0)
948
+ return h('div', { class: 'empty', text: 'Nothing was logged during this request.' })
949
+ const levels = [...new Set(logs.lines.map((line) => line.level))]
950
+ let lines = logs.lines.filter((line) => f.levels[line.level] !== false)
951
+ if (f.logText) {
952
+ const needle = f.logText.toLowerCase()
953
+ lines = lines.filter((line) => line.message.toLowerCase().includes(needle))
954
+ }
955
+ const tone = (level) =>
956
+ level === 'error' || level === 'fatal' ? 'bad' : level === 'warn' ? 'warn' : ''
957
+ return [
958
+ h(
959
+ 'div',
960
+ { class: 'toolbar' },
961
+ searchBox(
962
+ f.logText,
963
+ (value) => {
964
+ f.logText = value
965
+ },
966
+ 'Filter messages',
967
+ ),
968
+ levels.map((level) =>
969
+ h('button', {
970
+ class: 'chip',
971
+ dataset: { tone: tone(level), ...(f.levels[level] === false ? { off: '' } : {}) },
972
+ text: level,
973
+ onclick: () => {
974
+ f.levels[level] = f.levels[level] === false
975
+ render()
976
+ },
977
+ }),
978
+ ),
979
+ ),
980
+ h(
981
+ 'div',
982
+ { class: 'list' },
983
+ lines.map((line) =>
984
+ h(
985
+ 'div',
986
+ {
987
+ class: 'entry',
988
+ dataset: { tone: tone(line.level) },
989
+ onclick: (event) => {
990
+ if (getSelection()?.type === 'Range') return
991
+ event.currentTarget.toggleAttribute('data-expanded')
992
+ },
993
+ },
994
+ h('div', { class: 'num', text: `+${ms(line.at)}` }),
995
+ h('div', { class: 'sql', text: line.message }),
996
+ h('span', { class: 'chip', dataset: { tone: tone(line.level) }, text: line.level }),
997
+ line.context
998
+ ? h('div', { class: 'extra' }, h('pre', { text: json(line.context) }))
999
+ : null,
1000
+ ),
1001
+ ),
1002
+ ),
1003
+ ]
1004
+ },
1005
+
1006
+ events(profile) {
1007
+ const events = eventsOf(profile)
1008
+ const f = state.filters
1009
+ if (events.length === 0)
1010
+ return h('div', { class: 'empty', text: 'No event was emitted during this request.' })
1011
+ const shown = f.eventText
1012
+ ? events.filter((event) => event.name.toLowerCase().includes(f.eventText.toLowerCase()))
1013
+ : events
1014
+ return [
1015
+ h(
1016
+ 'div',
1017
+ { class: 'toolbar' },
1018
+ h('span', { text: `${panelOf(profile, 'events').total || events.length} events` }),
1019
+ searchBox(
1020
+ f.eventText,
1021
+ (value) => {
1022
+ f.eventText = value
1023
+ },
1024
+ 'Filter by name',
1025
+ ),
1026
+ ),
1027
+ h(
1028
+ 'div',
1029
+ { class: 'list' },
1030
+ shown.map((event) =>
1031
+ h(
1032
+ 'div',
1033
+ {
1034
+ class: 'entry',
1035
+ onclick: (e) => {
1036
+ if (getSelection()?.type === 'Range') return
1037
+ e.currentTarget.toggleAttribute('data-expanded')
1038
+ },
1039
+ },
1040
+ h('div', { class: 'num', text: `+${ms(event.at)}` }),
1041
+ h('div', { class: 'mono', text: event.name }),
1042
+ h('span', null),
1043
+ h('div', { class: 'extra' }, h('pre', { text: json(event.data) })),
1044
+ ),
1045
+ ),
1046
+ ),
1047
+ ]
1048
+ },
1049
+
1050
+ timeline(profile) {
1051
+ const total = Math.max(Number(profile.duration) || 0, 0.001)
1052
+ const lanes = [
1053
+ { kind: 'request', name: `${profile.method} ${profile.url}`, at: 0, duration: total },
1054
+ ]
1055
+ for (const query of queriesOf(profile).queries) {
1056
+ lanes.push({ kind: 'query', name: query.sql, at: query.at, duration: query.duration })
1057
+ }
1058
+ for (const line of logsOf(profile).lines) {
1059
+ lanes.push({
1060
+ kind: 'log',
1061
+ name: `${line.level}: ${line.message}`,
1062
+ at: line.at,
1063
+ duration: 0,
1064
+ })
1065
+ }
1066
+ for (const event of eventsOf(profile)) {
1067
+ lanes.push({ kind: 'event', name: event.name, at: event.at, duration: 0 })
1068
+ }
1069
+ lanes.sort((a, b) => a.at - b.at || (a.kind === 'request' ? -1 : 1))
1070
+ return [
1071
+ h(
1072
+ 'div',
1073
+ { class: 'toolbar' },
1074
+ ['request', 'query', 'log', 'event'].map((kind) =>
1075
+ h('span', { class: 'chip legend', dataset: { kind }, text: kind }),
1076
+ ),
1077
+ h('span', { text: `total ${ms(total)}` }),
1078
+ ),
1079
+ lanes.map((lane) =>
1080
+ h(
1081
+ 'div',
1082
+ { class: 'lane' },
1083
+ h('div', {
1084
+ class: lane.kind === 'query' ? 'name mono' : 'name',
1085
+ text: lane.name,
1086
+ title: lane.name,
1087
+ }),
1088
+ h(
1089
+ 'div',
1090
+ { class: 'track' },
1091
+ h('div', {
1092
+ class: 'span',
1093
+ dataset: { kind: lane.kind },
1094
+ style: `left:${Math.min(100, (lane.at / total) * 100)}%;width:${Math.min(100, (lane.duration / total) * 100)}%`,
1095
+ }),
1096
+ ),
1097
+ h('div', {
1098
+ class: 'num meta',
1099
+ text: lane.duration > 0 ? ms(lane.duration) : `+${ms(lane.at)}`,
1100
+ }),
1101
+ ),
1102
+ ),
1103
+ ]
1104
+ },
1105
+
1106
+ cache(profile) {
1107
+ const cache = cacheOf(profile)
1108
+ return [
1109
+ h(
1110
+ 'div',
1111
+ { class: 'toolbar' },
1112
+ h('span', {
1113
+ text: `${cache.hits} hits · ${cache.misses} misses · ${cache.writes} writes`,
1114
+ }),
1115
+ ),
1116
+ h(
1117
+ 'table',
1118
+ null,
1119
+ h(
1120
+ 'tr',
1121
+ null,
1122
+ h('th', { text: 'At' }),
1123
+ h('th', { text: 'Operation' }),
1124
+ h('th', { text: 'Key' }),
1125
+ h('th', { text: 'Store' }),
1126
+ ),
1127
+ cache.ops.map((op) =>
1128
+ h(
1129
+ 'tr',
1130
+ null,
1131
+ h('td', { class: 'meta', text: `+${ms(op.at)}` }),
1132
+ h('td', { text: op.name.slice('cache:'.length) }),
1133
+ h('td', { class: 'mono', text: op.data?.key ?? '' }),
1134
+ h('td', { class: 'meta', text: op.data?.store ?? '' }),
1135
+ ),
1136
+ ),
1137
+ ),
1138
+ ]
1139
+ },
1140
+
1141
+ mail(profile) {
1142
+ return h(
1143
+ 'div',
1144
+ { class: 'list' },
1145
+ mailsOf(profile).map((event) =>
1146
+ h(
1147
+ 'div',
1148
+ { class: 'entry', onclick: (e) => e.currentTarget.toggleAttribute('data-expanded') },
1149
+ h('div', { class: 'num', text: `+${ms(event.at)}` }),
1150
+ h('div', { class: 'mono', text: event.name }),
1151
+ h('span', null),
1152
+ h('div', { class: 'extra' }, h('pre', { text: json(event.data) })),
1153
+ ),
1154
+ ),
1155
+ )
1156
+ },
1157
+
1158
+ exception(profile) {
1159
+ const e = panelOf(profile, 'exception')
1160
+ return [
1161
+ h('h3', { text: e.name || 'Error' }),
1162
+ h('div', { class: 'bad', text: e.message || '' }),
1163
+ e.code ? h('div', { class: 'meta', text: `code ${e.code}` }) : null,
1164
+ e.stack ? h('pre', { text: e.stack }) : null,
1165
+ ]
1166
+ },
1167
+
1168
+ ajax() {
1169
+ return [
1170
+ h(
1171
+ 'div',
1172
+ { class: 'toolbar' },
1173
+ h('span', { text: `${state.requests.length - 1} requests made by this page` }),
1174
+ h('button', {
1175
+ class: 'btn',
1176
+ text: 'Clear',
1177
+ onclick: () => {
1178
+ state.requests.splice(1)
1179
+ state.current = state.requests[0] || null
1180
+ render()
1181
+ },
1182
+ }),
1183
+ ),
1184
+ h(
1185
+ 'table',
1186
+ null,
1187
+ h(
1188
+ 'tr',
1189
+ null,
1190
+ ['#', 'Method', 'URL', 'Status', 'Time', 'Type', 'Profile'].map((label) =>
1191
+ h('th', { text: label }),
1192
+ ),
1193
+ ),
1194
+ state.requests.map((r, index) =>
1195
+ h(
1196
+ 'tr',
1197
+ {
1198
+ class: r.profile ? 'pick' : '',
1199
+ dataset: state.current === r ? { active: '' } : {},
1200
+ onclick: () => {
1201
+ if (!r.profile) return
1202
+ state.current = r
1203
+ state.tab = 'request'
1204
+ render()
1205
+ },
1206
+ },
1207
+ h('td', { class: 'meta', text: index === 0 ? 'page' : String(index) }),
1208
+ h('td', { text: r.method }),
1209
+ h('td', { class: 'mono', text: r.url }),
1210
+ h(
1211
+ 'td',
1212
+ null,
1213
+ h('span', {
1214
+ class: 'chip',
1215
+ dataset: { tone: statusTone(r.status) },
1216
+ text: r.status || '—',
1217
+ }),
1218
+ ),
1219
+ h('td', { class: 'meta', text: ms(r.duration) }),
1220
+ h('td', { class: 'meta', text: r.kind }),
1221
+ h(
1222
+ 'td',
1223
+ null,
1224
+ TOKEN.test(r.token || '')
1225
+ ? h('a', {
1226
+ href: `${base}/${r.token}`,
1227
+ target: '_blank',
1228
+ rel: 'noopener',
1229
+ text: r.token,
1230
+ onclick: (e) => e.stopPropagation(),
1231
+ })
1232
+ : h('span', { class: 'meta', text: 'n/a' }),
1233
+ ),
1234
+ ),
1235
+ ),
1236
+ ),
1237
+ ]
1238
+ },
1239
+
1240
+ app(profile) {
1241
+ const app = panelOf(profile, 'app')
1242
+ const memory = panelOf(profile, 'memory')
1243
+ return [
1244
+ h('h3', { text: 'Application' }),
1245
+ table([
1246
+ ['ream', app.ream],
1247
+ ['Environment', app.environment],
1248
+ ['Profile token', profile.token],
1249
+ ['All profiles', h('a', { href: base, target: '_blank', rel: 'noopener', text: base })],
1250
+ ]),
1251
+ h('h3', { text: 'Runtime' }),
1252
+ table([
1253
+ ['Node.js', app.node],
1254
+ ['Platform', app.platform],
1255
+ ['Process id', app.pid],
1256
+ ['Uptime', app.uptime !== undefined ? `${app.uptime} s` : undefined],
1257
+ ['Heap used', mb(memory.usedMb)],
1258
+ ['Heap total', mb(memory.totalMb)],
1259
+ ['Resident', mb(memory.rssMb)],
1260
+ ]),
1261
+ ]
1262
+ },
1263
+ }
1264
+
1265
+ // ── Render ──────────────────────────────────────────────
1266
+
1267
+ function render() {
1268
+ const entry = state.current
1269
+ const profile = entry?.profile
1270
+ // Nothing to show until the page's profile has arrived.
1271
+ shell.style.display = profile || state.closed ? '' : 'none'
1272
+ shell.toggleAttribute('data-closed', state.closed)
1273
+ shell.toggleAttribute('data-open', Boolean(state.open && profile && !state.closed))
1274
+ if (profile) {
1275
+ renderBar(profile, entry)
1276
+ if (state.open && !state.closed) renderPanel(profile)
1277
+ }
1278
+ offsetPage()
1279
+ }
1280
+
1281
+ function mount() {
1282
+ document.body.append(host)
1283
+ state.requests.unshift({
1284
+ kind: 'page',
1285
+ method: 'GET',
1286
+ url: location.pathname + location.search,
1287
+ token: pageToken,
1288
+ duration: 0,
1289
+ })
1290
+ load(state.requests[0])
1291
+ window.addEventListener('resize', () => {
1292
+ state.height = clampHeight(state.height)
1293
+ render()
1294
+ })
1295
+ }
1296
+
1297
+ if (document.body) mount()
1298
+ else document.addEventListener('DOMContentLoaded', mount, { once: true })
1299
+ })()