@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,173 @@
1
+ /**
2
+ * The profiler's own pages, and the bar injected into yours.
3
+ *
4
+ * Plain HTML and one inline stylesheet. A dev tool that needed a build step,
5
+ * or pulled a framework into the page it is inspecting, would be a worse
6
+ * neighbour than the problem it reports — and the bar lives in a shadow root
7
+ * for the same reason: your stylesheet must not reach it, and it must not
8
+ * reach yours.
9
+ *
10
+ * EVERY value that came from a request is escaped. A profiler renders the
11
+ * URL someone asked for, the SQL a query ran and the message an error carried
12
+ * — all attacker-reachable in exactly the case you most want to look at, and a
13
+ * tool that turned a crafted URL into script in your own browser would be a
14
+ * hole with a friendly face.
15
+ */
16
+
17
+ import type { Profile, ProfileSummary } from './types.js'
18
+
19
+ /** `<`, `&` and the quotes. Everything below goes through it. */
20
+ export function escapeHtml(value: unknown): string {
21
+ return String(value)
22
+ .replaceAll('&', '&amp;')
23
+ .replaceAll('<', '&lt;')
24
+ .replaceAll('>', '&gt;')
25
+ .replaceAll('"', '&quot;')
26
+ .replaceAll("'", '&#39;')
27
+ }
28
+
29
+ const STYLE = `
30
+ :root { color-scheme: dark }
31
+ body { margin: 0; background: #14161a; color: #e6e6e6;
32
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace }
33
+ a { color: #6cb6ff; text-decoration: none }
34
+ a:hover { text-decoration: underline }
35
+ header { padding: 16px 20px; border-bottom: 1px solid #262a31 }
36
+ h1 { margin: 0; font-size: 15px; font-weight: 600 }
37
+ .meta { color: #8b949e; margin-top: 4px }
38
+ main { padding: 20px }
39
+ section { margin-bottom: 28px }
40
+ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
41
+ color: #8b949e; margin: 0 0 10px }
42
+ table { width: 100%; border-collapse: collapse }
43
+ td, th { text-align: left; padding: 6px 10px; border-bottom: 1px solid #21252c;
44
+ vertical-align: top }
45
+ th { color: #8b949e; font-weight: 500 }
46
+ .num { text-align: right; white-space: nowrap; color: #8b949e }
47
+ .sql { white-space: pre-wrap; word-break: break-word }
48
+ .slow { color: #f0883e }
49
+ .bad { color: #ff7b72 }
50
+ pre { white-space: pre-wrap; word-break: break-word; margin: 0; color: #c9d1d9 }
51
+ .pill { display: inline-block; padding: 1px 8px; border-radius: 999px;
52
+ background: #21262d; color: #8b949e; margin-right: 6px }
53
+ `
54
+
55
+ /** A whole page, so the profiler needs nothing served alongside it. */
56
+ function page(title: string, body: string): string {
57
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
58
+ <title>${escapeHtml(title)}</title><style>${STYLE}</style></head><body>${body}</body></html>`
59
+ }
60
+
61
+ /** The history: what a developer lands on with no token in hand. */
62
+ export function renderProfileList(profiles: readonly ProfileSummary[], missing?: string): string {
63
+ const warning =
64
+ missing === undefined
65
+ ? ''
66
+ : `<p class="bad">No profile for <code>${escapeHtml(missing)}</code> — it may have aged out.</p>`
67
+
68
+ const rows = profiles
69
+ .map(
70
+ (profile) => `<tr>
71
+ <td><a href="/__ream/profiler/${escapeHtml(profile.token)}">${escapeHtml(profile.token)}</a></td>
72
+ <td>${escapeHtml(profile.method)}</td>
73
+ <td class="sql">${escapeHtml(profile.url)}</td>
74
+ <td class="${profile.status >= 500 ? 'bad' : ''}">${escapeHtml(profile.status)}</td>
75
+ <td class="num">${escapeHtml(profile.duration.toFixed(1))} ms</td>
76
+ <td>${Object.values(profile.badges)
77
+ .map((badge) => `<span class="pill">${escapeHtml(badge)}</span>`)
78
+ .join('')}</td>
79
+ </tr>`,
80
+ )
81
+ .join('')
82
+
83
+ return page(
84
+ 'Profiler',
85
+ `<header><h1>Profiler</h1><div class="meta">${profiles.length} request${
86
+ profiles.length === 1 ? '' : 's'
87
+ } kept</div></header><main>${warning}
88
+ <table><tr><th>Token</th><th>Method</th><th>URL</th><th>Status</th>
89
+ <th class="num">Time</th><th>Collected</th></tr>${rows}</table></main>`,
90
+ )
91
+ }
92
+
93
+ /** One request, every panel. */
94
+ export function renderProfilePage(profile: Profile, badges: Record<string, string>): string {
95
+ const sections: string[] = []
96
+
97
+ const exception = profile.data.exception
98
+ if (exception !== undefined && typeof exception.message === 'string') {
99
+ sections.push(`<section><h2 class="bad">Exception</h2>
100
+ <p class="bad">${escapeHtml(exception.name ?? 'Error')}: ${escapeHtml(exception.message)}</p>
101
+ ${typeof exception.stack === 'string' ? `<pre>${escapeHtml(exception.stack)}</pre>` : ''}
102
+ </section>`)
103
+ }
104
+
105
+ sections.push(renderQueries(profile))
106
+
107
+ for (const [name, data] of Object.entries(profile.data)) {
108
+ if (name === 'database' || name === 'exception') continue
109
+ if (Object.keys(data).length === 0) continue
110
+ sections.push(`<section><h2>${escapeHtml(name)}</h2>
111
+ <pre>${escapeHtml(JSON.stringify(data, null, 2))}</pre></section>`)
112
+ }
113
+
114
+ return page(
115
+ `${profile.method} ${profile.url}`,
116
+ `<header>
117
+ <h1>${escapeHtml(profile.method)} ${escapeHtml(profile.url)}</h1>
118
+ <div class="meta">
119
+ ${escapeHtml(profile.status)} · ${escapeHtml(profile.duration.toFixed(1))} ms ·
120
+ ${escapeHtml(new Date(profile.time).toISOString())} ·
121
+ <a href="/__ream/profiler">all requests</a>
122
+ </div>
123
+ <div class="meta">${Object.values(badges)
124
+ .map((badge) => `<span class="pill">${escapeHtml(badge)}</span>`)
125
+ .join('')}</div>
126
+ </header><main>${sections.join('')}</main>`,
127
+ )
128
+ }
129
+
130
+ /** The panel the whole thing exists for. */
131
+ function renderQueries(profile: Profile): string {
132
+ const database = profile.data.database
133
+ if (database === undefined) return ''
134
+ const queries = Array.isArray(database.queries) ? database.queries : []
135
+ const total = typeof database.total === 'number' ? database.total : 0
136
+
137
+ if (total === 0) return '<section><h2>Database</h2><p class="meta">No query.</p></section>'
138
+
139
+ const rows = queries
140
+ .map((entry) => {
141
+ const query = asRecord(entry)
142
+ const duration = typeof query.duration === 'number' ? query.duration : 0
143
+ const bindings = Array.isArray(query.bindings) ? query.bindings : []
144
+ return `<tr>
145
+ <td class="num ${duration >= 50 ? 'slow' : ''}">${escapeHtml(duration.toFixed(2))} ms</td>
146
+ <td class="sql">${escapeHtml(query.sql)}${
147
+ bindings.length === 0
148
+ ? ''
149
+ : `<div class="meta">${escapeHtml(JSON.stringify(bindings))}</div>`
150
+ }${
151
+ typeof query.error === 'string' ? `<div class="bad">${escapeHtml(query.error)}</div>` : ''
152
+ }</td>
153
+ <td class="meta">${escapeHtml(query.model ?? '')}${
154
+ query.inTransaction === true ? ' <span class="pill">tx</span>' : ''
155
+ }</td>
156
+ </tr>`
157
+ })
158
+ .join('')
159
+
160
+ const capped =
161
+ database.capped === true
162
+ ? `<p class="meta">Listing stopped at ${queries.length}; ${escapeHtml(total)} ran.</p>`
163
+ : ''
164
+
165
+ return `<section><h2>Database</h2>${capped}
166
+ <table><tr><th class="num">Time</th><th>Query</th><th>Model</th></tr>${rows}</table></section>`
167
+ }
168
+
169
+ function asRecord(value: unknown): Record<string, unknown> {
170
+ return typeof value === 'object' && value !== null
171
+ ? Object.fromEntries(Object.entries(value))
172
+ : {}
173
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The bar at the bottom of the page.
3
+ *
4
+ * It carries its own token inline rather than reading `X-Debug-Token` off the
5
+ * response: a script in a page cannot see the headers of the navigation that
6
+ * delivered it. Symfony injects the token for the same reason.
7
+ *
8
+ * Everything lives in a shadow root — your stylesheet must not reach the bar,
9
+ * and the bar must not reach your page. It fetches its numbers once, on load,
10
+ * and then does nothing: a dev tool that polls is a dev tool that shows up in
11
+ * its own history.
12
+ *
13
+ * A string rather than a stringified function, as the hot-reload script is:
14
+ * this package compiles without the DOM library, deliberately — it is a server
15
+ * — so browser code here is text, and its only inputs are escaped below.
16
+ *
17
+ * It styles its nodes through the CSSOM — `node.style.cssText` — rather than
18
+ * with a `<style>` element. Under a CSP with `style-src 'self'` (or only a
19
+ * `default-src`, which is the common case) a stylesheet inserted at runtime is
20
+ * inline style and is dropped, with no error anywhere: the bar renders as a
21
+ * stack of grey text and nothing says why. Setting properties on an element is
22
+ * not inline style and is never blocked, which is also how the host below has
23
+ * always been positioned.
24
+ */
25
+
26
+ import { PROFILER_PATH } from './Profiler.js'
27
+
28
+ /**
29
+ * The `<script>` to append to an HTML response.
30
+ *
31
+ * `token` is generated by the profiler (hex) and `nonce` by the response, so
32
+ * neither is user input; both are still put through `JSON.stringify` rather
33
+ * than interpolated raw, because "cannot contain a quote" is an invariant that
34
+ * holds until someone changes how one of them is made.
35
+ */
36
+ export function toolbarScript(token: string, nonce?: string): string {
37
+ const attribute = nonce === undefined ? '' : ` nonce="${nonce.replace(/"/g, '&quot;')}"`
38
+ const config = JSON.stringify({ token, path: PROFILER_PATH })
39
+ return `<script${attribute}>(${BAR})(${config})</script>`
40
+ }
41
+
42
+ /**
43
+ * The bar, as it runs in the browser.
44
+ *
45
+ * Kept on one indented block so what ships is readable in a page's source —
46
+ * someone will open the inspector and find this, and a minified blob would
47
+ * tell them nothing about where it came from.
48
+ */
49
+ const BAR = `function(c){
50
+ var host=document.createElement('div');
51
+ host.style.cssText='position:fixed;left:0;right:0;bottom:0;z-index:2147483000';
52
+ var root=host.attachShadow({mode:'closed'});
53
+ document.body.appendChild(host);
54
+ var bar=document.createElement('div');
55
+ bar.style.cssText='display:flex;align-items:stretch;background:#14161a;color:#e6e6e6;'
56
+ +'font:12px/1 ui-monospace,Menlo,monospace;border-top:1px solid #262a31;'
57
+ +'box-shadow:0 -2px 8px rgba(0,0,0,.4)';
58
+ root.appendChild(bar);
59
+ var page=c.path+'/'+c.token;
60
+ var COLOR={bad:'#ff7b72',warn:'#f0883e',ok:'#7ee787'};
61
+ function cell(label,value,tone,plain){
62
+ var node=document.createElement('div');
63
+ node.style.cssText='padding:7px 11px;white-space:nowrap;'
64
+ +(plain?'flex:1;overflow:hidden;text-overflow:ellipsis;cursor:default'
65
+ :'border-right:1px solid #21252c;cursor:pointer');
66
+ if(label){
67
+ var tag=document.createElement('span');
68
+ tag.style.cssText='color:#8b949e;margin-right:6px';
69
+ tag.textContent=label;
70
+ node.appendChild(tag);
71
+ }
72
+ var text=document.createElement('span');
73
+ if(tone){text.style.color=COLOR[tone];}
74
+ text.textContent=value;
75
+ node.appendChild(text);
76
+ if(!plain){
77
+ node.addEventListener('mouseenter',function(){node.style.background='#1b1f25'});
78
+ node.addEventListener('mouseleave',function(){node.style.background=''});
79
+ node.addEventListener('click',function(){window.open(page,'_blank')});
80
+ }
81
+ bar.appendChild(node);
82
+ }
83
+ function panel(data,name){
84
+ var value=data&&data[name];
85
+ return (value&&typeof value==='object')?value:null;
86
+ }
87
+ fetch(page+'.json',{headers:{accept:'application/json'}})
88
+ .then(function(r){return r.ok?r.json():null})
89
+ .then(function(p){
90
+ if(!p){host.remove();return}
91
+ var status=Number(p.status||0);
92
+ cell('',String(status),status>=500?'bad':status>=400?'warn':'ok');
93
+ cell('time',Number(p.duration||0).toFixed(1)+' ms');
94
+ var db=panel(p.data,'database');
95
+ if(db){
96
+ var total=Number(db.total||0);
97
+ cell('db',total+' \u00b7 '+Number(db.duration||0).toFixed(1)+' ms',total>=50?'warn':'');
98
+ }
99
+ var mem=panel(p.data,'memory');
100
+ if(mem){cell('mem',Number(mem.usedMb||0).toFixed(1)+' MB');}
101
+ var err=panel(p.data,'exception');
102
+ if(err&&typeof err.message==='string'){cell('',err.message,'bad');}
103
+ cell('',String(p.url||''),'',true);
104
+ })
105
+ .catch(function(){host.remove()});
106
+ }`
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What the profiler keeps about one request.
3
+ *
4
+ * Shaped after Symfony's profiler rather than Laravel's debugbar, which is a
5
+ * decision about where the data lives: a token per request, an endpoint that
6
+ * serves it, and a history. Inlining it in the page is simpler until the
7
+ * request you need to look at was a POST that redirected — and then the data
8
+ * is gone, which is exactly when you wanted it.
9
+ */
10
+
11
+ /** One collector's contribution to a profile. Plain data: it crosses a wire. */
12
+ export type CollectedData = Record<string, unknown>
13
+
14
+ /**
15
+ * Everything kept about one request.
16
+ *
17
+ * `token` identifies it. It travels back on `X-Debug-Token`, so a client — the
18
+ * toolbar, curl, a test — can ask for the profile of a request it just made.
19
+ */
20
+ export interface Profile {
21
+ token: string
22
+ /** When the request arrived, epoch milliseconds. */
23
+ time: number
24
+ method: string
25
+ url: string
26
+ status: number
27
+ /** Wall-clock milliseconds, first middleware to serialised response. */
28
+ duration: number
29
+ /** Keyed by collector name. */
30
+ data: Record<string, CollectedData>
31
+ }
32
+
33
+ /** A profile without its data — what a listing shows. */
34
+ export type ProfileSummary = Omit<Profile, 'data'> & {
35
+ /** The headline each collector wants on the bar: `{ database: '12 queries' }`. */
36
+ badges: Record<string, string>
37
+ }
38
+
39
+ /**
40
+ * What a collector is handed while a request runs.
41
+ *
42
+ * Deliberately not the HttpContext: a collector observes, and handing it the
43
+ * context would let it answer the request. What it needs is a place to put
44
+ * what it saw and the ability to know when the request ends.
45
+ */
46
+ export interface CollectionScope {
47
+ token: string
48
+ /** Called by the framework when the request is done. */
49
+ readonly startedAt: number
50
+ }
51
+
52
+ /**
53
+ * A source of data about a request.
54
+ *
55
+ * `start` runs when the request begins and answers a bag the collector will
56
+ * fill; `finish` turns that bag into what is stored. Two methods rather than
57
+ * one, because the interesting collectors — queries, logs, events — are fed by
58
+ * things happening DURING the request, not by looking at it afterwards.
59
+ */
60
+ export interface ProfilerCollector<Bag = unknown> {
61
+ /** Unique. It keys the data and names the panel. */
62
+ readonly name: string
63
+ /** Shown on the bar, or nothing when there is nothing worth a badge. */
64
+ badge?: (data: CollectedData) => string | undefined
65
+ start(scope: CollectionScope): Bag
66
+ finish(bag: Bag, scope: CollectionScope): CollectedData
67
+ }
68
+
69
+ /** Where profiles are kept. Bounded: a dev session is not an archive. */
70
+ export interface ProfileStore {
71
+ save(profile: Profile): void
72
+ find(token: string): Profile | undefined
73
+ recent(limit?: number): ProfileSummary[]
74
+ clear(): void
75
+ }
@@ -1361,6 +1361,22 @@ export class Router extends Macroable {
1361
1361
  return manifest
1362
1362
  }
