@c9up/ream 0.2.23 → 0.2.24

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 (53) hide show
  1. package/dist/Ignitor.d.ts +22 -0
  2. package/dist/Ignitor.d.ts.map +1 -1
  3. package/dist/Ignitor.js.map +1 -1
  4. package/dist/dev/fullReload.d.ts +23 -0
  5. package/dist/dev/fullReload.d.ts.map +1 -0
  6. package/dist/dev/fullReload.js +26 -0
  7. package/dist/dev/fullReload.js.map +1 -0
  8. package/dist/dev/hotGlob.d.ts +28 -0
  9. package/dist/dev/hotGlob.d.ts.map +1 -0
  10. package/dist/dev/hotGlob.js +67 -0
  11. package/dist/dev/hotGlob.js.map +1 -0
  12. package/dist/dev/hotGraph.d.ts +58 -0
  13. package/dist/dev/hotGraph.d.ts.map +1 -0
  14. package/dist/dev/hotGraph.js +91 -0
  15. package/dist/dev/hotGraph.js.map +1 -0
  16. package/dist/dev/hotWatcher.d.ts +34 -0
  17. package/dist/dev/hotWatcher.d.ts.map +1 -0
  18. package/dist/dev/hotWatcher.js +72 -0
  19. package/dist/dev/hotWatcher.js.map +1 -0
  20. package/dist/env/loadEnvFiles.d.ts +13 -0
  21. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  22. package/dist/env/loadEnvFiles.js +65 -13
  23. package/dist/env/loadEnvFiles.js.map +1 -1
  24. package/dist/hot.d.ts +13 -16
  25. package/dist/hot.d.ts.map +1 -1
  26. package/dist/hot.js +110 -86
  27. package/dist/hot.js.map +1 -1
  28. package/dist/index.d.ts +1 -1
  29. package/dist/index.js +1 -1
  30. package/events.darwin-arm64.node +0 -0
  31. package/events.darwin-x64.node +0 -0
  32. package/events.linux-arm64-gnu.node +0 -0
  33. package/events.linux-x64-gnu.node +0 -0
  34. package/events.win32-x64-msvc.node +0 -0
  35. package/index.darwin-arm64.node +0 -0
  36. package/index.darwin-x64.node +0 -0
  37. package/index.linux-arm64-gnu.node +0 -0
  38. package/index.linux-x64-gnu.node +0 -0
  39. package/index.win32-x64-msvc.node +0 -0
  40. package/package.json +4 -7
  41. package/scheduler.darwin-arm64.node +0 -0
  42. package/scheduler.darwin-x64.node +0 -0
  43. package/scheduler.linux-arm64-gnu.node +0 -0
  44. package/scheduler.linux-x64-gnu.node +0 -0
  45. package/scheduler.win32-x64-msvc.node +0 -0
  46. package/src/Ignitor.ts +24 -0
  47. package/src/dev/fullReload.ts +34 -0
  48. package/src/dev/hotGlob.ts +66 -0
  49. package/src/dev/hotGraph.ts +109 -0
  50. package/src/dev/hotWatcher.ts +89 -0
  51. package/src/env/loadEnvFiles.ts +69 -14
  52. package/src/hot.ts +116 -105
  53. package/src/index.ts +1 -1
package/src/hot.ts CHANGED
@@ -5,132 +5,143 @@
5
5
  *
6
6
  * node --import @swc-node/register/esm-register --import @c9up/ream/hot bin/server.ts
7
7
  *
8
- * It is a thin shim over `hot-hook`, which does the actual work: it registers
9
- * an ESM loader that tracks the import graph, and when a file inside a declared
10
- * boundary changes it invalidates just that module instead of the process. The
11
- * `hotHook` key in the application's `package.json` configures it, exactly as
12
- * upstream reads it.
8
+ * It replaces a module that changed instead of the process that was serving it,
9
+ * so the container, the pools and the connections survive a save.
13
10
  *
14
- * NAMED DEVIATION — why this file exists at all.
11
+ * NAMED DEVIATION — this is ream's own, where AdonisJS uses `hot-hook`.
15
12
  *
