@c9up/ream 0.2.25 → 0.2.27

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 (178) hide show
  1. package/README.md +2 -0
  2. package/dist/Codemods.d.ts +12 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +114 -23
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/HttpKernel.d.ts +5 -1
  7. package/dist/HttpKernel.d.ts.map +1 -1
  8. package/dist/HttpKernel.js +6 -6
  9. package/dist/HttpKernel.js.map +1 -1
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +87 -14
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/console/Kernel.d.ts.map +1 -1
  14. package/dist/console/Kernel.js +20 -1
  15. package/dist/console/Kernel.js.map +1 -1
  16. package/dist/console/cliui.d.ts +9 -245
  17. package/dist/console/cliui.d.ts.map +1 -1
  18. package/dist/console/cliui.js +9 -583
  19. package/dist/console/cliui.js.map +1 -1
  20. package/dist/console/make/commands.d.ts +19 -0
  21. package/dist/console/make/commands.d.ts.map +1 -0
  22. package/dist/console/make/commands.js +252 -0
  23. package/dist/console/make/commands.js.map +1 -0
  24. package/dist/console/make/eject.d.ts +28 -0
  25. package/dist/console/make/eject.d.ts.map +1 -0
  26. package/dist/console/make/eject.js +161 -0
  27. package/dist/console/make/eject.js.map +1 -0
  28. package/dist/console/make/flush.d.ts +51 -0
  29. package/dist/console/make/flush.d.ts.map +1 -0
  30. package/dist/console/make/flush.js +93 -0
  31. package/dist/console/make/flush.js.map +1 -0
  32. package/dist/console/make/generators.d.ts +36 -0
  33. package/dist/console/make/generators.d.ts.map +1 -0
  34. package/dist/console/make/generators.js +148 -0
  35. package/dist/console/make/generators.js.map +1 -0
  36. package/dist/console/make/naming.d.ts +32 -0
  37. package/dist/console/make/naming.d.ts.map +1 -0
  38. package/dist/console/make/naming.js +82 -0
  39. package/dist/console/make/naming.js.map +1 -0
  40. package/dist/console/make/resolve.d.ts +46 -0
  41. package/dist/console/make/resolve.d.ts.map +1 -0
  42. package/dist/console/make/resolve.js +113 -0
  43. package/dist/console/make/resolve.js.map +1 -0
  44. package/dist/console/prompts.d.ts.map +1 -1
  45. package/dist/console/prompts.js +34 -46
  46. package/dist/console/prompts.js.map +1 -1
  47. package/dist/console/selection.d.ts +54 -0
  48. package/dist/console/selection.d.ts.map +1 -0
  49. package/dist/console/selection.js +235 -0
  50. package/dist/console/selection.js.map +1 -0
  51. package/dist/console/ui.d.ts +9 -15
  52. package/dist/console/ui.d.ts.map +1 -1
  53. package/dist/console/ui.js +11 -24
  54. package/dist/console/ui.js.map +1 -1
  55. package/dist/dev/fullReload.d.ts +35 -10
  56. package/dist/dev/fullReload.d.ts.map +1 -1
  57. package/dist/dev/fullReload.js +55 -14
  58. package/dist/dev/fullReload.js.map +1 -1
  59. package/dist/dev/hotWatcher.d.ts +19 -3
  60. package/dist/dev/hotWatcher.d.ts.map +1 -1
  61. package/dist/dev/hotWatcher.js +168 -18
  62. package/dist/dev/hotWatcher.js.map +1 -1
  63. package/dist/dev/readyBanner.d.ts +45 -0
  64. package/dist/dev/readyBanner.d.ts.map +1 -0
  65. package/dist/dev/readyBanner.js +72 -0
  66. package/dist/dev/readyBanner.js.map +1 -0
  67. package/dist/dev/shortcuts.d.ts +41 -0
  68. package/dist/dev/shortcuts.d.ts.map +1 -0
  69. package/dist/dev/shortcuts.js +142 -0
  70. package/dist/dev/shortcuts.js.map +1 -0
  71. package/dist/errors/prettyPrintError.d.ts.map +1 -1
  72. package/dist/errors/prettyPrintError.js +5 -8
  73. package/dist/errors/prettyPrintError.js.map +1 -1
  74. package/dist/errors/renderError.d.ts +46 -0
  75. package/dist/errors/renderError.d.ts.map +1 -0
  76. package/dist/errors/renderError.js +197 -0
  77. package/dist/errors/renderError.js.map +1 -0
  78. package/dist/hot.js +12 -2
  79. package/dist/hot.js.map +1 -1
  80. package/dist/index.d.ts +4 -3
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +5 -2
  83. package/dist/index.js.map +1 -1
  84. package/dist/profiler/MemoryProfileStore.d.ts +23 -0
  85. package/dist/profiler/MemoryProfileStore.d.ts.map +1 -0
  86. package/dist/profiler/MemoryProfileStore.js +70 -0
  87. package/dist/profiler/MemoryProfileStore.js.map +1 -0
  88. package/dist/profiler/Profiler.d.ts +56 -0
  89. package/dist/profiler/Profiler.d.ts.map +1 -0
  90. package/dist/profiler/Profiler.js +162 -0
  91. package/dist/profiler/Profiler.js.map +1 -0
  92. package/dist/profiler/collectors.d.ts +105 -0
  93. package/dist/profiler/collectors.d.ts.map +1 -0
  94. package/dist/profiler/collectors.js +164 -0
  95. package/dist/profiler/collectors.js.map +1 -0
  96. package/dist/profiler/endpoint.d.ts +27 -0
  97. package/dist/profiler/endpoint.d.ts.map +1 -0
  98. package/dist/profiler/endpoint.js +79 -0
  99. package/dist/profiler/endpoint.js.map +1 -0
  100. package/dist/profiler/index.d.ts +26 -0
  101. package/dist/profiler/index.d.ts.map +1 -0
  102. package/dist/profiler/index.js +41 -0
  103. package/dist/profiler/index.js.map +1 -0
  104. package/dist/profiler/middleware.d.ts +17 -0
  105. package/dist/profiler/middleware.d.ts.map +1 -0
  106. package/dist/profiler/middleware.js +79 -0
  107. package/dist/profiler/middleware.js.map +1 -0
  108. package/dist/profiler/render.d.ts +23 -0
  109. package/dist/profiler/render.d.ts.map +1 -0
  110. package/dist/profiler/render.js +141 -0
  111. package/dist/profiler/render.js.map +1 -0
  112. package/dist/profiler/toolbar.d.ts +34 -0
  113. package/dist/profiler/toolbar.d.ts.map +1 -0
  114. package/dist/profiler/toolbar.js +104 -0
  115. package/dist/profiler/toolbar.js.map +1 -0
  116. package/dist/profiler/types.d.ts +70 -0
  117. package/dist/profiler/types.d.ts.map +1 -0
  118. package/dist/profiler/types.js +11 -0
  119. package/dist/profiler/types.js.map +1 -0
  120. package/dist/router/Router.d.ts +11 -0
  121. package/dist/router/Router.d.ts.map +1 -1
  122. package/dist/router/Router.js +16 -0
  123. package/dist/router/Router.js.map +1 -1
  124. package/dist/stubs/index.d.ts +11 -0
  125. package/dist/stubs/index.d.ts.map +1 -0
  126. package/dist/stubs/index.js +12 -0
  127. package/dist/stubs/index.js.map +1 -0
  128. package/dist/stubs/template.d.ts +63 -0
  129. package/dist/stubs/template.d.ts.map +1 -0
  130. package/dist/stubs/template.js +209 -0
  131. package/dist/stubs/template.js.map +1 -0
  132. package/events.win32-x64-msvc.node +0 -0
  133. package/index.win32-x64-msvc.node +0 -0
  134. package/package.json +7 -1
  135. package/scheduler.win32-x64-msvc.node +0 -0
  136. package/src/Codemods.ts +153 -29
  137. package/src/HttpKernel.ts +12 -8
  138. package/src/Ignitor.ts +95 -14
  139. package/src/console/Kernel.ts +21 -1
  140. package/src/console/cliui.ts +44 -754
  141. package/src/console/make/commands.ts +270 -0
  142. package/src/console/make/eject.ts +163 -0
  143. package/src/console/make/flush.ts +125 -0
  144. package/src/console/make/generators.ts +176 -0
  145. package/src/console/make/naming.ts +85 -0
  146. package/src/console/make/resolve.ts +134 -0
  147. package/src/console/prompts.ts +45 -64
  148. package/src/console/selection.ts +274 -0
  149. package/src/console/ui.ts +12 -24
  150. package/src/dev/fullReload.ts +57 -15
  151. package/src/dev/hotWatcher.ts +165 -21
  152. package/src/dev/readyBanner.ts +84 -0
  153. package/src/dev/shortcuts.ts +158 -0
  154. package/src/errors/prettyPrintError.ts +5 -5
  155. package/src/errors/renderError.ts +230 -0
  156. package/src/hot.ts +12 -2
  157. package/src/index.ts +23 -3
  158. package/src/profiler/MemoryProfileStore.ts +75 -0
  159. package/src/profiler/Profiler.ts +182 -0
  160. package/src/profiler/collectors.ts +230 -0
  161. package/src/profiler/endpoint.ts +98 -0
  162. package/src/profiler/index.ts +56 -0
  163. package/src/profiler/middleware.ts +83 -0
  164. package/src/profiler/render.ts +173 -0
  165. package/src/profiler/toolbar.ts +106 -0
  166. package/src/profiler/types.ts +75 -0
  167. package/src/router/Router.ts +16 -0
  168. package/src/stubs/index.ts +14 -0
  169. package/src/stubs/template.ts +262 -0
  170. package/stubs/make/command.stub +29 -0
  171. package/stubs/make/controller.stub +30 -0
  172. package/stubs/make/entity.stub +11 -0
  173. package/stubs/make/event.stub +13 -0
  174. package/stubs/make/listener.stub +13 -0
  175. package/stubs/make/middleware.stub +16 -0
  176. package/stubs/make/provider.stub +26 -0
  177. package/stubs/make/service.stub +27 -0
  178. package/stubs/make/validator.stub +10 -0
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The profiler: one profile per request, and the token that finds it again.
3
+ *
4
+ * The hard part is attribution. A SQL query is reported by a listener the
5
+ * database package calls, with no idea which request caused it — and in a
6
+ * server answering three requests at once, "the current request" is not a
7
+ * variable anything can hold. `AsyncLocalStorage` is what makes it one: the
8
+ * request runs inside a store, and whatever a collector does during it, however
9
+ * deep in the await chain, lands in the right bag.
10
+ *
11
+ * Dev only, and hard-gated: a profiler that kept every request in production
12
+ * would be a memory leak wearing a feature's name.
13
+ */
14
+
15
+ import { AsyncLocalStorage } from 'node:async_hooks'
16
+ import { randomBytes } from 'node:crypto'
17
+ import type { CollectionScope, Profile, ProfilerCollector, ProfileStore } from './types.js'
18
+
19
+ /** What is live while one request runs. */
20
+ interface ActiveProfile {
21
+ scope: CollectionScope
22
+ /** Each collector's bag, keyed by collector name. */
23
+ bags: Map<string, unknown>
24
+ }
25
+
26
+ /** Where the toolbar and the profiler page are served. */
27
+ export const PROFILER_PATH = '/__ream/profiler'
28
+
29
+ /** The header carrying the token back, so a client can ask for the profile. */
30
+ export const PROFILER_TOKEN_HEADER = 'x-debug-token'
31
+
32
+ export class Profiler {
33
+ readonly #storage = new AsyncLocalStorage<ActiveProfile>()
34
+ readonly #collectors: readonly ProfilerCollector[]
35
+ readonly #store: ProfileStore
36
+
37
+ constructor(collectors: readonly ProfilerCollector[], store: ProfileStore) {
38
+ this.#collectors = collectors
39
+ this.#store = store
40
+ }
41
+
42
+ get store(): ProfileStore {
43
+ return this.#store
44
+ }
45
+
46
+ get collectors(): readonly ProfilerCollector[] {
47
+ return this.#collectors
48
+ }
49
+
50
+ /**
51
+ * Run one request inside a profile.
52
+ *
53
+ * `work` is handed the scope so it can stamp the token on the response
54
+ * before anything else runs: a request that throws unwinds past its caller,
55
+ * and a token written after the await would be missing on exactly the
56
+ * request worth looking at.
57
+ *
58
+ * `describe` is called AFTER the work, because the status is only known
59
+ * then — asking for it up front would record the request as it arrived
60
+ * rather than as it was answered. It is given whatever was thrown, since a
61
+ * handler that threw has not had its status written yet.
62
+ */
63
+ async profile<T>(
64
+ request: { method: string; url: string },
65
+ work: (scope: CollectionScope) => Promise<T>,
66
+ describe: (error?: unknown) => { status: number },
67
+ ): Promise<{ result: T; token: string }> {
68
+ const token = newToken()
69
+ const startedAt = Date.now()
70
+ const started = performance.now()
71
+ const active: ActiveProfile = { scope: { token, startedAt }, bags: new Map() }
72
+
73
+ for (const collector of this.#collectors) {
74
+ // A collector that throws while starting must not fail the request it was
75
+ // only meant to watch.
76
+ try {
77
+ active.bags.set(collector.name, collector.start(active.scope))
78
+ } catch {
79
+ active.bags.set(collector.name, undefined)
80
+ }
81
+ }
82
+
83
+ // A request that threw is the profile you most want to look at, so it is
84
+ // recorded like any other and the error is re-thrown afterwards. Losing it
85
+ // here would mean the profiler goes quiet exactly when something broke.
86
+ let result: T | undefined
87
+ let thrown: unknown
88
+ let failed = false
89
+ try {
90
+ result = await this.#storage.run(active, () => work(active.scope))
91
+ } catch (error) {
92
+ thrown = error
93
+ failed = true
94
+ recordException(active, error)
95
+ }
96
+
97
+ const profile: Profile = {
98
+ token,
99
+ time: startedAt,
100
+ method: request.method,
101
+ url: request.url,
102
+ status: safeStatus(describe, thrown),
103
+ duration: performance.now() - started,
104
+ data: {},
105
+ }
106
+ for (const collector of this.#collectors) {
107
+ try {
108
+ profile.data[collector.name] = collector.finish(
109
+ active.bags.get(collector.name),
110
+ active.scope,
111
+ )
112
+ } catch {
113
+ // Same rule on the way out: one broken panel, not a broken response.
114
+ profile.data[collector.name] = {}
115
+ }
116
+ }
117
+ this.#store.save(profile)
118
+ if (failed) throw thrown
119
+ // Only reachable when the work resolved, which is when `result` is set.
120
+ return { result: result as T, token }
121
+ }
122
+
123
+ /**
124
+ * The bag of the collector named, for the request running right now.
125
+ *
126
+ * `undefined` outside a request — a query from a console command, a boot-time
127
+ * warm-up — and a collector that writes into it must accept that.
128
+ */
129
+ current<Bag>(collector: string): Bag | undefined {
130
+ const active = this.#storage.getStore()
131
+ if (active === undefined) return undefined
132
+ const bag = active.bags.get(collector)
133
+ return bag === undefined ? undefined : (bag as Bag)
134
+ }
135
+
136
+ /** Whether a request is being profiled right now. */
137
+ get active(): boolean {
138
+ return this.#storage.getStore() !== undefined
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Put the error where the exception panel reads it.
144
+ *
145
+ * The collector's own bag, by name: the framework ships that collector, and a
146
+ * profiler that could not say what broke would miss the reason most people
147
+ * open it.
148
+ */
149
+ function recordException(active: ActiveProfile, error: unknown): void {
150
+ const bag = active.bags.get('exception')
151
+ if (typeof bag !== 'object' || bag === null) return
152
+ const target: Record<string, unknown> = Object.assign(bag)
153
+ if (error instanceof Error) {
154
+ target.name = error.name
155
+ target.message = error.message
156
+ if (error.stack !== undefined) target.stack = error.stack
157
+ const code = Reflect.get(error, 'code')
158
+ if (typeof code === 'string') target.code = code
159
+ } else {
160
+ target.name = 'Error'
161
+ target.message = String(error)
162
+ }
163
+ }
164
+
165
+ /**
166
+ * A token, short enough to read aloud and random enough not to collide.
167
+ *
168
+ * Six bytes of hex, as Symfony's is six characters: it identifies a request
169
+ * within one dev session, not across the internet.
170
+ */
171
+ function newToken(): string {
172
+ return randomBytes(6).toString('hex')
173
+ }
174
+
175
+ function safeStatus(describe: (error?: unknown) => { status: number }, error: unknown): number {
176
+ try {
177
+ const { status } = describe(error)
178
+ return Number.isInteger(status) ? status : 0
179
+ } catch {
180
+ return 0
181
+ }
182
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * What the profiler watches.
3
+ *
4
+ * Each one is independent and may be replaced: the database collector is the
5
+ * interesting case, because the data does not come from the request at all. It
6
+ * comes from `db:query` events the data layer emits with no idea which request
7
+ * caused them, and the profiler's async store is what puts each one back where
8
+ * it belongs.
9
+ *
10
+ * NO import of atlas. The collector listens on the application's emitter for
11
+ * `db:query`, which is the shape atlas documents for bridging its own registry
12
+ * (`onDbQuery((event) => emitter.emit('db:query', event))`). A project on
13
+ * another data layer emits the same event and gets the same panel.
14
+ */
15
+
16
+ import type { CollectedData, ProfilerCollector } from './types.js'
17
+
18
+ /** One SQL statement, as the panel shows it. */
19
+ export interface RecordedQuery {
20
+ sql: string
21
+ bindings: readonly unknown[]
22
+ /** Milliseconds. */
23
+ duration: number
24
+ connection?: string
25
+ model?: string
26
+ method?: string
27
+ inTransaction?: boolean
28
+ /** The message, when the statement threw. */
29
+ error?: string
30
+ }
31
+
32
+ /** What the emitter must give this collector. Structural: no package imported. */
33
+ export interface QueryEventSource {
34
+ on(event: string, listener: (payload: unknown) => void): unknown
35
+ }
36
+
37
+ /**
38
+ * How many statements one request may record.
39
+ *
40
+ * A runaway loop issuing ten thousand queries is exactly the bug this panel
41
+ * exists to show, and keeping all ten thousand would take the dev server down
42
+ * with it. The count stays true past the cap; the list stops.
43
+ */
44
+ const QUERY_CAP = 500
45
+
46
+ export function databaseCollector(
47
+ events: QueryEventSource,
48
+ read: () => QueryBag | undefined,
49
+ ): ProfilerCollector<QueryBag> {
50
+ // Subscribed once, at construction: a listener added per request would leak
51
+ // one per request, and removing it on the way out would miss a query still
52
+ // in flight.
53
+ events.on('db:query', (payload) => {
54
+ const bag = read()
55
+ if (bag === undefined) return
56
+ bag.total += 1
57
+ bag.duration += durationOf(payload)
58
+ if (bag.queries.length < QUERY_CAP) bag.queries.push(toQuery(payload))
59
+ })
60
+
61
+ return {
62
+ name: 'database',
63
+ badge: (data) => {
64
+ const total = typeof data.total === 'number' ? data.total : 0
65
+ const duration = typeof data.duration === 'number' ? data.duration : 0
66
+ return `${total} ${total === 1 ? 'query' : 'queries'} · ${round(duration)} ms`
67
+ },
68
+ start: () => ({ total: 0, duration: 0, queries: [] }),
69
+ finish: (bag) =>
70
+ bag === undefined
71
+ ? { total: 0, duration: 0, queries: [], capped: false }
72
+ : {
73
+ total: bag.total,
74
+ duration: round(bag.duration),
75
+ queries: bag.queries,
76
+ capped: bag.total > bag.queries.length,
77
+ },
78
+ }
79
+ }
80
+
81
+ export interface QueryBag {
82
+ total: number
83
+ duration: number
84
+ queries: RecordedQuery[]
85
+ }
86
+
87
+ /** The request and what it answered. */
88
+ export function requestCollector(): ProfilerCollector<RequestBag> {
89
+ return {
90
+ name: 'request',
91
+ badge: (data) => (typeof data.route === 'string' ? data.route : undefined),
92
+ start: () => ({}),
93
+ finish: (bag) => ({ ...(bag ?? {}) }),
94
+ }
95
+ }
96
+
97
+ export interface RequestBag {
98
+ route?: string
99
+ controller?: string
100
+ userId?: string
101
+ }
102
+
103
+ /**
104
+ * What went wrong, if anything.
105
+ *
106
+ * The one panel worth opening by itself: an exception that reached the handler
107
+ * is reported here so it shows up in the page rather than only in the terminal
108
+ * someone may not be looking at.
109
+ */
110
+ export function exceptionCollector(): ProfilerCollector<ExceptionBag> {
111
+ return {
112
+ name: 'exception',
113
+ badge: (data) => (typeof data.message === 'string' ? 'error' : undefined),
114
+ start: () => ({}),
115
+ finish: (bag) => ({ ...(bag ?? {}) }),
116
+ }
117
+ }
118
+
119
+ export interface ExceptionBag {
120
+ name?: string
121
+ message?: string
122
+ stack?: string
123
+ /** Where it was reported from, when the framework knows. */
124
+ code?: string
125
+ }
126
+
127
+ /**
128
+ * Lines logged while the request ran.
129
+ *
130
+ * Same arrangement as the database panel, and for the same reason: a logger
131
+ * writes a line with no idea which request caused it. It comes off the
132
+ * application's emitter as `log:line`, which is what `@c9up/spectrum` bridges
133
+ * its own registry to (`onLog((entry) => emitter.emit('log:line', entry))`),
134
+ * so a project on another logger emits the same event and gets the same panel.
135
+ */
136
+ export function logCollector(events: LogEventSource, read: () => LogBag | undefined) {
137
+ // Subscribed once, at construction, for the same reason as the queries: a
138
+ // listener per request leaks one per request.
139
+ events.on('log:line', (payload) => {
140
+ const bag = read()
141
+ if (bag === undefined) return
142
+ if (bag.lines.length < LINE_CAP) bag.lines.push(toLine(payload))
143
+ bag.total += 1
144
+ })
145
+
146
+ return {
147
+ name: 'logs',
148
+ badge: (data) => {
149
+ const total = typeof data.total === 'number' ? data.total : 0
150
+ return total === 0 ? undefined : `${total} ${total === 1 ? 'log' : 'logs'}`
151
+ },
152
+ start: () => ({ total: 0, lines: [] }),
153
+ // Nothing logged is nothing to show: an empty bag renders no panel at all,
154
+ // rather than a heading over an empty list.
155
+ finish: (bag) =>
156
+ bag === undefined || bag.total === 0 ? {} : { total: bag.total, lines: bag.lines },
157
+ } satisfies ProfilerCollector<LogBag>
158
+ }
159
+
160
+ /** What the emitter must give the log collector. Structural, as above. */
161
+ export interface LogEventSource {
162
+ on(event: string, listener: (payload: unknown) => void): unknown
163
+ }
164
+
165
+ /** As many lines as are worth scrolling; the count stays true past it. */
166
+ const LINE_CAP = 200
167
+
168
+ export interface LogBag {
169
+ total: number
170
+ lines: Array<{ level: string; message: string; time?: string }>
171
+ }
172
+
173
+ function toLine(payload: unknown): LogBag['lines'][number] {
174
+ const entry = asRecord(payload)
175
+ return {
176
+ level: typeof entry.level === 'string' ? entry.level : 'info',
177
+ message: typeof entry.message === 'string' ? entry.message : String(entry.message ?? ''),
178
+ ...(typeof entry.timestamp === 'string' ? { time: entry.timestamp } : {}),
179
+ }
180
+ }
181
+
182
+ /** Peak heap while the request ran, which is what a leak shows up in. */
183
+ export function memoryCollector(): ProfilerCollector<{ start: number }> {
184
+ return {
185
+ name: 'memory',
186
+ badge: (data) => (typeof data.usedMb === 'number' ? `${data.usedMb.toFixed(1)} MB` : undefined),
187
+ start: () => ({ start: process.memoryUsage().heapUsed }),
188
+ finish: (bag) => {
189
+ const used = process.memoryUsage().heapUsed
190
+ return {
191
+ usedMb: used / 1024 / 1024,
192
+ deltaMb: bag === undefined ? 0 : (used - bag.start) / 1024 / 1024,
193
+ }
194
+ },
195
+ }
196
+ }
197
+
198
+ function toQuery(payload: unknown): RecordedQuery {
199
+ const event = asRecord(payload)
200
+ return {
201
+ sql: typeof event.sql === 'string' ? event.sql : String(event.sql ?? ''),
202
+ bindings: Array.isArray(event.bindings) ? event.bindings : [],
203
+ duration: durationOf(payload),
204
+ ...pickString(event, 'connection'),
205
+ ...pickString(event, 'model'),
206
+ ...pickString(event, 'method'),
207
+ ...(event.inTransaction === true ? { inTransaction: true } : {}),
208
+ ...(event.error instanceof Error ? { error: event.error.message } : {}),
209
+ }
210
+ }
211
+
212
+ function durationOf(payload: unknown): number {
213
+ const value = asRecord(payload).duration
214
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
215
+ }
216
+
217
+ function pickString(event: Record<string, unknown>, key: string): CollectedData {
218
+ const value = event[key]
219
+ return typeof value === 'string' && value !== '' ? { [key]: value } : {}
220
+ }
221
+
222
+ function asRecord(value: unknown): Record<string, unknown> {
223
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
224
+ ? Object.fromEntries(Object.entries(value))
225
+ : {}
226
+ }
227
+
228
+ function round(value: number): number {
229
+ return Math.round(value * 100) / 100
230
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Where the toolbar and the profiler read from.
3
+ *
4
+ * Three routes, all under `/__ream/profiler` and all development-only:
5
+ *
6
+ * /__ream/profiler the history, newest first
7
+ * /__ream/profiler/<token> one request, every panel
8
+ * /__ream/profiler/<token>.json the same, for the bar in the page
9
+ *
10
+ * Server middleware rather than routes, like the hot-reload endpoint: it
11
+ * answers before the application's own middleware, so a profiler request does
12
+ * not run the authentication stack and end up in its own history.
13
+ */
14
+
15
+ import type { Profiler } from './Profiler.js'
16
+ import { PROFILER_PATH } from './Profiler.js'
17
+ import { renderProfileList, renderProfilePage } from './render.js'
18
+
19
+ /** The slice of the context this endpoint uses. */
20
+ export interface ProfilerContext {
21
+ request: { url: () => string }
22
+ response: {
23
+ header: (name: string, value: string) => unknown
24
+ send: (body: unknown) => unknown
25
+ status: (code: number) => unknown
26
+ }
27
+ }
28
+
29
+ export function profilerEndpoint(
30
+ profiler: Profiler,
31
+ ): (ctx: ProfilerContext, next: () => Promise<void>) => Promise<void> {
32
+ return async (ctx, next) => {
33
+ const path = ctx.request.url().split('?')[0] ?? ''
34
+ if (path !== PROFILER_PATH && !path.startsWith(`${PROFILER_PATH}/`)) {
35
+ await next()
36
+ return
37
+ }
38
+
39
+ if (path === PROFILER_PATH) {
40
+ ctx.response.header('content-type', 'text/html; charset=utf-8')
41
+ ctx.response.send(renderProfileList(profiler.store.recent(50)))
42
+ return
43
+ }
44
+
45
+ const rest = path.slice(`${PROFILER_PATH}/`.length)
46
+ const asJson = rest.endsWith('.json')
47
+ const token = asJson ? rest.slice(0, -'.json'.length) : rest
48
+ const profile = profiler.store.find(token)
49
+
50
+ if (profile === undefined) {
51
+ // 404 rather than an empty page: a token the store has dropped is a real
52
+ // answer, and the bar reads the status to stop asking.
53
+ ctx.response.status(404)
54
+ if (asJson) {
55
+ ctx.response.header('content-type', 'application/json')
56
+ ctx.response.send({ error: 'no profile for this token' })
57
+ } else {
58
+ ctx.response.header('content-type', 'text/html; charset=utf-8')
59
+ ctx.response.send(renderProfileList(profiler.store.recent(50), token))
60
+ }
61
+ return
62
+ }
63
+
64
+ if (asJson) {
65
+ ctx.response.header('content-type', 'application/json')
66
+ // No cache: the token is single-use in practice, and a cached profile
67
+ // would show the previous request's numbers after a reload.
68
+ ctx.response.header('cache-control', 'no-store')
69
+ ctx.response.send({
70
+ ...profile,
71
+ badges: badgesFor(profiler, profile.data),
72
+ })
73
+ return
74
+ }
75
+
76
+ ctx.response.header('content-type', 'text/html; charset=utf-8')
77
+ ctx.response.send(renderProfilePage(profile, badgesFor(profiler, profile.data)))
78
+ }
79
+ }
80
+
81
+ /** Each collector's headline, asked of the collector rather than guessed. */
82
+ function badgesFor(
83
+ profiler: Profiler,
84
+ data: Record<string, Record<string, unknown>>,
85
+ ): Record<string, string> {
86
+ const badges: Record<string, string> = {}
87
+ for (const collector of profiler.collectors) {
88
+ const panel = data[collector.name]
89
+ if (panel === undefined || collector.badge === undefined) continue
90
+ try {
91
+ const badge = collector.badge(panel)
92
+ if (badge !== undefined) badges[collector.name] = badge
93
+ } catch {
94
+ // A badge that throws costs its own label, nothing more.
95
+ }
96
+ }
97
+ return badges
98
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The profiler, assembled.
3
+ *
4
+ * One place that knows which collectors ship, so the Ignitor wires a profiler
5
+ * rather than a list — and an application that wants its own panel adds a
6
+ * collector here instead of rebuilding the set.
7
+ */
8
+
9
+ import {
10
+ databaseCollector,
11
+ exceptionCollector,
12
+ type LogBag,
13
+ logCollector,
14
+ memoryCollector,
15
+ type QueryBag,
16
+ type QueryEventSource,
17
+ requestCollector,
18
+ } from './collectors.js'
19
+ import { MemoryProfileStore } from './MemoryProfileStore.js'
20
+ import { Profiler } from './Profiler.js'
21
+ import type { ProfilerCollector } from './types.js'
22
+
23
+ export * from './collectors.js'
24
+ export { profilerEndpoint } from './endpoint.js'
25
+ export { MemoryProfileStore } from './MemoryProfileStore.js'
26
+ export { profileRequest } from './middleware.js'
27
+ export { PROFILER_PATH, PROFILER_TOKEN_HEADER, Profiler } from './Profiler.js'
28
+ export { toolbarScript } from './toolbar.js'
29
+ export * from './types.js'
30
+
31
+ /**
32
+ * The profiler a dev server runs with.
33
+ *
34
+ * `events` is the application's emitter. The database panel comes from
35
+ * `db:query` on it, which is how a data layer reports a statement without the
36
+ * profiler importing one.
37
+ */
38
+ export function createProfiler(
39
+ events: QueryEventSource,
40
+ extra: readonly ProfilerCollector[] = [],
41
+ ): Profiler {
42
+ // Built before the profiler so the database collector can read the bag of
43
+ // whichever request is running — the reference is filled in a line later,
44
+ // and nothing reads it until a request arrives.
45
+ let profiler: Profiler
46
+ const collectors: ProfilerCollector[] = [
47
+ requestCollector(),
48
+ databaseCollector(events, () => profiler.current<QueryBag>('database')),
49
+ exceptionCollector(),
50
+ logCollector(events, () => profiler.current<LogBag>('logs')),
51
+ memoryCollector(),
52
+ ...extra,
53
+ ]
54
+ profiler = new Profiler(collectors, new MemoryProfileStore(collectors))
55
+ return profiler
56
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The middleware that records a request.
3
+ *
4
+ * It wraps everything after it, so what it measures is the whole answer — the
5
+ * application's own middleware included — and it stamps the token on the
6
+ * response first thing. That header is how a client finds the profile again;
7
+ * the injected bar carries the same token inline, because a script in a page
8
+ * cannot read the headers of the navigation that delivered it.
9
+ *
10
+ * Registered AFTER the profiler's own endpoint and the hot-reload stream, so
11
+ * neither ends up in its own history — and so a reload stream, which is open
12
+ * for as long as the tab is, never sits in the middle of an unfinished profile.
13
+ */
14
+
15
+ import type { HttpContext } from '../http/HttpContext.js'
16
+ import type { MiddlewareFunction } from '../middleware/Pipeline.js'
17
+ import type { RequestBag } from './collectors.js'
18
+ import { PROFILER_TOKEN_HEADER, type Profiler } from './Profiler.js'
19
+
20
+ export function profileRequest(profiler: Profiler): MiddlewareFunction {
21
+ return async (ctx: HttpContext, next: () => Promise<void>) => {
22
+ await profiler.profile(
23
+ { method: ctx.request.method(), url: ctx.request.url() },
24
+ async (scope) => {
25
+ // Before the work, not after: a handler that throws unwinds past this
26
+ // line, and the request that broke is the one whose profile you most
27
+ // want to open.
28
+ ctx.response.header(PROFILER_TOKEN_HEADER, scope.token)
29
+ try {
30
+ await next()
31
+ } finally {
32
+ // After routing, because this runs ahead of it: the pattern and the
33
+ // controller are only on the context once a route has matched. In a
34
+ // `finally`, because a request that threw still matched one.
35
+ describeRoute(profiler.current<RequestBag>('request'), ctx)
36
+ }
37
+ },
38
+ (error) => ({ status: statusOf(ctx, error) }),
39
+ )
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The status the response will carry.
45
+ *
46
+ * When the work threw, the exception handler has not run yet — the error is
47
+ * still travelling outwards — so the response still holds whatever it held
48
+ * before. Read the error the way the handler will instead, and fall back to
49
+ * 500, which is what an error carrying no status becomes.
50
+ */
51
+ function statusOf(ctx: HttpContext, error: unknown): number {
52
+ if (error === undefined) return ctx.response.getStatus()
53
+ if (typeof error === 'object' && error !== null) {
54
+ const status = Reflect.get(error, 'status') ?? Reflect.get(error, 'statusCode')
55
+ if (typeof status === 'number' && Number.isInteger(status)) return status
56
+ }
57
+ return 500
58
+ }
59
+
60
+ /**
61
+ * What matched, as the request panel shows it.
62
+ *
63
+ * Read off the context rather than passed in, and every field optional: a 404
64
+ * matched nothing, and an inline handler has no controller to name.
65
+ */
66
+ function describeRoute(bag: RequestBag | undefined, ctx: HttpContext): void {
67
+ if (bag === undefined) return
68
+ const route = ctx.route
69
+ if (route === undefined) return
70
+ bag.route = route.name ?? route.pattern
71
+ const controller = route.controller
72
+ if (controller !== undefined && route.action !== undefined) {
73
+ // `route.controller` is the PROTOTYPE, not the class — that is what the
74
+ // decorator-reading middleware needs — so the name is on its constructor.
75
+ const name = Reflect.get(Reflect.get(controller, 'constructor') ?? {}, 'name')
76
+ bag.controller = `${typeof name === 'string' ? name : 'Controller'}.${route.action}`
77
+ }
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
+ }