@c9up/ream 0.2.28 → 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 (293) hide show
  1. package/README.md +5 -6
  2. package/dist/Codemods.d.ts +24 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +211 -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 +15 -2
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +33 -23
  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 +41 -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 +20 -1
  26. package/dist/bodyparser/qsParse.d.ts.map +1 -1
  27. package/dist/bodyparser/qsParse.js +78 -25
  28. package/dist/bodyparser/qsParse.js.map +1 -1
  29. package/dist/codemods/source.d.ts +57 -0
  30. package/dist/codemods/source.d.ts.map +1 -0
  31. package/dist/codemods/source.js +282 -0
  32. package/dist/codemods/source.js.map +1 -0
  33. package/dist/console/prompts.d.ts.map +1 -1
  34. package/dist/console/prompts.js +3 -1
  35. package/dist/console/prompts.js.map +1 -1
  36. package/dist/container/Container.d.ts.map +1 -1
  37. package/dist/container/Container.js +61 -71
  38. package/dist/container/Container.js.map +1 -1
  39. package/dist/events/Emitter.d.ts +12 -0
  40. package/dist/events/Emitter.d.ts.map +1 -1
  41. package/dist/events/Emitter.js +36 -9
  42. package/dist/events/Emitter.js.map +1 -1
  43. package/dist/events/helix/helpers.d.ts.map +1 -1
  44. package/dist/events/helix/helpers.js +19 -9
  45. package/dist/events/helix/helpers.js.map +1 -1
  46. package/dist/graphql/GraphQLEngine.d.ts +8 -0
  47. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  48. package/dist/graphql/GraphQLEngine.js +79 -12
  49. package/dist/graphql/GraphQLEngine.js.map +1 -1
  50. package/dist/helpers/MessageBuilder.d.ts +4 -2
  51. package/dist/helpers/MessageBuilder.d.ts.map +1 -1
  52. package/dist/helpers/MessageBuilder.js +4 -2
  53. package/dist/helpers/MessageBuilder.js.map +1 -1
  54. package/dist/helpers/errors.d.ts +11 -0
  55. package/dist/helpers/errors.d.ts.map +1 -0
  56. package/dist/helpers/errors.js +17 -0
  57. package/dist/helpers/errors.js.map +1 -0
  58. package/dist/helpers/napi-loader.d.ts.map +1 -1
  59. package/dist/helpers/napi-loader.js +3 -2
  60. package/dist/helpers/napi-loader.js.map +1 -1
  61. package/dist/http/Exception.d.ts +14 -1
  62. package/dist/http/Exception.d.ts.map +1 -1
  63. package/dist/http/Exception.js +10 -2
  64. package/dist/http/Exception.js.map +1 -1
  65. package/dist/http/HttpContext.d.ts +31 -2
  66. package/dist/http/HttpContext.d.ts.map +1 -1
  67. package/dist/http/HttpContext.js +12 -5
  68. package/dist/http/HttpContext.js.map +1 -1
  69. package/dist/http/RedirectBuilder.d.ts +9 -3
  70. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  71. package/dist/http/RedirectBuilder.js +43 -37
  72. package/dist/http/RedirectBuilder.js.map +1 -1
  73. package/dist/http/Request.d.ts +42 -17
  74. package/dist/http/Request.d.ts.map +1 -1
  75. package/dist/http/Request.js +80 -58
  76. package/dist/http/Request.js.map +1 -1
  77. package/dist/http/Response.d.ts +7 -4
  78. package/dist/http/Response.d.ts.map +1 -1
  79. package/dist/http/Response.js +14 -6
  80. package/dist/http/Response.js.map +1 -1
  81. package/dist/http/SseStream.js +6 -7
  82. package/dist/http/SseStream.js.map +1 -1
  83. package/dist/http/permissions.d.ts +25 -0
  84. package/dist/http/permissions.d.ts.map +1 -0
  85. package/dist/http/permissions.js +38 -0
  86. package/dist/http/permissions.js.map +1 -0
  87. package/dist/http-client/HttpClient.d.ts +111 -0
  88. package/dist/http-client/HttpClient.d.ts.map +1 -0
  89. package/dist/http-client/HttpClient.js +386 -0
  90. package/dist/http-client/HttpClient.js.map +1 -0
  91. package/dist/http-client/addresses.d.ts +14 -0
  92. package/dist/http-client/addresses.d.ts.map +1 -0
  93. package/dist/http-client/addresses.js +57 -0
  94. package/dist/http-client/addresses.js.map +1 -0
  95. package/dist/http-client/fake.d.ts +50 -0
  96. package/dist/http-client/fake.d.ts.map +1 -0
  97. package/dist/http-client/fake.js +95 -0
  98. package/dist/http-client/fake.js.map +1 -0
  99. package/dist/http-client/index.d.ts +7 -0
  100. package/dist/http-client/index.d.ts.map +1 -0
  101. package/dist/http-client/index.js +7 -0
  102. package/dist/http-client/index.js.map +1 -0
  103. package/dist/index.d.ts +4 -3
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +1 -1
  106. package/dist/index.js.map +1 -1
  107. package/dist/middleware/Pipeline.d.ts.map +1 -1
  108. package/dist/middleware/Pipeline.js +6 -3
  109. package/dist/middleware/Pipeline.js.map +1 -1
  110. package/dist/native/generated.d.ts +93 -36
  111. package/dist/native/generated.d.ts.map +1 -1
  112. package/dist/profiler/Profiler.d.ts.map +1 -1
  113. package/dist/profiler/Profiler.js +4 -1
  114. package/dist/profiler/Profiler.js.map +1 -1
  115. package/dist/profiler/client.generated.d.ts +5 -0
  116. package/dist/profiler/client.generated.d.ts.map +1 -0
  117. package/dist/profiler/client.generated.js +6 -0
  118. package/dist/profiler/client.generated.js.map +1 -0
  119. package/dist/profiler/collectors.d.ts +92 -13
  120. package/dist/profiler/collectors.d.ts.map +1 -1
  121. package/dist/profiler/collectors.js +106 -19
  122. package/dist/profiler/collectors.js.map +1 -1
  123. package/dist/profiler/endpoint.d.ts +2 -1
  124. package/dist/profiler/endpoint.d.ts.map +1 -1
  125. package/dist/profiler/endpoint.js +25 -1
  126. package/dist/profiler/endpoint.js.map +1 -1
  127. package/dist/profiler/index.d.ts +8 -4
  128. package/dist/profiler/index.d.ts.map +1 -1
  129. package/dist/profiler/index.js +11 -4
  130. package/dist/profiler/index.js.map +1 -1
  131. package/dist/profiler/middleware.d.ts.map +1 -1
  132. package/dist/profiler/middleware.js +76 -10
  133. package/dist/profiler/middleware.js.map +1 -1
  134. package/dist/profiler/preview.d.ts +17 -0
  135. package/dist/profiler/preview.d.ts.map +1 -0
  136. package/dist/profiler/preview.js +100 -0
  137. package/dist/profiler/preview.js.map +1 -0
  138. package/dist/profiler/toolbar.d.ts +15 -23
  139. package/dist/profiler/toolbar.d.ts.map +1 -1
  140. package/dist/profiler/toolbar.js +25 -91
  141. package/dist/profiler/toolbar.js.map +1 -1
  142. package/dist/profiler/types.d.ts +6 -1
  143. package/dist/profiler/types.d.ts.map +1 -1
  144. package/dist/router/Router.d.ts +6 -0
  145. package/dist/router/Router.d.ts.map +1 -1
  146. package/dist/router/Router.js +115 -12
  147. package/dist/router/Router.js.map +1 -1
  148. package/dist/rpc/RpcProvider.d.ts +6 -0
  149. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  150. package/dist/rpc/RpcProvider.js +4 -1
  151. package/dist/rpc/RpcProvider.js.map +1 -1
  152. package/dist/rpc/RpcRouter.d.ts +27 -14
  153. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  154. package/dist/rpc/RpcRouter.js +97 -40
  155. package/dist/rpc/RpcRouter.js.map +1 -1
  156. package/dist/scheduler/Scheduler.js +1 -1
  157. package/dist/scheduler/Scheduler.js.map +1 -1
  158. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
  159. package/dist/scheduler/locks/RedisLockBackend.js +13 -2
  160. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
  161. package/dist/security/CookieSigner.d.ts.map +1 -1
  162. package/dist/security/CookieSigner.js +32 -8
  163. package/dist/security/CookieSigner.js.map +1 -1
  164. package/dist/security/SignedUrl.d.ts.map +1 -1
  165. package/dist/security/SignedUrl.js +16 -19
  166. package/dist/security/SignedUrl.js.map +1 -1
  167. package/dist/server/Server.d.ts +44 -12
  168. package/dist/server/Server.d.ts.map +1 -1
  169. package/dist/server/Server.js +31 -10
  170. package/dist/server/Server.js.map +1 -1
  171. package/dist/server/nativeGrpc.d.ts +27 -0
  172. package/dist/server/nativeGrpc.d.ts.map +1 -0
  173. package/dist/server/nativeGrpc.js +47 -0
  174. package/dist/server/nativeGrpc.js.map +1 -0
  175. package/dist/server/nativeLimits.d.ts +57 -0
  176. package/dist/server/nativeLimits.d.ts.map +1 -0
  177. package/dist/server/nativeLimits.js +75 -0
  178. package/dist/server/nativeLimits.js.map +1 -0
  179. package/dist/session/Session.d.ts +12 -0
  180. package/dist/session/Session.d.ts.map +1 -1
  181. package/dist/session/Session.js +13 -4
  182. package/dist/session/Session.js.map +1 -1
  183. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  184. package/dist/session/SessionMiddleware.js +28 -9
  185. package/dist/session/SessionMiddleware.js.map +1 -1
  186. package/dist/session/config.d.ts +5 -0
  187. package/dist/session/config.d.ts.map +1 -1
  188. package/dist/session/config.js +8 -0
  189. package/dist/session/config.js.map +1 -1
  190. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  191. package/dist/session/drivers/MemoryDriver.js +26 -7
  192. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  193. package/dist/session/errors.d.ts +17 -0
  194. package/dist/session/errors.d.ts.map +1 -1
  195. package/dist/session/errors.js +21 -0
  196. package/dist/session/errors.js.map +1 -1
  197. package/dist/session/templateTags.d.ts +14 -0
  198. package/dist/session/templateTags.d.ts.map +1 -1
  199. package/dist/session/templateTags.js +45 -17
  200. package/dist/session/templateTags.js.map +1 -1
  201. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  202. package/dist/storage/StaticMiddleware.js +49 -27
  203. package/dist/storage/StaticMiddleware.js.map +1 -1
  204. package/dist/testing/ApiResponse.d.ts.map +1 -1
  205. package/dist/testing/ApiResponse.js +7 -1
  206. package/dist/testing/ApiResponse.js.map +1 -1
  207. package/dist/testing/TestClient.d.ts.map +1 -1
  208. package/dist/testing/TestClient.js +16 -4
  209. package/dist/testing/TestClient.js.map +1 -1
  210. package/dist/utils/Macroable.d.ts +1 -1
  211. package/dist/utils/Macroable.js +2 -2
  212. package/dist/utils/Macroable.js.map +1 -1
  213. package/events.darwin-arm64.node +0 -0
  214. package/events.darwin-x64.node +0 -0
  215. package/events.linux-arm64-gnu.node +0 -0
  216. package/events.linux-x64-gnu.node +0 -0
  217. package/events.win32-x64-msvc.node +0 -0
  218. package/index.darwin-arm64.node +0 -0
  219. package/index.darwin-x64.node +0 -0
  220. package/index.linux-arm64-gnu.node +0 -0
  221. package/index.linux-x64-gnu.node +0 -0
  222. package/index.win32-x64-msvc.node +0 -0
  223. package/package.json +11 -3
  224. package/scheduler.darwin-arm64.node +0 -0
  225. package/scheduler.darwin-x64.node +0 -0
  226. package/scheduler.linux-arm64-gnu.node +0 -0
  227. package/scheduler.linux-x64-gnu.node +0 -0
  228. package/scheduler.win32-x64-msvc.node +0 -0
  229. package/src/Codemods.ts +314 -3
  230. package/src/HttpKernel.ts +18 -7
  231. package/src/Ignitor.ts +58 -26
  232. package/src/bodyparser/BodyParserMiddleware.ts +45 -16
  233. package/src/bodyparser/MultipartFile.ts +46 -67
  234. package/src/bodyparser/parseSize.ts +17 -17
  235. package/src/bodyparser/qsParse.ts +96 -26
  236. package/src/codemods/source.ts +303 -0
  237. package/src/console/prompts.ts +3 -1
  238. package/src/container/Container.ts +97 -81
  239. package/src/events/Emitter.ts +37 -10
  240. package/src/events/helix/helpers.ts +24 -19
  241. package/src/graphql/GraphQLEngine.ts +91 -14
  242. package/src/helpers/MessageBuilder.ts +4 -2
  243. package/src/helpers/errors.ts +16 -0
  244. package/src/helpers/napi-loader.ts +3 -2
  245. package/src/http/Exception.ts +17 -2
  246. package/src/http/HttpContext.ts +34 -5
  247. package/src/http/RedirectBuilder.ts +51 -37
  248. package/src/http/Request.ts +102 -58
  249. package/src/http/Response.ts +15 -7
  250. package/src/http/SseStream.ts +6 -7
  251. package/src/http/permissions.ts +47 -0
  252. package/src/http-client/HttpClient.ts +505 -0
  253. package/src/http-client/addresses.ts +58 -0
  254. package/src/http-client/fake.ts +141 -0
  255. package/src/http-client/index.ts +21 -0
  256. package/src/index.ts +6 -1
  257. package/src/middleware/Pipeline.ts +7 -3
  258. package/src/native/generated.ts +106 -48
  259. package/src/profiler/Profiler.ts +4 -1
  260. package/src/profiler/client/toolbar.css +595 -0
  261. package/src/profiler/client/toolbar.js +1299 -0
  262. package/src/profiler/client.generated.ts +7 -0
  263. package/src/profiler/collectors.ts +156 -19
  264. package/src/profiler/endpoint.ts +27 -1
  265. package/src/profiler/index.ts +17 -3
  266. package/src/profiler/middleware.ts +74 -9
  267. package/src/profiler/preview.ts +104 -0
  268. package/src/profiler/toolbar.ts +28 -91
  269. package/src/profiler/types.ts +6 -1
  270. package/src/router/Router.ts +136 -13
  271. package/src/rpc/RpcProvider.ts +10 -1
  272. package/src/rpc/RpcRouter.ts +129 -45
  273. package/src/scheduler/Scheduler.ts +1 -1
  274. package/src/scheduler/locks/RedisLockBackend.ts +12 -4
  275. package/src/security/CookieSigner.ts +30 -8
  276. package/src/security/SignedUrl.ts +18 -17
  277. package/src/server/Server.ts +60 -12
  278. package/src/server/nativeGrpc.ts +59 -0
  279. package/src/server/nativeLimits.ts +111 -0
  280. package/src/session/Session.ts +29 -4
  281. package/src/session/SessionMiddleware.ts +30 -10
  282. package/src/session/config.ts +7 -0
  283. package/src/session/drivers/MemoryDriver.ts +26 -7
  284. package/src/session/errors.ts +31 -0
  285. package/src/session/templateTags.ts +57 -0
  286. package/src/storage/StaticMiddleware.ts +46 -27
  287. package/src/testing/ApiResponse.ts +7 -1
  288. package/src/testing/TestClient.ts +14 -5
  289. package/src/utils/Macroable.ts +2 -2
  290. package/scripts/build-napi-types.mjs +0 -69
  291. package/scripts/copy-napi.mjs +0 -121
  292. package/scripts/generate-mime.mjs +0 -119
  293. package/scripts/generate-napi-types.mjs +0 -187
