@c9up/ream 0.2.6 → 0.2.8

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 (71) hide show
  1. package/dist/HttpKernel.d.ts.map +1 -1
  2. package/dist/HttpKernel.js +10 -0
  3. package/dist/HttpKernel.js.map +1 -1
  4. package/dist/Ignitor.d.ts.map +1 -1
  5. package/dist/Ignitor.js +17 -0
  6. package/dist/Ignitor.js.map +1 -1
  7. package/dist/bootstrap/hyper-server.js.map +1 -1
  8. package/dist/debug.d.ts +22 -0
  9. package/dist/debug.d.ts.map +1 -0
  10. package/dist/debug.js +23 -0
  11. package/dist/debug.js.map +1 -0
  12. package/dist/dumper/Dumper.d.ts +80 -0
  13. package/dist/dumper/Dumper.d.ts.map +1 -0
  14. package/dist/dumper/Dumper.js +129 -0
  15. package/dist/dumper/Dumper.js.map +1 -0
  16. package/dist/helpers/is.d.ts.map +1 -1
  17. package/dist/helpers/is.js +1 -4
  18. package/dist/helpers/is.js.map +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/middleware/Pipeline.d.ts.map +1 -1
  22. package/dist/middleware/Pipeline.js +7 -0
  23. package/dist/middleware/Pipeline.js.map +1 -1
  24. package/dist/repl/Repl.d.ts +88 -0
  25. package/dist/repl/Repl.d.ts.map +1 -0
  26. package/dist/repl/Repl.js +123 -0
  27. package/dist/repl/Repl.js.map +1 -0
  28. package/dist/scheduler/Scheduler.d.ts.map +1 -1
  29. package/dist/scheduler/Scheduler.js +12 -4
  30. package/dist/scheduler/Scheduler.js.map +1 -1
  31. package/dist/services/dumper.d.ts +25 -0
  32. package/dist/services/dumper.d.ts.map +1 -0
  33. package/dist/services/dumper.js +27 -0
  34. package/dist/services/dumper.js.map +1 -0
  35. package/dist/services/logger.d.ts +29 -0
  36. package/dist/services/logger.d.ts.map +1 -0
  37. package/dist/services/logger.js +40 -0
  38. package/dist/services/logger.js.map +1 -0
  39. package/dist/services/repl.d.ts +19 -0
  40. package/dist/services/repl.d.ts.map +1 -0
  41. package/dist/services/repl.js +19 -0
  42. package/dist/services/repl.js.map +1 -0
  43. package/dist/session/Session.js.map +1 -1
  44. package/dist/testing/TestClient.d.ts +18 -3
  45. package/dist/testing/TestClient.d.ts.map +1 -1
  46. package/dist/testing/TestClient.js +45 -4
  47. package/dist/testing/TestClient.js.map +1 -1
  48. package/dist/testing/testUtils.d.ts +62 -0
  49. package/dist/testing/testUtils.d.ts.map +1 -0
  50. package/dist/testing/testUtils.js +77 -0
  51. package/dist/testing/testUtils.js.map +1 -0
  52. package/events.win32-x64-msvc.node +0 -0
  53. package/index.win32-x64-msvc.node +0 -0
  54. package/package.json +25 -1
  55. package/scheduler.win32-x64-msvc.node +0 -0
  56. package/src/HttpKernel.ts +27 -0
  57. package/src/Ignitor.ts +17 -1
  58. package/src/bootstrap/hyper-server.ts +1 -1
  59. package/src/debug.ts +25 -0
  60. package/src/dumper/Dumper.ts +164 -0
  61. package/src/helpers/is.ts +1 -6
  62. package/src/index.ts +1 -1
  63. package/src/middleware/Pipeline.ts +7 -0
  64. package/src/repl/Repl.ts +162 -0
  65. package/src/scheduler/Scheduler.ts +10 -4
  66. package/src/services/dumper.ts +30 -0
  67. package/src/services/logger.ts +49 -0
  68. package/src/services/repl.ts +21 -0
  69. package/src/session/Session.ts +2 -2
  70. package/src/testing/TestClient.ts +47 -5
  71. package/src/testing/testUtils.ts +90 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Value dumping — AdonisJS's `Dumper`, in shape and in method names.
