Skip to content

Commit a95cf64

Browse files
committed
fix: change bundler injection point
Was failing to call the onInject method for turbopack'ed modules.
1 parent 3f5b4ba commit a95cf64

2 files changed

Lines changed: 154 additions & 5 deletions

File tree

packages/server-utils/src/orchestrion/bundler/webpack-loader.ts

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,89 @@
44
// needs its own entrypoint/subpath rather than being reachable from another module.
55
import codeTransformerLoaderImpl from '@apm-js-collab/code-transformer-bundler-plugins/webpack-loader';
66

7-
// Explicitly typed so the emitted declaration doesn't reference the bundled devDependency.
8-
// (Nothing imports this subpath from TS — bundlers load it by file path — so the loose
9-
// signature is never consumed.)
10-
const codeTransformerLoader: (this: unknown, code: string, inputSourceMap?: unknown) => void =
11-
codeTransformerLoaderImpl;
7+
// The loader context we rely on beyond the transform itself: `resourcePath` to
8+
// name the module, `async` for the transformed result, and `_compilation` to
9+
// tell webpack (present) from Turbopack (absent).
10+
interface LoaderContext {
11+
resourcePath: string;
12+
async: () => (error: unknown, code?: string, map?: unknown) => void;
13+
_compilation?: unknown;
14+
}
15+
16+
type LoaderFn = (this: LoaderContext, code: string, inputSourceMap?: unknown) => void;
17+
18+
const upstreamLoader = codeTransformerLoaderImpl as unknown as LoaderFn;
19+
20+
/**
21+
* The npm package name for a module path.
22+
* Reads the segment after the LAST `node_modules`, so pnpm's nested layout
23+
* resolves to the real package. Matches the name that channel integrations
24+
* await.
25+
*/
26+
function packageNameFromPath(resourcePath: string): string | undefined {
27+
const marker = '/node_modules/';
28+
const normalized = resourcePath.replace(/\\/g, '/');
29+
const index = normalized.lastIndexOf(marker);
30+
if (index === -1) {
31+
return undefined;
32+
}
33+
34+
const [scopeOrName, name] = normalized.slice(index + marker.length).split('/');
35+
if (!scopeOrName) {
36+
return undefined;
37+
}
38+
39+
return scopeOrName.startsWith('@') && name ? `${scopeOrName}/${name}` : scopeOrName;
40+
}
41+
42+
/**
43+
* Announce a runtime-injected module the way the runtime `--import` hook does,
44+
* so the lazily-registered channel integrations subscribe. Appended to each
45+
* transformed module's code, it runs when that module loads.
46+
*/
47+
function onInjectSnippet(moduleName: string): string {
48+
return (
49+
';(function(){' +
50+
'var g=globalThis.__SENTRY_ORCHESTRION__;' +
51+
`if(g&&typeof g.onInject==='function')g.onInject(${JSON.stringify(moduleName)});` +
52+
'})();\n'
53+
);
54+
}
55+
56+
/**
57+
* Wraps the upstream code-transform loader.
58+
*
59+
* Under Turbopack the transform runs as a loader, but the webpack *plugin* that
60+
* emits the `injectDiagnostics` boot banner never runs, because Turbopack takes
61+
* loaders, not plugins. That banner is what calls `onInject` for bundled
62+
* modules, so without it the channel integrations never learn their module
63+
* loaded and never subscribe. When there is no webpack compilation (Turbopack
64+
* case), append the `onInject` call to each transformed module here instead.
65+
* Under webpack leave it to the banner, so signal fires exactly once per module
66+
*/
67+
const codeTransformerLoader: LoaderFn = function (code, inputSourceMap) {
68+
if (this._compilation) {
69+
upstreamLoader.call(this, code, inputSourceMap);
70+
return;
71+
}
72+
73+
const realAsync = this.async.bind(this);
74+
const { resourcePath } = this;
75+
76+
this.async = () => {
77+
const callback = realAsync();
78+
return (error: unknown, outputCode?: string, outputMap?: unknown): void => {
79+
// The upstream loader returns the input code unchanged when it did not
80+
// transform the module, so a changed string means a channel-publishing
81+
// module we must announce.
82+
const transformed = !error && typeof outputCode === 'string' && outputCode !== code;
83+
const moduleName = transformed ? packageNameFromPath(resourcePath) : undefined;
84+
const finalCode = moduleName ? `${outputCode}${onInjectSnippet(moduleName)}` : outputCode;
85+
callback(error, finalCode, outputMap);
86+
};
87+
};
88+
89+
upstreamLoader.call(this, code, inputSourceMap);
90+
};
1291

1392
export default codeTransformerLoader;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import loader from '../../src/orchestrion/bundler/webpack-loader';
3+
4+
// Stand in for the upstream code-transform loader: a changed string signals a
5+
// transformed module, the input unchanged signals a pass-through.
6+
vi.mock('@apm-js-collab/code-transformer-bundler-plugins/webpack-loader', () => ({
7+
default: function (this: { async: () => (e: unknown, c?: string, m?: unknown) => void }, code: string, map: unknown) {
8+
const callback = this.async();
9+
callback(null, code === 'PASS' ? code : `${code};//transformed`, map);
10+
},
11+
}));
12+
13+
interface Ctx {
14+
resourcePath: string;
15+
_compilation?: unknown;
16+
}
17+
18+
function runLoader(ctx: Ctx, code: string): string | undefined {
19+
let output: string | undefined;
20+
const context = {
21+
...ctx,
22+
async: () => (_error: unknown, outCode?: string) => {
23+
output = outCode;
24+
},
25+
};
26+
(loader as (this: unknown, code: string) => void).call(context, code);
27+
return output;
28+
}
29+
30+
const IOREDIS = '/app/node_modules/ioredis/built/Redis.js';
31+
32+
describe('orchestrion webpack/Turbopack loader', () => {
33+
it('appends the onInject call for a transformed module under Turbopack (no compilation)', () => {
34+
const output = runLoader({ resourcePath: IOREDIS }, 'code');
35+
36+
expect(output).toContain('code;//transformed');
37+
expect(output).toContain('g.onInject("ioredis")');
38+
});
39+
40+
it('does not append when webpack runs the plugin banner (compilation present)', () => {
41+
const output = runLoader({ resourcePath: IOREDIS, _compilation: {} }, 'code');
42+
43+
expect(output).toBe('code;//transformed');
44+
expect(output).not.toContain('onInject');
45+
});
46+
47+
it('does not append for a pass-through (untransformed) module', () => {
48+
const output = runLoader({ resourcePath: IOREDIS }, 'PASS');
49+
50+
expect(output).toBe('PASS');
51+
});
52+
53+
it.each([
54+
['/app/node_modules/ioredis/built/Redis.js', 'ioredis'],
55+
['/app/node_modules/@redis/client/dist/lib/client/index.js', '@redis/client'],
56+
// pnpm's nested layout: the real package is after the LAST node_modules.
57+
['/app/node_modules/.pnpm/ioredis@5.10.1/node_modules/ioredis/built/Redis.js', 'ioredis'],
58+
])('derives the package name from %s as %s', (resourcePath, expected) => {
59+
const output = runLoader({ resourcePath }, 'code');
60+
61+
expect(output).toContain(`g.onInject("${expected}")`);
62+
});
63+
64+
it('does not append when the path has no node_modules segment', () => {
65+
const output = runLoader({ resourcePath: '/app/src/index.js' }, 'code');
66+
67+
expect(output).toBe('code;//transformed');
68+
expect(output).not.toContain('onInject');
69+
});
70+
});

0 commit comments

Comments
 (0)