|
| 1 | +# Plan 003: Surface webpack `resolveId` errors as build errors and emit `Uint8Array` assets byte-exactly |
| 2 | + |
| 3 | +> **Executor instructions**: Follow this plan step by step. Run every |
| 4 | +> verification command and confirm the expected result before moving to the |
| 5 | +> next step. If anything in the "STOP conditions" section occurs, stop and |
| 6 | +> report — do not improvise. When done, update the status row for this plan |
| 7 | +> in `plans/README.md` — unless a reviewer dispatched you and told you they |
| 8 | +> maintain the index. |
| 9 | +> |
| 10 | +> **Drift check (run first)**: `git diff --stat 1ea89aa..HEAD -- src/webpack/index.ts src/webpack/context.ts test/unit-tests/webpack/context.test.ts test/unit-tests/resolve-id/resolve-id.test.ts` |
| 11 | +> If any in-scope file changed since this plan was written, compare the |
| 12 | +> "Current state" excerpts against the live code before proceeding; on a |
| 13 | +> mismatch, treat it as a STOP condition. |
| 14 | +
|
| 15 | +## Status |
| 16 | + |
| 17 | +- **Priority**: P1 |
| 18 | +- **Effort**: S |
| 19 | +- **Risk**: LOW |
| 20 | +- **Depends on**: plans/001-test-harness.md (for `runWebpack`/`runRspack` that reject on build errors) |
| 21 | +- **Category**: bug |
| 22 | +- **Planned at**: commit `1ea89aa`, 2026-10-27 |
| 23 | + |
| 24 | +## Why this matters |
| 25 | + |
| 26 | +Two independent, small bugs in the webpack adapter: |
| 27 | + |
| 28 | +1. The webpack resolver tap is registered with `tapAsync` but given an `async` function with no `try/catch`. Tapable ignores the returned promise, so when a user's `resolveId` hook throws (or `decodeURIComponent` throws on a malformed importer), the `callback` is never called and the rejection is unhandled. In `webpack --watch` / dev servers that is a process crash instead of a normal "Module build failed" error; in a one-shot build the resolve just hangs until Node exits. The rspack adapter uses `tapPromise` and does not have this problem; commit `839da03` (#610) fixed the equivalent gap in the webpack *load loader*, but not in the resolver. |
| 29 | + |
| 30 | +2. `getSource()` in `src/webpack/context.ts` converts a `Uint8Array` with `Buffer.from(fileSource.buffer)`. That wraps the *entire* underlying `ArrayBuffer`, ignoring `byteOffset` and `byteLength`. Node allocates small Buffers from a shared 8 KiB pool, so `this.emitFile({ source: Buffer.from('…') })` emits the whole pool (wrong size, garbage bytes). The rspack context already does this correctly with `Buffer.from(emittedFile.source)`. |
| 31 | + |
| 32 | +## Current state |
| 33 | + |
| 34 | +Files: |
| 35 | + |
| 36 | +- `src/webpack/index.ts:120-234` — resolver plugin; the `tapAsync` body. |
| 37 | +- `src/webpack/context.ts:25-33` — `getSource`. |
| 38 | +- `test/unit-tests/webpack/context.test.ts` — unit tests for the webpack build context (uses a mock `compilation.emitAsset`); line 43 calls `buildContext.emitFile({ fileName: 'output.js', source: 'content' })`. |
| 39 | +- `test/unit-tests/resolve-id/resolve-id.test.ts` — cross-bundler `resolveId` integration tests (vite, rollup, rolldown, webpack, rspack, esbuild, bun); good home for a "throwing resolveId rejects the build" case. |
| 40 | + |
| 41 | +### Excerpt: `src/webpack/index.ts:123-134` and `:186-233` |
| 42 | + |
| 43 | +```ts |
| 44 | + resolver |
| 45 | + .getHook('resolve') |
| 46 | + .tapAsync(plugin.name, async (request, resolveContext, callback) => { |
| 47 | + if (!request.request) |
| 48 | + return callback() |
| 49 | + |
| 50 | + // filter out invalid requests |
| 51 | + if (normalizeAbsolutePath(request.request).startsWith(plugin.__virtualModulePrefix)) |
| 52 | + return callback() |
| 53 | + |
| 54 | + const id = normalizeAbsolutePath(request.request) |
| 55 | + // ... |
| 56 | + const resolveIdResult = await handler.call!({ |
| 57 | + ...context, |
| 58 | + ...pluginContext, |
| 59 | + }, id, importer, { isEntry }) |
| 60 | + |
| 61 | + if (error != null) |
| 62 | + return callback(error) |
| 63 | + if (resolveIdResult == null) |
| 64 | + return callback() |
| 65 | + // ... |
| 66 | + // redirect the resolver |
| 67 | + resolver.doResolve(target, newRequest, null, resolveContext, callback) |
| 68 | + }) |
| 69 | +``` |
| 70 | + |
| 71 | +There is no `try`/`catch` anywhere in this function body. Note the existing `error` variable: `pluginContext.error(msg)` records a message and the code returns `callback(error)` after the handler — that path is fine; the missing piece is a *thrown* error / rejected promise. |
| 72 | + |
| 73 | +### Excerpt: `src/webpack/context.ts:25-33` |
| 74 | + |
| 75 | +```ts |
| 76 | +const require = createRequire(import.meta.url) |
| 77 | +export function getSource(fileSource: string | Uint8Array): sources.RawSource { |
| 78 | + const webpack = require('webpack') |
| 79 | + return new webpack.sources.RawSource( |
| 80 | + typeof fileSource === 'string' |
| 81 | + ? fileSource |
| 82 | + : Buffer.from(fileSource.buffer), |
| 83 | + ) |
| 84 | +} |
| 85 | +``` |
| 86 | + |
| 87 | +### Excerpt: `src/rspack/context.ts:30-40` (correct reference) |
| 88 | + |
| 89 | +```ts |
| 90 | + const { sources } = compilation.compiler.webpack |
| 91 | + compilation.emitAsset( |
| 92 | + outFileName, |
| 93 | + new sources.RawSource( |
| 94 | + typeof emittedFile.source === 'string' |
| 95 | + ? emittedFile.source |
| 96 | + : Buffer.from(emittedFile.source), |
| 97 | + ), |
| 98 | + ) |
| 99 | +``` |
| 100 | + |
| 101 | +### Excerpt: how the webpack load loader handles thrown errors, `src/webpack/loaders/load.ts:48-55` (pattern to mirror) |
| 102 | + |
| 103 | +```ts |
| 104 | + catch (error) { |
| 105 | + if (error instanceof Error) { |
| 106 | + callback(error) |
| 107 | + } |
| 108 | + else { |
| 109 | + callback(new Error(String(error))) |
| 110 | + } |
| 111 | + } |
| 112 | +``` |
| 113 | + |
| 114 | +### Conventions |
| 115 | + |
| 116 | +- Error normalisation to `Error` instances as above. |
| 117 | +- Tests for the webpack context live in `test/unit-tests/webpack/context.test.ts` and pass a mock `compilation` with `emitAsset: vi.fn()`; assert on what `emitAsset` received. `webpack.sources.RawSource` has `.buffer()` returning a `Buffer` and `.source()` returning string|Buffer — use `.buffer()` to compare bytes. |
| 118 | + |
| 119 | +## Commands you will need |
| 120 | + |
| 121 | +| Purpose | Command | Expected | |
| 122 | +|---|---|---| |
| 123 | +| Install | `pnpm install` | exit 0 | |
| 124 | +| Build (integration tests need `dist/`) | `pnpm build` | exit 0 | |
| 125 | +| Typecheck | `pnpm typecheck` | exit 0 | |
| 126 | +| Lint | `pnpm lint` | exit 0 | |
| 127 | +| Context unit tests | `pnpm vitest run test/unit-tests/webpack/context.test.ts` | all pass | |
| 128 | +| resolveId integration tests | `pnpm build && pnpm vitest run test/unit-tests/resolve-id` | all pass | |
| 129 | +| Full suite | `pnpm test` | exit 0 | |
| 130 | + |
| 131 | +## Scope |
| 132 | + |
| 133 | +**In scope**: |
| 134 | +- `src/webpack/index.ts` — only the `tapAsync` callback body |
| 135 | +- `src/webpack/context.ts` — only `getSource` |
| 136 | +- `test/unit-tests/webpack/context.test.ts` |
| 137 | +- `test/unit-tests/resolve-id/resolve-id.test.ts` |
| 138 | +- `plans/README.md` |
| 139 | + |
| 140 | +**Out of scope**: |
| 141 | +- Changing the resolver's target hook, the virtual-module prefix, or the double-invocation behaviour (`doResolve` back into `resolve`) — known, risky, tracked separately in `plans/README.md`. |
| 142 | +- `src/rspack/**` — already correct for both issues. |
| 143 | +- Extracting shared code with rspack — plan 009. |
| 144 | + |
| 145 | +## Git workflow |
| 146 | + |
| 147 | +- Branch: `fix/webpack-resolve-errors-and-emit-bytes` |
| 148 | +- Commits: `fix(webpack): forward resolveId hook errors to the resolver callback`, `fix(webpack): emit Uint8Array assets with their own byte range` |
| 149 | +- Do NOT push or open a PR unless the operator instructed it. |
| 150 | + |
| 151 | +## Steps |
| 152 | + |
| 153 | +### Step 1: Add a failing test — a throwing `resolveId` must reject the webpack build |
| 154 | + |
| 155 | +In `test/unit-tests/resolve-id/resolve-id.test.ts`, add a new `describe('resolveId errors', …)` with cases for `webpack` and `rspack` (rspack already behaves correctly; the test documents parity): |
| 156 | + |
| 157 | +```ts |
| 158 | +it('webpack rejects the build when resolveId throws', async () => { |
| 159 | + const plugin = createUnpluginWithCallback(() => { |
| 160 | + throw new Error('resolveId exploded') |
| 161 | + }).webpack |
| 162 | + await expect(runWebpack({ |
| 163 | + entry: path.resolve(__dirname, 'test-src/entry.js'), |
| 164 | + plugins: [plugin()], |
| 165 | + })).rejects.toThrow(/resolveId exploded/) |
| 166 | +}) |
| 167 | +``` |
| 168 | + |
| 169 | +Use `runWebpack`/`runRspack` from `../utils` (added by plan 001). Set a per-test timeout of ~10 s so a hang fails fast rather than hitting vitest's default: `it('…', async () => { … }, 10_000)`. |
| 170 | + |
| 171 | +**Verify**: `pnpm build && pnpm vitest run test/unit-tests/resolve-id` → the new webpack test **fails** (timeout or unhandled rejection), the rspack one passes. |
| 172 | + |
| 173 | +### Step 2: Wrap the resolver tap body in try/catch |
| 174 | + |
| 175 | +In `src/webpack/index.ts`, restructure the `tapAsync` callback so the whole body after the two early `return callback()` guards is inside `try { … } catch (error) { … }`, with the catch mirroring the load loader: |
| 176 | + |
| 177 | +```ts |
| 178 | + catch (error) { |
| 179 | + callback(error instanceof Error |
| 180 | + ? error |
| 181 | + : new Error(String(error))) |
| 182 | + } |
| 183 | +``` |
| 184 | + |
| 185 | +Make sure `resolver.doResolve(target, newRequest, null, resolveContext, callback)` remains the last statement in the `try` and that `callback` is invoked exactly once on every path (every `return callback(...)` already does; the catch handles the throw path). Do not change any other logic. |
| 186 | + |
| 187 | +**Verify**: `pnpm typecheck` → exit 0. `pnpm build && pnpm vitest run test/unit-tests/resolve-id` → all pass, including the new webpack test. |
| 188 | + |
| 189 | +### Step 3: Add a failing test — `emitFile` with a small Buffer emits exactly those bytes |
| 190 | + |
| 191 | +In `test/unit-tests/webpack/context.test.ts`, add: |
| 192 | + |
| 193 | +```ts |
| 194 | +it('emits a Uint8Array source byte for byte, not its backing pool', () => { |
| 195 | + const emitAsset = vi.fn() |
| 196 | + const context = createBuildContext(options, compiler, { emitAsset } as any) |
| 197 | + const bytes = Buffer.from('tiny') // small Buffers are slices of a shared pool |
| 198 | + context.emitFile({ type: 'asset', fileName: 'a.bin', source: bytes }) |
| 199 | + const [, rawSource] = emitAsset.mock.calls[0] |
| 200 | + expect(Buffer.from(rawSource.buffer()).equals(bytes)).toBe(true) |
| 201 | +}) |
| 202 | +``` |
| 203 | + |
| 204 | +Adapt variable names to the existing fixtures in that file (look at how the other `emitFile` test builds `compiler`/`compilation`). The key assertion is that the emitted `RawSource.buffer()` has length 4 and equals `bytes`. |
| 205 | + |
| 206 | +**Verify**: `pnpm vitest run test/unit-tests/webpack/context.test.ts` → the new test **fails** (length is 8192 or similar). |
| 207 | + |
| 208 | +### Step 4: Fix `getSource` |
| 209 | + |
| 210 | +In `src/webpack/context.ts`, replace `Buffer.from(fileSource.buffer)` with `Buffer.from(fileSource)` (copies exactly `byteLength` bytes starting at `byteOffset`). If you prefer zero-copy, `Buffer.from(fileSource.buffer, fileSource.byteOffset, fileSource.byteLength)` is also acceptable; the simple form matches `src/rspack/context.ts`. |
| 211 | + |
| 212 | +**Verify**: `pnpm vitest run test/unit-tests/webpack/context.test.ts` → all pass. `pnpm typecheck` → exit 0. |
| 213 | + |
| 214 | +### Step 5: Lint and full run |
| 215 | + |
| 216 | +**Verify**: `pnpm lint` → exit 0; `pnpm test` → exit 0. |
| 217 | + |
| 218 | +## Test plan |
| 219 | + |
| 220 | +- `resolve-id.test.ts`: webpack and rspack builds reject with the hook's error message when `resolveId` throws (2 new tests). |
| 221 | +- `webpack/context.test.ts`: `emitFile` with a pooled `Buffer` emits exactly its bytes (1 new test). |
| 222 | +- Pattern files: the existing `it('webpack', …)` in `resolve-id.test.ts`; existing `emitFile` test in `webpack/context.test.ts`. |
| 223 | + |
| 224 | +## Done criteria |
| 225 | + |
| 226 | +- [ ] `grep -n "catch (error)" src/webpack/index.ts` → at least 1 match inside the resolver tap |
| 227 | +- [ ] `grep -n "fileSource.buffer)" src/webpack/context.ts` → no match |
| 228 | +- [ ] `pnpm typecheck` and `pnpm lint` exit 0 |
| 229 | +- [ ] `pnpm build && pnpm vitest run test/unit-tests/resolve-id test/unit-tests/webpack/context.test.ts` → all pass, 3 new tests |
| 230 | +- [ ] `pnpm test` exits 0 |
| 231 | +- [ ] Only in-scope files modified; `plans/README.md` updated |
| 232 | + |
| 233 | +## STOP conditions |
| 234 | + |
| 235 | +- The resolver tap already contains a `try`/`catch` (fixed independently). |
| 236 | +- After Step 2 the webpack test still times out — the rejection is happening somewhere other than the handler call (e.g. inside `doResolve`); report with the stack trace. |
| 237 | +- The `rejects.toThrow` message doesn't contain the hook's text because webpack wraps it; relax to `rejects.toThrow()` **only if** the error output printed by `runWebpack` clearly contains `resolveId exploded` — and say so in the report. |
| 238 | + |
| 239 | +## Maintenance notes |
| 240 | + |
| 241 | +- Plan 009 will extract the shared resolveId invocation core for webpack and rspack; the `try/catch` added here must survive as the webpack-specific wrapper around `callback`. |
| 242 | +- Reviewers: confirm no code path in the tap can call `callback` twice (once in `try`, once in `catch`). `doResolve` calls `callback` itself asynchronously; if it throws *synchronously* before doing so, the catch is the only caller. That's the intended behaviour. |
0 commit comments