3
+ *
4
+ * import { dd } from '@c9up/ream/services/dumper'
5
+ * dd(user) // prints and stops the process
6
+ *
7
+ * import dumper from '@c9up/ream/services/dumper'
8
+ * response.send(dumper.dumpToHtml(user, { cspNonce: response.nonce }))
9
+ *
10
+ * The rendering is `node:util`'s `inspect`, not a dependency. It already knows
11
+ * about depth limits, circular references, getters, Maps, Sets and typed
12
+ * arrays, and it colours for a terminal — which is the whole of the ANSI side.
13
+ * The HTML side escapes that same output rather than building a second
14
+ * formatter, so the two can never describe a value differently.
15
+ */
16
+
17
+ import { inspect } from 'node:util'
18
+ import { escapeHTML } from '../helpers/string.js'
19
+
20
+ /** Where a dump was written, so the output can say so. */
21
+ export interface DumpSource {
22
+ location: string
23
+ line: number
24
+ }
25
+
26
+ /** Knobs for the terminal rendering — AdonisJS `ConsoleDumpConfig`. */
27
+ export interface ConsoleDumpConfig {
28
+ /** How deep to walk before printing `[Object]`. Default 4. */
29
+ depth?: number
30
+ /** Colour the output. Default: whether stdout is a TTY. */
31
+ colors?: boolean
32
+ /** Show non-enumerable properties. Default false. */
33
+ showHidden?: boolean
34
+ }
35
+
36
+ /** Knobs for the HTML rendering — AdonisJS `HTMLDumpConfig`. */
37
+ export interface HTMLDumpConfig {
38
+ /** How deep to walk. Default 4. */
39
+ depth?: number
40
+ /** Show non-enumerable properties. Default false. */
41
+ showHidden?: boolean
42
+ /** Style overrides for the emitted `<pre>`, as CSS declarations. */
43
+ styles?: Record<string, string>
44
+ }
45
+
46
+ const DEFAULT_STYLES: Record<string, string> = {
47
+ 'background-color': '#0b0b0d',
48
+ color: '#e6e6e6',
49
+ padding: '1rem',
50
+ 'border-radius': '6px',
51
+ 'font-family': 'ui-monospace, SFMono-Regular, Menlo, monospace',
52
+ 'font-size': '13px',
53
+ 'line-height': '1.5',
54
+ 'overflow-x': 'auto',
55
+ }
56
+
57
+ export class Dumper {
58
+ #html: HTMLDumpConfig = {}
59
+ #ansi: ConsoleDumpConfig = {}
60
+
61
+ /** Configure the HTML rendering. Chainable, as in AdonisJS. */
62
+ configureHtmlOutput(config: HTMLDumpConfig): this {
63
+ this.#html = { ...this.#html, ...config }
64
+ return this
65
+ }
66
+
67
+ /** Configure the terminal rendering. Chainable, as in AdonisJS. */
68
+ configureAnsiOutput(config: ConsoleDumpConfig): this {
69
+ this.#ansi = { ...this.#ansi, ...config }
70
+ return this
71
+ }
72
+
73
+ /**
74
+ * The `<style>` a page needs before its first HTML dump.
75
+ *
76
+ * Takes a CSP nonce because a page that sets a nonce-based policy rejects an
77
+ * unattributed inline `<style>` — and a dump landing in a page that silently
78
+ * drops its styling is worse than no dump. `response.nonce` is what to pass.
79
+ */
80
+ getHeadElements(cspNonce?: string): string {
81
+ const nonce = cspNonce ? ` nonce="${escapeHTML(cspNonce)}"` : ''
82
+ const styles = { ...DEFAULT_STYLES, ...this.#html.styles }
83
+ const declarations = Object.entries(styles)
84
+ .map(([property, value]) => ` ${property}: ${value};`)
85
+ .join('\n')
86
+ return `<style${nonce}>\n.ream-dump {\n${declarations}\n}\n</style>`
87
+ }
88
+
89
+ /** Render `value` for a terminal. */
90
+ dumpToAnsi(value: unknown, options?: { title?: string; source?: DumpSource }): string {
91
+ const body = inspect(value, {
92
+ depth: this.#ansi.depth ?? 4,
93
+ colors: this.#ansi.colors ?? process.stdout.isTTY === true,
94
+ showHidden: this.#ansi.showHidden ?? false,
95
+ // Long strings and arrays are the reason someone is dumping; truncating
96
+ // them defeats the call.
97
+ maxStringLength: null,
98
+ maxArrayLength: null,
99
+ })
100
+ return [header(options), body].filter(Boolean).join('\n')
101
+ }
102
+
103
+ /**
104
+ * Render `value` as HTML.
105
+ *
106
+ * The inspected output is escaped before it reaches the page: a dumped value
107
+ * is by definition data someone else may control, and a dump is something
108
+ * you reach for while debugging — exactly when a script tag in a field name
109
+ * would be least expected.
110
+ */
111
+ dumpToHtml(
112
+ value: unknown,
113
+ options?: { cspNonce?: string; title?: string; source?: DumpSource },
114
+ ): string {
115
+ const body = inspect(value, {
116
+ depth: this.#html.depth ?? 4,
117
+ colors: false,
118
+ showHidden: this.#html.showHidden ?? false,
119
+ maxStringLength: null,
120
+ maxArrayLength: null,
121
+ })
122
+ const heading = header(options)
123
+ const label = heading ? `<div class="ream-dump-header">${escapeHTML(heading)}</div>` : ''
124
+ return `${this.getHeadElements(options?.cspNonce)}\n${label}<pre class="ream-dump">${escapeHTML(body)}</pre>`
125
+ }
126
+
127
+ /**
128
+ * Dump and die — print to stderr and stop the process.
129
+ *
130
+ * `traceSourceIndex` selects which stack frame to report as the dump site:
131
+ * 1 is the caller of `dd`, which is right when `dd` is called directly, and
132
+ * a wrapper passes 2.
133
+ */
134
+ dd(value: unknown, traceSourceIndex = 1): void {
135
+ process.stderr.write(`${this.dumpToAnsi(value, { source: callSite(traceSourceIndex) })}\n`)
136
+ process.exit(1)
137
+ }
138
+ }
139
+
140
+ /** `title` and `source` as one line, or nothing when neither is given. */
141
+ function header(options?: { title?: string; source?: DumpSource }): string {
142
+ const parts: string[] = []
143
+ if (options?.title) parts.push(options.title)
144
+ if (options?.source) parts.push(`${options.source.location}:${options.source.line}`)
145
+ return parts.join(' — ')
146
+ }
147
+
148
+ /**
149
+ * Where `dd` was called, read from a thrown stack.
150
+ *
151
+ * Best-effort: a bundled or minified frame may not parse, and a dump that
152
+ * cannot name its own line is still a useful dump — so a miss returns nothing
153
+ * rather than throwing on the debugging path.
154
+ */
155
+ function callSite(index: number): DumpSource | undefined {
156
+ const frames = new Error().stack?.split('\n').slice(1)
157
+ const frame = frames?.[index]
158
+ if (!frame) return undefined
159
+ const match = /\(?(.+):(\d+):\d+\)?$/.exec(frame.trim())
160
+ const location = match?.[1]
161
+ const line = match?.[2]
162
+ if (!location || !line) return undefined
163
+ return { location, line: Number(line) }
164
+ }
package/src/helpers/is.ts CHANGED
@@ -62,12 +62,7 @@ export function isArray<T = unknown>(v: unknown): v is T[] {
62
62
  * @example isPromise(Promise.resolve()) // true
63
63
  */
64
64
  export function isPromise<T = unknown>(v: unknown): v is Promise<T> {
65
- return (
66
- v !== null &&
67
- typeof v === 'object' &&
68
- 'then' in v &&
69
- typeof (v as Record<string, unknown>).then === 'function'
70
- )
65
+ return v !== null && typeof v === 'object' && 'then' in v && typeof v.then === 'function'
71
66
  }
72
67
 
73
68
  /**
package/src/index.ts CHANGED
@@ -371,4 +371,4 @@ export {
371
371
  StatsTracker,
372
372
  } from './scheduler/index.js'
373
373
 
374
- export const VERSION = '0.2.6'
374
+ export const VERSION = '0.2.8'
@@ -11,6 +11,7 @@
11
11
  * 5. Handler
12
12
  */
13
13
 
14
+ import { debugHttp } from '../debug.js'
14
15
  import { ReamError } from '../errors/ReamError.js'
15
16
  import { E_FORBIDDEN, E_UNAUTHORIZED, E_VALIDATION_ERROR } from '../http/Exception.js'
16
17
  import type { HttpContext } from '../http/HttpContext.js'
@@ -196,6 +197,12 @@ function composeMiddleware(middleware: MiddlewareFunction[]): MiddlewareFunction
196
197
  const fn = i < middleware.length ? middleware[i] : finalNext
197
198
  if (!fn) return
198
199
 
200
+ if (debugHttp.enabled && i < middleware.length) {
201
+ // `fn.name` is what a named middleware function or a bound class
202
+ // method carries; an inline arrow has none, and its position is the
203
+ // only thing that identifies it.
204
+ debugHttp('middleware %d/%d %s', i + 1, middleware.length, fn.name || '(anonymous)')
205
+ }
199
206
  await fn(ctx, () => dispatch(i + 1))
200
207
  }
201
208
 
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The application REPL — AdonisJS's `Repl`, in shape and in method names.
3
+ *
4
+ * import repl from '@c9up/ream/services/repl'
5
+ * repl.addMethod('loadModels', async () => { … }, {
6
+ * description: 'Load every entity onto the prompt',
7
+ * })
8
+ * repl.start({ app, router })
9
+ *
10
+ * Built on `node:repl`, which is the REPL — this adds what makes it an
11
+ * APPLICATION repl: a context seeded with the booted services, helpers a
12
+ * project registers itself, and `.ls` to list them, because a prompt full of
13
+ * undiscoverable helpers is a prompt nobody uses.
14
+ */
15
+
16
+ import type { REPLServer } from 'node:repl'
17
+ import { start as startNodeRepl } from 'node:repl'
18
+
19
+ /** What a registered helper receives — the repl itself, then its arguments. */
20
+ export type MethodCallback = (repl: Repl, ...args: unknown[]) => unknown
21
+
22
+ /** How a helper describes itself in `.ls`. */
23
+ export interface MethodOptions {
24
+ description?: string
25
+ usage?: string
26
+ }
27
+
28
+ /**
29
+ * A TypeScript compiler for the prompt, when the host has one.
30
+ *
31
+ * `node:repl` evaluates JavaScript. A project whose code is TypeScript wants
32
+ * to paste TypeScript, so the host supplies the compiler it already runs
33
+ * rather than ream carrying one.
34
+ */
35
+ export interface Compiler {
36
+ compile(code: string, fileName: string): string
37
+ supportsTypescript: boolean
38
+ }
39
+
40
+ interface RegisteredMethod {
41
+ handler: MethodCallback
42
+ options: MethodOptions & { width: number }
43
+ }
44
+
45
+ export class Repl {
46
+ readonly #methods = new Map<string, RegisteredMethod>()
47
+ readonly #readyCallbacks: Array<(repl: Repl) => void> = []
48
+ #compiler: Compiler | undefined
49
+ #server: REPLServer | undefined
50
+ readonly #prompt: string
51
+ readonly #write: (message: string) => void
52
+
53
+ constructor(options: { prompt?: string; write?: (message: string) => void } = {}) {
54
+ this.#prompt = options.prompt ?? '> '
55
+ this.#write =
56
+ options.write ??
57
+ ((message) => {
58
+ process.stdout.write(message)
59
+ })
60
+ }
61
+
62
+ /**
63
+ * Print a message around the prompt.
64
+ *
65
+ * Newline-wrapped: written mid-session the text would otherwise land on the
66
+ * line the user is typing.
67
+ */
68
+ notify(message: string): void {
69
+ this.#write(`\n${message}\n`)
70
+ }
71
+
72
+ /** Run `callback` once the prompt is up. Chainable. */
73
+ ready(callback: (repl: Repl) => void): this {
74
+ this.#readyCallbacks.push(callback)
75
+ return this
76
+ }
77
+
78
+ /**
79
+ * Register a helper, available at the prompt by name. Chainable.
80
+ *
81
+ * `width` is the name's display width, recorded here so `.ls` can align a
82
+ * column without measuring the set twice.
83
+ */
84
+ addMethod(name: string, handler: MethodCallback, options: MethodOptions = {}): this {
85
+ this.#methods.set(name, { handler, options: { ...options, width: name.length } })
86
+ return this
87
+ }
88
+
89
+ /** Every registered helper, keyed by name — AdonisJS `getMethods`. */
90
+ getMethods(): Record<string, RegisteredMethod> {
91
+ return Object.fromEntries(this.#methods)
92
+ }
93
+
94
+ /** Use `compiler` for the code typed at the prompt. Chainable. */
95
+ useCompiler(compiler: Compiler): this {
96
+ this.#compiler = compiler
97
+ return this
98
+ }
99
+
100
+ /** The compiler in use, if any. */
101
+ get compiler(): Compiler | undefined {
102
+ return this.#compiler
103
+ }
104
+
105
+ /** The underlying `node:repl` server, once started. */
106
+ get server(): REPLServer | undefined {
107
+ return this.#server
108
+ }
109
+
110
+ /**
111
+ * List the registered helpers, one per line, names aligned.
112
+ *
113
+ * Exposed rather than private because it IS the `.ls` command, and a caller
114
+ * driving the repl programmatically (a test, a custom command) needs the
115
+ * same listing without a live prompt.
116
+ */
117
+ describeMethods(): string {
118
+ if (this.#methods.size === 0) return 'No methods registered'
119
+ const widest = Math.max(...[...this.#methods.values()].map((m) => m.options.width))
120
+ return [...this.#methods]
121
+ .map(([name, method]) => {
122
+ const description = method.options.description ?? ''
123
+ const usage = method.options.usage ? ` (${method.options.usage})` : ''
124
+ return `${name.padEnd(widest)} ${description}${usage}`.trimEnd()
125
+ })
126
+ .join('\n')
127
+ }
128
+
129
+ /**
130
+ * Start the prompt, seeding it with `context`.
131
+ *
132
+ * Registered helpers are bound to this repl before the prompt appears — a
133
+ * helper that only exists after the first keystroke is a helper the user
134
+ * finds missing.
135
+ */
136
+ start(context: Record<string, unknown> = {}): this {
137
+ const server = startNodeRepl({ prompt: this.#prompt, useGlobal: false })
138
+ this.#server = server
139
+
140
+ for (const [key, value] of Object.entries(context)) {
141
+ Object.defineProperty(server.context, key, { value, configurable: true, enumerable: true })
142
+ }
143
+ for (const [name, method] of this.#methods) {
144
+ Object.defineProperty(server.context, name, {
145
+ value: (...args: unknown[]) => method.handler(this, ...args),
146
+ configurable: true,
147
+ enumerable: true,
148
+ })
149
+ }
150
+
151
+ server.defineCommand('ls', {
152
+ help: 'List the helpers available at this prompt',
153
+ action: () => {
154
+ this.notify(this.describeMethods())
155
+ server.displayPrompt()
156
+ },
157
+ })
158
+
159
+ for (const callback of this.#readyCallbacks) callback(this)
160
+ return this
161
+ }
162
+ }
@@ -252,9 +252,12 @@ export class Scheduler {
252
252
  // `.then`-based detection + `Promise.resolve` wrap so non-native
253
253
  // thenables (e.g. RxJS-style) are handled uniformly.
254
254
  if (
255
+ // Object OR function: a thenable function is exotic but legal, and the
256
+ // guard this replaced accepted one. `in` narrows either.
257
+ (typeof result === 'object' || typeof result === 'function') &&
255
258
  result !== null &&
256
- result !== undefined &&
257
- typeof (result as { then?: unknown }).then === 'function'
259
+ 'then' in result &&
260
+ typeof result.then === 'function'
258
261
  ) {
259
262
  Promise.resolve(result).catch(() => {})
260
263
  }
@@ -334,9 +337,12 @@ export class Scheduler {
334
337
  // reporter never crashes the ticker.
335
338
  const result = this.#errorReporter(err, { taskName }) as unknown
336
339
  if (
340
+ // Object OR function: a thenable function is exotic but legal, and the
341
+ // guard this replaced accepted one. `in` narrows either.
342
+ (typeof result === 'object' || typeof result === 'function') &&
337
343
  result !== null &&
338
- result !== undefined &&
339
- typeof (result as { then?: unknown }).then === 'function'
344
+ 'then' in result &&
345
+ typeof result.then === 'function'
340
346
  ) {
341
347
  Promise.resolve(result).catch(() => {})
342
348
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Dumper singleton service.
3
+ *
4
+ * import { dd } from '@c9up/ream/services/dumper'
5
+ * dd(user)
6
+ *
7
+ * import dumper from '@c9up/ream/services/dumper'
8
+ * dumper.configureAnsiOutput({ depth: 8 })
9
+ *
10
+ * Unlike the other accessors this one holds a real instance rather than a
11
+ * proxy: the dumper depends on nothing the boot phase builds, and something
12
+ * you reach for mid-debug should not require a booted application.
13
+ */
14
+
15
+ import { Dumper } from '../dumper/Dumper.js'
16
+
17
+ const dumper = new Dumper()
18
+
19
+ /**
20
+ * Dump and die — AdonisJS's `dd`.
21
+ *
22
+ * The trace index is 2, not the default 1: the frame above is this function,
23
+ * so the caller of `dd` is one further out. Without it, every dump would
24
+ * report this file as its source.
25
+ */
26
+ export const dd = (value: unknown): void => {
27
+ dumper.dd(value, 2)
28
+ }
29
+
30
+ export default dumper
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Application logger singleton service.
3
+ *
4
+ * Usage:
5
+ * import logger from '@c9up/ream/services/logger'
6
+ * logger.info('user signed in', { userId })
7
+ *
8
+ * ream does not implement the logger — it declares the contract
9
+ * ({@link ContextLogger}) and reads whatever a provider bound as `'logger'`.
10
+ * `@c9up/spectrum` is the implementation in this universe, the way `hash` is
11
+ * sigil's and validation is rune's.
12
+ *
13
+ * `ctx.logger` stays the right thing to use inside a request: it is
14
+ * correlation-scoped. This is for everywhere else — a provider, a scheduled
15
+ * task, a console command — where there is no request to scope to.
16
+ */
17
+
18
+ import type { ChildLoggerSource } from '../http/HttpContext.js'
19
+ import { createServiceProxy } from './createServiceProxy.js'
20
+
21
+ let instance: ChildLoggerSource | undefined
22
+
23
+ /** @internal Set the logger (called by Ignitor once providers have booted). */
24
+ export function setLogger(logger: ChildLoggerSource): void {
25
+ instance = logger
26
+ }
27
+
28
+ /**
29
+ * @internal Unset the locator IF it still points at `logger` (called by
30
+ * Ignitor.stop()). Ownership-guarded — see services/app.ts.
31
+ */
32
+ export function clearLogger(logger: ChildLoggerSource): void {
33
+ if (instance === logger) instance = undefined
34
+ }
35
+
36
+ /** @internal Get the logger directly. */
37
+ export function getLogger(): ChildLoggerSource | undefined {
38
+ return instance
39
+ }
40
+
41
+ const logger: ChildLoggerSource = createServiceProxy<ChildLoggerSource>(
42
+ () => instance,
43
+ 'Logger accessed before initialization. ' +
44
+ 'ream declares the contract but implements no logger — install one (`@c9up/spectrum`) ' +
45
+ "and register its provider, which binds it as 'logger'. Inside a request, prefer " +
46
+ '`ctx.logger`, which is scoped to the correlation id.',
47
+ )
48
+
49
+ export default logger
@@ -0,0 +1,21 @@
1
+ /**
2
+ * REPL singleton service.
3
+ *
4
+ * // start/repl.ts, imported as a preload for the `repl` environment
5
+ * import repl from '@c9up/ream/services/repl'
6
+ * import { UserEntity } from '#app/entities/user'
7
+ *
8
+ * repl.addMethod('users', () => UserEntity, {
9
+ * description: 'The user entity, for querying at the prompt',
10
+ * })
11
+ *
12
+ * Holds a real instance rather than a proxy: helpers are registered BEFORE the
13
+ * prompt starts — that is the point of registering them — so the object has to
14
+ * exist before anything boots it.
15
+ */
16
+
17
+ import { Repl } from '../repl/Repl.js'
18
+
19
+ const repl = new Repl()
20
+
21
+ export default repl
@@ -56,9 +56,9 @@ export interface SessionDriverWithTagging extends SessionDriver {
56
56
  export function supportsTagging(driver: SessionDriver): driver is SessionDriverWithTagging {
57
57
  return (
58
58
  'tag' in driver &&
59
- typeof (driver as SessionDriverWithTagging).tag === 'function' &&
59
+ typeof driver.tag === 'function' &&
60
60
  'tagged' in driver &&
61
- typeof (driver as SessionDriverWithTagging).tagged === 'function'
61
+ typeof driver.tagged === 'function'
62
62
  )
63
63
  }
64
64
 
@@ -68,17 +68,51 @@ export class TestClient {
68
68
 
69
69
  #server: { port: number; close: () => Promise<void> | void } | null = null
70
70
 
71
- /** Boot the application on a random port. */
71
+ /**
72
+ * In flight or settled, so concurrent callers share one boot.
73
+ *
74
+ * Also what makes `boot()` optional: the first request awaits this, starting
75
+ * it if nobody has. A test file that never issues a request never starts a
76
+ * server — which is the difference between a unit file paying for one and
77
+ * not, and at ~400ms a file that is most of a campaign's time.
78
+ */
79
+ #booting: Promise<void> | null = null
80
+
81
+ /**
82
+ * Boot the application on a random port. Idempotent: a second call awaits the
83
+ * first rather than starting a second server.
84
+ *
85
+ * Optional — a request boots on its own. Call it when you need `port` before
86
+ * issuing one (an SSE or WebSocket connection opened with `fetch`).
87
+ */
72
88
  async boot(): Promise<void> {
89
+ this.#booting ??= this.#startServer()
90
+ return this.#booting
91
+ }
92
+
93
+ async #startServer(): Promise<void> {
73
94
  this.#server = await this.#bootFn(0)
74
95
  this.#port = this.#server.port
75
96
  }
76
97
 
77
- /** Close the server. */
98
+ /** Whether a server is currently running for this client. */
99
+ get booted(): boolean {
100
+ return this.#server !== null
101
+ }
102
+
103
+ /**
104
+ * Close the server. A no-op when nothing was ever started, so a teardown can
105
+ * call it unconditionally against a client whose file issued no request.
106
+ */
78
107
  async close(): Promise<void> {
108
+ // Awaited first: closing while a boot is in flight would otherwise leave
109
+ // the server that boot is about to assign running with nobody holding it.
110
+ if (this.#booting) await this.#booting.catch(() => {})
111
+ this.#booting = null
79
112
  if (this.#server) {
80
113
  await this.#server.close()
81
114
  this.#server = null
115
+ this.#port = 0
82
116
  }
83
117
  }
84
118
 
@@ -86,7 +120,11 @@ export class TestClient {
86
120
  * Ephemeral port the booted server bound to. Useful for long-lived
87
121
  * connections (SSE / WebSocket) that the fluent buffered request
88
122
  * surface can't model — open them with `fetch` against
89
- * `http://127.0.0.1:${client.port}/...`. Returns 0 before `boot()`.
123
+ * `http://127.0.0.1:${client.port}/...`.
124
+ *
125
+ * Returns 0 while no server is running. Requests boot on their own, so a
126
+ * client that has only been constructed has not bound a port yet — `await
127
+ * client.boot()` first when you need this before issuing one.
90
128
  */
91
129
  get port(): number {
92
130
  return this.#port
@@ -148,8 +186,11 @@ export class TestClient {
148
186
  * `form()`. The lower-level `request()` (below) stays for raw control.
149
187
  */
150
188
  fluent(method: HttpMethod, path: string): RequestBuilder {
151
- const sender: HttpSender = (m, p, init) =>
152
- sendRequest(
189
+ const sender: HttpSender = async (m, p, init) => {
190
+ // Boots on the first request and no earlier. `#port` is read here rather
191
+ // than captured, so it is the port this boot bound to.
192
+ await this.boot()
193
+ return sendRequest(
153
194
  this.#port,
154
195
  m,
155
196
  p,
@@ -157,6 +198,7 @@ export class TestClient {
157
198
  init.body.toString('utf8'),
158
199
  init.timeoutMs,
159
200
  )
201
+ }
160
202
  return new RequestBuilder(sender, method, path, this.#auth, this.#sessionSeeder)
161
203
  }
162
204