@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.
- package/README.md +1 -0
- package/dist/HttpKernel.d.ts +5 -1
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +6 -6
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +3 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +94 -14
- package/dist/Ignitor.js.map +1 -1
- package/dist/console/prompts.d.ts.map +1 -1
- package/dist/console/prompts.js +34 -46
- package/dist/console/prompts.js.map +1 -1
- package/dist/console/selection.d.ts +54 -0
- package/dist/console/selection.d.ts.map +1 -0
- package/dist/console/selection.js +235 -0
- package/dist/console/selection.js.map +1 -0
- package/dist/dev/fullReload.d.ts +35 -10
- package/dist/dev/fullReload.d.ts.map +1 -1
- package/dist/dev/fullReload.js +55 -14
- package/dist/dev/fullReload.js.map +1 -1
- package/dist/dev/hotWatcher.d.ts +19 -3
- package/dist/dev/hotWatcher.d.ts.map +1 -1
- package/dist/dev/hotWatcher.js +168 -18
- package/dist/dev/hotWatcher.js.map +1 -1
- package/dist/dev/readyBanner.d.ts +45 -0
- package/dist/dev/readyBanner.d.ts.map +1 -0
- package/dist/dev/readyBanner.js +72 -0
- package/dist/dev/readyBanner.js.map +1 -0
- package/dist/dev/shortcuts.d.ts +41 -0
- package/dist/dev/shortcuts.d.ts.map +1 -0
- package/dist/dev/shortcuts.js +142 -0
- package/dist/dev/shortcuts.js.map +1 -0
- package/dist/errors/prettyPrintError.d.ts.map +1 -1
- package/dist/errors/prettyPrintError.js +5 -8
- package/dist/errors/prettyPrintError.js.map +1 -1
- package/dist/errors/renderError.d.ts +46 -0
- package/dist/errors/renderError.d.ts.map +1 -0
- package/dist/errors/renderError.js +197 -0
- package/dist/errors/renderError.js.map +1 -0
- package/dist/hot.js +12 -2
- package/dist/hot.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/native/generated.d.ts +34 -0
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/profiler/MemoryProfileStore.d.ts +23 -0
- package/dist/profiler/MemoryProfileStore.d.ts.map +1 -0
- package/dist/profiler/MemoryProfileStore.js +70 -0
- package/dist/profiler/MemoryProfileStore.js.map +1 -0
- package/dist/profiler/Profiler.d.ts +56 -0
- package/dist/profiler/Profiler.d.ts.map +1 -0
- package/dist/profiler/Profiler.js +162 -0
- package/dist/profiler/Profiler.js.map +1 -0
- package/dist/profiler/collectors.d.ts +105 -0
- package/dist/profiler/collectors.d.ts.map +1 -0
- package/dist/profiler/collectors.js +164 -0
- package/dist/profiler/collectors.js.map +1 -0
- package/dist/profiler/endpoint.d.ts +27 -0
- package/dist/profiler/endpoint.d.ts.map +1 -0
- package/dist/profiler/endpoint.js +79 -0
- package/dist/profiler/endpoint.js.map +1 -0
- package/dist/profiler/index.d.ts +26 -0
- package/dist/profiler/index.d.ts.map +1 -0
- package/dist/profiler/index.js +41 -0
- package/dist/profiler/index.js.map +1 -0
- package/dist/profiler/middleware.d.ts +17 -0
- package/dist/profiler/middleware.d.ts.map +1 -0
- package/dist/profiler/middleware.js +79 -0
- package/dist/profiler/middleware.js.map +1 -0
- package/dist/profiler/render.d.ts +23 -0
- package/dist/profiler/render.d.ts.map +1 -0
- package/dist/profiler/render.js +141 -0
- package/dist/profiler/render.js.map +1 -0
- package/dist/profiler/toolbar.d.ts +34 -0
- package/dist/profiler/toolbar.d.ts.map +1 -0
- package/dist/profiler/toolbar.js +104 -0
- package/dist/profiler/toolbar.js.map +1 -0
- package/dist/profiler/types.d.ts +70 -0
- package/dist/profiler/types.d.ts.map +1 -0
- package/dist/profiler/types.js +11 -0
- package/dist/profiler/types.js.map +1 -0
- package/dist/router/Router.d.ts +11 -0
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +16 -0
- package/dist/router/Router.js.map +1 -1
- package/dist/server/Server.d.ts +37 -0
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +30 -0
- package/dist/server/Server.js.map +1 -1
- package/dist/session/Session.d.ts +33 -0
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +31 -2
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +24 -4
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/drivers/CookieDriver.d.ts +12 -3
- package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
- package/dist/session/drivers/CookieDriver.js +20 -6
- package/dist/session/drivers/CookieDriver.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
- package/dist/session/drivers/DatabaseDriver.js +6 -0
- package/dist/session/drivers/DatabaseDriver.js.map +1 -1
- package/dist/session/drivers/FileDriver.d.ts +8 -0
- package/dist/session/drivers/FileDriver.d.ts.map +1 -1
- package/dist/session/drivers/FileDriver.js +85 -11
- package/dist/session/drivers/FileDriver.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts +2 -0
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +9 -0
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +2 -0
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +6 -0
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/session/errors.d.ts +10 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +12 -0
- package/dist/session/errors.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/HttpKernel.ts +12 -8
- package/src/Ignitor.ts +109 -15
- package/src/console/prompts.ts +45 -64
- package/src/console/selection.ts +274 -0
- package/src/dev/fullReload.ts +57 -15
- package/src/dev/hotWatcher.ts +165 -21
- package/src/dev/readyBanner.ts +84 -0
- package/src/dev/shortcuts.ts +158 -0
- package/src/errors/prettyPrintError.ts +5 -5
- package/src/errors/renderError.ts +230 -0
- package/src/hot.ts +12 -2
- package/src/index.ts +11 -1
- package/src/native/generated.ts +38 -0
- package/src/profiler/MemoryProfileStore.ts +75 -0
- package/src/profiler/Profiler.ts +182 -0
- package/src/profiler/collectors.ts +230 -0
- package/src/profiler/endpoint.ts +98 -0
- package/src/profiler/index.ts +56 -0
- package/src/profiler/middleware.ts +83 -0
- package/src/profiler/render.ts +173 -0
- package/src/profiler/toolbar.ts +106 -0
- package/src/profiler/types.ts +75 -0
- package/src/router/Router.ts +16 -0
- package/src/server/Server.ts +49 -0
- package/src/session/Session.ts +56 -2
- package/src/session/SessionMiddleware.ts +23 -4
- package/src/session/drivers/CookieDriver.ts +21 -6
- package/src/session/drivers/DatabaseDriver.ts +10 -0
- package/src/session/drivers/FileDriver.ts +91 -11
- package/src/session/drivers/MemoryDriver.ts +9 -0
- package/src/session/drivers/RedisDriver.ts +7 -0
- 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('&', '&')
|
|
23
|
+
.replaceAll('<', '<')
|
|
24
|
+
.replaceAll('>', '>')
|
|
25
|
+
.replaceAll('"', '"')
|
|
26
|
+
.replaceAll("'", ''')
|
|
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, '"')}"`
|
|
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
|
+
}
|
package/src/router/Router.ts
CHANGED
|
@@ -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
|
package/src/server/Server.ts
CHANGED
|
@@ -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):
|
package/src/session/Session.ts
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { SessionRedisClientSource } from './drivers/RedisDriver.js'
|
|
14
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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 :
|
|
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
|