Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .changeset/quiet-hooks-skip.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 20 additions & 5 deletions packages/hot_hook/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
}

Expand Down
77 changes: 77 additions & 0 deletions packages/hot_hook/src/source_map.ts
Original file line number Diff line number Diff line change
@@ -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)
)
}
66 changes: 65 additions & 1 deletion packages/hot_hook/tests/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) => {
Expand Down Expand Up @@ -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)

Expand Down
Loading