From e4b4602d5058feb32d70cf5df18db71d6358550e Mon Sep 17 00:00:00 2001 From: Julien Ripouteau Date: Fri, 6 Nov 2026 21:32:06 +0000 Subject: [PATCH] fix: inject import.meta.hot into every module using import.meta ed28a8c only injected the helpers into modules containing `import.meta.hot`. @adonisjs/fold re-imports controllers only when `"hot" in import.meta`, so AdonisJS stopped hot reloading controllers. A module can only reach its `import.meta` by writing it in its own source, so inject into every module containing `import.meta` and leave the others unchanged. To keep positions exact in injected modules, shift their inline source map (tsx, ts-node, @poppinss/ts-exec) by the length of the injected code. Also decode `source` with TextDecoder: `toString()` on a Uint8Array returns its comma-separated bytes. Refs #15 Amp-Thread-ID: https://ampcode.com/threads/T-01a1b212-dc0a-7380-bad5-624903b77b96 --- .changeset/quiet-hooks-skip.md | 6 +- packages/hot_hook/src/loader.ts | 25 +++++++-- packages/hot_hook/src/source_map.ts | 77 ++++++++++++++++++++++++++ packages/hot_hook/tests/loader.spec.ts | 66 +++++++++++++++++++++- 4 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 packages/hot_hook/src/source_map.ts diff --git a/.changeset/quiet-hooks-skip.md b/.changeset/quiet-hooks-skip.md index bf5fe17..752d903 100644 --- a/.changeset/quiet-hooks-skip.md +++ b/.changeset/quiet-hooks-skip.md @@ -2,8 +2,6 @@ 'hot-hook': patch --- -Only inject the `import.meta.hot` helpers into modules that reference `import.meta.hot`. Other modules, including `node_modules` dependencies, are now loaded unchanged, so their first-line columns and source maps are no longer shifted by the injected code. See #15. +Only inject the `import.meta.hot` helpers into modules that reference `import.meta`. Other modules, including most `node_modules` dependencies, are now loaded unchanged, so their positions and source maps are no longer shifted by the injected code. See #15. -If you need hot-hook in a module without the injection, import it directly: `import { hot } from 'hot-hook'`, then call `hot.dispose(import.meta.url, callback)` or `hot.decline(import.meta.url)`. - -Modules that only access `import.meta.hot` indirectly (for example `const meta = import.meta; meta.hot`) no longer get it. +Modules that get the helpers keep exact positions when they have an inline source map, as produced by tsx, ts-node or `@poppinss/ts-exec`: hot-hook now shifts it to account for the injected code. diff --git a/packages/hot_hook/src/loader.ts b/packages/hot_hook/src/loader.ts index ff3791a..9d9d78b 100644 --- a/packages/hot_hook/src/loader.ts +++ b/packages/hot_hook/src/loader.ts @@ -8,6 +8,7 @@ import type { InitializeHook, LoadHook, ResolveHook } from 'node:module' import debug from './debug.js' import { Matcher } from './matcher.js' import DependencyTree from './dependency_tree.js' +import { shiftInlineSourceMap } from './source_map.js' import type { InitializeHookOptions } from './types.js' import { DynamicImportChecker } from './dynamic_import_checker.js' import { FileNotImportedDynamicallyException } from './errors/file_not_imported_dynamically_exception.js' @@ -224,13 +225,27 @@ export class HotHookLoader { if (result.format !== 'module') return result /** - * Only inject into modules that reference `import.meta.hot`, so other - * modules keep their original source and positions intact. + * Decode explicitly: `source` can be a Uint8Array, and `toString()` + * would produce its comma-separated bytes */ - const source = result.source!.toString() - if (!source.includes('import.meta.hot')) return result + const source = + typeof result.source === 'string' ? result.source : new TextDecoder().decode(result.source) - result.source = this.#getImportMetaHotSource() + source + /** + * A module can only access its `import.meta` by writing it in its own + * source (`eval` and `new Function` can't), so modules without it can't + * observe `import.meta.hot` and are loaded unchanged. Matching only + * `import.meta.hot` would miss accesses like `"hot" in import.meta` + * used by @adonisjs/fold. + */ + if (!source.includes('import.meta')) return result + + /** + * The helpers are prepended on line 1, so shift the module's inline + * source map (from tsx, ts-node, ...) to keep positions exact. + */ + const hotSource = this.#getImportMetaHotSource() + result.source = shiftInlineSourceMap(hotSource + source, hotSource.length) return result } diff --git a/packages/hot_hook/src/source_map.ts b/packages/hot_hook/src/source_map.ts new file mode 100644 index 0000000..92ddae0 --- /dev/null +++ b/packages/hot_hook/src/source_map.ts @@ -0,0 +1,77 @@ +const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +/** + * Inline source map comment. Not necessarily at the very end: ts-exec appends + * a `//# sourceURL=` comment after it + */ +const INLINE_MAP = + /^\/\/[#@] sourceMappingURL=data:application\/json(?:;charset=[^,;]+)?;base64,([\d+/=A-Za-z]+)[\t ]*$/gm + +/** + * Decodes the first Base64 VLQ value of `mappings` + */ +function decodeFirstVlq(mappings: string) { + let shift = 0 + let value = 0 + let length = 0 + + for (;;) { + const digit = BASE64.indexOf(mappings[length++]) + value += (digit & 31) << shift + shift += 5 + if (!(digit & 32)) break + } + + return { value: value & 1 ? -(value >>> 1) : value >>> 1, length } +} + +function encodeVlq(value: number) { + let vlq = value < 0 ? (-value << 1) | 1 : value << 1 + let encoded = '' + + do { + let digit = vlq & 31 + vlq >>>= 5 + if (vlq) digit |= 32 + encoded += BASE64[digit] + } while (vlq) + + return encoded +} + +/** + * Shifts the generated columns of the first line of an inline source map by + * `offset`, to account for code prepended on that line. + * + * Only the first segment of line 1 holds an absolute column, the following + * ones are relative to it, so rewriting that single value is enough. + * Sources without an inline source map are returned unchanged. + */ +export function shiftInlineSourceMap(source: string, offset: number) { + /** + * Like Node.js, use the last source map comment + */ + const match = [...source.matchAll(INLINE_MAP)].at(-1) + if (!match) return source + + let map: { mappings?: string; sections?: unknown } + try { + map = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')) + } catch { + return source + } + + /** + * Nothing is mapped on line 1, or an index map (not supported) + */ + if (!map.mappings || map.mappings[0] === ';' || map.sections) return source + + const first = decodeFirstVlq(map.mappings) + map.mappings = encodeVlq(first.value + offset) + map.mappings.slice(first.length) + + const encoded = Buffer.from(JSON.stringify(map)).toString('base64') + return ( + source.slice(0, match.index) + + `//# sourceMappingURL=data:application/json;base64,${encoded}` + + source.slice(match.index + match[0].length) + ) +} diff --git a/packages/hot_hook/tests/loader.spec.ts b/packages/hot_hook/tests/loader.spec.ts index 98cdbbd..ab7cd72 100644 --- a/packages/hot_hook/tests/loader.spec.ts +++ b/packages/hot_hook/tests/loader.spec.ts @@ -179,7 +179,7 @@ test.group('Loader', () => { assert.isDefined(result) }) - test('does not shift positions of modules that do not use import.meta.hot', async ({ + test('does not shift positions of modules that do not use import.meta', async ({ fs, assert, }) => { @@ -207,6 +207,70 @@ test.group('Loader', () => { assert.match(result.frame, /app\.js\?hot-hook=0:1:32\)$/) }) + test('injects import.meta.hot into modules checking `"hot" in import.meta`', async ({ + fs, + assert, + }) => { + await fakeInstall(fs.basePath) + + /** + * Like @adonisjs/fold, which re-imports controllers only when `hot` is set + */ + await fs.createJson('package.json', { type: 'module' }) + await fs.create('app.js', `export const hasHot = 'hot' in import.meta`) + await fs.create( + 'server.js', + `import { hot } from 'hot-hook' + + await hot.init({ root: import.meta.filename }) + + const app = await import('./app.js') + process.send({ type: 'has-hot', value: app.hasHot })`, + ) + + const server = runProcess('server.js', { cwd: fs.basePath }) + const result: any = await pEvent(server.child, 'message', (m: any) => m?.type === 'has-hot') + + assert.isTrue(result.value) + }) + + test('keeps inline source maps exact on the first line', async ({ fs, assert }) => { + await fakeInstall(fs.basePath) + + /** + * Segments: col 0 -> 1:0, col 28 (`new Error`) -> 1:10, col 60 -> 5:0. + * Without shifting, the error position falls on the last segment (5:0) + */ + const map = { version: 3, sources: ['original.ts'], names: [], mappings: 'AAAA,4BAAU,gCAIV' } + await fs.createJson('package.json', { type: 'module' }) + await fs.create( + 'app.js', + `export function boom() { throw new Error(import.meta.url) } /* padding */\n` + + `//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString('base64')}\n`, + ) + await fs.create( + 'server.js', + `import { hot } from 'hot-hook' + + await hot.init({ root: import.meta.filename }) + + const app = await import('./app.js', { with: { hot: 'true' } }) + try { + app.boom() + } catch (error) { + process.send({ type: 'stack', frame: error.stack.split('\\n')[1] }) + }`, + ) + + const server = runProcess('server.js', { + cwd: fs.basePath, + nodeOptions: ['--enable-source-maps'], + }) + const result: any = await pEvent(server.child, 'message', (m: any) => m?.type === 'stack') + + assert.match(result.frame, /original\.ts:1:11\)$/) + }) + test('send invalidated message when file is invalidated', async ({ fs, assert }) => { await fakeInstall(fs.basePath)