@@ -0,0 +1,7 @@
1
+ // Generated by scripts/build-profiler-client.mjs from src/profiler/client/. Do not edit.
2
+
3
+ /** The debug toolbar's script, served at /__ream/profiler/_assets/toolbar.js. */
4
+ export const TOOLBAR_JS: string = "/*\n * The debug toolbar, as it runs in the browser.\n *\n * Served from /__ream/profiler/_assets/toolbar.js and loaded by a <script src>\n * that carries the request's profile token in data attributes: no inline code,\n * so a `script-src 'self'` policy runs it, and a nonce-only one runs it through\n * the nonce the server stamps on the tag.\n *\n * Built with the DOM API and `textContent` only. Everything it shows came from\n * a request — a URL, SQL, a log message — and none of it is ever parsed as\n * markup.\n *\n * Plain readable JS, on purpose: someone will open the inspector and land\n * here.\n */\n;(() => {\n const script = document.currentScript\n if (!script || window.__reamProfiler) return\n const base = script.dataset.path || '/__ream/profiler'\n const pageToken = script.dataset.token || ''\n const nonce = script.nonce || script.getAttribute('nonce') || ''\n const TOKEN = /^[A-Za-z0-9_-]{1,64}$/\n const STORE = 'ream/profiler/toolbar'\n const BAR = 34\n\n // ── State ───────────────────────────────────────────────\n\n const saved = read()\n const state = {\n closed: saved.closed === true,\n open: saved.open === true,\n tab: typeof saved.tab === 'string' ? saved.tab : 'request',\n height: clampHeight(typeof saved.height === 'number' ? saved.height : 320),\n /** Every request this page knows: itself first, then each fetch/XHR. */\n requests: [],\n /** The one the bar shows. */\n current: null,\n filters: {\n query: '',\n onlyDuplicates: false,\n sortBySlowest: false,\n levels: {},\n logText: '',\n eventText: '',\n },\n }\n window.__reamProfiler = { state }\n\n function read() {\n try {\n const value = JSON.parse(localStorage.getItem(STORE) || '{}')\n return value && typeof value === 'object' ? value : {}\n } catch {\n return {}\n }\n }\n\n function persist() {\n try {\n localStorage.setItem(\n STORE,\n JSON.stringify({\n closed: state.closed,\n open: state.open,\n tab: state.tab,\n height: state.height,\n }),\n )\n } catch {\n // Private mode or storage disabled: the bar still works, it just forgets.\n }\n }\n\n function clampHeight(value) {\n const max = Math.max(160, window.innerHeight - BAR - 60)\n return Math.min(Math.max(value, 120), max)\n }\n\n // ── Requests made by the page after it loaded ───────────\n\n function track(entry) {\n state.requests.push(entry)\n if (state.requests.length > 100) state.requests.splice(1, 1)\n if (entry.token) load(entry)\n else render()\n }\n\n function own(url) {\n try {\n return new URL(url, location.href).pathname.startsWith(base)\n } catch {\n return false\n }\n }\n\n const originalFetch = window.fetch\n if (typeof originalFetch === 'function') {\n window.fetch = function (...args) {\n const [input, init] = args\n const url = typeof input === 'string' ? input : input?.url || String(input)\n const method = (init?.method || input?.method || 'GET').toUpperCase()\n const started = performance.now()\n const promise = originalFetch.apply(this, args)\n if (own(url)) return promise\n promise.then(\n (response) =>\n track({\n kind: 'fetch',\n method,\n url,\n status: response.status,\n duration: performance.now() - started,\n token: response.headers.get('x-debug-token') || '',\n }),\n () =>\n track({\n kind: 'fetch',\n method,\n url,\n status: 0,\n duration: performance.now() - started,\n token: '',\n }),\n )\n return promise\n }\n }\n\n const xhrOpen = XMLHttpRequest.prototype.open\n XMLHttpRequest.prototype.open = function (...args) {\n const [method, url] = args\n if (!own(String(url))) {\n const started = performance.now()\n this.addEventListener('loadend', () => {\n // Only read a header the response actually exposes: asking for one it\n // does not prints \"Refused to get unsafe header\" in the console.\n const has = /^x-debug-token:/im.test(this.getAllResponseHeaders())\n track({\n kind: 'xhr',\n method: String(method).toUpperCase(),\n url: String(url),\n status: this.status,\n duration: performance.now() - started,\n token: has ? this.getResponseHeader('x-debug-token') || '' : '',\n })\n })\n }\n return xhrOpen.apply(this, args)\n }\n\n /** Fetch one profile; the server may still be saving it, so retry a little. */\n function load(entry, attempt = 0) {\n if (!TOKEN.test(entry.token)) return render()\n originalFetch\n .call(window, `${base}/${entry.token}.json`, { headers: { accept: 'application/json' } })\n .then((response) => {\n if (response.status === 404 && attempt < 5) {\n setTimeout(() => load(entry, attempt + 1), 120 * (attempt + 1))\n return null\n }\n return response.ok ? response.json() : null\n })\n .then((profile) => {\n if (profile) {\n entry.profile = profile\n entry.status = profile.status\n // The page's own load is timed by the server, not by an interceptor.\n if (entry.kind === 'page') entry.duration = profile.duration\n if (state.current === null && entry.kind === 'page') state.current = entry\n }\n render()\n })\n .catch(() => render())\n }\n\n // ── DOM helpers ─────────────────────────────────────────\n\n function h(tag, props, ...children) {\n const node = document.createElement(tag)\n for (const [key, value] of Object.entries(props || {})) {\n if (value === undefined || value === null || value === false) continue\n if (key === 'text') node.textContent = String(value)\n else if (key === 'class') node.className = value\n // Through the CSSOM: a `style` attribute is inline style, which a\n // `style-src` policy without 'unsafe-inline' silently drops.\n else if (key === 'style') node.style.cssText = value\n else if (key.startsWith('on')) node.addEventListener(key.slice(2), value)\n else if (key === 'dataset') Object.assign(node.dataset, value)\n else node.setAttribute(key, value === true ? '' : String(value))\n }\n for (const child of children.flat(Infinity)) {\n if (child === null || child === undefined || child === false) continue\n node.append(child instanceof Node ? child : document.createTextNode(String(child)))\n }\n return node\n }\n\n function ms(value) {\n const n = Number(value) || 0\n if (n < 1) return `${n.toFixed(2)} ms`\n if (n < 100) return `${n.toFixed(1)} ms`\n return `${Math.round(n)} ms`\n }\n\n function mb(value) {\n return `${(Number(value) || 0).toFixed(1)} MB`\n }\n\n function json(value) {\n try {\n return JSON.stringify(value, null, 2)\n } catch {\n return String(value)\n }\n }\n\n function statusTone(status) {\n if (!status || status >= 500) return 'bad'\n if (status >= 400) return 'warn'\n if (status >= 300) return 'warn'\n return 'ok'\n }\n\n function row(label, value) {\n if (value === undefined || value === null || value === '') return null\n return h('div', { class: 'row' }, h('b', { text: label }), h('span', { text: String(value) }))\n }\n\n function copy(text, button) {\n const done = () => {\n button.textContent = 'copied'\n setTimeout(() => {\n button.textContent = 'copy'\n }, 1500)\n }\n if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(done, () => {})\n }\n\n // ── What each panel derives from a profile ──────────────\n\n function panelOf(profile, name) {\n const value = profile?.data?.[name]\n return value && typeof value === 'object' ? value : {}\n }\n\n function queriesOf(profile) {\n const db = panelOf(profile, 'database')\n const queries = Array.isArray(db.queries) ? db.queries : []\n const counts = new Map()\n for (const query of queries) {\n const key = `${query.sql}\\u0000${JSON.stringify(query.bindings)}\\u0000${query.connection || ''}`\n query.key = key\n counts.set(key, (counts.get(key) || 0) + 1)\n }\n let duplicates = 0\n for (const query of queries) {\n query.copies = counts.get(query.key) || 1\n if (query.copies > 1) duplicates += 1\n }\n return {\n total: Number(db.total) || 0,\n failed: Number(db.failed) || 0,\n duration: Number(db.duration) || 0,\n capped: db.capped === true,\n queries,\n duplicates,\n unique: counts.size,\n slowest: queries.reduce((max, query) => Math.max(max, Number(query.duration) || 0), 0),\n }\n }\n\n function eventsOf(profile) {\n const panel = panelOf(profile, 'events')\n return Array.isArray(panel.events) ? panel.events : []\n }\n\n function cacheOf(profile) {\n const ops = eventsOf(profile).filter((event) => event.name.startsWith('cache:'))\n const count = (suffix) => ops.filter((event) => event.name === `cache:${suffix}`).length\n return { ops, hits: count('hit'), misses: count('miss'), writes: count('written') }\n }\n\n function mailsOf(profile) {\n return eventsOf(profile).filter((event) => /^mail[:.]/.test(event.name))\n }\n\n function logsOf(profile) {\n const panel = panelOf(profile, 'logs')\n return {\n total: Number(panel.total) || 0,\n errors: Number(panel.errors) || 0,\n warnings: Number(panel.warnings) || 0,\n lines: Array.isArray(panel.lines) ? panel.lines : [],\n }\n }\n\n // ── The shell ───────────────────────────────────────────\n\n const host = h('div', { 'data-ream-profiler': '' })\n host.style.cssText = 'all:initial'\n const root = host.attachShadow({ mode: 'open' })\n root.append(\n h('link', {\n rel: 'stylesheet',\n href: `${base}/_assets/toolbar.css`,\n nonce: nonce || undefined,\n }),\n )\n const shell = h('div', { class: 'rp', role: 'region', 'aria-label': 'Debug toolbar' })\n const panel = h('div', { class: 'panel' })\n const grip = h('div', { class: 'grip', title: 'Drag to resize' })\n const tabs = h('nav', { class: 'tabs' })\n const body = h('div', { class: 'body' })\n const bar = h('div', { class: 'bar', role: 'toolbar' })\n const restore = h('button', {\n class: 'restore',\n text: 'R',\n title: 'Show the debug toolbar',\n onclick: () => {\n state.closed = false\n persist()\n render()\n },\n })\n panel.append(grip, tabs, body)\n shell.append(panel, bar, restore)\n root.append(shell)\n\n // ── Keeping the page visible ────────────────────────────\n\n // The page keeps its own bottom padding; the bar adds its height to it, so\n // nothing at the bottom of the page ends up underneath.\n let originalPadding = null\n function offsetPage() {\n if (!document.body) return\n if (originalPadding === null) {\n originalPadding = Number.parseFloat(getComputedStyle(document.body).paddingBottom) || 0\n }\n const covered = state.closed ? 0 : shell.getBoundingClientRect().height\n document.body.style.paddingBottom = covered > 0 ? `${originalPadding + covered}px` : ''\n }\n\n // ── Resizing ────────────────────────────────────────────\n\n grip.addEventListener('mousedown', (start) => {\n start.preventDefault()\n const startY = start.clientY\n const startHeight = state.height\n const move = (event) => {\n state.height = clampHeight(startHeight + (startY - event.clientY))\n panel.style.height = `${state.height}px`\n offsetPage()\n }\n const up = () => {\n document.removeEventListener('mousemove', move)\n document.removeEventListener('mouseup', up)\n persist()\n }\n document.addEventListener('mousemove', move)\n document.addEventListener('mouseup', up)\n })\n\n document.addEventListener('keydown', (event) => {\n if (event.key === 'Escape' && state.open) {\n state.open = false\n persist()\n render()\n }\n })\n\n function openTab(name) {\n if (state.open && state.tab === name) state.open = false\n else {\n state.open = true\n state.tab = name\n }\n persist()\n render()\n }\n\n // ── The bar ─────────────────────────────────────────────\n\n function item(opts) {\n return h(\n 'div',\n {\n class: 'item',\n tabindex: '0',\n role: 'button',\n dataset: {\n tone: opts.tone || '',\n ...(state.open && state.tab === opts.tab ? { active: '' } : {}),\n },\n onclick: () => opts.tab && openTab(opts.tab),\n onkeydown: (event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n if (opts.tab) openTab(opts.tab)\n }\n },\n },\n opts.lead || null,\n opts.label ? h('span', { class: 'label', text: opts.label }) : null,\n h('span', { class: 'value', text: opts.value }),\n opts.info\n ? h('div', { class: 'info', onclick: (event) => event.stopPropagation() }, opts.info)\n : null,\n )\n }\n\n function renderBar(profile, entry) {\n const items = []\n const request = panelOf(profile, 'request')\n const status = Number(profile.status) || 0\n items.push(\n item({\n tab: 'request',\n lead: h('span', {\n class: 'status',\n dataset: { tone: statusTone(status) },\n text: String(status),\n }),\n value: `${profile.method} ${request.route || request.path || profile.url}`,\n info: [\n row('Status', status),\n row('Method', profile.method),\n row('URL', profile.url),\n row('Route', request.route),\n row('Pattern', request.pattern),\n row('Controller', request.controller),\n row('User', request.userId),\n row('Session', request.session ? 'yes' : 'no'),\n row('Content type', request.response?.contentType),\n ],\n }),\n )\n\n const duration = Number(profile.duration) || 0\n const q = queriesOf(profile)\n items.push(\n item({\n tab: 'timeline',\n label: 'time',\n value: ms(duration),\n tone: duration > 1000 ? 'warn' : '',\n info: [\n row('Total time', ms(duration)),\n row(\n 'In SQL',\n `${ms(q.duration)} (${duration > 0 ? Math.round((q.duration / duration) * 100) : 0}%)`,\n ),\n row('Profile', profile.token),\n ],\n }),\n )\n\n const memory = panelOf(profile, 'memory')\n items.push(\n item({\n tab: 'timeline',\n label: 'mem',\n value: mb(memory.usedMb),\n tone: memory.usedMb > 512 ? 'warn' : '',\n info: [\n row('Heap used', mb(memory.usedMb)),\n row('Heap total', mb(memory.totalMb)),\n row('Resident', mb(memory.rssMb)),\n row('During request', `${memory.deltaMb >= 0 ? '+' : ''}${mb(memory.deltaMb)}`),\n ],\n }),\n )\n\n items.push(\n item({\n tab: 'queries',\n label: 'db',\n value: `${q.total} · ${ms(q.duration)}`,\n tone: q.failed > 0 ? 'bad' : q.duplicates > 0 || q.total >= 50 ? 'warn' : '',\n info: [\n row('Statements', q.total),\n row('Duplicated', q.duplicates > 0 ? `${q.duplicates} (${q.unique} unique)` : 'none'),\n row('Failed', q.failed || 'none'),\n row('Total time', ms(q.duration)),\n row('Slowest', q.total > 0 ? ms(q.slowest) : ''),\n ],\n }),\n )\n\n const logs = logsOf(profile)\n if (logs.total > 0) {\n items.push(\n item({\n tab: 'logs',\n label: 'logs',\n value: String(logs.errors || logs.warnings || logs.total),\n tone: logs.errors > 0 ? 'bad' : logs.warnings > 0 ? 'warn' : '',\n info: [\n row('Errors', logs.errors),\n row('Warnings', logs.warnings),\n row('Lines', logs.total),\n ],\n }),\n )\n }\n\n const events = eventsOf(profile)\n if (events.length > 0) {\n const names = new Map()\n for (const event of events) names.set(event.name, (names.get(event.name) || 0) + 1)\n items.push(\n item({\n tab: 'events',\n label: 'events',\n value: String(panelOf(profile, 'events').total || events.length),\n info: [...names.entries()].slice(0, 12).map(([name, count]) => row(name, `× ${count}`)),\n }),\n )\n }\n\n const cache = cacheOf(profile)\n if (cache.ops.length > 0) {\n const reads = cache.hits + cache.misses\n items.push(\n item({\n tab: 'cache',\n label: 'cache',\n value: String(cache.ops.length),\n info: [\n row('Operations', cache.ops.length),\n row(\n 'Hits',\n reads > 0\n ? `${cache.hits} / ${reads} (${Math.round((cache.hits / reads) * 100)}%)`\n : 0,\n ),\n row('Misses', cache.misses),\n row('Writes', cache.writes),\n ],\n }),\n )\n }\n\n const mails = mailsOf(profile)\n if (mails.length > 0) {\n items.push(item({ tab: 'mail', label: 'mail', value: String(mails.length) }))\n }\n\n const exception = panelOf(profile, 'exception')\n if (typeof exception.message === 'string') {\n items.push(\n item({\n tab: 'exception',\n tone: 'bad',\n value: `${exception.name || 'Error'}`,\n info: [row('Message', exception.message), row('Code', exception.code)],\n }),\n )\n }\n\n const ajax = state.requests.filter((r) => r.kind !== 'page')\n if (ajax.length > 0) {\n const failed = ajax.slice(-4).some((r) => !r.status || r.status >= 400)\n items.push(\n item({\n tab: 'ajax',\n label: 'ajax',\n value: String(ajax.length),\n tone: failed ? 'bad' : '',\n info: ajax\n .slice(-8)\n .reverse()\n .map((r) => row(`${r.method} ${r.status || '—'}`, `${r.url} · ${ms(r.duration)}`)),\n }),\n )\n }\n\n const spacer = h('div', { class: 'spacer' }, profile.url || '')\n if (entry && entry.kind !== 'page') {\n spacer.append(\n h('button', {\n class: 'viewing',\n text: `viewing ${entry.kind} · back to page`,\n onclick: () => {\n state.current = state.requests[0] || null\n render()\n },\n }),\n )\n }\n\n const app = panelOf(profile, 'app')\n const appItem = item({\n tab: 'app',\n value: app.ream ? `ream ${app.ream}` : 'ream',\n info: [\n row('ream', app.ream),\n row('Node.js', app.node),\n row('Environment', app.environment),\n row('Platform', app.platform),\n row('Process', app.pid),\n row('Uptime', app.uptime !== undefined ? `${app.uptime} s` : ''),\n ],\n })\n\n const full = h('a', {\n class: 'tool',\n href: TOKEN.test(profile.token) ? `${base}/${profile.token}` : base,\n target: '_blank',\n rel: 'noopener',\n title: 'Open the full profile',\n text: '↗',\n })\n const toggle = h('button', {\n class: 'tool',\n title: state.open ? 'Collapse the panel' : 'Expand the panel',\n text: state.open ? '▾' : '▴',\n onclick: () => openTab(state.tab),\n })\n const close = h('button', {\n class: 'tool',\n title: 'Hide the toolbar',\n text: '✕',\n onclick: () => {\n state.closed = true\n state.open = false\n persist()\n render()\n },\n })\n\n bar.replaceChildren(...items, spacer, appItem, full, toggle, close)\n }\n\n // ── The panel ───────────────────────────────────────────\n\n const TABS = [\n { name: 'request', label: 'Request' },\n { name: 'queries', label: 'Queries', count: (p) => queriesOf(p).total, tone: (p) => dbTone(p) },\n { name: 'logs', label: 'Logs', count: (p) => logsOf(p).total, tone: (p) => logTone(p) },\n { name: 'events', label: 'Events', count: (p) => eventsOf(p).length },\n { name: 'timeline', label: 'Timeline' },\n { name: 'cache', label: 'Cache', count: (p) => cacheOf(p).ops.length, hideEmpty: true },\n { name: 'mail', label: 'Mail', count: (p) => mailsOf(p).length, hideEmpty: true },\n {\n name: 'exception',\n label: 'Exception',\n count: (p) => (typeof panelOf(p, 'exception').message === 'string' ? 1 : 0),\n tone: () => 'bad',\n hideEmpty: true,\n },\n { name: 'ajax', label: 'AJAX', count: () => state.requests.length - 1, hideEmpty: true },\n { name: 'app', label: 'App' },\n ]\n\n function dbTone(profile) {\n const q = queriesOf(profile)\n return q.failed > 0 ? 'bad' : q.duplicates > 0 ? 'warn' : ''\n }\n\n function logTone(profile) {\n const logs = logsOf(profile)\n return logs.errors > 0 ? 'bad' : logs.warnings > 0 ? 'warn' : ''\n }\n\n function renderPanel(profile) {\n panel.style.height = `${state.height}px`\n const visible = TABS.filter((tab) => !tab.hideEmpty || (tab.count?.(profile) || 0) > 0)\n if (!visible.some((tab) => tab.name === state.tab)) state.tab = 'request'\n tabs.replaceChildren(\n ...visible.map((tab) => {\n const count = tab.count ? tab.count(profile) : undefined\n return h(\n 'button',\n {\n class: 'tab',\n dataset: state.tab === tab.name ? { active: '' } : {},\n onclick: () => {\n state.tab = tab.name\n persist()\n render()\n },\n },\n tab.label,\n count\n ? h('span', {\n class: 'count',\n dataset: { tone: tab.tone?.(profile) || '' },\n text: count,\n })\n : null,\n )\n }),\n )\n const view = VIEWS[state.tab] || VIEWS.request\n // replaceChildren would print a null as the text \"null\".\n body.replaceChildren(\n ...[view(profile)].flat(Infinity).filter((node) => node !== null && node !== undefined),\n )\n }\n\n function table(rows) {\n const present = rows.filter(\n ([, value]) => value !== undefined && value !== null && value !== '',\n )\n if (present.length === 0) return h('div', { class: 'empty', text: 'Nothing.' })\n return h(\n 'table',\n null,\n present.map(([key, value]) =>\n h(\n 'tr',\n null,\n h('td', { class: 'key', text: key }),\n h(\n 'td',\n null,\n value instanceof Node\n ? value\n : typeof value === 'object'\n ? h('pre', { text: json(value) })\n : h('span', { text: String(value) }),\n ),\n ),\n ),\n )\n }\n\n function objectTable(value) {\n if (!value || typeof value !== 'object' || Object.keys(value).length === 0) {\n return h('div', { class: 'empty', text: 'Empty.' })\n }\n return table(Object.entries(value))\n }\n\n function searchBox(value, onInput, placeholder) {\n const input = h('input', { type: 'search', placeholder, value })\n input.addEventListener('input', () => {\n onInput(input.value)\n const at = input.selectionStart\n render()\n const again = body.querySelector('input[type=search]')\n if (again) {\n again.focus()\n again.setSelectionRange(at, at)\n }\n })\n return input\n }\n\n const VIEWS = {\n request(profile) {\n const r = panelOf(profile, 'request')\n return [\n h('h3', { text: 'Route' }),\n table([\n ['Name', r.route],\n ['Pattern', r.pattern],\n ['Controller', r.controller],\n ['Params', r.params && Object.keys(r.params).length ? r.params : undefined],\n ]),\n h('h3', { text: 'Request' }),\n table([\n ['Method', profile.method],\n ['URL', profile.url],\n ['Path', r.path],\n ['IP', r.ip],\n ['Request id', r.requestId],\n ['AJAX', r.ajax ? 'yes' : undefined],\n ['Time', new Date(profile.time).toLocaleString()],\n ]),\n h('h3', { text: 'Query string' }),\n objectTable(r.query),\n h('h3', { text: 'Body' }),\n r.body === undefined\n ? h('div', { class: 'empty', text: 'No body.' })\n : h('pre', { text: json(r.body) }),\n h('h3', { text: 'Headers' }),\n objectTable(r.headers),\n h('h3', { text: 'Cookies' }),\n r.cookies?.length\n ? h(\n 'div',\n null,\n r.cookies.map((name) => [h('span', { class: 'chip', text: name }), ' ']),\n )\n : h('div', { class: 'empty', text: 'None.' }),\n h('h3', { text: 'Session' }),\n r.session === undefined\n ? h('div', { class: 'empty', text: 'No session.' })\n : objectTable(r.session),\n h('h3', { text: 'User' }),\n r.user === undefined\n ? h('div', { class: 'empty', text: 'Guest.' })\n : h('pre', { text: json(r.user) }),\n h('h3', { text: 'Response' }),\n table([\n ['Status', profile.status],\n ['Content type', r.response?.contentType],\n ['Duration', ms(profile.duration)],\n ]),\n r.response ? objectTable(r.response.headers) : null,\n ]\n },\n\n queries(profile) {\n const q = queriesOf(profile)\n const f = state.filters\n if (q.total === 0) {\n return h('div', {\n class: 'empty',\n text: 'No query reached the profiler for this request. A data layer reports statements as `db:query` on the application emitter.',\n })\n }\n let rows = q.queries.slice()\n if (f.onlyDuplicates) rows = rows.filter((query) => query.copies > 1)\n if (f.query) {\n const needle = f.query.toLowerCase()\n rows = rows.filter((query) =>\n `${query.sql} ${JSON.stringify(query.bindings)}`.toLowerCase().includes(needle),\n )\n }\n if (f.sortBySlowest) rows.sort((a, b) => b.duration - a.duration)\n const max = Math.max(q.slowest, 0.0001)\n return [\n h(\n 'div',\n { class: 'toolbar' },\n h('span', {\n text: `${q.total} statements · ${q.unique} unique · ${q.duplicates} duplicated · ${q.failed} failed · ${ms(q.duration)}`,\n }),\n q.capped\n ? h('span', {\n class: 'chip',\n dataset: { tone: 'warn' },\n text: `listing stopped at ${q.queries.length}`,\n })\n : null,\n searchBox(\n f.query,\n (value) => {\n f.query = value\n },\n 'Filter SQL',\n ),\n h('button', {\n class: 'btn',\n dataset: f.onlyDuplicates ? { on: '' } : {},\n text: 'Only duplicates',\n onclick: () => {\n f.onlyDuplicates = !f.onlyDuplicates\n render()\n },\n }),\n h('button', {\n class: 'btn',\n dataset: f.sortBySlowest ? { on: '' } : {},\n text: 'Slowest first',\n onclick: () => {\n f.sortBySlowest = !f.sortBySlowest\n render()\n },\n }),\n ),\n h(\n 'div',\n { class: 'list' },\n rows.map((query) => {\n const copyButton = h('button', {\n class: 'btn',\n text: 'copy',\n onclick: (event) => {\n event.stopPropagation()\n copy(query.sql, copyButton)\n },\n })\n return h(\n 'div',\n {\n class: 'entry',\n dataset: { tone: query.error ? 'bad' : query.copies > 1 ? 'warn' : '' },\n onclick: (event) => {\n if (getSelection()?.type === 'Range') return\n event.currentTarget.toggleAttribute('data-expanded')\n },\n },\n h(\n 'div',\n { class: 'num' },\n ms(query.duration),\n h('div', {\n class: 'meter',\n style: `width:${Math.max(4, (query.duration / max) * 100)}%`,\n }),\n ),\n h(\n 'div',\n null,\n h('div', { class: 'sql', text: query.sql }),\n query.error ? h('div', { class: 'bad', text: query.error }) : null,\n h(\n 'div',\n { class: 'meta' },\n query.bindings?.length\n ? h('span', { class: 'mono', text: JSON.stringify(query.bindings) })\n : null,\n ),\n ),\n h(\n 'div',\n null,\n query.copies > 1\n ? [\n h('span', {\n class: 'chip',\n dataset: { tone: 'warn' },\n text: `× ${query.copies}`,\n }),\n ' ',\n ]\n : null,\n query.model ? [h('span', { class: 'chip', text: query.model }), ' '] : null,\n query.method ? [h('span', { class: 'chip', text: query.method }), ' '] : null,\n query.inTransaction ? [h('span', { class: 'chip', text: 'trx' }), ' '] : null,\n query.connection\n ? [h('span', { class: 'chip', text: query.connection }), ' ']\n : null,\n copyButton,\n ),\n h(\n 'div',\n { class: 'extra' },\n table([\n ['Started at', `+${ms(query.at)}`],\n ['Bindings', query.bindings],\n ['Copies', query.copies > 1 ? query.copies : undefined],\n ]),\n ),\n )\n }),\n ),\n ]\n },\n\n logs(profile) {\n const logs = logsOf(profile)\n const f = state.filters\n if (logs.total === 0)\n return h('div', { class: 'empty', text: 'Nothing was logged during this request.' })\n const levels = [...new Set(logs.lines.map((line) => line.level))]\n let lines = logs.lines.filter((line) => f.levels[line.level] !== false)\n if (f.logText) {\n const needle = f.logText.toLowerCase()\n lines = lines.filter((line) => line.message.toLowerCase().includes(needle))\n }\n const tone = (level) =>\n level === 'error' || level === 'fatal' ? 'bad' : level === 'warn' ? 'warn' : ''\n return [\n h(\n 'div',\n { class: 'toolbar' },\n searchBox(\n f.logText,\n (value) => {\n f.logText = value\n },\n 'Filter messages',\n ),\n levels.map((level) =>\n h('button', {\n class: 'chip',\n dataset: { tone: tone(level), ...(f.levels[level] === false ? { off: '' } : {}) },\n text: level,\n onclick: () => {\n f.levels[level] = f.levels[level] === false\n render()\n },\n }),\n ),\n ),\n h(\n 'div',\n { class: 'list' },\n lines.map((line) =>\n h(\n 'div',\n {\n class: 'entry',\n dataset: { tone: tone(line.level) },\n onclick: (event) => {\n if (getSelection()?.type === 'Range') return\n event.currentTarget.toggleAttribute('data-expanded')\n },\n },\n h('div', { class: 'num', text: `+${ms(line.at)}` }),\n h('div', { class: 'sql', text: line.message }),\n h('span', { class: 'chip', dataset: { tone: tone(line.level) }, text: line.level }),\n line.context\n ? h('div', { class: 'extra' }, h('pre', { text: json(line.context) }))\n : null,\n ),\n ),\n ),\n ]\n },\n\n events(profile) {\n const events = eventsOf(profile)\n const f = state.filters\n if (events.length === 0)\n return h('div', { class: 'empty', text: 'No event was emitted during this request.' })\n const shown = f.eventText\n ? events.filter((event) => event.name.toLowerCase().includes(f.eventText.toLowerCase()))\n : events\n return [\n h(\n 'div',\n { class: 'toolbar' },\n h('span', { text: `${panelOf(profile, 'events').total || events.length} events` }),\n searchBox(\n f.eventText,\n (value) => {\n f.eventText = value\n },\n 'Filter by name',\n ),\n ),\n h(\n 'div',\n { class: 'list' },\n shown.map((event) =>\n h(\n 'div',\n {\n class: 'entry',\n onclick: (e) => {\n if (getSelection()?.type === 'Range') return\n e.currentTarget.toggleAttribute('data-expanded')\n },\n },\n h('div', { class: 'num', text: `+${ms(event.at)}` }),\n h('div', { class: 'mono', text: event.name }),\n h('span', null),\n h('div', { class: 'extra' }, h('pre', { text: json(event.data) })),\n ),\n ),\n ),\n ]\n },\n\n timeline(profile) {\n const total = Math.max(Number(profile.duration) || 0, 0.001)\n const lanes = [\n { kind: 'request', name: `${profile.method} ${profile.url}`, at: 0, duration: total },\n ]\n for (const query of queriesOf(profile).queries) {\n lanes.push({ kind: 'query', name: query.sql, at: query.at, duration: query.duration })\n }\n for (const line of logsOf(profile).lines) {\n lanes.push({\n kind: 'log',\n name: `${line.level}: ${line.message}`,\n at: line.at,\n duration: 0,\n })\n }\n for (const event of eventsOf(profile)) {\n lanes.push({ kind: 'event', name: event.name, at: event.at, duration: 0 })\n }\n lanes.sort((a, b) => a.at - b.at || (a.kind === 'request' ? -1 : 1))\n return [\n h(\n 'div',\n { class: 'toolbar' },\n ['request', 'query', 'log', 'event'].map((kind) =>\n h('span', { class: 'chip legend', dataset: { kind }, text: kind }),\n ),\n h('span', { text: `total ${ms(total)}` }),\n ),\n lanes.map((lane) =>\n h(\n 'div',\n { class: 'lane' },\n h('div', {\n class: lane.kind === 'query' ? 'name mono' : 'name',\n text: lane.name,\n title: lane.name,\n }),\n h(\n 'div',\n { class: 'track' },\n h('div', {\n class: 'span',\n dataset: { kind: lane.kind },\n style: `left:${Math.min(100, (lane.at / total) * 100)}%;width:${Math.min(100, (lane.duration / total) * 100)}%`,\n }),\n ),\n h('div', {\n class: 'num meta',\n text: lane.duration > 0 ? ms(lane.duration) : `+${ms(lane.at)}`,\n }),\n ),\n ),\n ]\n },\n\n cache(profile) {\n const cache = cacheOf(profile)\n return [\n h(\n 'div',\n { class: 'toolbar' },\n h('span', {\n text: `${cache.hits} hits · ${cache.misses} misses · ${cache.writes} writes`,\n }),\n ),\n h(\n 'table',\n null,\n h(\n 'tr',\n null,\n h('th', { text: 'At' }),\n h('th', { text: 'Operation' }),\n h('th', { text: 'Key' }),\n h('th', { text: 'Store' }),\n ),\n cache.ops.map((op) =>\n h(\n 'tr',\n null,\n h('td', { class: 'meta', text: `+${ms(op.at)}` }),\n h('td', { text: op.name.slice('cache:'.length) }),\n h('td', { class: 'mono', text: op.data?.key ?? '' }),\n h('td', { class: 'meta', text: op.data?.store ?? '' }),\n ),\n ),\n ),\n ]\n },\n\n mail(profile) {\n return h(\n 'div',\n { class: 'list' },\n mailsOf(profile).map((event) =>\n h(\n 'div',\n { class: 'entry', onclick: (e) => e.currentTarget.toggleAttribute('data-expanded') },\n h('div', { class: 'num', text: `+${ms(event.at)}` }),\n h('div', { class: 'mono', text: event.name }),\n h('span', null),\n h('div', { class: 'extra' }, h('pre', { text: json(event.data) })),\n ),\n ),\n )\n },\n\n exception(profile) {\n const e = panelOf(profile, 'exception')\n return [\n h('h3', { text: e.name || 'Error' }),\n h('div', { class: 'bad', text: e.message || '' }),\n e.code ? h('div', { class: 'meta', text: `code ${e.code}` }) : null,\n e.stack ? h('pre', { text: e.stack }) : null,\n ]\n },\n\n ajax() {\n return [\n h(\n 'div',\n { class: 'toolbar' },\n h('span', { text: `${state.requests.length - 1} requests made by this page` }),\n h('button', {\n class: 'btn',\n text: 'Clear',\n onclick: () => {\n state.requests.splice(1)\n state.current = state.requests[0] || null\n render()\n },\n }),\n ),\n h(\n 'table',\n null,\n h(\n 'tr',\n null,\n ['#', 'Method', 'URL', 'Status', 'Time', 'Type', 'Profile'].map((label) =>\n h('th', { text: label }),\n ),\n ),\n state.requests.map((r, index) =>\n h(\n 'tr',\n {\n class: r.profile ? 'pick' : '',\n dataset: state.current === r ? { active: '' } : {},\n onclick: () => {\n if (!r.profile) return\n state.current = r\n state.tab = 'request'\n render()\n },\n },\n h('td', { class: 'meta', text: index === 0 ? 'page' : String(index) }),\n h('td', { text: r.method }),\n h('td', { class: 'mono', text: r.url }),\n h(\n 'td',\n null,\n h('span', {\n class: 'chip',\n dataset: { tone: statusTone(r.status) },\n text: r.status || '—',\n }),\n ),\n h('td', { class: 'meta', text: ms(r.duration) }),\n h('td', { class: 'meta', text: r.kind }),\n h(\n 'td',\n null,\n TOKEN.test(r.token || '')\n ? h('a', {\n href: `${base}/${r.token}`,\n target: '_blank',\n rel: 'noopener',\n text: r.token,\n onclick: (e) => e.stopPropagation(),\n })\n : h('span', { class: 'meta', text: 'n/a' }),\n ),\n ),\n ),\n ),\n ]\n },\n\n app(profile) {\n const app = panelOf(profile, 'app')\n const memory = panelOf(profile, 'memory')\n return [\n h('h3', { text: 'Application' }),\n table([\n ['ream', app.ream],\n ['Environment', app.environment],\n ['Profile token', profile.token],\n ['All profiles', h('a', { href: base, target: '_blank', rel: 'noopener', text: base })],\n ]),\n h('h3', { text: 'Runtime' }),\n table([\n ['Node.js', app.node],\n ['Platform', app.platform],\n ['Process id', app.pid],\n ['Uptime', app.uptime !== undefined ? `${app.uptime} s` : undefined],\n ['Heap used', mb(memory.usedMb)],\n ['Heap total', mb(memory.totalMb)],\n ['Resident', mb(memory.rssMb)],\n ]),\n ]\n },\n }\n\n // ── Render ──────────────────────────────────────────────\n\n function render() {\n const entry = state.current\n const profile = entry?.profile\n // Nothing to show until the page's profile has arrived.\n shell.style.display = profile || state.closed ? '' : 'none'\n shell.toggleAttribute('data-closed', state.closed)\n shell.toggleAttribute('data-open', Boolean(state.open && profile && !state.closed))\n if (profile) {\n renderBar(profile, entry)\n if (state.open && !state.closed) renderPanel(profile)\n }\n offsetPage()\n }\n\n function mount() {\n document.body.append(host)\n state.requests.unshift({\n kind: 'page',\n method: 'GET',\n url: location.pathname + location.search,\n token: pageToken,\n duration: 0,\n })\n load(state.requests[0])\n window.addEventListener('resize', () => {\n state.height = clampHeight(state.height)\n render()\n })\n }\n\n if (document.body) mount()\n else document.addEventListener('DOMContentLoaded', mount, { once: true })\n})()\n"
5
+
6
+ /** The debug toolbar's stylesheet, served at /__ream/profiler/_assets/toolbar.css. */
7
+ export const TOOLBAR_CSS: string = "/*\n * The debug toolbar's stylesheet. Loaded into the bar's shadow root from\n * /__ream/profiler/_assets/toolbar.css: a same-origin stylesheet passes a\n * `style-src 'self'` policy, where an inline one would be dropped.\n */\n\n:host {\n all: initial;\n}\n\n* {\n box-sizing: border-box;\n}\n\n.rp {\n --bg: #17191d;\n --bg-2: #1f2228;\n --bg-3: #272b33;\n --line: #2f343d;\n --text: #e6e8eb;\n --muted: #8b939e;\n --accent: #6cb6ff;\n --ok: #3fb950;\n --warn: #d29922;\n --bad: #f85149;\n --bar: 34px;\n position: fixed;\n left: 0;\n right: 0;\n bottom: 0;\n z-index: 2147483000;\n display: flex;\n flex-direction: column;\n color: var(--text);\n font:\n 12px / 1.45 ui-sans-serif,\n system-ui,\n -apple-system,\n \"Segoe UI\",\n sans-serif;\n text-align: left;\n}\n\n.rp[data-closed] {\n left: auto;\n right: 8px;\n bottom: 8px;\n}\n\n.mono,\n.sql,\npre,\ncode {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n font-size: 12px;\n}\n\nbutton {\n font: inherit;\n color: inherit;\n background: none;\n border: 0;\n cursor: pointer;\n padding: 0;\n}\n\n/* The closed state: one small button in the corner. */\n.restore {\n display: none;\n width: 34px;\n height: 34px;\n border-radius: 8px;\n background: var(--bg);\n border: 1px solid var(--line);\n color: var(--accent);\n font-weight: 700;\n box-shadow: 0 2px 10px rgb(0 0 0 / 0.4);\n}\n\n.rp[data-closed] .restore {\n display: block;\n}\n\n/* ── The bar ─────────────────────────────────────────────── */\n\n.bar {\n display: flex;\n align-items: stretch;\n height: var(--bar);\n background: var(--bg);\n border-top: 1px solid var(--line);\n box-shadow: 0 -2px 10px rgb(0 0 0 / 0.35);\n}\n\n.item {\n position: relative;\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 0 11px;\n border-right: 1px solid var(--line);\n white-space: nowrap;\n cursor: pointer;\n}\n\n.item:hover,\n.item:focus-visible,\n.item[data-active] {\n background: var(--bg-2);\n outline: none;\n}\n\n.item .label {\n color: var(--muted);\n}\n\n.item .value {\n font-weight: 600;\n}\n\n.item[data-tone=\"ok\"] .value {\n color: var(--ok);\n}\n\n.item[data-tone=\"warn\"] {\n box-shadow: inset 0 -3px 0 var(--warn);\n}\n\n.item[data-tone=\"warn\"] .value {\n color: var(--warn);\n}\n\n.item[data-tone=\"bad\"] {\n box-shadow: inset 0 -3px 0 var(--bad);\n}\n\n.item[data-tone=\"bad\"] .value {\n color: var(--bad);\n}\n\n.status {\n display: inline-block;\n min-width: 34px;\n padding: 2px 6px;\n border-radius: 4px;\n text-align: center;\n font-weight: 700;\n color: #fff;\n background: #238636;\n}\n\n.status[data-tone=\"warn\"] {\n background: #9e6a03;\n}\n\n.status[data-tone=\"bad\"] {\n background: #b62324;\n}\n\n.spacer {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n padding: 0 10px;\n color: var(--muted);\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.viewing {\n margin-left: 8px;\n padding: 1px 8px;\n border-radius: 999px;\n background: #1f3a5f;\n color: var(--accent);\n}\n\n.tool {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 34px;\n border-left: 1px solid var(--line);\n color: var(--muted);\n}\n\n.tool:hover {\n color: var(--text);\n background: var(--bg-2);\n}\n\na {\n color: var(--accent);\n text-decoration: none;\n}\n\na:hover {\n text-decoration: underline;\n}\n\n/* ── Hover summaries ─────────────────────────────────────── */\n\n.info {\n display: none;\n position: absolute;\n bottom: calc(100% + 1px);\n left: 0;\n min-width: 240px;\n max-width: 520px;\n max-height: 420px;\n overflow: auto;\n padding: 8px 10px;\n background: var(--bg-2);\n border: 1px solid var(--line);\n border-radius: 6px 6px 0 0;\n box-shadow: 0 -4px 14px rgb(0 0 0 / 0.4);\n cursor: default;\n white-space: normal;\n}\n\n.item:hover .info,\n.item:focus-within .info {\n display: block;\n}\n\n.rp[data-open] .item .info {\n display: none;\n}\n\n.row {\n display: flex;\n gap: 12px;\n padding: 3px 0;\n}\n\n.row b {\n flex: 0 0 130px;\n color: var(--muted);\n font-weight: 500;\n}\n\n.row span {\n overflow-wrap: anywhere;\n}\n\n/* ── The panel ───────────────────────────────────────────── */\n\n.panel {\n display: none;\n flex-direction: column;\n background: var(--bg);\n border-top: 1px solid var(--line);\n box-shadow: 0 -4px 16px rgb(0 0 0 / 0.4);\n}\n\n.rp[data-open] .panel {\n display: flex;\n}\n\n.rp[data-closed] .bar,\n.rp[data-closed] .panel {\n display: none;\n}\n\n.grip {\n height: 5px;\n cursor: row-resize;\n background: var(--bg-3);\n}\n\n.grip:hover {\n background: var(--accent);\n}\n\n.tabs {\n display: flex;\n gap: 2px;\n padding: 0 6px;\n border-bottom: 1px solid var(--line);\n background: var(--bg-2);\n overflow-x: auto;\n}\n\n.tab {\n padding: 7px 12px;\n color: var(--muted);\n border-bottom: 2px solid transparent;\n white-space: nowrap;\n}\n\n.tab:hover {\n color: var(--text);\n}\n\n.tab[data-active] {\n color: var(--text);\n border-bottom-color: var(--accent);\n}\n\n.tab .count {\n margin-left: 6px;\n padding: 0 6px;\n border-radius: 999px;\n background: var(--bg-3);\n color: var(--text);\n font-size: 11px;\n}\n\n.tab .count[data-tone=\"warn\"] {\n background: #5c4106;\n}\n\n.tab .count[data-tone=\"bad\"] {\n background: #6e1a1b;\n}\n\n.body {\n flex: 1;\n overflow: auto;\n padding: 10px 12px 14px;\n}\n\n.toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n margin-bottom: 10px;\n color: var(--muted);\n}\n\n.toolbar input[type=\"search\"] {\n min-width: 220px;\n padding: 4px 8px;\n border: 1px solid var(--line);\n border-radius: 4px;\n background: var(--bg-2);\n color: var(--text);\n font: inherit;\n}\n\n.chip {\n display: inline-block;\n padding: 1px 7px;\n border-radius: 999px;\n background: var(--bg-3);\n color: var(--muted);\n font-size: 11px;\n}\n\nbutton.chip[data-off] {\n text-decoration: line-through;\n opacity: 0.55;\n}\n\n.chip[data-tone=\"warn\"] {\n background: #5c4106;\n color: #f2cc60;\n}\n\n.chip[data-tone=\"bad\"] {\n background: #6e1a1b;\n color: #ffa198;\n}\n\n.chip[data-tone=\"ok\"] {\n background: #133d20;\n color: #7ee787;\n}\n\n.btn {\n padding: 3px 9px;\n border: 1px solid var(--line);\n border-radius: 4px;\n background: var(--bg-2);\n}\n\n.btn:hover,\n.btn[data-on] {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n.list {\n border-top: 1px solid var(--line);\n}\n\n.entry {\n display: grid;\n grid-template-columns: 78px 1fr auto;\n gap: 10px;\n padding: 6px 4px;\n border-bottom: 1px solid var(--line);\n cursor: pointer;\n}\n\n.entry:hover {\n background: var(--bg-2);\n}\n\n.entry[data-tone=\"warn\"] {\n box-shadow: inset 3px 0 0 var(--warn);\n}\n\n.entry[data-tone=\"bad\"] {\n box-shadow: inset 3px 0 0 var(--bad);\n}\n\n.entry .num {\n color: var(--muted);\n text-align: right;\n}\n\n.entry .meter {\n height: 3px;\n margin-top: 4px;\n background: var(--accent);\n border-radius: 2px;\n opacity: 0.6;\n}\n\n.entry .extra {\n display: none;\n grid-column: 2 / 4;\n}\n\n.entry[data-expanded] .extra {\n display: block;\n}\n\n.sql {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.meta {\n color: var(--muted);\n}\n\n.bad {\n color: var(--bad);\n}\n\npre {\n margin: 6px 0 0;\n padding: 8px;\n max-height: 320px;\n overflow: auto;\n border-radius: 4px;\n background: var(--bg-2);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\nh3 {\n margin: 14px 0 6px;\n font-size: 11px;\n font-weight: 600;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n color: var(--muted);\n}\n\nh3:first-child {\n margin-top: 0;\n}\n\ntable {\n width: 100%;\n border-collapse: collapse;\n}\n\ntd,\nth {\n padding: 4px 8px;\n border-bottom: 1px solid var(--line);\n text-align: left;\n vertical-align: top;\n}\n\nth {\n color: var(--muted);\n font-weight: 500;\n}\n\ntd.key {\n width: 200px;\n color: var(--muted);\n}\n\ntr.pick {\n cursor: pointer;\n}\n\ntr.pick:hover,\ntr.pick[data-active] {\n background: var(--bg-2);\n}\n\n.empty {\n padding: 18px 4px;\n color: var(--muted);\n}\n\n/* ── Timeline ────────────────────────────────────────────── */\n\n.lane {\n display: grid;\n grid-template-columns: 260px 1fr 70px;\n align-items: center;\n gap: 10px;\n padding: 3px 0;\n}\n\n.lane .name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.track {\n position: relative;\n height: 12px;\n background: var(--bg-2);\n border-radius: 2px;\n}\n\n.span {\n position: absolute;\n top: 0;\n bottom: 0;\n min-width: 2px;\n border-radius: 2px;\n background: var(--accent);\n}\n\n.legend::before {\n content: \"\";\n display: inline-block;\n width: 8px;\n height: 8px;\n margin-right: 5px;\n border-radius: 2px;\n background: var(--accent);\n}\n\n.legend[data-kind=\"request\"]::before {\n background: #3b4250;\n}\n\n.legend[data-kind=\"query\"]::before {\n background: #a371f7;\n}\n\n.legend[data-kind=\"log\"]::before {\n background: var(--warn);\n}\n\n.legend[data-kind=\"event\"]::before {\n background: var(--ok);\n}\n\n.span[data-kind=\"request\"] {\n background: #3b4250;\n}\n\n.span[data-kind=\"query\"] {\n background: #a371f7;\n}\n\n.span[data-kind=\"log\"] {\n background: var(--warn);\n}\n\n.span[data-kind=\"event\"] {\n background: var(--ok);\n}\n\n@media (max-width: 760px) {\n .item .label {\n display: none;\n }\n\n .lane {\n grid-template-columns: 140px 1fr 56px;\n }\n}\n\n@media print {\n .rp {\n display: none;\n }\n}\n"
@@ -11,9 +11,13 @@
11
11
  * `db:query`, which is the shape atlas documents for bridging its own registry
