@c9up/ream 0.2.24 → 0.2.26

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 (96) hide show
  1. package/README.md +2 -0
  2. package/dist/Codemods.d.ts +13 -0
  3. package/dist/Codemods.d.ts.map +1 -1
  4. package/dist/Codemods.js +158 -23
  5. package/dist/Codemods.js.map +1 -1
  6. package/dist/Ignitor.d.ts +28 -0
  7. package/dist/Ignitor.d.ts.map +1 -1
  8. package/dist/Ignitor.js +15 -15
  9. package/dist/Ignitor.js.map +1 -1
  10. package/dist/console/Kernel.d.ts.map +1 -1
  11. package/dist/console/Kernel.js +20 -1
  12. package/dist/console/Kernel.js.map +1 -1
  13. package/dist/console/cliui.d.ts +9 -245
  14. package/dist/console/cliui.d.ts.map +1 -1
  15. package/dist/console/cliui.js +9 -583
  16. package/dist/console/cliui.js.map +1 -1
  17. package/dist/console/make/commands.d.ts +19 -0
  18. package/dist/console/make/commands.d.ts.map +1 -0
  19. package/dist/console/make/commands.js +252 -0
  20. package/dist/console/make/commands.js.map +1 -0
  21. package/dist/console/make/eject.d.ts +28 -0
  22. package/dist/console/make/eject.d.ts.map +1 -0
  23. package/dist/console/make/eject.js +161 -0
  24. package/dist/console/make/eject.js.map +1 -0
  25. package/dist/console/make/flush.d.ts +51 -0
  26. package/dist/console/make/flush.d.ts.map +1 -0
  27. package/dist/console/make/flush.js +93 -0
  28. package/dist/console/make/flush.js.map +1 -0
  29. package/dist/console/make/generators.d.ts +36 -0
  30. package/dist/console/make/generators.d.ts.map +1 -0
  31. package/dist/console/make/generators.js +148 -0
  32. package/dist/console/make/generators.js.map +1 -0
  33. package/dist/console/make/naming.d.ts +32 -0
  34. package/dist/console/make/naming.d.ts.map +1 -0
  35. package/dist/console/make/naming.js +82 -0
  36. package/dist/console/make/naming.js.map +1 -0
  37. package/dist/console/make/resolve.d.ts +46 -0
  38. package/dist/console/make/resolve.d.ts.map +1 -0
  39. package/dist/console/make/resolve.js +113 -0
  40. package/dist/console/make/resolve.js.map +1 -0
  41. package/dist/console/ui.d.ts +9 -15
  42. package/dist/console/ui.d.ts.map +1 -1
  43. package/dist/console/ui.js +11 -24
  44. package/dist/console/ui.js.map +1 -1
  45. package/dist/dev/hmr.d.ts +52 -4
  46. package/dist/dev/hmr.d.ts.map +1 -1
  47. package/dist/dev/hmr.js +75 -9
  48. package/dist/dev/hmr.js.map +1 -1
  49. package/dist/dev/hotGlob.d.ts +10 -3
  50. package/dist/dev/hotGlob.d.ts.map +1 -1
  51. package/dist/dev/hotGlob.js +28 -3
  52. package/dist/dev/hotGlob.js.map +1 -1
  53. package/dist/hot.js +56 -3
  54. package/dist/hot.js.map +1 -1
  55. package/dist/index.d.ts +3 -3
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +4 -2
  58. package/dist/index.js.map +1 -1
  59. package/dist/stubs/index.d.ts +11 -0
  60. package/dist/stubs/index.d.ts.map +1 -0
  61. package/dist/stubs/index.js +12 -0
  62. package/dist/stubs/index.js.map +1 -0
  63. package/dist/stubs/template.d.ts +63 -0
  64. package/dist/stubs/template.d.ts.map +1 -0
  65. package/dist/stubs/template.js +209 -0
  66. package/dist/stubs/template.js.map +1 -0
  67. package/events.win32-x64-msvc.node +0 -0
  68. package/index.win32-x64-msvc.node +0 -0
  69. package/package.json +7 -1
  70. package/scheduler.win32-x64-msvc.node +0 -0
  71. package/src/Codemods.ts +206 -29
  72. package/src/Ignitor.ts +44 -14
  73. package/src/console/Kernel.ts +21 -1
  74. package/src/console/cliui.ts +44 -754
  75. package/src/console/make/commands.ts +270 -0
  76. package/src/console/make/eject.ts +163 -0
  77. package/src/console/make/flush.ts +125 -0
  78. package/src/console/make/generators.ts +176 -0
  79. package/src/console/make/naming.ts +85 -0
  80. package/src/console/make/resolve.ts +134 -0
  81. package/src/console/ui.ts +12 -24
  82. package/src/dev/hmr.ts +102 -9
  83. package/src/dev/hotGlob.ts +28 -3
  84. package/src/hot.ts +59 -3
  85. package/src/index.ts +17 -3
  86. package/src/stubs/index.ts +14 -0
  87. package/src/stubs/template.ts +262 -0
  88. package/stubs/make/command.stub +29 -0
  89. package/stubs/make/controller.stub +30 -0
  90. package/stubs/make/entity.stub +11 -0
  91. package/stubs/make/event.stub +13 -0
  92. package/stubs/make/listener.stub +13 -0
  93. package/stubs/make/middleware.stub +16 -0
  94. package/stubs/make/provider.stub +26 -0
  95. package/stubs/make/service.stub +27 -0
  96. package/stubs/make/validator.stub +10 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * What each `make:` command generates.
