@c9up/ream 0.2.26 → 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.
- 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.map +1 -1
- package/dist/Ignitor.js +87 -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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.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/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/HttpKernel.ts +12 -8
- package/src/Ignitor.ts +95 -14
- 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 +7 -1
- 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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picking from a list with the arrow keys.
|
|
3
|
+
*
|
|
4
|
+
* Upstream's prompts are enquirer, so its selection prompts navigate: up and
|
|
5
|
+
* down move a pointer, space ticks a box, enter answers. Typing a number
|
|
6
|
+
* instead — which is what this package did — is the same prompt only in the
|
|
7
|
+
* sense that it eventually returns a value.
|
|
8
|
+
*
|
|
9
|
+
* Written here rather than taken from a package, for the reason lumen was
|
|
10
|
+
* written rather than `@poppinss/cliui` taken: a dev-time dependency is a
|
|
11
|
+
* dependency every application installs. What a selection prompt needs is
|
|
12
|
+
* bounded — raw mode, five keys, and redrawing in place — and it is all below.
|
|
13
|
+
*
|
|
14
|
+
* Only ever entered on a TTY. A prompt with nothing to read from is refused
|
|
15
|
+
* before this by the caller, which is what makes the loop here able to assume
|
|
16
|
+
* a terminal.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { stdin, stdout } from 'node:process'
|
|
20
|
+
import { clearLine, cursorTo, moveCursor } from 'node:readline'
|
|
21
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
22
|
+
import { colourise } from './ui.js'
|
|
23
|
+
|
|
24
|
+
/** One line of the list, as the renderer needs it. */
|
|
25
|
+
export interface SelectionItem {
|
|
26
|
+
label: string
|
|
27
|
+
hint?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SelectionOptions {
|
|
31
|
+
/** Printed above the list. */
|
|
32
|
+
title: string
|
|
33
|
+
items: readonly SelectionItem[]
|
|
34
|
+
/** Tick boxes and let several be picked. */
|
|
35
|
+
many?: boolean
|
|
36
|
+
/** Where the pointer starts, and what is ticked to begin with. */
|
|
37
|
+
selected?: readonly number[]
|
|
38
|
+
/** Type to narrow the list — the autocomplete case. */
|
|
39
|
+
filter?: boolean
|
|
40
|
+
/** How many lines of the list to show at once. */
|
|
41
|
+
limit?: number
|
|
42
|
+
/** Where the keys come from. Defaults to this process's stdin. */
|
|
43
|
+
input?: NodeJS.ReadStream
|
|
44
|
+
/** Where the list is drawn. Defaults to this process's stdout. */
|
|
45
|
+
output?: NodeJS.WriteStream
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the user did. Cancelling is not an empty answer, it is no answer. */
|
|
49
|
+
export type SelectionResult = { kind: 'picked'; indexes: number[] } | { kind: 'cancelled' }
|
|
50
|
+
|
|
51
|
+
const ARROW_UP = '\u001B[A'
|
|
52
|
+
const ARROW_DOWN = '\u001B[B'
|
|
53
|
+
const ARROW_LEFT = '\u001B[D'
|
|
54
|
+
const ARROW_RIGHT = '\u001B[C'
|
|
55
|
+
const ENTER = ['\r', '\n']
|
|
56
|
+
const CTRL_C = '\u0003'
|
|
57
|
+
const CTRL_D = '\u0004'
|
|
58
|
+
const ESCAPE = '\u001B'
|
|
59
|
+
const BACKSPACE = ['\u0008', '\u007F']
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the list until the user answers.
|
|
63
|
+
*
|
|
64
|
+
* Resolves with the chosen indexes, in the order they were ticked for a
|
|
65
|
+
* multiple — the order the user built them in is the order they expect back.
|
|
66
|
+
*/
|
|
67
|
+
export function runSelection(options: SelectionOptions): Promise<SelectionResult> {
|
|
68
|
+
if (options.items.length === 0) {
|
|
69
|
+
return Promise.reject(
|
|
70
|
+
new ReamError('E_CONSOLE_EMPTY_CHOICES', `"${options.title}" was asked with no options.`),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const input = options.input ?? stdin
|
|
75
|
+
const output = options.output ?? stdout
|
|
76
|
+
|
|
77
|
+
const state = {
|
|
78
|
+
// The pointer starts on the default rather than at the top: for a single
|
|
79
|
+
// choice that IS the default, since enter takes whatever it is on.
|
|
80
|
+
cursor: Math.max(0, options.selected?.[0] ?? 0),
|
|
81
|
+
ticked: new Set<number>(options.selected ?? []),
|
|
82
|
+
query: '',
|
|
83
|
+
/** Lines drawn last time, so the next draw can wipe exactly those. */
|
|
84
|
+
drawn: 0,
|
|
85
|
+
}
|
|
86
|
+
const limit = Math.max(1, options.limit ?? 10)
|
|
87
|
+
|
|
88
|
+
const visible = (): number[] => {
|
|
89
|
+
if (options.filter !== true || state.query === '') return options.items.map((_, i) => i)
|
|
90
|
+
const needle = state.query.toLowerCase()
|
|
91
|
+
return options.items
|
|
92
|
+
.map((_, index) => index)
|
|
93
|
+
.filter((index) => (options.items[index]?.label ?? '').toLowerCase().includes(needle))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new Promise<SelectionResult>((resolve, reject) => {
|
|
97
|
+
const finish = (result: SelectionResult): void => {
|
|
98
|
+
erase(output, state.drawn)
|
|
99
|
+
teardown()
|
|
100
|
+
resolve(result)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const onData = (chunk: Buffer | string): void => {
|
|
104
|
+
const key = chunk.toString()
|
|
105
|
+
const shown = visible()
|
|
106
|
+
|
|
107
|
+
if (key === CTRL_C || key === CTRL_D || key === ESCAPE) {
|
|
108
|
+
finish({ kind: 'cancelled' })
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (ENTER.includes(key)) {
|
|
113
|
+
if (options.many === true) {
|
|
114
|
+
finish({ kind: 'picked', indexes: [...state.ticked] })
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const index = shown[state.cursor]
|
|
118
|
+
// Enter on a filter that matches nothing answers nothing: there is no
|
|
119
|
+
// line under the pointer to take.
|
|
120
|
+
if (index === undefined) return
|
|
121
|
+
finish({ kind: 'picked', indexes: [index] })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (key === ARROW_UP) {
|
|
126
|
+
state.cursor = state.cursor === 0 ? shown.length - 1 : state.cursor - 1
|
|
127
|
+
} else if (key === ARROW_DOWN) {
|
|
128
|
+
state.cursor = state.cursor === shown.length - 1 ? 0 : state.cursor + 1
|
|
129
|
+
} else if (options.many === true && key === ' ') {
|
|
130
|
+
const index = shown[state.cursor]
|
|
131
|
+
if (index !== undefined) {
|
|
132
|
+
if (state.ticked.has(index)) state.ticked.delete(index)
|
|
133
|
+
else state.ticked.add(index)
|
|
134
|
+
}
|
|
135
|
+
} else if (options.many === true && (key === ARROW_LEFT || key === ARROW_RIGHT)) {
|
|
136
|
+
// Upstream's multiple ticks and unticks with left and right too.
|
|
137
|
+
const index = shown[state.cursor]
|
|
138
|
+
if (index !== undefined) {
|
|
139
|
+
if (key === ARROW_RIGHT) state.ticked.add(index)
|
|
140
|
+
else state.ticked.delete(index)
|
|
141
|
+
}
|
|
142
|
+
} else if (options.filter === true && BACKSPACE.includes(key)) {
|
|
143
|
+
state.query = state.query.slice(0, -1)
|
|
144
|
+
state.cursor = 0
|
|
145
|
+
} else if (options.filter === true && isTypable(key)) {
|
|
146
|
+
state.query += key
|
|
147
|
+
state.cursor = 0
|
|
148
|
+
} else {
|
|
149
|
+
// A key nothing is bound to must not redraw: a mouse event arrives as
|
|
150
|
+
// a burst of escape sequences, and redrawing on each one flickers.
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
draw()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const draw = (): void => {
|
|
158
|
+
erase(output, state.drawn)
|
|
159
|
+
const shown = visible()
|
|
160
|
+
if (state.cursor >= shown.length) state.cursor = Math.max(0, shown.length - 1)
|
|
161
|
+
|
|
162
|
+
const lines: string[] = [heading(options, state.query)]
|
|
163
|
+
const window = windowOf(shown.length, state.cursor, limit)
|
|
164
|
+
for (let row = window.start; row < window.end; row++) {
|
|
165
|
+
const index = shown[row]
|
|
166
|
+
if (index === undefined) continue
|
|
167
|
+
const item = options.items[index]
|
|
168
|
+
if (item === undefined) continue
|
|
169
|
+
lines.push(
|
|
170
|
+
renderItem(item, row === state.cursor, options.many === true, state.ticked.has(index)),
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
if (shown.length === 0) lines.push(colourise(' no match', 'dim'))
|
|
174
|
+
if (shown.length > window.end - window.start) {
|
|
175
|
+
lines.push(colourise(` … ${shown.length - (window.end - window.start)} more`, 'dim'))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const line of lines) output.write(`${line}\n`)
|
|
179
|
+
state.drawn = lines.length
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const teardown = (): void => {
|
|
183
|
+
input.removeListener('data', onData)
|
|
184
|
+
if (input.isTTY) input.setRawMode(false)
|
|
185
|
+
input.pause()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
if (!input.isTTY) {
|
|
190
|
+
throw new ReamError(
|
|
191
|
+
'E_CONSOLE_NOT_INTERACTIVE',
|
|
192
|
+
`Cannot prompt for "${options.title}" — stdin is not interactive.`,
|
|
193
|
+
{ hint: 'Pass the value as a flag, or script the answer with prompt.trap(name).' },
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
input.setRawMode(true)
|
|
197
|
+
input.resume()
|
|
198
|
+
input.on('data', onData)
|
|
199
|
+
draw()
|
|
200
|
+
} catch (error) {
|
|
201
|
+
teardown()
|
|
202
|
+
reject(error)
|
|
203
|
+
}
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The title line, with what has been typed so far when there is a filter. */
|
|
208
|
+
function heading(options: SelectionOptions, query: string): string {
|
|
209
|
+
const help =
|
|
210
|
+
options.many === true
|
|
211
|
+
? colourise(' (space to pick, enter to confirm)', 'dim')
|
|
212
|
+
: colourise(' (use arrow keys)', 'dim')
|
|
213
|
+
const typed = options.filter === true && query !== '' ? ` ${colourise(query, 'cyan')}` : ''
|
|
214
|
+
return `${colourise('?', 'green')} ${options.title}${typed}${help}`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* One line of the list.
|
|
219
|
+
*
|
|
220
|
+
* A pointer says where you are; a box, only on a multiple, says what is
|
|
221
|
+
* taken. Without the box a multiple has no way to show four ticks at once,
|
|
222
|
+
* which is the whole of what it is for.
|
|
223
|
+
*/
|
|
224
|
+
function renderItem(
|
|
225
|
+
item: SelectionItem,
|
|
226
|
+
underCursor: boolean,
|
|
227
|
+
many: boolean,
|
|
228
|
+
ticked: boolean,
|
|
229
|
+
): string {
|
|
230
|
+
const pointer = underCursor ? colourise('❯', 'cyan') : ' '
|
|
231
|
+
const box = many ? `${ticked ? colourise('◉', 'cyan') : '◯'} ` : ''
|
|
232
|
+
const label = underCursor ? colourise(item.label, 'cyan') : item.label
|
|
233
|
+
const hint = item.hint === undefined ? '' : colourise(` — ${item.hint}`, 'dim')
|
|
234
|
+
return `${pointer} ${box}${label}${hint}`
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Which slice of a long list to show.
|
|
239
|
+
*
|
|
240
|
+
* Kept around the cursor rather than paged: a pointer that walks off the
|
|
241
|
+
* bottom and reappears at the top of a new page loses the reader.
|
|
242
|
+
*/
|
|
243
|
+
function windowOf(total: number, cursor: number, limit: number): { start: number; end: number } {
|
|
244
|
+
if (total <= limit) return { start: 0, end: total }
|
|
245
|
+
const half = Math.floor(limit / 2)
|
|
246
|
+
const start = Math.min(Math.max(0, cursor - half), total - limit)
|
|
247
|
+
return { start, end: start + limit }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Wipe the lines drawn last time, so the next draw replaces them. */
|
|
251
|
+
function erase(output: NodeJS.WriteStream, lines: number): void {
|
|
252
|
+
if (lines === 0) return
|
|
253
|
+
for (let row = 0; row < lines; row++) {
|
|
254
|
+
moveCursor(output, 0, -1)
|
|
255
|
+
cursorTo(output, 0)
|
|
256
|
+
clearLine(output, 0)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Printable text, and not an escape sequence.
|
|
262
|
+
*
|
|
263
|
+
* A chunk, not a character: typing quickly — or pasting — delivers several at
|
|
264
|
+
* once, and a filter that took only single-character chunks silently dropped
|
|
265
|
+
* everything typed faster than the event loop.
|
|
266
|
+
*/
|
|
267
|
+
function isTypable(chunk: string): boolean {
|
|
268
|
+
if (chunk.length === 0) return false
|
|
269
|
+
for (const character of chunk) {
|
|
270
|
+
const code = character.codePointAt(0) ?? 0
|
|
271
|
+
if (code < 0x20 || code === 0x7f) return false
|
|
272
|
+
}
|
|
273
|
+
return true
|
|
274
|
+
}
|
package/src/dev/fullReload.ts
CHANGED
|
@@ -1,34 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* What the dev server says when a file changed.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Upstream's vocabulary, read off `@adonisjs/assembler`'s build rather than
|
|
5
|
+
* invented: a module the loader swapped in place is `invalidated <path>`, and
|
|
6
|
+
* one that could not be — so the process restarts — is `update <path>`. The
|
|
7
|
+
* word is green, the path is not, and that is the whole line.
|
|
8
|
+
*
|
|
9
|
+
* It used to carry the reason as well: which `hotHook` entry the file fell
|
|
10
|
+
* outside of. That read well the first time and was noise every time after,
|
|
11
|
+
* and it is not what upstream prints. What the reason existed for — a boot
|
|
12
|
+
* banner appearing twice with nothing to explain it — is answered by the line
|
|
13
|
+
* being there at all.
|
|
14
|
+
*
|
|
15
|
+
* The terminal is cleared first, as upstream clears it, so the logs after a
|
|
16
|
+
* restart are the only ones on screen. `ream dev --no-clear` turns it off —
|
|
17
|
+
* `ace serve --no-clear` is the same flag for the same reason.
|
|
9
18
|
*
|
|
10
19
|
* Kept apart from the loader entry so it can be tested: importing that entry
|
|
11
20
|
* registers module hooks, which a test cannot undo.
|
|
12
21
|
*/
|
|
13
22
|
|
|
23
|
+
import { ansiColors, silentColors, supportsColor } from '@c9up/lumen'
|
|
24
|
+
|
|
14
25
|
/** Why a change could not be swapped in place. */
|
|
15
26
|
export type ReloadReason = 'outside-boundaries' | 'restart-list'
|
|
16
27
|
|
|
17
28
|
/**
|
|
18
|
-
*
|
|
29
|
+
* Coloured only when the stream takes colour.
|
|
30
|
+
*
|
|
31
|
+
* Decided per call rather than once at import: this module is loaded by the
|
|
32
|
+
* hot entry long before anything knows where its output goes, and a cached
|
|
33
|
+
* answer would be the wrong one for a piped run.
|
|
34
|
+
*/
|
|
35
|
+
function colors(): ReturnType<typeof ansiColors> {
|
|
36
|
+
return supportsColor(process.stdout) ? ansiColors() : silentColors()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wipe the terminal, the way upstream does before a restart.
|
|
41
|
+
*
|
|
42
|
+
* `\u001Bc` is the full reset upstream writes, not a scroll — the point is
|
|
43
|
+
* that what follows is the only thing on screen. Off when the dev server was
|
|
44
|
+
* started with `--no-clear`, and off when nothing is watching a terminal at
|
|
45
|
+
* all: writing escape codes into a pipe leaves them in the file.
|
|
46
|
+
*/
|
|
47
|
+
export function clearScreen(): void {
|
|
48
|
+
if (process.env.REAM_DEV_CLEAR_SCREEN === 'false') return
|
|
49
|
+
if (!process.stdout.isTTY) return
|
|
50
|
+
process.stdout.write('\u001Bc')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The line printed when a module was swapped in the running process.
|
|
19
55
|
*
|
|
20
56
|
* @param file - absolute path of the file that changed.
|
|
21
|
-
* @param relativise - turns it into the path the editor shows
|
|
22
|
-
|
|
57
|
+
* @param relativise - turns it into the path the editor shows.
|
|
58
|
+
*/
|
|
59
|
+
export function invalidatedNotice(file: string, relativise: (file: string) => string): string {
|
|
60
|
+
return `${colors().green('invalidated')} ${relativise(file)}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The line printed before the process restarts.
|
|
65
|
+
*
|
|
66
|
+
* `reason` is not in the text — it is what the caller already decided, and
|
|
67
|
+
* upstream prints one word — but it stays in the signature because the caller
|
|
68
|
+
* has it and a future line may want it.
|
|
23
69
|
*/
|
|
24
70
|
export function fullReloadNotice(
|
|
25
71
|
file: string,
|
|
26
|
-
|
|
72
|
+
_reason: ReloadReason,
|
|
27
73
|
relativise: (file: string) => string,
|
|
28
74
|
): string {
|
|
29
|
-
|
|
30
|
-
reason === 'restart-list'
|
|
31
|
-
? ' (it is on `hotHook.restart`, which always restarts)'
|
|
32
|
-
: ' (nothing importing it is inside a `hotHook.boundaries` entry, so it could not be swapped)'
|
|
33
|
-
return `[ream] full reload — ${relativise(file)}${why}`
|
|
75
|
+
return `${colors().green('update')} ${relativise(file)}`
|
|
34
76
|
}
|
package/src/dev/hotWatcher.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Watching the project for the changes a hot reload reacts to.
|
|
3
3
|
*
|
|
4
|
-
* `fs.watch`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* ONE `fs.watch` PER DIRECTORY, walked ourselves — not `recursive: true`, and
|
|
5
|
+
* never a watch on a file. That is not a preference, it is the bug this module
|
|
6
|
+
* was reported for: most editors save by writing a temporary file and renaming
|
|
7
|
+
* it over the original (JetBrains IDEs, several formatters, anything using
|
|
8
|
+
* write-and-replace for crash safety). The rename gives the path a NEW inode,
|
|
9
|
+
* and a watcher holding the old one is watching something that no longer has a
|
|
10
|
+
* name. Measured on Node 25, Linux:
|
|
11
|
+
*
|
|
12
|
+
* fs.watch(dir, { recursive: true }) first rename fires, then silence
|
|
13
|
+
* fs.watch(file) first rename fires, then silence
|
|
14
|
+
* fs.watch(dir) every rename fires
|
|
15
|
+
*
|
|
16
|
+
* So the first save hot-swapped and every one after it was ignored without a
|
|
17
|
+
* word — no reload, no restart, nothing in the log. A directory keeps its inode
|
|
18
|
+
* when a file inside it is replaced, which is why watching directories is the
|
|
19
|
+
* form that survives. A file to watch is watched through its parent instead,
|
|
20
|
+
* filtered by name.
|
|
7
21
|
*
|
|
8
22
|
* Two behaviours are worth stating because they are not obvious from the API:
|
|
9
23
|
*
|
|
@@ -18,12 +32,33 @@
|
|
|
18
32
|
* that window, and hands back over by restarting the process.
|
|
19
33
|
*/
|
|
20
34
|
|
|
21
|
-
import { type FSWatcher, watch } from 'node:fs'
|
|
22
|
-
import { join, resolve } from 'node:path'
|
|
35
|
+
import { type FSWatcher, readdirSync, realpathSync, statSync, watch } from 'node:fs'
|
|
36
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
23
37
|
|
|
24
38
|
/** How long to wait for a save to finish arriving before acting on it. */
|
|
25
39
|
const SETTLE_MS = 40
|
|
26
40
|
|
|
41
|
+
/** Never walked: none of it is application source, and some of it is enormous. */
|
|
42
|
+
const SKIP_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.wolf'])
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The half-written files an atomic save leaves behind for a few milliseconds.
|
|
46
|
+
*
|
|
47
|
+
* Reported, they would be a change to `Controller.ts.tmp8f3` — a file the graph
|
|
48
|
+
* has never heard of, on its way to being renamed into the one that matters.
|
|
49
|
+
*/
|
|
50
|
+
function isEditorArtefact(name: string): boolean {
|
|
51
|
+
return (
|
|
52
|
+
name.endsWith('~') ||
|
|
53
|
+
name.endsWith('.swp') ||
|
|
54
|
+
name.endsWith('.swx') ||
|
|
55
|
+
name.startsWith('.#') ||
|
|
56
|
+
name.startsWith('.goutputstream') ||
|
|
57
|
+
/\.tmp[\w-]*$/.test(name) ||
|
|
58
|
+
/^\.?[\w.-]+\.(?:tmp|temp|crswap|part|partial)$/i.test(name)
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
27
62
|
export interface WatcherOptions {
|
|
28
63
|
/** Project root; every reported path is absolute, resolved against it. */
|
|
29
64
|
root: string
|
|
@@ -33,6 +68,8 @@ export interface WatcherOptions {
|
|
|
33
68
|
files: readonly string[]
|
|
34
69
|
/** Called once per settled change, with an absolute path. */
|
|
35
70
|
onChange: (file: string) => void
|
|
71
|
+
/** Told when a directory could not be watched for a reason worth knowing. */
|
|
72
|
+
onWarning?: (message: string) => void
|
|
36
73
|
}
|
|
37
74
|
|
|
38
75
|
export interface Watcher {
|
|
@@ -40,8 +77,11 @@ export interface Watcher {
|
|
|
40
77
|
}
|
|
41
78
|
|
|
42
79
|
export function watchProject(options: WatcherOptions): Watcher {
|
|
43
|
-
const watchers
|
|
80
|
+
const watchers = new Map<string, FSWatcher>()
|
|
44
81
|
const pending = new Map<string, NodeJS.Timeout>()
|
|
82
|
+
/** Real paths already walked, so a symlink loop cannot spin forever. */
|
|
83
|
+
const visited = new Set<string>()
|
|
84
|
+
let closed = false
|
|
45
85
|
|
|
46
86
|
const report = (file: string): void => {
|
|
47
87
|
const existing = pending.get(file)
|
|
@@ -55,35 +95,139 @@ export function watchProject(options: WatcherOptions): Watcher {
|
|
|
55
95
|
pending.set(file, timer)
|
|
56
96
|
}
|
|
57
97
|
|
|
58
|
-
|
|
59
|
-
|
|
98
|
+
const warn = (message: string): void => options.onWarning?.(message)
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Watch one directory, then everything under it.
|
|
102
|
+
*
|
|
103
|
+
* Called again for a directory that appears later: a `mkdir app/billing`
|
|
104
|
+
* during a session must be watched, and the parent's event is what says so.
|
|
105
|
+
*
|
|
106
|
+
* `announce` closes the gap between that mkdir and the watch being in place.
|
|
107
|
+
* `mkdir -p app/billing && cp Controller.ts app/billing/` writes the file
|
|
108
|
+
* before the watcher on the new directory exists, so what is already inside a
|
|
109
|
+
* directory discovered mid-session is reported as a change. At startup it is
|
|
110
|
+
* false, or booting would report every file in the project.
|
|
111
|
+
*/
|
|
112
|
+
const watchDirectory = (absolute: string, announce = false): void => {
|
|
113
|
+
if (closed || watchers.has(absolute)) return
|
|
114
|
+
|
|
115
|
+
let real: string
|
|
116
|
+
try {
|
|
117
|
+
real = realpathSync(absolute)
|
|
118
|
+
if (!statSync(real).isDirectory()) return
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// Absent is ordinary — the list is the framework's conventional layout,
|
|
121
|
+
// not a requirement, and a directory can be removed mid-session. Any
|
|
122
|
+
// other reason (no permission, too many open files) is worth saying, or
|
|
123
|
+
// the project silently stops hot-reloading.
|
|
124
|
+
if (!isMissing(error)) warn(`cannot watch ${absolute}: ${describe(error)}`)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (visited.has(real)) return
|
|
128
|
+
visited.add(real)
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const watcher = watch(absolute, (_event, name) => {
|
|
132
|
+
if (typeof name !== 'string' || name === '') return
|
|
133
|
+
const child = join(absolute, name)
|
|
134
|
+
// A new directory has to be picked up, or nothing in it is ever seen.
|
|
135
|
+
// Cheap: only for names that are directories right now.
|
|
136
|
+
if (!SKIP_DIRECTORIES.has(name) && isDirectory(child)) watchDirectory(child, true)
|
|
137
|
+
if (isEditorArtefact(name)) return
|
|
138
|
+
report(child)
|
|
139
|
+
})
|
|
140
|
+
watcher.on('error', (error) => {
|
|
141
|
+
// A watcher whose directory was removed is done; anything else is a
|
|
142
|
+
// surprise the developer should hear about rather than lose.
|
|
143
|
+
watchers.delete(absolute)
|
|
144
|
+
if (!isMissing(error)) warn(`watch on ${absolute} failed: ${describe(error)}`)
|
|
145
|
+
})
|
|
146
|
+
watchers.set(absolute, watcher)
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!isMissing(error)) warn(`cannot watch ${absolute}: ${describe(error)}`)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let entries: string[] = []
|
|
60
153
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}),
|
|
66
|
-
)
|
|
67
|
-
} catch {
|
|
68
|
-
// A directory this project does not have is not an error: the list is
|
|
69
|
-
// the framework's conventional layout, not a requirement.
|
|
154
|
+
entries = readdirSync(absolute)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (!isMissing(error)) warn(`cannot read ${absolute}: ${describe(error)}`)
|
|
157
|
+
return
|
|
70
158
|
}
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
if (SKIP_DIRECTORIES.has(entry)) continue
|
|
161
|
+
const child = join(absolute, entry)
|
|
162
|
+
if (isDirectory(child)) {
|
|
163
|
+
watchDirectory(child, announce)
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
if (announce && !isEditorArtefact(entry)) report(child)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const directory of options.directories) {
|
|
171
|
+
watchDirectory(resolve(options.root, directory))
|
|
71
172
|
}
|
|
72
173
|
|
|
174
|
+
// A file is watched through its parent directory, for the reason at the top
|
|
175
|
+
// of this module: `fs.watch` on the file itself holds an inode, and an atomic
|
|
176
|
+
// save replaces it. `.env` is saved by the same editors as everything else.
|
|
177
|
+
const watchedFiles = new Map<string, Set<string>>()
|
|
73
178
|
for (const file of options.files) {
|
|
74
179
|
const absolute = resolve(options.root, file)
|
|
180
|
+
const parent = dirname(absolute)
|
|
181
|
+
const names = watchedFiles.get(parent) ?? new Set<string>()
|
|
182
|
+
names.add(basename(absolute))
|
|
183
|
+
watchedFiles.set(parent, names)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [parent, names] of watchedFiles) {
|
|
187
|
+
// The parent may already be covered as a watched directory — `.env` sits at
|
|
188
|
+
// the root, which is not in the list, but a caller is free to pass both.
|
|
189
|
+
if (watchers.has(parent)) continue
|
|
75
190
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
191
|
+
const watcher = watch(parent, (_event, name) => {
|
|
192
|
+
if (typeof name !== 'string' || !names.has(name)) return
|
|
193
|
+
report(join(parent, name))
|
|
194
|
+
})
|
|
195
|
+
watcher.on('error', (error) => {
|
|
196
|
+
watchers.delete(parent)
|
|
197
|
+
if (!isMissing(error)) warn(`watch on ${parent} failed: ${describe(error)}`)
|
|
198
|
+
})
|
|
199
|
+
watchers.set(parent, watcher)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (!isMissing(error)) warn(`cannot watch ${parent}: ${describe(error)}`)
|
|
79
202
|
}
|
|
80
203
|
}
|
|
81
204
|
|
|
82
205
|
return {
|
|
83
206
|
close: () => {
|
|
207
|
+
closed = true
|
|
84
208
|
for (const timer of pending.values()) clearTimeout(timer)
|
|
85
209
|
pending.clear()
|
|
86
|
-
for (const watcher of watchers) watcher.close()
|
|
210
|
+
for (const watcher of watchers.values()) watcher.close()
|
|
211
|
+
watchers.clear()
|
|
87
212
|
},
|
|
88
213
|
}
|
|
89
214
|
}
|
|
215
|
+
|
|
216
|
+
function isDirectory(path: string): boolean {
|
|
217
|
+
try {
|
|
218
|
+
return statSync(path).isDirectory()
|
|
219
|
+
} catch {
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** ENOENT and friends: the path is simply not there, which is not a failure. */
|
|
225
|
+
function isMissing(error: unknown): boolean {
|
|
226
|
+
const code = (error as { code?: unknown }).code
|
|
227
|
+
return code === 'ENOENT' || code === 'ENOTDIR'
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function describe(error: unknown): string {
|
|
231
|
+
if (error instanceof Error) return error.message
|
|
232
|
+
return String(error)
|
|
233
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the dev server says once it is listening.
|
|
3
|
+
*
|
|
4
|
+
* Upstream prints a sticker the moment the child reports ready: the address,
|
|
5
|
+
* the mode, how long the boot took. Read off `@adonisjs/assembler`'s build:
|
|
6
|
+
*
|
|
7
|
+
* this.ui.sticker()
|
|
8
|
+
* .add(`Server address: ${colors.cyan(serverUrl)}`)
|
|
9
|
+
* .add(`Mode: ${colors.cyan(this.mode)}`)
|
|
10
|
+
* .add(`Ready in: ${colors.cyan(prettyHrtime(message.duration))}`)
|
|
11
|
+
*
|
|
12
|
+
* Ours says the same, `Press h to show help` included — the shortcuts are in
|
|
13
|
+
* `./shortcuts.ts`, so the line is an offer this process keeps.
|
|
14
|
+
*
|
|
15
|
+
* Printed from the application rather than from the `ream dev` parent, which
|
|
16
|
+
* is where upstream prints it. The parent is a Rust process here and the port
|
|
17
|
+
* is not its to know: a dev server that found 3000 taken binds 3001, and only
|
|
18
|
+
* the process that bound it can say so. Upstream solves the same problem by
|
|
19
|
+
* having the child send the port over IPC — the address it prints is the
|
|
20
|
+
* child's either way.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { lumen } from '@c9up/lumen'
|
|
24
|
+
|
|
25
|
+
/** Whether `ream dev` is what started this process. */
|
|
26
|
+
export function inDevServer(): boolean {
|
|
27
|
+
return process.env.REAM_DEV === 'true'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReadyBanner {
|
|
31
|
+
/** The host the server bound, as the Ignitor resolved it. */
|
|
32
|
+
host: string
|
|
33
|
+
port: number
|
|
34
|
+
/** `HMR` when the loader is swapping modules, `watch` when it only restarts. */
|
|
35
|
+
mode: string
|
|
36
|
+
/** Milliseconds from process start to listening. */
|
|
37
|
+
bootMs?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The address to show for a bound host and port.
|
|
42
|
+
*
|
|
43
|
+
* `localhost` rather than `0.0.0.0`: bound to every interface is not a link
|
|
44
|
+
* that opens, and printing it sends you looking in the wrong place. Shared
|
|
45
|
+
* with the `o` shortcut so the browser goes where the sticker said.
|
|
46
|
+
*/
|
|
47
|
+
export function devServerUrl(host: string, port: number): string {
|
|
48
|
+
const shown = host === '0.0.0.0' || host === '::' ? 'localhost' : host
|
|
49
|
+
return `http://${shown}:${port}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The lines of the sticker, so a test can read them without a terminal. */
|
|
53
|
+
export function readyBannerLines(banner: ReadyBanner): string[] {
|
|
54
|
+
const ui = lumen()
|
|
55
|
+
const lines = [
|
|
56
|
+
`Server address: ${ui.colors.cyan(devServerUrl(banner.host, banner.port))}`,
|
|
57
|
+
`Mode: ${ui.colors.cyan(banner.mode)}`,
|
|
58
|
+
]
|
|
59
|
+
if (banner.bootMs !== undefined) {
|
|
60
|
+
lines.push(`Ready in: ${ui.colors.cyan(formatBootTime(banner.bootMs))}`)
|
|
61
|
+
}
|
|
62
|
+
// Only where there is a keyboard: piped into a file, the offer is a lie.
|
|
63
|
+
if (process.stdin.isTTY) lines.push(`Press ${ui.colors.dim('h')} to show help`)
|
|
64
|
+
return lines
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Print it, the way upstream prints its sticker. */
|
|
68
|
+
export function printReadyBanner(banner: ReadyBanner): void {
|
|
69
|
+
const ui = lumen()
|
|
70
|
+
const sticker = ui.sticker()
|
|
71
|
+
for (const line of readyBannerLines(banner)) sticker.add(line)
|
|
72
|
+
sticker.render()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `412 ms`, `1.2 s` — upstream runs the duration through `pretty-hrtime`.
|
|
77
|
+
*
|
|
78
|
+
* Not that dependency: this prints one number once per boot, and the package
|
|
79
|
+
* is 60 lines of unit selection for a case we have exactly one of.
|
|
80
|
+
*/
|
|
81
|
+
function formatBootTime(ms: number): string {
|
|
82
|
+
if (ms < 1000) return `${Math.round(ms)} ms`
|
|
83
|
+
return `${(ms / 1000).toFixed(1)} s`
|
|
84
|
+
}
|