@c9up/ream 0.2.26 → 0.2.28

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 (170) hide show
  1. package/README.md +1 -0
  2. package/dist/HttpKernel.d.ts +5 -1
  3. package/dist/HttpKernel.d.ts.map +1 -1
  4. package/dist/HttpKernel.js +6 -6
  5. package/dist/HttpKernel.js.map +1 -1
  6. package/dist/Ignitor.d.ts +3 -1
  7. package/dist/Ignitor.d.ts.map +1 -1
  8. package/dist/Ignitor.js +94 -14
  9. package/dist/Ignitor.js.map +1 -1
  10. package/dist/console/prompts.d.ts.map +1 -1
  11. package/dist/console/prompts.js +34 -46
  12. package/dist/console/prompts.js.map +1 -1
  13. package/dist/console/selection.d.ts +54 -0
  14. package/dist/console/selection.d.ts.map +1 -0
  15. package/dist/console/selection.js +235 -0
  16. package/dist/console/selection.js.map +1 -0
  17. package/dist/dev/fullReload.d.ts +35 -10
  18. package/dist/dev/fullReload.d.ts.map +1 -1
  19. package/dist/dev/fullReload.js +55 -14
  20. package/dist/dev/fullReload.js.map +1 -1
  21. package/dist/dev/hotWatcher.d.ts +19 -3
  22. package/dist/dev/hotWatcher.d.ts.map +1 -1
  23. package/dist/dev/hotWatcher.js +168 -18
  24. package/dist/dev/hotWatcher.js.map +1 -1
  25. package/dist/dev/readyBanner.d.ts +45 -0
  26. package/dist/dev/readyBanner.d.ts.map +1 -0
  27. package/dist/dev/readyBanner.js +72 -0
  28. package/dist/dev/readyBanner.js.map +1 -0
  29. package/dist/dev/shortcuts.d.ts +41 -0
  30. package/dist/dev/shortcuts.d.ts.map +1 -0
  31. package/dist/dev/shortcuts.js +142 -0
  32. package/dist/dev/shortcuts.js.map +1 -0
  33. package/dist/errors/prettyPrintError.d.ts.map +1 -1
  34. package/dist/errors/prettyPrintError.js +5 -8
  35. package/dist/errors/prettyPrintError.js.map +1 -1
  36. package/dist/errors/renderError.d.ts +46 -0
  37. package/dist/errors/renderError.d.ts.map +1 -0
  38. package/dist/errors/renderError.js +197 -0
  39. package/dist/errors/renderError.js.map +1 -0
  40. package/dist/hot.js +12 -2
  41. package/dist/hot.js.map +1 -1
  42. package/dist/index.d.ts +4 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +3 -2
  45. package/dist/index.js.map +1 -1
  46. package/dist/native/generated.d.ts +34 -0
  47. package/dist/native/generated.d.ts.map +1 -1
  48. package/dist/profiler/MemoryProfileStore.d.ts +23 -0
  49. package/dist/profiler/MemoryProfileStore.d.ts.map +1 -0
  50. package/dist/profiler/MemoryProfileStore.js +70 -0
  51. package/dist/profiler/MemoryProfileStore.js.map +1 -0
  52. package/dist/profiler/Profiler.d.ts +56 -0
  53. package/dist/profiler/Profiler.d.ts.map +1 -0
  54. package/dist/profiler/Profiler.js +162 -0
  55. package/dist/profiler/Profiler.js.map +1 -0
  56. package/dist/profiler/collectors.d.ts +105 -0
  57. package/dist/profiler/collectors.d.ts.map +1 -0
  58. package/dist/profiler/collectors.js +164 -0
  59. package/dist/profiler/collectors.js.map +1 -0
  60. package/dist/profiler/endpoint.d.ts +27 -0
  61. package/dist/profiler/endpoint.d.ts.map +1 -0
  62. package/dist/profiler/endpoint.js +79 -0
  63. package/dist/profiler/endpoint.js.map +1 -0
  64. package/dist/profiler/index.d.ts +26 -0
  65. package/dist/profiler/index.d.ts.map +1 -0
  66. package/dist/profiler/index.js +41 -0
  67. package/dist/profiler/index.js.map +1 -0
  68. package/dist/profiler/middleware.d.ts +17 -0
  69. package/dist/profiler/middleware.d.ts.map +1 -0
  70. package/dist/profiler/middleware.js +79 -0
  71. package/dist/profiler/middleware.js.map +1 -0
  72. package/dist/profiler/render.d.ts +23 -0
  73. package/dist/profiler/render.d.ts.map +1 -0
  74. package/dist/profiler/render.js +141 -0
  75. package/dist/profiler/render.js.map +1 -0
  76. package/dist/profiler/toolbar.d.ts +34 -0
  77. package/dist/profiler/toolbar.d.ts.map +1 -0
  78. package/dist/profiler/toolbar.js +104 -0
  79. package/dist/profiler/toolbar.js.map +1 -0
  80. package/dist/profiler/types.d.ts +70 -0
  81. package/dist/profiler/types.d.ts.map +1 -0
  82. package/dist/profiler/types.js +11 -0
  83. package/dist/profiler/types.js.map +1 -0
  84. package/dist/router/Router.d.ts +11 -0
  85. package/dist/router/Router.d.ts.map +1 -1
  86. package/dist/router/Router.js +16 -0
  87. package/dist/router/Router.js.map +1 -1
  88. package/dist/server/Server.d.ts +37 -0
  89. package/dist/server/Server.d.ts.map +1 -1
  90. package/dist/server/Server.js +30 -0
  91. package/dist/server/Server.js.map +1 -1
  92. package/dist/session/Session.d.ts +33 -0
  93. package/dist/session/Session.d.ts.map +1 -1
  94. package/dist/session/Session.js +31 -2
  95. package/dist/session/Session.js.map +1 -1
  96. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  97. package/dist/session/SessionMiddleware.js +24 -4
  98. package/dist/session/SessionMiddleware.js.map +1 -1
  99. package/dist/session/drivers/CookieDriver.d.ts +12 -3
  100. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  101. package/dist/session/drivers/CookieDriver.js +20 -6
  102. package/dist/session/drivers/CookieDriver.js.map +1 -1
  103. package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
  104. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  105. package/dist/session/drivers/DatabaseDriver.js +6 -0
  106. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  107. package/dist/session/drivers/FileDriver.d.ts +8 -0
  108. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  109. package/dist/session/drivers/FileDriver.js +85 -11
  110. package/dist/session/drivers/FileDriver.js.map +1 -1
  111. package/dist/session/drivers/MemoryDriver.d.ts +2 -0
  112. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  113. package/dist/session/drivers/MemoryDriver.js +9 -0
  114. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  115. package/dist/session/drivers/RedisDriver.d.ts +2 -0
  116. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  117. package/dist/session/drivers/RedisDriver.js +6 -0
  118. package/dist/session/drivers/RedisDriver.js.map +1 -1
  119. package/dist/session/errors.d.ts +10 -0
  120. package/dist/session/errors.d.ts.map +1 -1
  121. package/dist/session/errors.js +12 -0
  122. package/dist/session/errors.js.map +1 -1
  123. package/events.darwin-arm64.node +0 -0
  124. package/events.darwin-x64.node +0 -0
  125. package/events.linux-arm64-gnu.node +0 -0
  126. package/events.linux-x64-gnu.node +0 -0
  127. package/events.win32-x64-msvc.node +0 -0
  128. package/index.darwin-arm64.node +0 -0
  129. package/index.darwin-x64.node +0 -0
  130. package/index.linux-arm64-gnu.node +0 -0
  131. package/index.linux-x64-gnu.node +0 -0
  132. package/index.win32-x64-msvc.node +0 -0
  133. package/package.json +1 -1
  134. package/scheduler.darwin-arm64.node +0 -0
  135. package/scheduler.darwin-x64.node +0 -0
  136. package/scheduler.linux-arm64-gnu.node +0 -0
  137. package/scheduler.linux-x64-gnu.node +0 -0
  138. package/scheduler.win32-x64-msvc.node +0 -0
  139. package/src/HttpKernel.ts +12 -8
  140. package/src/Ignitor.ts +109 -15
  141. package/src/console/prompts.ts +45 -64
  142. package/src/console/selection.ts +274 -0
  143. package/src/dev/fullReload.ts +57 -15
  144. package/src/dev/hotWatcher.ts +165 -21
  145. package/src/dev/readyBanner.ts +84 -0
  146. package/src/dev/shortcuts.ts +158 -0
  147. package/src/errors/prettyPrintError.ts +5 -5
  148. package/src/errors/renderError.ts +230 -0
  149. package/src/hot.ts +12 -2
  150. package/src/index.ts +11 -1
  151. package/src/native/generated.ts +38 -0
  152. package/src/profiler/MemoryProfileStore.ts +75 -0
  153. package/src/profiler/Profiler.ts +182 -0
  154. package/src/profiler/collectors.ts +230 -0
  155. package/src/profiler/endpoint.ts +98 -0
  156. package/src/profiler/index.ts +56 -0
  157. package/src/profiler/middleware.ts +83 -0
  158. package/src/profiler/render.ts +173 -0
  159. package/src/profiler/toolbar.ts +106 -0
  160. package/src/profiler/types.ts +75 -0
  161. package/src/router/Router.ts +16 -0
  162. package/src/server/Server.ts +49 -0
  163. package/src/session/Session.ts +56 -2
  164. package/src/session/SessionMiddleware.ts +23 -4
  165. package/src/session/drivers/CookieDriver.ts +21 -6
  166. package/src/session/drivers/DatabaseDriver.ts +10 -0
  167. package/src/session/drivers/FileDriver.ts +91 -11
  168. package/src/session/drivers/MemoryDriver.ts +9 -0
  169. package/src/session/drivers/RedisDriver.ts +7 -0
  170. package/src/session/errors.ts +17 -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
