Skip to content

Commit 61e692b

Browse files
authored
docs: add advisor implementation plans from codebase audit (#628)
1 parent 1ea89aa commit 61e692b

11 files changed

Lines changed: 2718 additions & 0 deletions

‎plans/001-test-harness.md‎

Lines changed: 276 additions & 0 deletions
Large diffs are not rendered by default.

‎plans/002-rspack-loader-parity.md‎

Lines changed: 303 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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

Comments
 (0)