| 1 | # Plan 011: Return 404 on out-of-range slide-patch requests |
| 2 | |
| 3 | > **Executor instructions**: Follow this plan step by step. Run every |
| 4 | > verification command and confirm the expected result. If anything in "STOP |
| 5 | > conditions" occurs, stop and report. When done, update the status row in |
| 6 | > `plans/README.md`. |
| 7 | > |
| 8 | > **Drift check (run first)**: `git diff --stat c63cb120..HEAD -- packages/slidev/node/vite/loaders.ts` |
| 9 | > On a mismatch with the excerpt below, treat it as a STOP condition. |
| 10 | |
| 11 | ## Status |
| 12 | |
| 13 | - **Priority**: P3 |
| 14 | - **Effort**: S |
| 15 | - **Risk**: LOW |
| 16 | - **Depends on**: none |
| 17 | - **Category**: bug |
| 18 | - **Planned at**: commit `c63cb120`, 2026-07-10 |
| 19 | |
| 20 | ## Why this matters |
| 21 | |
| 22 | The dev-server middleware for `/__slidev/slides/<n>.json` computes |
| 23 | `idx = Number.parseInt(no) - 1` and, on `POST`, immediately dereferences |
| 24 | `data.slides[idx].source.content`. If `n` is past the deck length (a stale |
| 25 | editor after a slide was deleted, or a bad request), `slide` is `undefined` and |
| 26 | the handler throws inside the async middleware → hung request + unhandled |
| 27 | rejection. The `GET` branch tolerates a missing slide; `POST` should too. |
| 28 | |
| 29 | ## Current state |
| 30 | |
| 31 | `packages/slidev/node/vite/loaders.ts:79-138` (inside `configureServer`): |
| 32 | ```ts |
| 33 | server.middlewares.use(async (req, res, next) => { |
| 34 | const match = req.url?.match(regexSlideReqPath) |
| 35 | if (!match) return next() |
| 36 | const [, no] = match |
| 37 | const idx = Number.parseInt(no) - 1 |
| 38 | if (req.method === 'GET') { |
| 39 | res.write(JSON.stringify(withRenderedNote(data.slides[idx]))) |
| 40 | return res.end() |
| 41 | } |
| 42 | else if (req.method === 'POST') { |
| 43 | const body: SlidePatch = await getBodyJson(req) |
| 44 | const slide = data.slides[idx] // ← may be undefined |
| 45 | if (body.content && body.content !== slide.source.content) // ← throws here |
| 46 | hmrSlidesIndexes.add(idx) |
| 47 | // ... more slide.* mutations, then parser.save(...) |
| 48 | } |
| 49 | next() |
| 50 | }) |
| 51 | ``` |
| 52 | `withRenderedNote` (`loaders.ts:426-432`) already handles `undefined` via |
| 53 | optional chaining, so `GET` degrades gracefully; only `POST` is unguarded. |
| 54 | |
| 55 | This middleware has no colocated test (the loader is untested today). |
| 56 | |
| 57 | ## Commands you will need |
| 58 | |
| 59 | | Purpose | Command | Expected | |
| 60 | |---------|---------|----------| |
| 61 | | Install | `pnpm install` | exit 0 | |
| 62 | | Build | `pnpm build` | exit 0 | |
| 63 | | Typecheck | `pnpm typecheck` | exit 0 | |
| 64 | | Lint | `pnpm lint` | exit 0 | |
| 65 | |
| 66 | ## Scope |
| 67 | |
| 68 | **In scope**: |
| 69 | - `packages/slidev/node/vite/loaders.ts` (add a bounds check in the middleware) |
| 70 | |
| 71 | **Out of scope**: |
| 72 | - The unauthenticated-endpoint concern (plans 015/018). |
| 73 | - The HMR/no-op-utils issues (plan 012). |
| 74 | - Any change to `getBodyJson` or `parser.save`. |
| 75 | |
| 76 | ## Git workflow |
| 77 | |
| 78 | - Branch: `fix/slide-patch-bounds`. |
| 79 | - Conventional commit: `fix(server): 404 on out-of-range slide patch`. |
| 80 | - Do NOT push/PR unless instructed. |
| 81 | |
| 82 | ## Steps |
| 83 | |
| 84 | ### Step 1: Guard both branches on a missing slide |
| 85 | |
| 86 | Right after `const idx = Number.parseInt(no) - 1`, add a bounds check that |
| 87 | serves a 404 for an out-of-range (or NaN) index, before either branch runs: |
| 88 | ```ts |
| 89 | const idx = Number.parseInt(no) - 1 |
| 90 | const targetSlide = data.slides[idx] |
| 91 | if (!targetSlide) { |
| 92 | res.statusCode = 404 |
| 93 | return res.end() |
| 94 | } |
| 95 | ``` |
| 96 | Then use `targetSlide` (or keep the existing `data.slides[idx]` reads, now known |
| 97 | to be defined) in both the `GET` and `POST` branches. Ensure the `POST` branch's |
| 98 | `const slide = data.slides[idx]` still resolves to the same object. |
| 99 | |
| 100 | **Verify**: reading the handler, no code path dereferences `data.slides[idx]` |
| 101 | without the preceding `if (!targetSlide) return 404`. |
| 102 | |
| 103 | ### Step 2: Build / typecheck / lint |
| 104 | |
| 105 | **Verify**: `pnpm build && pnpm typecheck && pnpm lint` exit 0. |
| 106 | |
| 107 | ## Test plan |
| 108 | |
| 109 | - The loader middleware has no unit harness today, so no automated test is added |
| 110 | in this plan (adding one is plan 022's scope). The fix is a defensive |
| 111 | bounds-check verified by code review + typecheck. Optional manual check with a |
| 112 | running `pnpm demo:dev`: `curl -X POST localhost:<port>/__slidev/slides/9999.json` |
| 113 | returns `404` instead of hanging. |
| 114 | |
| 115 | ## Done criteria |
| 116 | |
| 117 | - [ ] An out-of-range/NaN slide index returns HTTP 404 for both GET and POST |
| 118 | - [ ] No dereference of `data.slides[idx]` occurs before the bounds check |
| 119 | - [ ] `pnpm build`, `pnpm typecheck`, `pnpm lint` exit 0 |
| 120 | - [ ] Only `loaders.ts` modified (`git status`) |
| 121 | - [ ] `plans/README.md` status row updated |
| 122 | |
| 123 | ## STOP conditions |
| 124 | |
| 125 | Stop and report if: |
| 126 | |
| 127 | - `regexSlideReqPath` allows non-numeric `no` in a way that makes `Number.parseInt` |
| 128 | produce a surprising index (re-check `vite/common.ts`); adjust the guard to |
| 129 | reject NaN explicitly (`Number.isNaN(idx)`). |
| 130 | |
| 131 | ## Maintenance notes |
| 132 | |
| 133 | - When plan 022 adds loader tests, add a case for the out-of-range POST → 404. |
| 134 | - Reviewer: confirm the 404 doesn't break the editor's normal save flow (valid |
| 135 | indices are unaffected). |
| 136 |