@@ -6,6 +6,7 @@
6
6
  * - server.errorHandler(() => import('#exceptions/handler')) — custom exception handler
7
7
  */
8
8
 
9
+ import { ReamError } from '../errors/ReamError.js'
9
10
  import type { ExceptionHandler } from '../http/Exception.js'
10
11
  import type { HttpContext } from '../http/HttpContext.js'
11
12
  import type { MiddlewareFunction } from '../middleware/Pipeline.js'
@@ -32,12 +33,30 @@ export type MiddlewareClassConstructor = new (...args: never[]) => MiddlewareCla
32
33
  /** What server.use() and router.use() accept: lazy imports or direct functions. */
33
34
  export type MiddlewareEntry = LazyImport<MiddlewareClassConstructor> | MiddlewareFunction
34
35
 
36
+ /** One unary gRPC call, as the native server hands it over. */
37
+ export interface GrpcCall {
38
+ /** `/package.Service/Method`, as it arrived. */
39
+ path: string
40
+ /** Request metadata, header names lowercased. */
41
+ metadata: Record<string, string>
42
+ /** The request message, still encoded. */
43
+ message: Uint8Array
44
+ /** Resolved client IP, by the same trusted-proxy rules as `request.ip()`. */
45
+ ip: string
46
+ }
47
+
48
+ /** A reply message, still encoded — or a gRPC status code instead of one. */
49
+ export type GrpcReply = { message: Uint8Array } | { code: number; details: string }
50
+
51
+ export type GrpcHandler = (call: GrpcCall) => Promise<GrpcReply>
52
+
35
53
  export class Server {
36
54
  #router: Router
37
55
  #serverMiddleware: MiddlewareFunction[] = []
38
56
  #errorHandlerImporter?: LazyImport<ErrorHandlerClass>
39
57
  #resolvedErrorHandler?: ExceptionHandler
40
58
  #trustedProxies: string[] = []
59
+ #grpcHandler?: GrpcHandler
41
60
 
42
61
  constructor(router: Router) {
43
62
  this.#router = router
@@ -65,6 +84,36 @@ export class Server {
65
84
  return this.#trustedProxies
66
85
  }
67
86
 
87
+ /**
88
+ * Serve gRPC on the HTTP port. Once a handler is set, a request whose
89
+ * content-type is `application/grpc*` is a gRPC call handed to it — ahead
90
+ * of the router and the middleware, which never see it. The native server
91
+ * still applies its rate limiter and security filter first. Without a
92
+ * handler such a request stays an ordinary HTTP request.
93
+ *
94
+ * No AdonisJS counterpart: it serves on Node's `http.Server`, where gRPC
95
+ * would attach a library of its own. Here the native server owns the
96
+ * socket, so the gRPC branch is part of it, and this is how a package
97
+ * reaches it — `@c9up/neutrino` registers its dispatcher here.
98
+ *
99
+ * One handler per server: a second one throws. Two packages both serving
100
+ * gRPC is a conflict to resolve, not to settle by load order.
101
+ */
102
+ grpc(handler: GrpcHandler): this {
103
+ if (this.#grpcHandler) {
104
+ throw new ReamError('E_SERVER_GRPC_ALREADY_SET', 'A gRPC handler is already registered.', {
105
+ hint: 'Only one package can serve gRPC on the HTTP port.',
106
+ })
107
+ }
108
+ this.#grpcHandler = handler
109
+ return this
110
+ }
111
+
112
+ /** The gRPC handler, if one is set (consumed by Ignitor at boot). */
113
+ getGrpcHandler(): GrpcHandler | undefined {
114
+ return this.#grpcHandler
115
+ }
116
+
68
117
  /**
69
118
  * Register global server middleware.
70
119
  * Accepts lazy imports of middleware classes (AdonisJS pattern):
@@ -11,7 +11,11 @@
11
11
  */
12
12
 
13
13
  import type { SessionRedisClientSource } from './drivers/RedisDriver.js'
14
- import { E_SESSION_NOT_READY, E_SESSION_TAGGING_NOT_SUPPORTED } from './errors.js'
14
+ import {
15
+ E_SESSION_NOT_READY,
16
+ E_SESSION_STORE_NOT_CONDITIONAL,
17
+ E_SESSION_TAGGING_NOT_SUPPORTED,
18
+ } from './errors.js'
15
19
  import { ReadOnlyValuesStore } from './ReadOnlyValuesStore.js'
16
20
 
17
21
  export interface SessionDriver {
@@ -26,8 +30,27 @@ export interface SessionDriver {
26
30
  */
27
31
  read(sessionId: string): Promise<Record<string, unknown> | null>
28
32
  write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void>
33
+ /**
34
+ * Write only over an entry that is still there, in one step; `false` when it
35
+ * is gone, and nothing written.
36
+ *
37
+ * What `commit()` uses for a session that existed when the request came in.
38
+ * A request still running when the same session was logged out, rotated or
39
+ * destroyed everywhere would otherwise write its stale copy back — the user
40
+ * id included — and undo the logout. Upstream writes unconditionally and has
41
+ * that race; this is the named deviation that closes it.
42
+ *
43
+ * Required, and checked when the store is attached: a read followed by a
44
+ * write leaves the race open between the two calls.
45
+ */
46
+ update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean>
29
47
  destroy(sessionId: string): Promise<void>
30
48
  touch(sessionId: string, ttl: number): Promise<void>
49
+ /**
50
+ * The session lives with the client, not here: nothing on the server can
51
+ * delete a copy of it. Only the cookie store sets it.
52
+ */
53
+ readonly stateless?: boolean
31
54
  }
32
55
 
33
56
  /** One session belonging to a tagged user (AdonisJS `TaggedSession`). */
@@ -179,6 +202,8 @@ export class Session {
179
202
  #ttl = 0
180
203
  /** Whether {@link initiate} already ran, so a second call is a no-op. */
181
204
  #initiated = false
205
+ /** The entry vanished while this request ran — see {@link wasLost}. */
206
+ #lost = false
182
207
 
183
208
  constructor(sessionId: string, data: Record<string, unknown> = {}) {
184
209
  this.#sessionId = sessionId
@@ -228,6 +253,15 @@ export class Session {
228
253
  return this.#regenerated
229
254
  }
230
255
 
256
+ /**
257
+ * @internal — the stored entry was gone by the time {@link commit} ran: it
258
+ * was logged out, rotated or destroyed by another request meanwhile. Nothing
259
+ * was written, and the middleware must not hand the browser this id again.
260
+ */
261
+ wasLost(): boolean {
262
+ return this.#lost
263
+ }
264
+
231
265
  /** @internal — the id captured at request arrival; used to destroy the old driver entry post-regenerate. */
232
266
  originalSessionId(): string {
233
267
  return this.#originalSessionId
@@ -554,6 +588,11 @@ export class Session {
554
588
  * {@link initiate} / {@link commit} then have nothing to do.
555
589
  */
556
590
  setStore(driver: SessionDriver, options: { fresh: boolean; ttl: number }): void {
591
+ // A store written in JavaScript, or against the older contract, reaches
592
+ // here without the method the type requires.
593
+ if (typeof Reflect.get(driver, 'update') !== 'function') {
594
+ throw new E_SESSION_STORE_NOT_CONDITIONAL()
595
+ }
557
596
  this.#driver = driver
558
597
  this.#fresh = options.fresh
559
598
  this.#ttl = options.ttl
@@ -600,12 +639,27 @@ export class Session {
600
639
  return
601
640
  }
602
641
  if (this.#dirty) {
603
- await driver.write(this.#sessionId, this.toJSON(), this.#ttl)
642
+ if (this.#fresh) {
643
+ await driver.write(this.#sessionId, this.toJSON(), this.#ttl)
644
+ return
645
+ }
646
+ this.#lost = !(await driver.update(this.#sessionId, this.toJSON(), this.#ttl))
604
647
  return
605
648
  }
606
649
  if (!this.#fresh) await driver.touch(this.#sessionId, this.#ttl)
607
650
  }
608
651
 
652
+ /**
653
+ * Whether ending this session on the server ends every copy of it.
654
+ *
655
+ * False under the cookie store, where the session travels with the client:
656
+ * a logout replaces the cookie in one browser and cannot reach a copy. What
657
+ * an auth guard reads to refuse signing in on a store it cannot revoke.
658
+ */
659
+ get revocable(): boolean {
660
+ return this.#driver !== undefined && this.#driver.stateless !== true
661
+ }
662
+
609
663
  /**
610
664
  * Whether the store behind this session can tag sessions with a user
611
665
  * (AdonisJS `supportsTagging`). False under the cookie and file drivers.
@@ -79,7 +79,7 @@ export default class SessionMiddleware {
79
79
  'Cookie session driver requires a secret. Set session.secret in your config.',
80
80
  )
81
81
  }
82
- this.#cookieDriver = new CookieDriver(config.secret)
82
+ this.#cookieDriver = new CookieDriver(config.secret, this.#config.cookieName)
83
83
  this.#driver = this.#cookieDriver
84
84
  } else if (this.#config.driver === 'memory') {
85
85
  this.#driver = new MemoryDriver()
@@ -136,6 +136,9 @@ export default class SessionMiddleware {
136
136
 
137
137
  // Cookie driver: the cookie value IS the session data (encrypted)
138
138
  if (this.#cookieDriver) {
139
+ // When the data this request carries was last vouched for. See the
140
+ // re-emission below.
141
+ const readAt = Date.now()
139
142
  const data = await this.#driver.read(sessionId ?? '')
140
143
  sessionId = sessionId ?? generateSessionId()
141
144
  // `null` means the cookie carried nothing readable — an empty session,
@@ -162,8 +165,20 @@ export default class SessionMiddleware {
162
165
  // the cookie maxAge window slides forward (active users don't time out
163
166
  // mid-session). Caller opts in via SessionConfig.rolling.
164
167
  const shouldEmit = session.isDirty() || (rolling && hadIncomingCookie)
165
- if (shouldEmit) {
166
- const encoded = this.#cookieDriver.encode(session.toJSON())
168
+ // The re-emitted cookie lives `maxAge` from when this request READ the
169
+ // session, not from its response. Counted from the response, a request
170
+ // that outlasted a logout re-issued the signed-in data with a full new
171
+ // lifetime — past the window a revocation is kept for, after which the
172
+ // sign-in it carried was accepted again. Counted from the read, no cookie
173
+ // carrying a sign-in outlives the logout by more than `maxAge`. A session
174
+ // regenerated during the request (a login) is new data, so it counts from
175
+ // now; so does a session that came in with no cookie.
176
+ const issuedAt = hadIncomingCookie && !session.wasRegenerated() ? readAt : Date.now()
177
+ const remaining = issuedAt + maxAge * 1000 - Date.now()
178
+ // Expired while the request ran: nothing to hand back, and the browser
179
+ // keeps whatever cookie it was given since.
180
+ if (shouldEmit && remaining > 0) {
181
+ const encoded = this.#cookieDriver.encode(session.toJSON(), remaining / 1000)
167
182
  if (encoded.length > 4096) {
168
183
  process.stderr.write(
169
184
  `[session] Cookie session exceeds 4096 bytes (${encoded.length}). ` +
@@ -174,7 +189,7 @@ export default class SessionMiddleware {
174
189
  // Already encrypted by the cookie driver; packing it again would
175
190
  // only make the cookie bigger and change nothing.
176
191
  encode: false,
177
- maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
192
+ maxAge: this.#config.clearWithBrowser ? undefined : Math.ceil(remaining / 1000),
178
193
  path: '/',
179
194
  httpOnly: true,
180
195
  sameSite: 'lax',
@@ -216,6 +231,10 @@ export default class SessionMiddleware {
216
231
  // no server-side row (touch() is a no-op without a backing entry),
217
232
  // defeats HTTP caching, and leaks a Set-Cookie to CDNs. This mirrors
218
233
  // the cookie-driver path above (which already omits `isNew`).
234
+ // A session logged out or rotated by another request while this one ran
235
+ // was not written back (see `Session.wasLost`); handing the browser its id
236
+ // again would only point it at nothing, over the cookie it got since.
237
+ if (session.wasLost()) return
219
238
  if (session.isDirty() || regenerated || (rolling && hadIncomingCookie)) {
220
239
  // SIGNED, as AdonisJS signs it (`response.cookie(cookieName, id, …)`).
221
240
  // The id is 192 bits of entropy, so it was never guessable — what the