16
- * Upstream's dev server is itself a Node process: it forks the app, so the two
17
- * talk over Node's IPC channel, and `hot-hook` reports a change it cannot swap
18
- * by calling `process.send({ type: 'hot-hook:full-reload' })`. Our dev server is
19
- * a Rust binary. A process it spawns has NO IPC channel, so `process.send` is
20
- * `undefined` and that message goes nowhere: the server would keep serving the
21
- * old module and say nothing.
13
+ * Upstream ships that package in its starter kit, which means every application
14
+ * installs it, and one created before the convention existed silently loses hot
15
+ * reloading. The parts that once justified a library are now in Node:
16
+ * `module.registerHooks` gives the import graph, `fs.watch` gives the changes.
17
+ * What is left is the decision of what to invalidate, which is this file and
18
+ * its three neighbours — and no entry in anyone's `package.json`.
22
19
  *
23
- * `hot.init()` takes an `onFullReloadAsked` callback for exactly this case, and
24
- * that is the whole of the shim: turn "I cannot hot-swap this" into an exit
25
- * code the Rust parent understands and restarts on. Everything else is
26
- * upstream's behaviour, unmodified.
20
+ * The graph comes from the resolver rather than from reading files: a resolve
21
+ * hook is handed the importer and the imported, which is exactly an edge, and
22
+ * it sees a dynamic import at the moment it happens rather than guessing at it
23
+ * from the source.
27
24
  */
28
25
 
26
+ import { existsSync, writeSync } from 'node:fs'
27
+ import { readFile } from 'node:fs/promises'
28
+ import { registerHooks } from 'node:module'
29
+ import { dirname, relative, resolve as resolvePath } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+ import { fullReloadNotice } from './dev/fullReload.js'
29
32
  import { hotReloadHappened } from './dev/hmr.js'
30
- import { type HotHookConfig, readHotHookConfig } from './dev/hotConfig.js'
33
+ import { readHotHookConfig } from './dev/hotConfig.js'
34
+ import { globMatcher } from './dev/hotGlob.js'
35
+ import { HotGraph } from './dev/hotGraph.js'
36
+ import { watchProject } from './dev/hotWatcher.js'
37
+ import { envFileNames } from './env/loadEnvFiles.js'
31
38
 
32
39
  /** Exit code meaning "restart me" — see `EXIT_RESTART` in the CLI's dev module. */
33
40
  const FULL_RELOAD_EXIT_CODE = 75
34
41
 
35
- interface HotHook {
36
- init(options: {
37
- rootDirectory: string
38
- root?: string
39
- boundaries?: string[]
40
- restart?: string[]
41
- ignore?: string[]
42
- throwWhenBoundariesAreNotDynamicallyImported?: boolean
43
- onFullReloadAsked?: () => void
44
- }): Promise<void>
45
- }
42
+ /** The query parameter that makes the loader fetch a module again. */
43
+ const VERSION_PARAM = 'ream_hot'
44
+
45
+ /** Directories a project keeps its own code in — the CLI watches the same list. */
46
+ const WATCH_DIRS = ['app', 'bin', 'config', 'start', 'database', 'providers', 'commands']
46
47
 
47
- const { readFile } = await import('node:fs/promises')
48
- const { dirname, resolve } = await import('node:path')
48
+ const packageJsonPath = resolvePath(process.cwd(), 'package.json')
49
+ const root = dirname(packageJsonPath)
49
50
 
50
- const packageJsonPath = resolve(process.cwd(), 'package.json')
51
- let config: HotHookConfig = {}
51
+ let config = {}
52
52
  try {
53
53
  config = readHotHookConfig(JSON.parse(await readFile(packageJsonPath, 'utf8')))
54
54
  } catch {
55
- // No package.json, or unreadable: fall through to the conventional
56
- // boundaries below rather than stopping dev over it.
55
+ // No package.json, or unreadable: a project with no boundaries declared gets
56
+ // a working server that restarts on change, which is the old behaviour.
57
57
  }
58
+ const { boundaries = [], restart = [] } = config as { boundaries?: string[]; restart?: string[] }
58
59
 
