Skip to content

Commit 7acdd3d

Browse files
authored
chore(dev): make the plugin bundle debuggable in Obsidian (#57)
Obsidian appends its own sourceURL after plugin code and strips sourceMappingURL directives, so breakpoints in an attached Chromium debugger never resolved to Qoderian sources. The SDK timer patches also ran after esbuild emitted its map, shifting every position that followed. Wrap the dev bundle so both directives land in the order Chromium expects, bypass the directive stripping during reload, and keep patched regions line- and column-neutral.
1 parent 8602b5c commit 7acdd3d

6 files changed

Lines changed: 129 additions & 3 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ node_modules/
1919

2020
# Editors
2121
.idea/
22-
.vscode/
22+
.vscode/*
23+
!.vscode/launch.json
2324
*.swp
2425

2526
# OS

.vscode/launch.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"version": "0.2.0",
3+
"configurations": [
4+
{
5+
"type": "pwa-chrome",
6+
"request": "attach",
7+
"name": "Attach to Obsidian",
8+
"address": "127.0.0.1",
9+
"port": 9222,
10+
"webRoot": "${workspaceFolder}",
11+
"urlFilter": "app://obsidian.md/*",
12+
"sourceMaps": true,
13+
"sourceMapPathOverrides": {
14+
"src/*": "${workspaceFolder}/src/*"
15+
},
16+
"timeout": 30000
17+
}
18+
]
19+
}

esbuild.config.mjs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,47 @@ const patchRendererUnsafeUnref = {
145145
},
146146
};
147147

148+
// Obsidian evaluates community plugins and appends its own `sourceURL` comment
149+
// after the file contents. Chromium only associates a source map when the
150+
// `sourceMappingURL` directive comes after `sourceURL`, so esbuild's normal
151+
// inline map becomes invisible to attached debuggers. Keep production output
152+
// unchanged, but evaluate the development bundle once more with the directives
153+
// in the order Chromium expects.
154+
const exposeDevSourceMapToDebugger = {
155+
name: 'expose-dev-source-map-to-debugger',
156+
setup(build) {
157+
build.onEnd(async (result) => {
158+
if (result.errors.length > 0 || !existsSync('main.js')) return;
159+
160+
const bundlePath = path.join(process.cwd(), 'main.js');
161+
const contents = await fsPromises.readFile(bundlePath, 'utf8');
162+
const sourceMapPattern = /\n\/\/# sourceMappingURL=data:application\/json;base64,[A-Za-z0-9+/=]+\s*$/;
163+
const match = sourceMapPattern.exec(contents);
164+
165+
if (!match) {
166+
throw new Error('Development bundle is missing its inline source map.');
167+
}
168+
169+
const sourceMapDirective = match[0].trim();
170+
const sourceMapUrl = sourceMapDirective.slice('//# sourceMappingURL='.length);
171+
const bundleWithoutMap = contents.slice(0, match.index);
172+
const wrapper = [
173+
'// Development-only wrapper: exposes the inline source map to Chromium.',
174+
// Obsidian strips source-map directives before evaluating community
175+
// plugins. Assemble both directives at runtime so its source scanner
176+
// cannot remove the map while reading this outer wrapper.
177+
`const __qoderianDebugBundle = ${JSON.stringify(bundleWithoutMap)}`,
178+
` + '\\n//# source' + 'URL=plugin:qoderian-debug'`,
179+
` + '\\n//# sourceMapping' + 'URL=' + ${JSON.stringify(sourceMapUrl)} + '\\n';`,
180+
'eval(__qoderianDebugBundle);',
181+
'',
182+
].join('\n');
183+
184+
await fsPromises.writeFile(bundlePath, wrapper, 'utf8');
185+
});
186+
},
187+
};
188+
148189
// Obsidian plugin folder path (set via OBSIDIAN_VAULT env var or .env.local)
149190
const OBSIDIAN_VAULT = process.env.OBSIDIAN_VAULT;
150191
const OBSIDIAN_CONFIG_PATH = OBSIDIAN_VAULT && existsSync(OBSIDIAN_VAULT)
@@ -288,6 +329,7 @@ const context = await esbuild.context({
288329
plugins: [
289330
patchSdkImportMeta,
290331
patchRendererUnsafeUnref,
332+
...(prod ? [] : [exposeDevSourceMapToDebugger]),
291333
...(prod ? [] : [copyToObsidian]),
292334
],
293335
external: [

scripts/dev-reloader/main.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const WATCHED_ARTIFACTS = ['main.js', 'manifest.json', 'styles.css'];
1919
// Long enough for esbuild to finish copying all three artifacts, short enough to
2020
// still feel immediate.
2121
const RELOAD_DEBOUNCE_MS = 400;
22+
const DEBUG_PLUGIN_STORAGE_KEY = 'debug-plugin';
2223

2324
module.exports = class QoderianDevReloader extends Plugin {
2425
onload() {
@@ -56,12 +57,50 @@ module.exports = class QoderianDevReloader extends Plugin {
5657
// Respect a manually disabled target instead of force-enabling it.
5758
if (!plugins.enabledPlugins.has(TARGET_PLUGIN_ID)) return;
5859

60+
const previousDebugPlugin = window.localStorage.getItem(DEBUG_PLUGIN_STORAGE_KEY);
61+
const restoreAdapterRead = this.preserveSourceMapDuringPluginRead();
62+
5963
try {
6064
await plugins.disablePlugin(TARGET_PLUGIN_ID);
61-
await plugins.enablePlugin(TARGET_PLUGIN_ID);
65+
window.localStorage.setItem(DEBUG_PLUGIN_STORAGE_KEY, '1');
66+
67+
try {
68+
await plugins.unloadPlugin(TARGET_PLUGIN_ID);
69+
await plugins.loadPlugin(TARGET_PLUGIN_ID);
70+
await plugins.enablePlugin(TARGET_PLUGIN_ID);
71+
} finally {
72+
if (previousDebugPlugin === null) {
73+
window.localStorage.removeItem(DEBUG_PLUGIN_STORAGE_KEY);
74+
} else {
75+
window.localStorage.setItem(DEBUG_PLUGIN_STORAGE_KEY, previousDebugPlugin);
76+
}
77+
restoreAdapterRead();
78+
}
79+
6280
new Notice('Qoderian reloaded');
6381
} catch (error) {
82+
restoreAdapterRead();
6483
new Notice(`Qoderian reload failed: ${error?.message ?? error}`);
6584
}
6685
}
86+
87+
// Obsidian strips source map directives while loading community plugins.
88+
// A trailing marker bypasses that rewrite for the development bundle, so
89+
// Chromium receives the inline map that esbuild emitted.
90+
preserveSourceMapDuringPluginRead() {
91+
const adapter = this.app.vault.adapter;
92+
const originalRead = adapter.read;
93+
const targetSuffix = `/plugins/${TARGET_PLUGIN_ID}/main.js`;
94+
95+
const guardedRead = function (filePath, ...args) {
96+
const result = originalRead.call(this, filePath, ...args);
97+
if (!filePath.endsWith(targetSuffix)) return result;
98+
return Promise.resolve(result).then(contents => `${contents}\n/* nosourcemap */`);
99+
};
100+
101+
adapter.read = guardedRead;
102+
return () => {
103+
if (adapter.read === guardedRead) adapter.read = originalRead;
104+
};
105+
}
67106
};

scripts/renderer-safe-unref.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,15 @@ function patchRendererUnsafeUnrefSites(contents) {
9595
if (matchCount === 0) {
9696
continue;
9797
}
98-
nextContents = nextContents.replace(patch.pattern, patch.replacement);
98+
nextContents = nextContents.replace(patch.pattern, (matched, ...args) => {
99+
const captures = args.slice(0, -2);
100+
const expandedReplacement = patch.replacement.replace(
101+
/\$(\d+)/g,
102+
(_placeholder, index) => captures[Number(index) - 1] ?? '',
103+
);
104+
105+
return preserveFollowingGeneratedPositions(matched, expandedReplacement);
106+
});
99107
appliedPatches.push({ name: patch.name, count: matchCount });
100108
}
101109

@@ -105,6 +113,20 @@ function patchRendererUnsafeUnrefSites(contents) {
105113
};
106114
}
107115

116+
// These rewrites run after esbuild has generated its source map. Preserve the
117+
// matched region's newline count and ending column so mappings for all code
118+
// after an SDK patch (including Qoderian's own sources) remain accurate.
119+
function preserveFollowingGeneratedPositions(original, replacement) {
120+
const newlineCount = (original.match(/\n/g) ?? []).length;
121+
if (newlineCount === 0) return replacement.replace(/\s*\n\s*/g, ' ');
122+
123+
const originalLastLineLength = original.length - original.lastIndexOf('\n') - 1;
124+
const singleLineReplacement = replacement.replace(/\s*\n\s*/g, ' ');
125+
return singleLineReplacement
126+
+ '\n'.repeat(newlineCount)
127+
+ ' '.repeat(originalLastLineLength);
128+
}
129+
108130
function findUnsafeTimerUnrefSites(contents) {
109131
const matches = [];
110132

tests/unit/scripts/renderer-safe-unref.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('rendererSafeUnref helpers', () => {
2727
expect(result.contents).toContain('forceKillTimer.unref?.();');
2828
expect(result.contents).toContain('closeTimeout.unref?.();');
2929
expect(findUnsafeTimerUnrefSites(result.contents)).toEqual([]);
30+
expect(result.contents.split('\n')).toHaveLength(input.split('\n').length);
3031
});
3132

3233
it('patches the current qoder-sdk shape with a block-bodied exit handler', () => {
@@ -51,6 +52,7 @@ describe('rendererSafeUnref helpers', () => {
5152
expect(result.contents).toContain('forceKillTimer.unref?.();');
5253
expect(result.contents).toContain('this.processExitHandler');
5354
expect(findUnsafeTimerUnrefSites(result.contents)).toEqual([]);
55+
expect(result.contents.split('\n')).toHaveLength(input.split('\n').length);
5456
});
5557

5658
it('patches the latest qoder-sdk async close callback shape', () => {
@@ -82,6 +84,7 @@ describe('rendererSafeUnref helpers', () => {
8284
expect(result.contents).toContain('windowsForceKillTimer.unref?.();');
8385
expect(result.contents).toContain('forceKillTimer.unref?.();');
8486
expect(findUnsafeTimerUnrefSites(result.contents)).toEqual([]);
87+
expect(result.contents.split('\n')).toHaveLength(input.split('\n').length);
8588
});
8689

8790
it('reports remaining direct timer .unref() calls but ignores guarded usage', () => {

0 commit comments

Comments
 (0)