12
12
  * (`onDbQuery((event) => emitter.emit('db:query', event))`). A project on
13
13
  * another data layer emits the same event and gets the same panel.
14
+ *
15
+ * Every entry is stamped with `at`, milliseconds since the request arrived, so
16
+ * the bar can lay queries, log lines and events out on one timeline.
14
17
  */
15
18
 
16
- import type { CollectedData, ProfilerCollector } from './types.js'
19
+ import { preview } from './preview.js'
20
+ import type { CollectedData, CollectionScope, ProfilerCollector } from './types.js'
17
21
 
18
22
  /** One SQL statement, as the panel shows it. */
19
23
  export interface RecordedQuery {
@@ -21,6 +25,8 @@ export interface RecordedQuery {
21
25
  bindings: readonly unknown[]
22
26
  /** Milliseconds. */
23
27
  duration: number
28
+ /** When it started, milliseconds since the request arrived. */
29
+ at: number
24
30
  connection?: string
25
31
  model?: string
26
32
  method?: string
@@ -53,9 +59,14 @@ export function databaseCollector(
53
59
  events.on('db:query', (payload) => {
54
60
  const bag = read()
55
61
  if (bag === undefined) return
62
+ const duration = durationOf(payload)
56
63
  bag.total += 1
57
- bag.duration += durationOf(payload)
58
- if (bag.queries.length < QUERY_CAP) bag.queries.push(toQuery(payload))
64
+ bag.duration += duration
65
+ if (isFailed(payload)) bag.failed += 1
66
+ if (bag.queries.length < QUERY_CAP) {
67
+ // The event arrives when the statement ENDS: it started `duration` ago.
68
+ bag.queries.push(toQuery(payload, Math.max(0, bag.scope.elapsed() - duration)))
69
+ }
59
70
  })
60
71
 
61
72
  return {
@@ -65,12 +76,13 @@ export function databaseCollector(
65
76
  const duration = typeof data.duration === 'number' ? data.duration : 0
66
77
  return `${total} ${total === 1 ? 'query' : 'queries'} · ${round(duration)} ms`
67
78
  },
68
- start: () => ({ total: 0, duration: 0, queries: [] }),
79
+ start: (scope) => ({ scope, total: 0, failed: 0, duration: 0, queries: [] }),
69
80
  finish: (bag) =>
70
81
  bag === undefined
71
- ? { total: 0, duration: 0, queries: [], capped: false }
82
+ ? { total: 0, failed: 0, duration: 0, queries: [], capped: false }
72
83
  : {
73
84
  total: bag.total,
85
+ failed: bag.failed,
74
86
  duration: round(bag.duration),
75
87
  queries: bag.queries,
76
88
  capped: bag.total > bag.queries.length,
@@ -79,12 +91,20 @@ export function databaseCollector(
79
91
  }
80
92
 
81
93
  export interface QueryBag {
94
+ scope: CollectionScope
82
95
  total: number
96
+ failed: number
83
97
  duration: number
84
98
  queries: RecordedQuery[]
85
99
  }
86
100
 
87
- /** The request and what it answered. */
101
+ /**
102
+ * The request and what it answered.
103
+ *
104
+ * Filled by the profiling middleware once the handler has run — routing, the
105
+ * session and the user are only known then. Every value that came from the
106
+ * client goes through {@link preview}, which caps it and hides secrets.
107
+ */
88
108
  export function requestCollector(): ProfilerCollector<RequestBag> {
89
109
  return {
90
110
  name: 'request',
@@ -96,8 +116,21 @@ export function requestCollector(): ProfilerCollector<RequestBag> {
96
116
 
97
117
  export interface RequestBag {
98
118
  route?: string
119
+ pattern?: string
99
120
  controller?: string
100
121
  userId?: string
122
+ user?: unknown
123
+ path?: string
124
+ ip?: string
125
+ requestId?: string
126
+ ajax?: boolean
127
+ params?: unknown
128
+ query?: unknown
129
+ body?: unknown
130
+ headers?: unknown
131
+ cookies?: string[]
132
+ session?: unknown
133
+ response?: { status: number; contentType?: string; headers: unknown }
101
134
  }
102
135
 
103
136
  /**
@@ -139,8 +172,11 @@ export function logCollector(events: LogEventSource, read: () => LogBag | undefi
139
172
  events.on('log:line', (payload) => {
140
173
  const bag = read()
141
174
  if (bag === undefined) return
142
- if (bag.lines.length < LINE_CAP) bag.lines.push(toLine(payload))
175
+ const line = toLine(payload, bag.scope.elapsed())
143
176
  bag.total += 1
177
+ if (line.level === 'error' || line.level === 'fatal') bag.errors += 1
178
+ if (line.level === 'warn') bag.warnings += 1
179
+ if (bag.lines.length < LINE_CAP) bag.lines.push(line)
144
180
  })
145
181
 
146
182
  return {
@@ -149,11 +185,13 @@ export function logCollector(events: LogEventSource, read: () => LogBag | undefi
149
185
  const total = typeof data.total === 'number' ? data.total : 0
150
186
  return total === 0 ? undefined : `${total} ${total === 1 ? 'log' : 'logs'}`
151
187
  },
152
- start: () => ({ total: 0, lines: [] }),
188
+ start: (scope) => ({ scope, total: 0, errors: 0, warnings: 0, lines: [] }),
153
189
  // Nothing logged is nothing to show: an empty bag renders no panel at all,
154
190
  // rather than a heading over an empty list.
155
191
  finish: (bag) =>
156
- bag === undefined || bag.total === 0 ? {} : { total: bag.total, lines: bag.lines },
192
+ bag === undefined || bag.total === 0
193
+ ? {}
194
+ : { total: bag.total, errors: bag.errors, warnings: bag.warnings, lines: bag.lines },
157
195
  } satisfies ProfilerCollector<LogBag>
158
196
  }
159
197
 
@@ -165,42 +203,137 @@ export interface LogEventSource {
165
203
  /** As many lines as are worth scrolling; the count stays true past it. */
166
204
  const LINE_CAP = 200
167
205
 
206
+ export interface LogLine {
207
+ level: string
208
+ message: string
209
+ time?: string
210
+ /** Milliseconds since the request arrived. */
211
+ at: number
212
+ context?: unknown
213
+ }
214
+
168
215
  export interface LogBag {
216
+ scope: CollectionScope
169
217
  total: number
170
- lines: Array<{ level: string; message: string; time?: string }>
218
+ errors: number
219
+ warnings: number
220
+ lines: LogLine[]
171
221
  }
172
222
 
173
- function toLine(payload: unknown): LogBag['lines'][number] {
223
+ /** Fields of a log entry that are its envelope, not its context. */
224
+ const LOG_ENVELOPE = new Set(['level', 'message', 'msg', 'timestamp', 'time'])
225
+
226
+ function toLine(payload: unknown, at: number): LogLine {
174
227
  const entry = asRecord(payload)
228
+ const message = entry.message ?? entry.msg
229
+ const context = Object.fromEntries(
230
+ Object.entries(entry).filter(([key]) => !LOG_ENVELOPE.has(key)),
231
+ )
175
232
  return {
176
233
  level: typeof entry.level === 'string' ? entry.level : 'info',
177
- message: typeof entry.message === 'string' ? entry.message : String(entry.message ?? ''),
234
+ message: typeof message === 'string' ? message : String(message ?? ''),
235
+ at: round(at),
178
236
  ...(typeof entry.timestamp === 'string' ? { time: entry.timestamp } : {}),
237
+ ...(Object.keys(context).length > 0 ? { context: preview(context) } : {}),
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Every event the application emitted while the request ran — cache hits,
243
+ * mail sent, authorisation checks, the application's own domain events.
244
+ *
245
+ * It reads them through the emitter's `tap`, which runs in the emitting code's
246
+ * async context. `onAny` would not do: it is delivered through the native bus,
247
+ * later and outside the request. Queries and log lines are left out: they have
248
+ * their own panels.
249
+ */
250
+ export function eventsCollector(
251
+ source: EventTapSource,
252
+ read: () => EventBag | undefined,
253
+ ): ProfilerCollector<EventBag> {
254
+ source.tap((name, data) => {
255
+ if (OWN_PANEL.has(name)) return
256
+ const bag = read()
257
+ if (bag === undefined) return
258
+ bag.total += 1
259
+ if (bag.events.length < EVENT_CAP) {
260
+ bag.events.push({ name, at: round(bag.scope.elapsed()), data: preview(data) })
261
+ }
262
+ })
263
+
264
+ return {
265
+ name: 'events',
266
+ badge: (data) => {
267
+ const total = typeof data.total === 'number' ? data.total : 0
268
+ return total === 0 ? undefined : `${total} ${total === 1 ? 'event' : 'events'}`
269
+ },
270
+ start: (scope) => ({ scope, total: 0, events: [] }),
271
+ finish: (bag) =>
272
+ bag === undefined || bag.total === 0
273
+ ? {}
274
+ : { total: bag.total, events: bag.events, capped: bag.total > bag.events.length },
179
275
  }
180
276
  }
181
277
 
182
- /** Peak heap while the request ran, which is what a leak shows up in. */
278
+ /** Events that already have a panel of their own. */
279
+ const OWN_PANEL = new Set(['db:query', 'log:line'])
280
+
281
+ const EVENT_CAP = 300
282
+
283
+ /** What the emitter must give the events collector. Structural. */
284
+ export interface EventTapSource {
285
+ tap(observer: (event: string, data: unknown) => void): unknown
286
+ }
287
+
288
+ export interface EventBag {
289
+ scope: CollectionScope
290
+ total: number
291
+ events: Array<{ name: string; at: number; data: unknown }>
292
+ }
293
+
294
+ /** Heap and resident memory once the request is answered. */
183
295
  export function memoryCollector(): ProfilerCollector<{ start: number }> {
184
296
  return {
185
297
  name: 'memory',
186
298
  badge: (data) => (typeof data.usedMb === 'number' ? `${data.usedMb.toFixed(1)} MB` : undefined),
187
299
  start: () => ({ start: process.memoryUsage().heapUsed }),
188
300
  finish: (bag) => {
189
- const used = process.memoryUsage().heapUsed
301
+ const usage = process.memoryUsage()
190
302
  return {
191
- usedMb: used / 1024 / 1024,
192
- deltaMb: bag === undefined ? 0 : (used - bag.start) / 1024 / 1024,
303
+ usedMb: usage.heapUsed / MB,
304
+ totalMb: usage.heapTotal / MB,
305
+ rssMb: usage.rss / MB,
306
+ deltaMb: bag === undefined ? 0 : (usage.heapUsed - bag.start) / MB,
193
307
  }
194
308
  },
195
309
  }
196
310
  }
197
311
 
198
- function toQuery(payload: unknown): RecordedQuery {
312
+ const MB = 1024 * 1024
313
+
314
+ /** The application and the runtime it answered on: the bar's right-hand item. */
315
+ export function appCollector(info: { version?: string; environment?: string } = {}) {
316
+ return {
317
+ name: 'app',
318
+ start: () => undefined,
319
+ finish: () => ({
320
+ ...(info.version === undefined ? {} : { ream: info.version }),
321
+ node: process.version,
322
+ environment: info.environment ?? process.env.NODE_ENV ?? 'development',
323
+ platform: `${process.platform}-${process.arch}`,
324
+ pid: process.pid,
325
+ uptime: Math.round(process.uptime()),
326
+ }),
327
+ } satisfies ProfilerCollector<undefined>
328
+ }
329
+
330
+ function toQuery(payload: unknown, at: number): RecordedQuery {
199
331
  const event = asRecord(payload)
200
332
  return {
201
333
  sql: typeof event.sql === 'string' ? event.sql : String(event.sql ?? ''),
202
- bindings: Array.isArray(event.bindings) ? event.bindings : [],
203
- duration: durationOf(payload),
334
+ bindings: Array.isArray(event.bindings) ? event.bindings.map((value) => preview(value)) : [],
335
+ duration: round(durationOf(payload)),
336
+ at: round(at),
204
337
  ...pickString(event, 'connection'),
205
338
  ...pickString(event, 'model'),
206
339
  ...pickString(event, 'method'),
@@ -209,6 +342,10 @@ function toQuery(payload: unknown): RecordedQuery {
209
342
  }
210
343
  }
211
344
 
345
+ function isFailed(payload: unknown): boolean {
346
+ return asRecord(payload).error instanceof Error
347
+ }
348
+
212
349
  function durationOf(payload: unknown): number {
213
350
  const value = asRecord(payload).duration
214
351
  return typeof value === 'number' && Number.isFinite(value) ? value : 0
@@ -1,20 +1,29 @@
1
1
  /**
2
2
  * Where the toolbar and the profiler read from.
3
3
  *
4
- * Three routes, all under `/__ream/profiler` and all development-only:
4
+ * All under `/__ream/profiler` and all development-only:
5
5
  *
6
6
  * /__ream/profiler the history, newest first
7
7
  * /__ream/profiler/<token> one request, every panel
8
8
  * /__ream/profiler/<token>.json the same, for the bar in the page
9
+ * /__ream/profiler/_assets/… the bar's own script and stylesheet
9
10
  *
10
11
  * Server middleware rather than routes, like the hot-reload endpoint: it
11
12
  * answers before the application's own middleware, so a profiler request does
12
13
  * not run the authentication stack and end up in its own history.
13
14
  */
14
15
 
16
+ import { TOOLBAR_CSS, TOOLBAR_JS } from './client.generated.js'
15
17
  import type { Profiler } from './Profiler.js'
16
18
  import { PROFILER_PATH } from './Profiler.js'
17
19
  import { renderProfileList, renderProfilePage } from './render.js'
20
+ import { PROFILER_ASSETS_PATH } from './toolbar.js'
21
+
22
+ /** The bar's files, by the name the page asks for. */
23
+ const ASSETS: Record<string, { type: string; body: string }> = {
24
+ 'toolbar.js': { type: 'text/javascript; charset=utf-8', body: TOOLBAR_JS },
25
+ 'toolbar.css': { type: 'text/css; charset=utf-8', body: TOOLBAR_CSS },
26
+ }
18
27
 
19
28
  /** The slice of the context this endpoint uses. */
20
29
  export interface ProfilerContext {
@@ -36,6 +45,23 @@ export function profilerEndpoint(
36
45
  return
37
46
  }
38
47
 
48
+ if (path.startsWith(`${PROFILER_ASSETS_PATH}/`)) {
49
+ const asset = Object.hasOwn(ASSETS, path.slice(PROFILER_ASSETS_PATH.length + 1))
50
+ ? ASSETS[path.slice(PROFILER_ASSETS_PATH.length + 1)]
51
+ : undefined
52
+ if (asset === undefined) {
53
+ ctx.response.status(404)
54
+ ctx.response.send('')
55
+ return
56
+ }
57
+ ctx.response.header('content-type', asset.type)
58
+ // Revalidated each time: the bar changes with the framework, and a stale
59
+ // copy would show the last version's panels after an upgrade.
60
+ ctx.response.header('cache-control', 'no-cache')
61
+ ctx.response.send(asset.body)
62
+ return
63
+ }
64
+
39
65
  if (path === PROFILER_PATH) {
40
66
  ctx.response.header('content-type', 'text/html; charset=utf-8')
41
67
  ctx.response.send(renderProfileList(profiler.store.recent(50)))
@@ -7,7 +7,11 @@
7
7
  */
8
8
 
9
9
  import {
10
+ appCollector,
10
11
  databaseCollector,
12
+ type EventBag,
13
+ type EventTapSource,
14
+ eventsCollector,
11
15
  exceptionCollector,
12
16
  type LogBag,
13
17
  logCollector,
@@ -25,7 +29,8 @@ export { profilerEndpoint } from './endpoint.js'
25
29
  export { MemoryProfileStore } from './MemoryProfileStore.js'
26
30
  export { profileRequest } from './middleware.js'
27
31
  export { PROFILER_PATH, PROFILER_TOKEN_HEADER, Profiler } from './Profiler.js'
28
- export { toolbarScript } from './toolbar.js'
32
+ export { isSecretKey, preview, REDACTED } from './preview.js'
33
+ export { PROFILER_ASSETS_PATH, toolbarScript } from './toolbar.js'
29
34
  export * from './types.js'
30
35
 
31
36
  /**
@@ -33,11 +38,12 @@ export * from './types.js'
33
38
  *
34
39
  * `events` is the application's emitter. The database panel comes from
35
40
  * `db:query` on it, which is how a data layer reports a statement without the
36
- * profiler importing one.
41
+ * profiler importing one; the events panel from its `tap`, when it has one.
37
42
  */
38
43
  export function createProfiler(
39
- events: QueryEventSource,
44
+ events: QueryEventSource & Partial<EventTapSource>,
40
45
  extra: readonly ProfilerCollector[] = [],
46
+ info: { version?: string; environment?: string } = {},
41
47
  ): Profiler {
42
48
  // Built before the profiler so the database collector can read the bag of
43
49
  // whichever request is running — the reference is filled in a line later,
@@ -48,7 +54,15 @@ export function createProfiler(
48
54
  databaseCollector(events, () => profiler.current<QueryBag>('database')),
49
55
  exceptionCollector(),
50
56
  logCollector(events, () => profiler.current<LogBag>('logs')),
57
+ ...(typeof events.tap === 'function'
58
+ ? [
59
+ eventsCollector({ tap: events.tap.bind(events) }, () =>
60
+ profiler.current<EventBag>('events'),
61
+ ),
62
+ ]
63
+ : []),
51
64
  memoryCollector(),
65
+ appCollector(info),
52
66
  ...extra,
53
67
  ]
54
68
  profiler = new Profiler(collectors, new MemoryProfileStore(collectors))
@@ -16,6 +16,7 @@ import type { HttpContext } from '../http/HttpContext.js'
16
16
  import type { MiddlewareFunction } from '../middleware/Pipeline.js'
17
17
  import type { RequestBag } from './collectors.js'
18
18
  import { PROFILER_TOKEN_HEADER, type Profiler } from './Profiler.js'
19
+ import { isSecretKey, preview, REDACTED } from './preview.js'
19
20
 
20
21
  export function profileRequest(profiler: Profiler): MiddlewareFunction {
21
22
  return async (ctx: HttpContext, next: () => Promise<void>) => {
@@ -32,7 +33,7 @@ export function profileRequest(profiler: Profiler): MiddlewareFunction {
32
33
  // After routing, because this runs ahead of it: the pattern and the
33
34
  // controller are only on the context once a route has matched. In a
34
35
  // `finally`, because a request that threw still matched one.
35
- describeRoute(profiler.current<RequestBag>('request'), ctx)
36
+ describeRequest(profiler.current<RequestBag>('request'), ctx)
36
37
  }
37
38
  },
38
39
  (error) => ({ status: statusOf(ctx, error) }),
@@ -58,16 +59,85 @@ function statusOf(ctx: HttpContext, error: unknown): number {
58
59
  }
59
60
 
60
61
  /**
61
- * What matched, as the request panel shows it.
62
+ * The request and its answer, as the request panel shows them.
63
+ *
64
+ * Each part is read on its own: one accessor that throws (a body the parser
65
+ * refused, a session store that is down) costs its own row, not the panel.
66
+ */
67
+ function describeRequest(bag: RequestBag | undefined, ctx: HttpContext): void {
68
+ if (bag === undefined) return
69
+ const request = ctx.request
70
+ attempt(() => {
71
+ const safe = request.serializeSafe()
72
+ bag.headers = safe.headers
73
+ bag.query = preview(safe.qs)
74
+ bag.params = preview(safe.params)
75
+ })
76
+ attempt(() => {
77
+ bag.path = request.path()
78
+ bag.ip = request.ip()
79
+ bag.ajax = request.ajax()
80
+ const id = request.id()
81
+ if (id !== undefined) bag.requestId = id
82
+ })
83
+ attempt(() => {
84
+ // Names only: a cookie's value is a session id or a signed token.
85
+ bag.cookies = Object.keys(request.cookiesList())
86
+ })
87
+ attempt(() => {
88
+ const body = request.body()
89
+ const sent =
90
+ request.hasBody() ||
91
+ (typeof body === 'string'
92
+ ? body !== ''
93
+ : typeof body === 'object' && body !== null && Object.keys(body).length > 0)
94
+ if (sent) bag.body = preview(body)
95
+ })
96
+ attempt(() => {
97
+ const session = ctx.session
98
+ if (session !== undefined) bag.session = preview(session.all())
99
+ })
100
+ attempt(() => {
101
+ const response = ctx.response
102
+ const headers: Record<string, string> = {}
103
+ for (const [name, value] of Object.entries(response.getHeaders())) {
104
+ headers[name] = name === 'set-cookie' || isSecretKey(name) ? REDACTED : value
105
+ }
106
+ const contentType = response.getHeader('content-type')
107
+ bag.response = {
108
+ status: response.getStatus(),
109
+ headers,
110
+ ...(contentType === undefined ? {} : { contentType }),
111
+ }
112
+ })
113
+ describeRoute(bag, ctx)
114
+ }
115
+
116
+ function attempt(read: () => void): void {
117
+ try {
118
+ read()
119
+ } catch {
120
+ // One unreadable part of the request; the rest of the panel still shows.
121
+ }
122
+ }
123
+
124
+ /**
125
+ * What matched.
62
126
  *
63
127
  * Read off the context rather than passed in, and every field optional: a 404
64
128
  * matched nothing, and an inline handler has no controller to name.
65
129
  */
66
- function describeRoute(bag: RequestBag | undefined, ctx: HttpContext): void {
67
- if (bag === undefined) return
130
+ function describeRoute(bag: RequestBag, ctx: HttpContext): void {
131
+ const user = ctx.auth?.user
132
+ if (typeof user === 'object' && user !== null) {
133
+ const id = Reflect.get(user, 'id')
134
+ if (typeof id === 'string' || typeof id === 'number') bag.userId = String(id)
135
+ bag.user = preview(user)
136
+ }
68
137
  const route = ctx.route
69
138
  if (route === undefined) return
70
139
  bag.route = route.name ?? route.pattern
140
+ bag.pattern = route.pattern
71
141
  const controller = route.controller
72
142
  if (controller !== undefined && route.action !== undefined) {
73
143
  // `route.controller` is the PROTOTYPE, not the class — that is what the
@@ -75,9 +145,4 @@ function describeRoute(bag: RequestBag | undefined, ctx: HttpContext): void {
75
145
  const name = Reflect.get(Reflect.get(controller, 'constructor') ?? {}, 'name')
76
146
  bag.controller = `${typeof name === 'string' ? name : 'Controller'}.${route.action}`
77
147
  }
78
- const user = ctx.auth?.user
79
- if (typeof user === 'object' && user !== null) {
80
- const id = Reflect.get(user, 'id')
81
- if (typeof id === 'string' || typeof id === 'number') bag.userId = String(id)
82
- }
83
148
  }