3
+ *
4
+ * One table rather than ten near-identical commands: every generator is a
5
+ * name, a stub and the variables that stub reads. The differences that matter
6
+ * — which suffix a class carries, whether the name is snake_cased for the file
7
+ * — live here, in one place, instead of being spread across ten files that
8
+ * drift.
9
+ *
10
+ * The DESTINATION is not here. It is in the stub's own front matter, so an
11
+ * application that publishes `stubs/make/controller.stub` can move the file as
12
+ * well as change its contents.
13
+ */
14
+
15
+ import type { StubState } from '../../stubs/template.js'
16
+ import { ensureSuffix, stripSuffixInsensitive, toPascalCase, toSnakeCase } from './naming.js'
17
+
18
+ /** Extra input a generator takes beyond the name. */
19
+ export interface GeneratorInput {
20
+ /** `app/<module>/…` — where a module-scoped file lands. */
21
+ module?: string
22
+ /** `make:middleware --stack server|router|named`. */
23
+ stack?: string
24
+ /** `make:listener --event OrderShipped`. */
25
+ event?: string
26
+ }
27
+
28
+ export interface Generator {
29
+ /** The stub, under `stubs/make/`. */
30
+ kind: string
31
+ commandName: string
32
+ description: string
33
+ /** Does it take `--module`? */
34
+ moduleScoped: boolean
35
+ /** The variables its stub reads. */
36
+ state(name: string, input: GeneratorInput): StubState
37
+ }
38
+
39
+ /** The shared trio every stub may read. */
40
+ function base(name: string, className: string, module: string): StubState {
41
+ return { className, module, name }
42
+ }
43
+
44
+ export const GENERATORS: readonly Generator[] = [
45
+ {
46
+ kind: 'controller',
47
+ commandName: 'make:controller',
48
+ description: 'Generate an HTTP controller',
49
+ moduleScoped: true,
50
+ state: (name, input) =>
51
+ base(name, ensureSuffix(name, 'Controller'), input.module ?? 'controllers'),
52
+ },
53
+ {
54
+ kind: 'service',
55
+ commandName: 'make:service',
56
+ description: 'Generate a service class',
57
+ moduleScoped: true,
58
+ state: (name, input) => base(name, ensureSuffix(name, 'Service'), input.module ?? 'services'),
59
+ },
60
+ {
61
+ kind: 'entity',
62
+ commandName: 'make:entity',
63
+ description: 'Generate an Atlas entity',
64
+ moduleScoped: true,
65
+ state: (name, input) => ({
66
+ // No suffix: an entity is `User`, not `UserEntity`.
67
+ ...base(name, toPascalCase(name), input.module ?? 'entities'),
68
+ tableName: `${toSnakeCase(name)}s`,
69
+ }),
70
+ },
71
+ {
72
+ kind: 'validator',
73
+ commandName: 'make:validator',
74
+ description: 'Generate a Rune validator',
75
+ moduleScoped: true,
76
+ state: (name, input) =>
77
+ base(name, ensureSuffix(name, 'Validator'), input.module ?? 'validators'),
78
+ },
79
+ {
80
+ kind: 'provider',
81
+ commandName: 'make:provider',
82
+ description: 'Generate a service provider',
83
+ moduleScoped: false,
84
+ state: (name) => base(name, ensureSuffix(name, 'Provider'), ''),
85
+ },
86
+ {
87
+ kind: 'command',
88
+ commandName: 'make:command',
89
+ description: 'Generate a console command',
90
+ moduleScoped: false,
91
+ state: (name) => {
92
+ // `app:provision` is the command NAME; the class and the file come from
93
+ // its last segment, or the published stub would generate a command
94
+ // unreachable under the name it was asked for.
95
+ const segments = name.split(':')
96
+ const last = segments[segments.length - 1] ?? name
97
+ return {
98
+ className: toPascalCase(toSnakeCase(last)),
99
+ fileName: toSnakeCase(last),
100
+ name,
101
+ }
102
+ },
103
+ },
104
+ {
105
+ kind: 'middleware',
106
+ commandName: 'make:middleware',
107
+ description: 'Generate an HTTP middleware',
108
+ moduleScoped: false,
109
+ state: (name, input) => {
110
+ const fileName = toSnakeCase(stripSuffixInsensitive(name, 'middleware'))
111
+ const stack = input.stack ?? 'router'
112
+ const importer = `() => import('#middleware/${fileName}_middleware.js')`
113
+ // The line the user pastes into their kernel — different per stack, and
114
+ // wrong in a way that is hard to spot if it is guessed.
115
+ const registration =
116
+ stack === 'server'
117
+ ? `server.use([${importer}])`
118
+ : stack === 'named'
119
+ ? `router.named({ ${fileName}: ${importer} })`
120
+ : `router.use([${importer}])`
121
+ return {
122
+ className: `${toPascalCase(fileName)}Middleware`,
123
+ fileName,
124
+ name,
125
+ registration,
126
+ stack,
127
+ }
128
+ },
129
+ },
130
+ {
131
+ kind: 'event',
132
+ commandName: 'make:event',
133
+ description: 'Generate an event class',
134
+ moduleScoped: false,
135
+ state: (name) => {
136
+ const fileName = toSnakeCase(name)
137
+ return { className: toPascalCase(fileName), fileName, name }
138
+ },
139
+ },
140
+ {
141
+ kind: 'listener',
142
+ commandName: 'make:listener',
143
+ description: 'Generate an event listener',
144
+ moduleScoped: false,
145
+ state: (name, input) => {
146
+ const fileName = toSnakeCase(name)
147
+ const className = toPascalCase(fileName)
148
+ if (input.event === undefined) {
149
+ // Without `--event` there is nothing to type the handler with, and
150
+ // inventing an import would point at a file that does not exist.
151
+ return {
152
+ className,
153
+ fileName,
154
+ name,
155
+ importLine: '',
156
+ eventType: 'unknown',
157
+ registration: `emitter.on(SomeEvent, ${className})`,
158
+ }
159
+ }
160
+ const eventFile = toSnakeCase(input.event)
161
+ const eventClass = toPascalCase(eventFile)
162
+ return {
163
+ className,
164
+ fileName,
165
+ name,
166
+ importLine: `import type ${eventClass} from '#app/events/${eventFile}.js'\n\n`,
167
+ eventType: eventClass,
168
+ registration: `emitter.on(${eventClass}, ${className})`,
169
+ }
170
+ },
171
+ },
172
+ ]
173
+
174
+ export function generatorFor(kind: string): Generator | undefined {
175
+ return GENERATORS.find((generator) => generator.kind === kind)
176
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The naming conventions the generators follow.
3
+ *
4
+ * Ported from the Rust generator they replace, case for case: a rename here
5
+ * changes what `make:controller user` produces, so the rules are written out
6
+ * rather than approximated.
7
+ */
8
+
9
+ /** Add the suffix unless the name already carries it. */
10
+ export function ensureSuffix(name: string, suffix: string): string {
11
+ return name.endsWith(suffix) ? name : `${name}${suffix}`
12
+ }
13
+
14
+ /** `user_profile` / `user-profile` / `user profile` → `UserProfile`. */
15
+ export function toPascalCase(name: string): string {
16
+ return name
17
+ .split(/[_\-\s]/)
18
+ .filter((part) => part !== '')
19
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
20
+ .join('')
21
+ }
22
+
23
+ /**
24
+ * `UserProfile` → `user_profile`, `HTTPServer` → `http_server`.
25
+ *
26
+ * The acronym case is why this is not a one-line regex: an underscore goes
27
+ * before a capital that FOLLOWS a lowercase, or that follows a capital and is
28
+ * followed by a lowercase — which is where `HTTPServer` splits.
29
+ */
30
+ export function toSnakeCase(name: string): string {
31
+ const chars = [...name]
32
+ let out = ''
33
+ for (const [index, char] of chars.entries()) {
34
+ if (char.toUpperCase() === char && char.toLowerCase() !== char) {
35
+ const previous = chars[index - 1]
36
+ const next = chars[index + 1]
37
+ const previousLower =
38
+ previous !== undefined &&
39
+ previous === previous.toLowerCase() &&
40
+ previous !== previous.toUpperCase()
41
+ const nextLower =
42
+ next !== undefined && next === next.toLowerCase() && next !== next.toUpperCase()
43
+ const previousUpper =
44
+ previous !== undefined &&
45
+ previous === previous.toUpperCase() &&
46
+ previous !== previous.toLowerCase()
47
+ if (index > 0 && (previousLower || (nextLower && previousUpper))) out += '_'
48
+ out += char.toLowerCase()
49
+ continue
50
+ }
51
+ out += char
52
+ }
53
+ return out
54
+ }
55
+
56
+ /** Drop a trailing `Middleware` / `middleware` / `_middleware`, whatever the case. */
57
+ export function stripSuffixInsensitive(name: string, suffix: string): string {
58
+ const lower = name.toLowerCase()
59
+ const suffixLower = suffix.toLowerCase()
60
+ if (lower.length > suffixLower.length && lower.endsWith(suffixLower)) {
61
+ return name.slice(0, name.length - suffix.length).replace(/[_-]+$/, '')
62
+ }
63
+ return name
64
+ }
65
+
66
+ /** Longer than this is not a name, it is a paste accident. */
67
+ export const MAX_NAME_LENGTH = 128
68
+
69
+ /**
70
+ * Is this a name a generator may build a path from?
71
+ *
72
+ * Refused rather than sanitised: a name with a separator in it is either a
73
+ * mistake or an attempt to write outside the project, and quietly rewriting it
74
+ * would produce a file somewhere the user did not ask for.
75
+ */
76
+ export function nameProblem(name: string): string | undefined {
77
+ if (name.trim() === '') return 'the name is empty'
78
+ if (name.length > MAX_NAME_LENGTH) return `the name is longer than ${MAX_NAME_LENGTH} characters`
79
+ if (/[/\\]/.test(name)) return 'a name may not contain a path separator'
80
+ if (name.includes('..')) return 'a name may not contain ".."'
81
+ if (!/^[A-Za-z][A-Za-z0-9_\-:]*$/.test(name)) {
82
+ return 'a name starts with a letter and holds letters, digits, "_", "-" or ":"'
83
+ }
84
+ return undefined
85
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Finding a stub, rendering it, and knowing where it goes.
3
+ *
4
+ * A stub is addressed the way `codemods.makeUsingStub` addresses one — a
5
+ * package's `stubsRoot` plus a path inside it, `make/controller.stub` — so a
6
+ * generator and a package's `configure()` hook read the same file through the
7
+ * same rule. The application's own copy under `stubs/` wins over the shipped
8
+ * one, which is what `ream eject` is for.
9
+ */
10
+
11
+ import * as fs from 'node:fs'
12
+ import * as path from 'node:path'
13
+ import { render, type StubState } from '../../stubs/template.js'
14
+
15
+ /** Where an application keeps its own copies — Adonis' `stubs/` root. */
16
+ export const PUBLISHED_ROOT = 'stubs'
17
+
18
+ /** The stubs this package ships. */
19
+ export const REAM_STUBS_ROOT = path.join(import.meta.dirname, '..', '..', '..', 'stubs')
20
+
21
+ export interface RenderedStub {
22
+ /** Relative to the project root, as the stub's front matter declares it. */
23
+ path: string
24
+ contents: string
25
+ /** Did the application's own copy win? */
26
+ published: boolean
27
+ }
28
+
29
+ /**
30
+ * A stub path that could escape its root never reaches the filesystem.
31
+ *
32
+ * `eject` takes one from the command line, so this is the boundary: no
33
+ * absolute path, no `..`, and a `.stub` file at the end of it.
34
+ */
35
+ export function isSafeStubPath(stubPath: string): boolean {
36
+ if (stubPath === '' || stubPath.length > 256) return false
37
+ if (path.isAbsolute(stubPath)) return false
38
+ const segments = stubPath.split(/[/\\]/)
39
+ if (segments.includes('..') || segments.includes('')) return false
40
+ return segments.every((segment) => /^[\w.-]+$/.test(segment))
41
+ }
42
+
43
+ /** Where the application's copy of `stubPath` would live. */
44
+ export function publishedPath(stubPath: string, cwd: string): string | undefined {
45
+ if (!isSafeStubPath(stubPath)) return undefined
46
+ return path.join(cwd, PUBLISHED_ROOT, stubPath)
47
+ }
48
+
49
+ /** Read `stubPath` — the application's copy when there is one. */
50
+ export function readStub(
51
+ stubsRoot: string,
52
+ stubPath: string,
53
+ cwd: string,
54
+ ): { source: string; published: boolean } {
55
+ const published = publishedPath(stubPath, cwd)
56
+ if (published !== undefined && fs.existsSync(published)) {
57
+ return { source: fs.readFileSync(published, 'utf8'), published: true }
58
+ }
59
+ if (!isSafeStubPath(stubPath)) {
60
+ throw new Error(`[make] unusable stub path: ${stubPath}`)
61
+ }
62
+ const shipped = path.join(stubsRoot, stubPath)
63
+ if (!fs.existsSync(shipped)) {
64
+ throw new Error(`[make] no stub at "${stubPath}" (looked in ${stubsRoot})`)
65
+ }
66
+ return { source: fs.readFileSync(shipped, 'utf8'), published: false }
67
+ }
68
+
69
+ /** Split `---\nto: …\n---\n<body>`; the front matter names the destination. */
70
+ export function splitFrontMatter(template: string): { to?: string; body: string } {
71
+ const trimmed = template.replace(/^[\r\n]+/, '')
72
+ if (!trimmed.startsWith('---')) return { body: template }
73
+ const after = trimmed.slice(3).replace(/^\r?\n/, '')
74
+ const end = after.indexOf('\n---')
75
+ if (end === -1) {
76
+ throw new Error('[make] a stub opened its front matter with `---` and never closed it')
77
+ }
78
+ const block = after.slice(0, end)
79
+ const body = after.slice(end + 4).replace(/^\r?\n/, '')
80
+ let to: string | undefined
81
+ for (const rawLine of block.split('\n')) {
82
+ const line = rawLine.trim()
83
+ if (line === '' || line.startsWith('#')) continue
84
+ const at = line.indexOf(':')
85
+ if (at === -1) throw new Error(`[make] unreadable stub front-matter line: \`${line}\``)
86
+ if (line.slice(0, at).trim() === 'to') to = line.slice(at + 1).trim()
87
+ }
88
+ return to === undefined ? { body } : { to, body }
89
+ }
90
+
91
+ /**
92
+ * The destination a stub declares, checked.
93
+ *
94
+ * Absolute paths and `..` are refused rather than normalised: a published stub
95
+ * is a file in the project, and a generator is not a way to write outside it.
96
+ */
97
+ function assertInsideProject(destination: string): void {
98
+ if (destination === '') throw new Error('[make] the stub declares an empty destination')
99
+ if (path.isAbsolute(destination)) {
100
+ throw new Error(`[make] a stub may not write to an absolute path: ${destination}`)
101
+ }
102
+ if (destination.split(/[/\\]/).includes('..')) {
103
+ throw new Error(`[make] a stub may not write outside the project: ${destination}`)
104
+ }
105
+ }
106
+
107
+ export function renderStub(
108
+ stubsRoot: string,
109
+ stubPath: string,
110
+ state: StubState,
111
+ cwd: string,
112
+ fallbackPath?: string,
113
+ ): RenderedStub {
114
+ const { source, published } = readStub(stubsRoot, stubPath, cwd)
115
+ const { to, body } = splitFrontMatter(source)
116
+ const destination = to === undefined ? fallbackPath : render(to, state)
117
+ if (destination === undefined) {
118
+ throw new Error(
119
+ `[make] the stub at "${stubPath}" declares no destination — add a \`to:\` front-matter line.`,
120
+ )
121
+ }
122
+ assertInsideProject(destination)
123
+ return { path: destination, contents: render(body, state), published }
124
+ }
125
+
126
+ /** `make/controller.stub` — the stub a generator of that kind reads. */
127
+ export function stubPathFor(kind: string): string {
128
+ return `make/${kind}.stub`
129
+ }
130
+
131
+ /** Render one of this package's own generator stubs. */
132
+ export function renderStubFor(kind: string, state: StubState, cwd: string): RenderedStub {
133
+ return renderStub(REAM_STUBS_ROOT, stubPathFor(kind), state, cwd)
134
+ }
package/src/console/ui.ts CHANGED
@@ -1,37 +1,25 @@
1
1
  /**
2
- * Console UI — `this.logger` and `this.prompt` on every command (Console parity).
2
+ * The one colour helper the Kernel's help and the prompts use.
3
3
  *
4
- * Kept dependency-free: the framework ships no colour or prompt library, and
5
- * pulling one in for the CLI surface alone is not worth the install weight.
4
+ * It is a thin call into `@c9up/lumen` rather than a second palette: two
5
+ * tables of escape codes in one package is how `[ warn ]` ends up yellow in
6
+ * one command and plain in another.
7
+ *
8
+ * The environment is read on EVERY call, not cached, because a command can be
9
+ * run in-process with the environment changed around it — which is exactly
10
+ * what the tests covering `FORCE_COLOR` do.
6
11
  */
7
12
 
8
13
  import { stdout } from 'node:process'
14
+ import { ansiColors, type Colors, silentColors, supportsColor } from '@c9up/lumen'
9
15
 
10
- /** Colour is opt-out via NO_COLOR and skipped when piped to a file. */
11
- function supportsColour(stream: NodeJS.WriteStream): boolean {
12
- if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
13
- if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '') return true
14
- return Boolean(stream.isTTY)
15
- }
16
-
17
- const CODES = {
18
- reset: '\u001B[0m',
19
- dim: '\u001B[2m',
20
- red: '\u001B[31m',
21
- green: '\u001B[32m',
22
- yellow: '\u001B[33m',
23
- blue: '\u001B[34m',
24
- magenta: '\u001B[35m',
25
- cyan: '\u001B[36m',
26
- } as const
27
-
28
- export type Colour = keyof Omit<typeof CODES, 'reset'>
16
+ export type Colour = 'dim' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan'
29
17
 
30
18
  export function colourise(
31
19
  text: string,
32
20
  colour: Colour,
33
21
  stream: NodeJS.WriteStream = stdout,
34
22
  ): string {
35
- if (!supportsColour(stream)) return text
36
- return `${CODES[colour]}${text}${CODES.reset}`
23
+ const colors: Colors = supportsColor(stream) ? ansiColors() : silentColors()
24
+ return colors[colour](text)
37
25
  }
package/src/dev/hmr.ts CHANGED
@@ -45,6 +45,90 @@ function holder(): CounterHolder {
45
45
  export function hotReloadHappened(): void {
46
46
  const store = holder()
47
47
  store[COUNTER] = (store[COUNTER] ?? 0) + 1
48
+ notifyClients()
49
+ }
50
+
51
+ /**
52
+ * The dev clients currently listening.
53
+ *
54
+ * A `Set` and not a count: each entry is pushed to on a swap, and dropped when
55
+ * the tab closes. `unknown`-typed on purpose — this module must not import the
56
+ * HTTP layer, which imports it back.
57
+ */
58
+ interface DevClient {
59
+ send: (event: string, data: unknown) => Promise<boolean>
60
+ onClose: (callback: () => void) => void
61
+ }
62
+
63
+ const clients = new Set<DevClient>()
64
+
65
+ /** The event name the injected script listens for. */
66
+ const HMR_EVENT = 'ream:hmr'
67
+
68
+ /**
69
+ * Answer the dev client BEFORE any application middleware runs, and PUSH.
70
+ *
71
+ * Two problems, one change.
72
+ *
73
+ * It used to be an ordinary route polled once a second, per open tab, and a
74
+ * route runs the whole middleware stack. An application that resolves a user
75
+ * from a session cookie therefore ran `SELECT * FROM users` once a second for
76
+ * a response whose entire content is a process id and a counter, and which
77
+ * depends on no user, no session and no body.
78
+ *
79
+ * It is a stream now, so the cost is paid ONCE per tab instead of once a
80
+ * second, and a change reaches the browser immediately rather than up to a
81
+ * second later. Upstream gets the same thing from Vite's websocket; this needs
82
+ * no bundler, because the server already speaks SSE.
83
+ *
84
+ * Registered FIRST rather than merely outside the router: middleware that
85
+ * authenticates lives at either tier, and an application is free to put it in
86
+ * `server.use([...])` — many do, because it must run before routing.
87
+ *
88
+ * Development only. In production nothing injects the script and nothing
89
+ * registers this.
90
+ */
91
+ export function hmrEndpoint(): (
92
+ ctx: {
93
+ request: { url: () => string }
94
+ response: { sse: () => Promise<DevClient> }
95
+ },
96
+ next: () => Promise<void>,
97
+ ) => Promise<void> {
98
+ return async (ctx, next) => {
99
+ // `url()` carries the query string; a client may append a cache-buster.
100
+ const path = ctx.request.url().split('?')[0]
101
+ if (path !== HMR_PATH) {
102
+ await next()
103
+ return
104
+ }
105
+
106
+ let stream: DevClient
107
+ try {
108
+ stream = await ctx.response.sse()
109
+ } catch {
110
+ // A host without the streaming NAPI — a mock server in a test, say.
111
+ // Losing the reload is fine; failing the request is not.
112
+ await next()
113
+ return
114
+ }
115
+
116
+ clients.add(stream)
117
+ stream.onClose(() => clients.delete(stream))
118
+ // Immediately, so a tab that connects after a restart compares against the
119
+ // CURRENT token rather than waiting for the next swap to learn anything.
120
+ await stream.send(HMR_EVENT, hmrToken())
121
+ }
122
+ }
123
+
124
+ /** Tell every open tab the token moved. */
125
+ function notifyClients(): void {
126
+ const token = hmrToken()
127
+ for (const client of clients) {
128
+ // Detached: a swap must not wait on a socket, and a dead one is dropped by
129
+ // its own close handler.
130
+ void client.send(HMR_EVENT, token).catch(() => clients.delete(client))
131
+ }
48
132
  }
49
133
 
50
134
  /** How many hot swaps this process has seen. */
@@ -63,10 +147,15 @@ export function hmrToken(): string {
63
147
  /**
64
148
  * The script injected into HTML pages in dev.
65
149
  *
66
- * It reloads when the token changes, NOT when the request fails: a server that
67
- * is restarting is unreachable for a moment, and reloading then would only show
68
- * the browser's error page. It waits for an answer, and the answer carries a
69
- * new boot id.
150
+ * An `EventSource`, not a poll. The server pushes the token, so a change
151
+ * reaches the page immediately and an idle tab costs one open connection
152
+ * rather than a request per second through the whole middleware stack.
153
+ *
154
+ * It reloads when the token CHANGES, never when the connection fails: a
155
+ * restarting server is unreachable for a moment, and reloading then would only
156
+ * show the browser's error page. `EventSource` reconnects on its own, and the
157
+ * server greets the new connection with its token — which after a restart is a
158
+ * different boot id, so the reload happens then, once the server can serve it.
70
159
  */
71
160
  export function hmrClientScript(nonce?: string): string {
72
161
  // A nonce-based CSP — which the scaffold ships, via `@c9up/blackhole` —
@@ -75,9 +164,13 @@ export function hmrClientScript(nonce?: string): string {
75
164
  // are escaped because a nonce is generated, not user input, but a broken
76
165
  // attribute would silently disable the tag rather than fail loudly.
77
166
  const attribute = nonce === undefined ? '' : ` nonce="${nonce.replace(/"/g, '&quot;')}"`
78
- // A failed poll RETRIES. Returning on `!r.ok` stopped the loop for good, so a
79
- // page opened in the window before the endpoint is mounted — or during any
80
- // blip — never reloaded again for the life of that tab, which looks exactly
81
- // like the feature not working.
82
- return `<script${attribute}>(()=>{let t=null;const p=${JSON.stringify(HMR_PATH)};const tick=async()=>{try{const r=await fetch(p,{cache:'no-store'});if(r.ok){const n=(await r.text()).trim();if(t===null){t=n}else if(n!==t){location.reload();return}}}catch(e){}setTimeout(tick,1000)};tick()})()</script>`
167
+ // `addEventListener(HMR_EVENT)`, NOT `onmessage`: the stream names its
168
+ // events, and `onmessage` fires only for unnamed ones — a client wired that
169
+ // way receives nothing at all, silently, which is a mistake this codebase
170
+ // has already made once with relay.
171
+ return `<script${attribute}>(()=>{let t=null;const s=new EventSource(${JSON.stringify(
172
+ HMR_PATH,
173
+ )});s.addEventListener(${JSON.stringify(
174
+ HMR_EVENT,
175
+ )},e=>{let n;try{n=JSON.parse(e.data)}catch(_){n=e.data}if(t===null){t=n}else if(n!==t){s.close();location.reload()}})})()</script>`
83
176
  }
@@ -7,9 +7,16 @@
7
7
  * what they need is to be right, not fast.
8
8
  *
9
9
  * The dialect is the one those files already use:
10
- * * any run of characters inside one path segment
11
- * ** any number of segments, including none
12
- * ? exactly one character inside a segment
10
+ * * any run of characters inside one path segment
11
+ * ** any number of segments, including none
12
+ * ? exactly one character inside a segment
13
+ * {a,b} either alternative
14
+ *
15
+ * The brace form is the one a `metaFiles` entry uses —
16
+ * `resources/lang/**\/*.{json,yaml,yml}` — and it has to be understood on this
17
+ * side too: the CLI matches the same pattern when it copies those files into
18
+ * the build, and a dialect that differed between the two would let an entry
19
+ * copy at build time and never fire in development, or the reverse.
13
20
  *
14
21
  * A leading `./` is optional, and matching is done on POSIX-shaped paths
15
22
  * relative to the project root, so a Windows checkout and a Linux one read the
@@ -42,6 +49,24 @@ export function globToRegExp(pattern: string): RegExp {
42
49
  out += '[^/]'
43
50
  continue
44
51
  }
52
+ if (char === '{') {
53
+ const close = cleaned.indexOf('}', i)
54
+ if (close !== -1) {
55
+ // Alternatives are literal text, never nested globs: that is the whole
56
+ // of the form these patterns use, and a recursive reading would accept
57
+ // shapes the CLI's matcher does not.
58
+ const options = cleaned
59
+ .slice(i + 1, close)
60
+ .split(',')
61
+ .map((option) => option.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
62
+ out += `(?:${options.join('|')})`
63
+ i = close
64
+ continue
65
+ }
66
+ // Unclosed: read literally rather than throwing. A pattern is a string in
67
+ // a config file, and a malformed one should match nothing, not take the
68
+ // dev server down at boot.
69
+ }
45
70
  // Everything else is literal, including the characters a regular
46
71
  // expression would otherwise read as syntax.
47
72
  out += (char ?? '').replace(/[.+^${}()|[\]\\]/g, '\\$&')