1363
1363
 
1364
+ /**
1365
+ * Every NAMED route with its path pattern AND the HTTP methods it answers,
1366
+ * e.g. `{ 'users.update': { path: '/users/:id', methods: ['PUT'] } }` — what
1367
+ * a client needs to follow a route by name and pick its verb, the way
1368
+ * AdonisJS's Tuyau registry gives `route` + `methods`. `namedManifest()`
1369
+ * stays the lighter name → path map.
1370
+ */
1371
+ namedRoutes(): Record<string, { path: string; methods: string[] }> {
1372
+ if (this.#indexDirty) this.#buildIndex()
1373
+ const routes: Record<string, { path: string; methods: string[] }> = {}
1374
+ for (const [name, route] of this.#nameIndex) {
1375
+ routes[name] = { path: route.path, methods: [route.method] }
1376
+ }
1377
+ return routes
1378
+ }
1379
+
1364
1380
  /**
1365
1381
  * Generate a TypeScript source string typing every named route and its
1366
1382
  * params (AdonisJS `generateTypes`) — a `RouteName` union plus a
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@c9up/ream/stubs` — the stub engine, and where this package keeps its own.
3
+ *
4
+ * Every Ream package exports a `stubsRoot` from this subpath: it is what
5
+ * `codemods.makeUsingStub(stubsRoot, 'make/controller.stub')` takes, and what
6
+ * `ream eject --pkg` reads to copy a template into the application.
7
+ */
8
+
9
+ import * as path from 'node:path'
10
+
11
+ export * from './template.js'
12
+
13
+ /** `<package>/stubs` — resolved from this file, so it survives the build. */
14
+ export const stubsRoot = path.join(import.meta.dirname, '..', '..', 'stubs')
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The stub template engine.
3
+ *
4
+ * A port of the one upstream uses (tempura), grammar for grammar: comments,
5
+ * declared props, inline variables, conditionals, loops, custom blocks, and
6
+ * the two interpolation forms. A template is compiled into a JavaScript
7
+ * function once and then called — the same design, for the same reason: a
8
+ * tree-walking interpreter re-reads the template on every render, and a stub
9
+ * is rendered in a loop by the generators.
10
+ *
11
+ * {{! a comment }}
12
+ * {{#expect name, entity }}
13
+ * {{#var plural = name + 's' }}
14
+ * {{#if entity.isModel }}…{{#elif entity.isView }}…{{#else}}…{{/if}}
15
+ * {{#each items as item, index }}…{{/each}}
16
+ * {{ name }} the value
17
+ * {{{ name }}} the value, unescaped
18
+ *
19
+ * NAMED DEVIATION — upstream escapes HTML in `{{ }}` and leaves `{{{ }}}`
20
+ * raw, because its templates are web pages. Ours are SOURCE FILES: escaping
21
+ * `"` into `&quot;` there produces code that does not parse, and the mistake
22
+ * is silent. So `{{ }}` does not escape by default, `{{{ }}}` is accepted as
23
+ * the same thing (a stub written for upstream keeps working), and an `escape`
24
+ * option turns it back on for anyone templating markup.
25
+ *
26
+ * `new Function` compiles the result. That is how upstream does it too, and
27
+ * the trust boundary is the same: a stub ships inside a package whose
28
+ * `configure` hook is already executing.
29
+ */
30
+
31
+ /** What a template may read. Scalars, lists and nested records. */
32
+ export type StubValue =
33
+ | string
34
+ | number
35
+ | boolean
36
+ | null
37
+ | undefined
38
+ | StubValue[]
39
+ | { [key: string]: StubValue }
40
+
41
+ export type StubState = Record<string, StubValue>
42
+
43
+ /** A custom block — `{{#myBlock key=value }}` calls it with those arguments. */
44
+ export type StubBlock = (args: Record<string, StubValue>) => string
45
+
46
+ export interface TemplateOptions {
47
+ /** Names the template may read even when the state does not carry them. */
48
+ props?: string[]
49
+ /** Custom `{{#name}}` blocks. */
50
+ blocks?: Record<string, StubBlock>
51
+ /** Applied to every `{{ }}`. Identity by default — see the header. */
52
+ escape?: (value: unknown) => string
53
+ }
54
+
55
+ /** Raised when a template cannot be read, naming what is wrong with it. */
56
+ export class StubSyntaxError extends Error {
57
+ readonly code = 'E_REAM_STUB_SYNTAX'
58
+ constructor(message: string) {
59
+ super(message)
60
+ this.name = 'StubSyntaxError'
61
+ }
62
+ }
63
+
64
+ /** HTML escaping, for a stub that does template markup. */
65
+ export function escapeHtml(value: unknown): string {
66
+ const text = value === null || value === undefined ? '' : String(value)
67
+ return text.replace(/[&"<>]/g, (char) => {
68
+ if (char === '&') return '&amp;'
69
+ if (char === '"') return '&quot;'
70
+ if (char === '<') return '&lt;'
71
+ return '&gt;'
72
+ })
73
+ }
74
+
75
+ /** The default: a source file wants its quotes and its angle brackets back. */
76
+ function asIs(value: unknown): string {
77
+ return value === null || value === undefined ? '' : String(value)
78
+ }
79
+
80
+ const CURLY = /{{{?\s*([\s\S]*?)\s*}}}?/g
81
+ const BLOCK = /^#\s*(\w[\w\d]*)\s*([\s\S]*)/
82
+ const ARGS = /([a-zA-Z$_][^\s=]*)\s*=\s*((["`'])(?:(?=(\\?))\4.)*?\3|{[^}]*}|\[[^\]]*]|\S+)/g
83
+
84
+ /**
85
+ * Compile a template into the body of a function.
86
+ *
87
+ * Kept separate from `compile` so a test can read what a template became —
88
+ * the generated source is the only place a subtle mistake shows itself.
89
+ */
90
+ export function generate(input: string, options: TemplateOptions = {}): string {
91
+ const blocks = options.blocks ?? {}
92
+ const declared = new Set<string>(options.props ?? [])
93
+ const stack: string[] = []
94
+
95
+ let body = ''
96
+ let literal = ''
97
+ let last = 0
98
+ CURLY.lastIndex = 0
99
+
100
+ /** Flush the literal text collected since the last tag. */
101
+ const flush = (): void => {
102
+ if (literal.length > 0) {
103
+ body += `${body ? 'x+=' : '='}\`${literal}\`;`
104
+ } else if (body.length === 0) {
105
+ body = '="";'
106
+ }
107
+ literal = ''
108
+ }
109
+
110
+ let match: RegExpExecArray | null = CURLY.exec(input)
111
+ while (match !== null) {
112
+ literal += escapeForTemplate(input.slice(last, match.index))
113
+ last = match.index + match[0].length
114
+
115
+ const inner = (match[1] ?? '').trim()
116
+ const first = inner.charAt(0)
117
+
118
+ if (first === '!') {
119
+ // A comment. It leaves no trace in the output.
120
+ } else if (first === '#') {
121
+ flush()
122
+ const parsed = BLOCK.exec(inner)
123
+ if (parsed === null) {
124
+ throw new StubSyntaxError(`Unreadable block: {{${inner}}}`)
125
+ }
126
+ const action = parsed[1] ?? ''
127
+ const rest = (parsed[2] ?? '').trim()
128
+ body += openBlock(action, rest, stack, declared, blocks)
129
+ } else if (first === '/') {
130
+ const closing = inner.slice(1).trim()
131
+ const opened = stack.pop()
132
+ flush()
133
+ if (opened === undefined) {
134
+ throw new StubSyntaxError(`Closed "${closing}" with nothing open.`)
135
+ }
136
+ if (opened !== closing) {
137
+ throw new StubSyntaxError(`Expected to close "${opened}"; closed "${closing}" instead.`)
138
+ }
139
+ body += '}'
140
+ } else {
141
+ // Both forms interpolate the same way — see the header.
142
+ literal += `\${$$e(${inner})}`
143
+ }
144
+
145
+ match = CURLY.exec(input)
146
+ }
147
+
148
+ if (stack.length > 0) {
149
+ throw new StubSyntaxError(`Unterminated "${stack.pop()}" block.`)
150
+ }
151
+ if (last < input.length) {
152
+ literal += escapeForTemplate(input.slice(last))
153
+ }
154
+ flush()
155
+
156
+ const bindings = declared.size > 0 ? `{${[...declared].join()}}=$$s,x` : ' x'
157
+ return `var${bindings}${body}return x`
158
+ }
159
+
160
+ function openBlock(
161
+ action: string,
162
+ rest: string,
163
+ stack: string[],
164
+ declared: Set<string>,
165
+ blocks: Record<string, StubBlock>,
166
+ ): string {
167
+ if (action === 'expect') {
168
+ // Names the template reads. Declaring them is what lets an unknown one
169
+ // fail as `x is not defined` rather than as `undefined` in the output.
170
+ for (const key of rest.split(/[\n\r\s\t]*,[\n\r\s\t]*/g)) {
171
+ if (key !== '') declared.add(key)
172
+ }
173
+ return ''
174
+ }
175
+
176
+ if (action === 'var') {
177
+ const at = rest.indexOf('=')
178
+ if (at === -1) {
179
+ throw new StubSyntaxError(`{{#var}} needs an assignment: {{#var ${rest}}}`)
180
+ }
181
+ const name = rest.slice(0, at).trim()
182
+ const value = rest
183
+ .slice(at + 1)
184
+ .trim()
185
+ .replace(/;$/, '')
186
+ return `var ${name}=${value};`
187
+ }
188
+
189
+ if (action === 'each') {
190
+ stack.push(action)
191
+ const at = rest.indexOf(' as ')
192
+ if (at === -1) {
193
+ return `for(var i=0,$$a=${rest};i<$$a.length;i++){`
194
+ }
195
+ const list = rest.slice(0, at).trim()
196
+ const [item = 'item', index = 'i'] = rest
197
+ .slice(at + 4)
198
+ .trim()
199
+ .replace(/[()\s]/g, '')
200
+ .split(',')
201
+ return `for(var ${index}=0,${item},$$a=${list};${index}<$$a.length;${index}++){${item}=$$a[${index}];`
202
+ }
203
+
204
+ if (action === 'if') {
205
+ stack.push(action)
206
+ return `if(${rest}){`
207
+ }
208
+ if (action === 'elif') return `}else if(${rest}){`
209
+ if (action === 'else') return '}else{'
210
+
211
+ if (action in blocks) {
212
+ // `{{#note tone="warn" }}` — the arguments become one object.
213
+ const args: string[] = []
214
+ let found: RegExpExecArray | null = ARGS.exec(rest)
215
+ while (found !== null) {
216
+ args.push(`${found[1]}:${found[2]}`)
217
+ found = ARGS.exec(rest)
218
+ }
219
+ ARGS.lastIndex = 0
220
+ return `x+=$$b.${action}(${args.length > 0 ? `{${args.join()}}` : '{}'});`
221
+ }
222
+
223
+ throw new StubSyntaxError(
224
+ `Unknown "${action}" block. Known: expect, var, if, elif, else, each${
225
+ Object.keys(blocks).length > 0 ? `, ${Object.keys(blocks).join(', ')}` : ''
226
+ }.`,
227
+ )
228
+ }
229
+
230
+ /** Protect the literal text from the template literal it is about to live in. */
231
+ function escapeForTemplate(text: string): string {
232
+ return text.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
233
+ }
234
+
235
+ /** Compile a template into a render function. */
236
+ export function compile(
237
+ input: string,
238
+ options: TemplateOptions = {},
239
+ ): (state: StubState) => string {
240
+ const source = generate(input, options)
241
+ let render: (escaper: (value: unknown) => string, blocks: unknown, state: StubState) => string
242
+ try {
243
+ // eslint-disable-next-line no-new-func -- the whole point of a compiler
244
+ render = new Function('$$e', '$$b', '$$s', source) as typeof render
245
+ } catch (error) {
246
+ throw new StubSyntaxError(
247
+ `This stub does not compile: ${error instanceof Error ? error.message : String(error)}`,
248
+ )
249
+ }
250
+ const escaper = options.escape ?? asIs
251
+ const blocks = options.blocks ?? {}
252
+ return (state: StubState) => render(escaper, blocks, state)
253
+ }
254
+
255
+ /** Compile and render in one go. */
256
+ export function render(
257
+ input: string,
258
+ state: StubState = {},
259
+ options: TemplateOptions = {},
260
+ ): string {
261
+ return compile(input, { props: Object.keys(state), ...options })(state)
262
+ }
@@ -0,0 +1,29 @@
1
+ ---
2
+ to: commands/{{ fileName }}.ts
3
+ ---
4
+ import { BaseCommand, flags } from '@c9up/ream/console'
5
+ import type { CommandOptions } from '@c9up/ream/console'
6
+
7
+ export default class {{ className }} extends BaseCommand {
8
+ static commandName = '{{ name }}'
9
+ static description = 'TODO describe what this command does'
10
+
11
+ /**
12
+ * `startApp` boots providers and the container before `run()`. Off by
13
+ * default: a command that only touches the filesystem has no
14
+ * reason to open a database connection. Turn it on to reach `this.app`.
15
+ */
16
+ static options: CommandOptions = { startApp: false }
17
+
18
+ @flags.boolean({ description: 'Report what would happen without doing it' })
19
+ declare dryRun: boolean
20
+
21
+ async run(): Promise<void> {
22
+ if (this.dryRun) {
23
+ this.logger.info('Dry run — nothing was changed.')
24
+ return
25
+ }
26
+
27
+ this.logger.success('{{ name }} ran.')
28
+ }
29
+ }