59
- // `hot-hook` is an optional peer, declared by the application because it is a
60
- // dev dependency there and has no business in a production install. Imported by
61
- // specifier built at runtime so a production bundle never resolves it.
62
- function isHotHook(value: unknown): value is HotHook {
63
- return (
64
- typeof value === 'object' && value !== null && typeof Reflect.get(value, 'init') === 'function'
65
- )
66
- }
60
+ // Env files are not modules: nothing imports one, so the graph never sees them
61
+ // and an edit to `.env` did nothing at all — the values are read once, at boot.
62
+ // They always restart.
63
+ const restartFiles = new Set(
64
+ envFileNames()
65
+ .map((name) => resolvePath(root, name))
66
+ .filter((file) => existsSync(file)),
67
+ )
67
68
 
68
- // `hot-hook` belongs to the APPLICATION: it is a dev dependency there and has
69
- // no business in a production install of the framework. So it is resolved from
70
- // the project directory rather than from this file — under pnpm's strict layout
71
- // a bare `import("hot-hook")` here looks inside @c9up/ream and finds nothing.
72
- // It is the same reason upstream passes `hot-hook/register` from the app's own
73
- // cwd instead of re-exporting it from the framework.
74
- const { createRequire } = await import('node:module')
75
- const { pathToFileURL } = await import('node:url')
69
+ const relativeToRoot = (file: string): string => relative(root, file).replace(/\\/g, '/')
70
+ const matchesBoundary = globMatcher(boundaries)
71
+ const matchesRestartGlob = globMatcher(restart)
76
72
 
77
- let hot: HotHook | undefined
78
- try {
79
- const resolved = createRequire(packageJsonPath).resolve('hot-hook')
80
- const module: unknown = await import(pathToFileURL(resolved).href)
81
- const candidate =
82
- typeof module === 'object' && module !== null ? Reflect.get(module, 'hot') : undefined
83
- if (!isHotHook(candidate)) {
84
- throw new Error('hot-hook did not expose a `hot` object with init()')
85
- }
86
- hot = candidate
87
- } catch (error) {
88
- // Loud, and it names the fix. Silence here would start a perfectly healthy
89
- // server that never picks up an edit, which reads as "HMR is broken" rather
90
- // than "HMR is not installed".
91
- //
92
- // It does NOT throw: a dev server that refuses to start is worse than one
93
- // without hot reloading, and the CLI only loads this file when it has
94
- // already seen hot-hook in the project — so reaching here means it went
95
- // missing under a running session.
96
- console.error(
97
- `[ream] hot module replacement is unavailable: ${error instanceof Error ? error.message : String(error)}\n` +
98
- '[ream] Install it with: pnpm add -D hot-hook, then restart `ream dev`.\n' +
99
- '[ream] Until then the server runs, but an edit will not be picked up.',
100
- )
101
- }
73
+ const graph = new HotGraph({
74
+ isBoundary: (file) => matchesBoundary(relativeToRoot(file)),
75
+ isRestart: (file) => restartFiles.has(file) || matchesRestartGlob(relativeToRoot(file)),
76
+ })
102
77
 
103
- // Impersonate the IPC channel upstream's dev server provides.
104
- //
105
- // hot-hook reports BOTH of its outcomes through `process.send`: a change it
106
- // swapped in place (`hot-hook:invalidated`) and one it could not
107
- // (`hot-hook:full-reload`). Under a Node parent that function exists and both
108
- // arrive; under our Rust parent it is `undefined` and both are dropped —
109
- // `onFullReloadAsked` alone recovers only half of that, leaving a hot swap
110
- // invisible to the very process it happened in, and so to the browser.
111
- //
112
- // This is not a trick played on hot-hook: it is exactly the contract it
113
- // expects, provided by us instead of by Node.
114
- const previousSend = process.send
115
- process.send = (message: unknown, ...rest: unknown[]): boolean => {
116
- const type =
117
- typeof message === 'object' && message !== null ? Reflect.get(message, 'type') : undefined
118
- if (type === 'hot-hook:invalidated') {
119
- hotReloadHappened()
120
- }
121
- // If a real channel ever exists (embedded under a Node supervisor) it still
122
- // gets its message: this observes, it does not intercept.
123
- if (typeof previousSend === 'function') {
124
- return Reflect.apply(previousSend, process, [message, ...rest]) === true
78
+ /**
79
+ * Track every edge the resolver reports, and hand back a versioned URL for a
80
+ * module that has been invalidated.
81
+ *
82
+ * The version travels in the URL because that is the only handle V8 offers: a
83
+ * module is keyed by its URL, so a different one is a different module. The
84
+ * parameter is stripped off the importer before delegating, or a relative
85
+ * specifier would be resolved against a URL that does not name a directory.
86
+ */
87
+ registerHooks({
88
+ resolve(specifier, context, nextResolve) {
89
+ let parentURL = context.parentURL
90
+ let parentPath: string | undefined
91
+ if (typeof parentURL === 'string' && parentURL.startsWith('file:')) {
92
+ const url = new URL(parentURL)
93
+ if (url.searchParams.has(VERSION_PARAM)) {
94
+ url.searchParams.delete(VERSION_PARAM)
95
+ parentURL = url.href
96
+ }
97
+ parentPath = fileURLToPath(url)
98
+ }
99
+
100
+ const result = nextResolve(specifier, { ...context, parentURL })
101
+ if (!result.url.startsWith('file:')) return result
102
+
103
+ const resolved = new URL(result.url)
104
+ resolved.searchParams.delete(VERSION_PARAM)
105
+ const file = fileURLToPath(resolved)
106
+ graph.link(file, parentPath)
107
+
108
+ const version = graph.version(file)
109
+ if (version === 0) return result
110
+ resolved.searchParams.set(VERSION_PARAM, String(version))
111
+ return { ...result, url: resolved.href }
112
+ },
113
+ })
114
+
115
+ /** Print a line that must survive the exit on the very next statement. */
116
+ function sayNow(line: string): void {
117
+ // Synchronously: a piped stdout — what `ream dev` hands this process when it
118
+ // runs an asset watcher alongside — is asynchronous, and the line would be
119
+ // dropped on the way out.
120
+ try {
121
+ writeSync(1, `${line}\n`)
122
+ } catch {
123
+ // The parent can close the pipe first on Ctrl-C. Losing the line is fine.
125
124
  }
126
- return true
127
125
  }
128
126
 
129
- await hot?.init({
130
- ...config,
131
- rootDirectory: dirname(packageJsonPath),
132
- root: config.root ? resolve(dirname(packageJsonPath), config.root) : undefined,
133
- // Exiting on a known code is how the Rust parent learns it must restart —
134
- // see `EXIT_RESTART` in the CLI's dev module.
135
- onFullReloadAsked: () => process.exit(FULL_RELOAD_EXIT_CODE),
127
+ watchProject({
128
+ root,
129
+ directories: WATCH_DIRS,
130
+ files: [...restartFiles].map((file) => relative(root, file)),
131
+ onChange: (file) => {
132
+ const decision = graph.decide(file)
133
+ if (decision.kind === 'ignore') return
134
+ if (decision.kind === 'reload') {
135
+ sayNow(fullReloadNotice(decision.file, decision.reason, relativeToRoot))
136
+ // Exiting on a known code is how the Rust parent learns it must restart —
137
+ // see `EXIT_RESTART` in the CLI's dev module.
138
+ process.exit(FULL_RELOAD_EXIT_CODE)
139
+ }
140
+ // The router caches a promoted controller per route, and the kernel drops
141
+ // that cache when this counter moves. Without it the swap happens and the
142
+ // process keeps serving the class it promoted: a stable PID and stale
143
+ // output, which is exactly how it was first reported.
144
+ hotReloadHappened()
145
+ sayNow(`[ream] hot swap — ${relativeToRoot(file)}`)
146
+ },
136
147
  })
package/src/index.ts CHANGED
@@ -385,4 +385,4 @@ export {
385
385
  StatsTracker,
386
386
  } from './scheduler/index.js'
387
387
 
388
- export const VERSION = '0.2.23'
388
+ export const VERSION = '0.2.24'