diff --git a/PLAN.md b/PLAN.md index ebe03fe..de9f60c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -600,10 +600,19 @@ public addresses only, with the hostname **resolved first** because `evil.exampl is free to publish an A record of `169.254.169.254`; redirects followed by hand, at most three, revalidating every hop, because a redirect is the standard way past a check that only looks at what the user typed; a response size cap enforced *while reading* rather -than by trusting `content-length`; and a hard timeout. The residual hole is stated -rather than papered over: between the DNS check and the connection a record can change, -and closing that needs the socket pinned to the address that was checked, which `fetch` -does not expose. +than by trusting `content-length`; and a hard timeout. + +The last of those guards is the one with no visible output. A check that resolves a name +and a transport that resolves it again leave a window between them in which the record +can change — DNS rebinding — and `fetch` exposes no way to pin a socket. So the resolved +addresses are **returned rather than merely approved**, and the request is made through +`node:https` with a `lookup` that hands them straight back instead of asking again. Where +the connection goes and who it has to prove it is stay separate questions: SNI and +certificate validation still go by hostname, so nothing about TLS is weakened. All the +validated addresses are offered, not only the first, so a host with one dead address +still fails over. A runtime with no `node:https` degrades to `fetch` — and that is the +same runtime that had no `node:dns`, so it never had a first resolution to disagree +with. Addresses are **expanded, not prefix-matched**. `::ffff:127.0.0.1` and `::ffff:7f00:1` are the same address written two ways, and a check looking for a dotted quad sees only @@ -897,6 +906,53 @@ identical chairs are one draw call), frustum culling, static geometry merged per and only the active floor rendered by default with lower floors available as a dimmed underlay. +**Measured: everything except the renderer.** `pnpm bench` runs `scene.bench.ts`, which +puts 500 placements on a floor at three densities and times the passes they feed. It is +not in CI, for the reason no timing should be: a benchmark that gates a merge fails on +whatever else the machine was doing. + +Milliseconds per call, 500 placements, Node 24 on a Windows laptop, 2026-09-04. *Sparse* +is a 1.1m pitch — a furnished floor with clearance around everything. *Touching* is +520mm, where every item overlaps its neighbours and the validation panel has something +to say about all of them. *Piled* is 120mm, which is not a plan anyone drew and is here +to show where the cost comes from. + +| | sparse | touching | piled | +|---|---|---|---| +| `buildScene` | 0.10 | 0.10 | 0.11 | +| `buildStack` | 0.13 | 0.13 | 0.14 | +| `blockersOf` | 0.004 | 0.004 | 0.005 | +| `findCollisions` | 0.37 | 5.1 | 188 | +| `validateFloor` | 0.37 | 5.9 | 216 | +| **`stepWalker`** | **0.011** | **0.027** | **0.028** | + +Three things fall out of that. + +`stepWalker` is the only row that runs inside a frame; everything above it runs once per +edit, against a scene the cache in `ui/space/scene-cache.ts` keeps until the document +changes. At 0.011–0.028ms against 500 blockers it uses well under a percent of the +16.7ms budget, which means the CPU half of the target is not where the risk is. Whether +500 placements hold 60fps is a question about draw calls, and this says nothing about it. + +Placement count is not the variable. **Colliding pairs** are: 17 pairs cost 0.37ms and +17,315 pairs cost 216ms, on the same 500 placements. The broad phase is doing its job — +the sparse case rejects almost everything on bounding boxes — and what is left is a +polygon-clipping pass per surviving pair, which is the honest cost of reporting an +overlap. A floor with 17,000 overlaps on it is a floor where every answer is "this does +not fit", so the pathological case is one nobody reaches by planning a room. + +The 520mm row is the one to watch. 5.9ms per edit is inside a frame but not by much, and +it is reached by a plausible document: 500 items packed against each other. If that +becomes a real complaint the fix is a grid index in `findCollisions`, which the sweep +already leaves room for, and not instancing — instancing is a renderer question and this +row never reaches the renderer. + +**Still unmeasured: the renderer.** One mesh per solid, no instancing. The captures in +`docs/media` are taken in headless Chromium on a software rasteriser, so a frame rate +from them would be a number about SwiftShader wearing the clothes of a number about the +application. The seam for instancing is unchanged — the scene model already groups by +catalog item — and so is the fact that nobody has run it. + --- ## 11. Multi-Room and Multi-Floor @@ -1024,11 +1080,11 @@ isolated so neither blocks the core editor. | **2** | **Plan editor** — Konva stage, pan/zoom, wall/room/shape tools, dimension tool, grid + snapping, selection and transform (wall endpoint and body drag), mode toggle, undo/redo. Draw a floor plan by hand and save it. Room *reposition* is deliberately not included: rooms and their walls are separate entities, and moving one without the other desynchronises them — redraw instead until phase 8 relates them. **Phase 8 kept half of this.** Detection is the relating mechanism: a boundary derived from the wall graph re-derives when the walls move, so the way to reposition a room is now to move its walls and press Detect rooms, which reshapes the room in place and keeps its name and ceiling. Dragging a room boundary directly is still not implemented and is no longer planned for v1 — it is the gesture that desynchronises, and the one that does not now exists. | | **3** | **Import + calibration** — PDF via pdfjs (dynamically imported, so the 437kB renderer stays off first paint), image import, the blocking calibration gate, background transform/opacity/lock, tracing over a real plan. An uncalibrated background is shown at a nominal 6m width so the reference line is drawable *and* so the transform is invertible before a real scale exists; calibrating rescales about `refA` so the point the user anchored on does not move, and `transform.position` stays a float because rounding it would drift the anchor on every recalibration. Import deliberately does not re-fit the viewport. Deferred: thumbnails and File System Access (phase 9), vector path extraction (v2). | | **4** | **Inventory** — catalog/placement split, manual entry, preset library, quantity tracking, placement onto the plan with wall snap, surface snap, rotation, and 3D overlap warnings. **First genuinely useful build.** Wall snap seats the footprint's *back edge* (local −y) on the wall's near face and rotates to match, never the centre on the centreline. The calibration gate stops being decorative here: `addPlacement` throws `PlacementBlockedError` carrying the same sentence the validation panel shows, and the Place button is disabled rather than offered-and-refused. Deleting a placement re-seats anything surface-mounted on it, so the document never references a host that is gone. Headroom arrives early — `exceedsHeadroom` already existed — but clearance zones (7) and door swing (6) are still out. | -| **5** | **3D space view** — extrusion from document geometry, orbit mode, walk mode with arrow-key traversal and collision, mount types (floor/surface/wall/ceiling), elevation editing, headroom checks, saved views. **Includes opening *geometry*** — wall-hosted openings and the holes they cut in the extruded walls, without swing. A sealed walker who cannot leave the first room does not demonstrate traversal, so the doorways have to exist here. An opening cuts a wall in *elevation*, not in plan, so `ExtrudeGeometry` holes were never the answer: `wallSegments` **splits** the wall into the solid boxes that remain — flank, sill wall, lintel, flank — which needs no CSG and hands the same list to the renderer, the walker and the validation panel. A doorway is passable because the only solid above it starts at 2032mm, with no "is this a door" check anywhere in traversal. The walk simulation deliberately lives *outside* three.js: a plain rAF loop over pure functions, so the camera consumes the walker rather than owning it, the position readout survives a browser with no WebGL, and traversal is testable without a GPU. Deferred and stated rather than claimed: **instancing** (§10.4's 500-at-60fps target is unmeasured — one mesh per solid today), and a real contact-normal collision resolver (moves are retried per axis, so diagonal walls slide stickily). | +| **5** | **3D space view** — extrusion from document geometry, orbit mode, walk mode with arrow-key traversal and collision, mount types (floor/surface/wall/ceiling), elevation editing, headroom checks, saved views. **Includes opening *geometry*** — wall-hosted openings and the holes they cut in the extruded walls, without swing. A sealed walker who cannot leave the first room does not demonstrate traversal, so the doorways have to exist here. An opening cuts a wall in *elevation*, not in plan, so `ExtrudeGeometry` holes were never the answer: `wallSegments` **splits** the wall into the solid boxes that remain — flank, sill wall, lintel, flank — which needs no CSG and hands the same list to the renderer, the walker and the validation panel. A doorway is passable because the only solid above it starts at 2032mm, with no "is this a door" check anywhere in traversal. The walk simulation deliberately lives *outside* three.js: a plain rAF loop over pure functions, so the camera consumes the walker rather than owning it, the position readout survives a browser with no WebGL, and traversal is testable without a GPU. Deferred and stated rather than claimed: **instancing** — §10.4's 500-at-60fps target is unmeasured and there is one mesh per solid today. The contact-normal resolver was deferred here too and has since landed: a blocked move drops the component going into the contact normal and keeps the rest, which on an axis-aligned wall *is* the old x-then-y retry and on a diagonal is the case the axis retries could not answer, since one axis is the move that was refused and the other lands where the walker already stands. The retries stay behind it for inside corners, where two normals average to a direction out of the corner that neither surface allows. | | **6** | **Openings, complete** — swing arcs in 2D, hinged door panels and window panes in 3D, sliding/pocket/cased variants, swing-vs-object clearance. The five kinds behave in four different ways, and the difference is the reason the kinds exist: hinged doors need their swept sector clear, sliders need the wall they park over clear, pocket doors need **nothing** in the room clear and instead need a cavity that can exist, and cased openings and windows need nothing at all. The swept sector is computed once and serves three consumers — its boundary *is* the 2D door symbol (closed leaf, arc, open leaf), it is the clearance polygon, and it positions the 3D panel — so the drawing and the check cannot disagree about where the door goes. Hanging the leaf is done with **flip buttons, not selects**, because there is no honest label for the two sides of a wall; the arc in the drawing is what makes the choice legible. The angle field holds its text locally and commits on blur or Enter, like the room name and every length field: writing per keystroke to a value that is *clamped* means the `1` of `135` is stored as `15` and the rest of the number is typed against that, so no angle whose first digit falls below the floor can be entered at all. `fill()` in a test never sees it, because it delivers the whole value in one change event. Deferred and stated rather than claimed: **windows do not open** (a casement sash would swing like a door and is not built), and the 3D layer-toggle gate on a door leaf is covered by an exhaustive unit test over `refIsEditable` rather than end to end — in the orbit view a leaf is a slab a few pixels wide seen edge-on, and walk mode does not take selection clicks at all, so hunting for it with a grid of clicks would test where the camera happens to sit. | | **7** | **Clearance and circulation** — clearance zones on catalog items, the standard preset library, walkway width probe, consolidated validation panel across overlap/headroom/clearance/swing. Two checks that sound alike and are not: a zone asks whether a drawer opens, the probe asks whether a person fits, and they differ on whether walls count (see §9.3 — they do not for a zone, they do for the probe). Zones are drawn on the selected item only, for the reason the swing arc is drawn: a warning that says "the bookcase blocks the drawer pull" is an argument and the hatched rectangle is the evidence — but six dining chairs with pull-out zones would carpet the floor in hatching and say nothing. The probe stores the *route*, not the number, so it re-answers as furniture moves; the tool stays live in furnish mode for the same reason. The panel groups by what you would do about a problem rather than by the issue enum, keeping `validateFloor`'s blocking-first order rather than forming a second opinion about severity in the component least qualified to have one. Deferred and stated rather than claimed: **the 900mm probe height in §9.3 was wrong and is now a body interval** — a sofa back is 840mm, so the specified ray passed over the one piece of furniture the spec named; and the full medial-axis navmesh remains explicitly out of scope, so the probe reports the narrowest gap *at a sample*, not the true infimum. | | **8** | **Multi-room and multi-floor** — room detection and areas, per-room ceiling heights, floor stacking, ghost underlay, 3D floor toggles. Detection keeps planar-graph faces **by sign** rather than by magnitude, because a courtyard's outer face is smaller than the room around it; and it splits walls at T-junctions as well as crossings, which is the pass that decides whether it works on a real plan at all. Boundaries are centrelines, matching the Room tool, and rings are canonicalised so a second run is a genuine no-op — both asserted, because two paths that describe the same walls with different numbers is the failure this phase exists to avoid. Detection **never deletes**: an Area-tool room has no walls by design, so removing what detection cannot see would delete a legitimate room every run; unmatched rooms are reported and left. Ceiling height became editable, which is what makes it worth having — the headroom check reads it through `ceilingHeightAt`. On the stack: `index` is the ordering and nothing reads array position, a floor switch is silent in history but dirty on disk, and moving a placement carries everything standing on it while re-seating what named a wall it left behind. The plan ghost participates in nothing — not the hit graph, not the counts, not `floorBounds` — and the `listening` flag is load-bearing rather than tidy, since PlanStage reads empty canvas by `e.target === stage`. **Collision stays on the active floor whatever the 3D toggle says**, the same two-questions split as §9.3 — and in the space view a solid on another floor declines the click *before* stopping propagation, or the top storey would swallow every click meant for the floor below it. Deferred and stated rather than claimed: room-boundary dragging is dropped for v1 in favour of move-the-walls-and-re-detect (see row 2), floors can only be added at the ends of the stack, and a detected room is a simple ring — an island of walls inside one does not punch a hole in it. | -| **9** | **Polish and portability** — File System Access save-in-place, IndexedDB autosave and recovery, thumbnails, product URL lookup endpoint + confirm dialog, export/import e2e, migration tests. Two decisions carry the persistence half. `supportsSaveInPlace()` reads `window` **at call time**: headless Chromium has the API, so a module-load snapshot would leave the download path with no end-to-end coverage and make the in-place path undrivable from a stub — the discriminating test is a *count* of picker openings across two saves, since a wiring that re-prompts still writes the right bytes. And autosave keeps assets in their own IndexedDB store: document-only would recover a space with no background, which is the failure `assetMapFor` throws to prevent by another door, while rewriting the raster every tick is absurd — an asset is immutable once stored, so it is written by id once. Recovery has two offers because §5's "newer than the opened file" misses the case that matters after a crash, where there is no opened file; a record is deleted when its document is saved, which is what stops the prompt becoming a nag you dismiss unread. Thumbnails render offscreen from document geometry — the Konva stage is unmounted in 3D and would capture the current pan — and frame placements as well as structure, unlike `floorBounds`. On the lookup half: scraped numbers go through a parser that **refuses a unitless number**, including a `QuantitativeValue` with no `unitCode`; the client decides the endpoint is absent by **content type**, not `response.ok`, because a static host answers an unknown POST with a 200 carrying `index.html`; and the endpoint treats itself as SSRF by construction — resolving before connecting, revalidating every redirect hop, and capping the read as it goes. Four defects found on the way, each with a test watched failing first: the autosave debounce was never re-armed, so the twenty-second deadline could not bind and the mechanism in the comments was not the one running; `parseTargetUrl` waved through a literal private address, leaning on a resolver step an edge runtime skips; the labelled dimension matcher stopped at `6 ft` and dropped the inches, because a flattened table cell reads `Height6 ft 2 in` and has no word boundary for the row matcher; and — oldest and worst — the item form prefilled raw millimetres into fields parsed in the display unit, so opening a 2'7" armchair and pressing Save with no other change made it 67'6" wide. Deferred and stated rather than claimed: the product fixtures are **synthetic** and §13 now says so, DNS rebinding between the check and the connect is open because `fetch` will not pin a socket, and the confidence flag records "was any dimension accepted exactly as scraped" rather than anything about whether the page was right. | +| **9** | **Polish and portability** — File System Access save-in-place, IndexedDB autosave and recovery, thumbnails, product URL lookup endpoint + confirm dialog, export/import e2e, migration tests. Two decisions carry the persistence half. `supportsSaveInPlace()` reads `window` **at call time**: headless Chromium has the API, so a module-load snapshot would leave the download path with no end-to-end coverage and make the in-place path undrivable from a stub — the discriminating test is a *count* of picker openings across two saves, since a wiring that re-prompts still writes the right bytes. And autosave keeps assets in their own IndexedDB store: document-only would recover a space with no background, which is the failure `assetMapFor` throws to prevent by another door, while rewriting the raster every tick is absurd — an asset is immutable once stored, so it is written by id once. Recovery has two offers because §5's "newer than the opened file" misses the case that matters after a crash, where there is no opened file; a record is deleted when its document is saved, which is what stops the prompt becoming a nag you dismiss unread. Thumbnails render offscreen from document geometry — the Konva stage is unmounted in 3D and would capture the current pan — and frame placements as well as structure, unlike `floorBounds`. On the lookup half: scraped numbers go through a parser that **refuses a unitless number**, including a `QuantitativeValue` with no `unitCode`; the client decides the endpoint is absent by **content type**, not `response.ok`, because a static host answers an unknown POST with a 200 carrying `index.html`; and the endpoint treats itself as SSRF by construction — resolving before connecting, revalidating every redirect hop, and capping the read as it goes. Four defects found on the way, each with a test watched failing first: the autosave debounce was never re-armed, so the twenty-second deadline could not bind and the mechanism in the comments was not the one running; `parseTargetUrl` waved through a literal private address, leaning on a resolver step an edge runtime skips; the labelled dimension matcher stopped at `6 ft` and dropped the inches, because a flattened table cell reads `Height6 ft 2 in` and has no word boundary for the row matcher; and — oldest and worst — the item form prefilled raw millimetres into fields parsed in the display unit, so opening a 2'7" armchair and pressing Save with no other change made it 67'6" wide. Deferred and stated rather than claimed: the product fixtures are **synthetic** and §13 now says so, and the confidence flag records "was any dimension accepted exactly as scraped" rather than anything about whether the page was right. DNS rebinding was listed here as open, on the grounds that `fetch` will not pin a socket — it is now closed by not using `fetch`: `resolvePublicHost` returns the addresses it validated and the request goes through `node:https` with a `lookup` that returns them, which pins where the connection lands without touching what the certificate has to say. See §7.3. | Phases 4 and 5 together are the point at which the application does what it exists to do. Everything after is depth. @@ -1082,16 +1138,3 @@ do. Everything after is depth. - Multi-segment vertical profiles (a stack of solid intervals per item, rather than one span with a void beneath). v2 escalation — see §4.2. Until then, chairs tucked under tables report a benign overlap warning. - ---- - -## 15. Open Questions - -1. **Retailer coverage for URL import** — which sites to build fixtures against first? - IKEA, Wayfair, Article, West Elm, CB2 all publish clean JSON-LD; Amazon does not. -2. **Room auto-detection** — worth building the wall-graph loop finder in phase 8, or - is manual room tracing sufficient indefinitely? -3. **Sloped ceilings** — attics and dormers break the flat `ceilingHeightMm` assumption. - Model as a ceiling plane with a slope, or defer? -4. **Stairs** — as a placement category with a footprint, or as real structure connecting - floors? Real structure is correct and considerably more work. diff --git a/README.md b/README.md index 39fae66..c10e417 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Spatial planning for real rooms. Bring a floor plan — a PDF, an image, or nothing at all — trace it, build an inventory of the things you own, place them, and walk through -the result in 3D. Phases 0–9 of [PLAN.md](./PLAN.md) are built: import and calibration, -the plan editor, inventory and placement, the space view, door swing, clearance and -circulation, room detection and floor stacking, save-in-place and crash recovery. +the result in 3D. Import and calibration, the plan editor, inventory and placement, the +space view, door swing, clearance and circulation, room detection and floor stacking, +save-in-place and crash recovery. **One decision explains most of the rest of it.** Every object carries a real height and a real base elevation, and the geometry is three-dimensional everywhere rather than a @@ -136,8 +136,9 @@ pnpm dev # http://localhost:5190 | `pnpm preview` | Serve the production build — no lookup endpoint, like a static deploy | | `pnpm typecheck` | `tsc --noEmit` | | `pnpm lint` | ESLint | -| `pnpm test` | Vitest — 765 unit tests | +| `pnpm test` | Vitest — 768 unit tests | | `pnpm test:watch` | Vitest in watch mode | +| `pnpm bench` | Time the geometry passes at 500 placements — see PLAN.md §10.4 | | `pnpm e2e` | Playwright — 124 end-to-end tests, against a production build | | `pnpm e2e:install` | One-time Playwright browser install | | `pnpm media` | Redraw every picture in this README (needs `ffmpeg`) | @@ -155,7 +156,8 @@ src/ └── styles/ global CSS e2e/ Playwright specs media/ the capture script behind docs/media -PLAN.md architecture, decisions, and the phasing table +PLAN.md architecture, decisions, the phasing table, and what is + deliberately out of scope for v1 ``` `src/core/` is free of React and of any renderer. The geometry engine is pure functions @@ -178,37 +180,6 @@ three. --- -## What is not built, and what is not measured - -Read this before believing anything above is finished. - -**Not built, and not planned for v1.** Dragging a room boundary directly: rooms and their -walls are separate entities and moving one without the other desynchronises them, so the -gesture that does not exist is the one that would break. Move the walls and press Detect -rooms instead. Windows do not open — a casement sash would swing like a door and is not -implemented. Floors can only be added at the ends of the stack. A detected room is a -simple ring, so an island of walls inside one does not punch a hole in it. PDF vector path -extraction is v2: a PDF is rasterised and traced by hand. - -**Built, and known to be approximate.** The walkway probe reports the narrowest gap *at a -sample*, not the true infimum — the medial-axis navmesh that would give the real answer is -explicitly out of scope. Collision is resolved by retrying a move per axis rather than -against a contact normal, so a walker slides stickily along a diagonal wall. The product -lookup's confidence flag records whether any dimension was accepted exactly as scraped; it -records nothing about whether the page was right. DNS rebinding between the endpoint's -address check and its connect is open, because `fetch` will not pin a socket. - -**Not measured.** There are no performance numbers here, because none have been taken. -PLAN.md §10.4 sets a target of 500 objects at 60fps; the space view currently builds one -mesh per solid with no instancing, and nobody has run that test. The pictures on this page -are captured in headless Chromium on a software rasteriser, so they demonstrate what the -app draws and say nothing at all about how fast it draws it. - -**Synthetic.** The product-page fixtures the lookup parser is tested against are written -by hand, not captured from real retailers, and the sample plan in the calibration -screenshot is drawn by the capture script. Neither has been run against a real shop or a -real estate agent's PDF. - ## The pictures Every image above is generated by driving the real application — `media/capture.spec.ts`, diff --git a/e2e/floors.spec.ts b/e2e/floors.spec.ts index bb54734..fdd80d8 100644 --- a/e2e/floors.spec.ts +++ b/e2e/floors.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; import { clickAt, dragBetween, selectTool } from './coords'; import { disableSaveInPlace } from './save'; +import { clickSpaceCentre } from './space'; /** * Multiple floors — PLAN.md §11. @@ -233,9 +234,7 @@ test.describe('clicking through the stack in 3D', () => { await expect(page.getByTestId('space-view')).toBeVisible(); await page.getByTestId('floors-all').click(); - const canvas = page.locator('.space__canvas canvas'); - const box = (await canvas.boundingBox())!; - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await clickSpaceCentre(page); await expect(page.getByTestId('wall-properties')).toBeVisible(); }); diff --git a/e2e/space.spec.ts b/e2e/space.spec.ts index c4e2bd6..14f7d65 100644 --- a/e2e/space.spec.ts +++ b/e2e/space.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from '@playwright/test'; import { clickAt, dragBetween, selectTool } from './coords'; import { disableSaveInPlace } from './save'; +import { clickSpaceCentre } from './space'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -207,11 +208,9 @@ test.describe('the layer toggle', () => { await roomWithDoor(page); await page.getByRole('button', { name: 'Arrange furniture', exact: true }).click(); - const canvas = page.locator('.space__canvas canvas'); - const box = (await canvas.boundingBox())!; // The orbit view frames the whole room, so the middle of the canvas is a wall or // the floor either way — and neither may select while structure is locked. - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await clickSpaceCentre(page); await expect(page.getByTestId('wall-properties')).toHaveCount(0); await expect(page.getByRole('button', { name: 'Delete', exact: true })).toHaveCount(0); @@ -220,9 +219,7 @@ test.describe('the layer toggle', () => { test('still selects a wall in 3D when structure is editable', async ({ page }) => { await roomWithDoor(page); - const canvas = page.locator('.space__canvas canvas'); - const box = (await canvas.boundingBox())!; - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await clickSpaceCentre(page); // Something got selected — the click reaches the scene, so the test above is // asserting a real refusal rather than a raycast that never hit anything. diff --git a/e2e/space.ts b/e2e/space.ts new file mode 100644 index 0000000..92c971c --- /dev/null +++ b/e2e/space.ts @@ -0,0 +1,52 @@ +import { expect, type Locator, type Page } from '@playwright/test'; + +/** + * The 3D canvas, once the renderer has taken its size. + * + * Clicking "the middle of the canvas" is two measurements, and both of them start out + * wrong. A `` with no width or height attributes lays out at 300 x 150 until + * something sizes it, so a bounding box taken on sight describes a rectangle in the + * corner of the cell rather than the canvas the user sees. And React Three Fiber keeps + * its *own* record of that size, taken from a ResizeObserver a frame or two later, + * which is what it divides a pointer offset by to get normalised device coordinates — + * so a click that is dead centre of the element is off the edge of the frustum until + * the renderer has caught up, and hits nothing at all. + * + * Both were live in `floors.spec.ts` and `space.spec.ts`, and between them they made a + * raycast test fail about half the time on this machine. Neither is a product bug: the + * scene, the camera and the click handling are deterministic — eight runs of the + * failing test produced eight byte-identical canvas screenshots. Only the arithmetic + * that turned "the middle" into a screen pixel was done against the wrong numbers. + * + * The gate is the drawing buffer. `gl.setSize` writes `canvas.width` from the size + * React Three Fiber has measured, so a buffer at least as wide as the element is proof + * that the observer has fired and the raycaster is dividing by the right number. It is + * `>=` rather than `===` because the buffer is multiplied by the device pixel ratio. + */ +export async function spaceCanvas(page: Page): Promise { + const canvas = page.locator('.space__canvas canvas'); + await expect(canvas).toBeVisible(); + await expect + .poll( + async () => + canvas.evaluate((el) => { + const c = el as HTMLCanvasElement; + return c.clientWidth > 0 && c.width >= c.clientWidth; + }), + { message: 'the 3D renderer never took the size of its canvas' }, + ) + .toBe(true); + return canvas; +} + +/** + * Click the middle of the 3D view. + * + * Every 3D selection test wants this and none of them wants to own the measurement, + * which is how the same mistake ended up in three places. + */ +export async function clickSpaceCentre(page: Page): Promise { + const canvas = await spaceCanvas(page); + const box = (await canvas.boundingBox())!; + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); +} diff --git a/package.json b/package.json index 9d4b354..a3e1f58 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run", + "bench": "vitest bench --run", "test:watch": "vitest", "e2e": "playwright test", "e2e:install": "playwright install --with-deps chromium", diff --git a/src/core/geometry/vec.ts b/src/core/geometry/vec.ts index 20ad5a3..3dfad97 100644 --- a/src/core/geometry/vec.ts +++ b/src/core/geometry/vec.ts @@ -62,20 +62,32 @@ export function equals(a: Vec2, b: Vec2, tolerance = 0): boolean { } /** - * Perpendicular distance from a point to a **segment**, not to the infinite line. + * The point on a **segment** nearest to `p` — not on the infinite line, so the ends + * are the answer when the foot of the perpendicular falls beyond them. * - * Lives here rather than in `wall.ts` because walls are not the only thing measured - * against: the walker's capsule is tested against every polygon edge in the scene, - * and that must not have to import a wall to do it. + * Split out from `distanceToSegment` because the walker needs the point and not only + * the distance: sliding along a surface means knowing which way that surface faces, + * and the direction from the nearest point to the body is exactly that. */ -export function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number { +export function closestPointOnSegment(p: Vec2, a: Vec2, b: Vec2): Vec2 { const dx = b.x - a.x; const dy = b.y - a.y; const lenSq = dx * dx + dy * dy; - if (lenSq === 0) return distance(p, a); + if (lenSq === 0) return a; const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq)); - return distance(p, { x: a.x + t * dx, y: a.y + t * dy }); + return { x: a.x + t * dx, y: a.y + t * dy }; +} + +/** + * Perpendicular distance from a point to a **segment**, not to the infinite line. + * + * Lives here rather than in `wall.ts` because walls are not the only thing measured + * against: the walker's capsule is tested against every polygon edge in the scene, + * and that must not have to import a wall to do it. + */ +export function distanceToSegment(p: Vec2, a: Vec2, b: Vec2): number { + return distance(p, closestPointOnSegment(p, a, b)); } export function toDegrees(radians: number): number { diff --git a/src/core/scene.bench.ts b/src/core/scene.bench.ts new file mode 100644 index 0000000..09ba51e --- /dev/null +++ b/src/core/scene.bench.ts @@ -0,0 +1,113 @@ +import { bench, describe } from 'vitest'; +import { createCatalogItem } from './catalog'; +import { findCollisions } from './geometry/collision'; +import { createDocument, type Floor, type SpaceDocument } from './document'; +import { blockersOf, buildScene, buildStack } from './scene'; +import { commitRoomRect } from './tools'; +import { validateFloor } from './validation'; +import { NO_INPUT, createWalker, stepWalker } from './walk'; + +/** + * What PLAN.md §10.4's target can honestly be checked against without a GPU. + * + * The target is 500 placements at 60fps, and most of what decides that is the + * renderer — draw calls, culling, whether repeated catalog items are instanced. None + * of it can be measured here, and a number taken from a software rasteriser in a + * headless browser would read as a frame rate while measuring nothing of the sort. + * + * What *is* measurable is everything the renderer is handed, and one thing that runs + * inside the frame. `buildScene` and `validateFloor` run once per edit; + * `stepWalker` runs sixty times a second against the cached blocker list, so it is + * the only figure here that comes out of the 16.7ms budget. + * + * Three densities, because placement count turns out not to be the variable that + * matters — the number of *overlapping pairs* is, and those are two very different + * numbers. Five hundred items on a 1.1m pitch is a furnished floor; on a 520mm pitch + * every item touches its neighbours, which is a floor with a problem on it; on a + * 120mm pitch they are piled on each other, which is not a plan anyone drew. + * + * Run with `pnpm bench`. Not part of `pnpm test` and not in CI: a timing that gates a + * merge fails on whatever else the machine was doing. + */ + +const PITCHES = { + /** Furnished: a metre of clear floor around everything. */ + sparse: 1100, + /** Every item against its neighbours. A floor the validation panel has notes on. */ + touching: 520, + /** Piled. Not a plan, and here to show where the cost actually comes from. */ + piled: 120, +} as const; + +const COUNT = 500; + +let seq = 0; +const id = () => `id-${seq++}`; + +function floorOf(pitchMm: number): { doc: SpaceDocument; floor: Floor } { + seq = 0; + const doc = createDocument({ id: 'd', floorId: 'f', now: '2026-01-01T00:00:00.000Z' }); + const built = commitRoomRect({ x: 0, y: 0 }, { x: 40000, y: 32000 }, { name: 'Hall', makeId: id })!; + const floor = doc.floors[0]!; + floor.rooms.push(built.room); + floor.walls.push(...built.walls); + + // Six catalog items over five hundred placements — the repetition §10.4's + // instancing note is about, and the reason the scene model groups by item. + const items = ['Chair', 'Table', 'Shelf', 'Lamp', 'Rug', 'Box'].map((name, i) => + createCatalogItem( + { name, category: 'other', widthMm: 500 + i * 40, depthMm: 500, heightMm: 700, shape: 'rect' }, + id(), + ), + ); + doc.catalog.push(...items); + + const cols = Math.ceil(Math.sqrt(COUNT)); + for (let k = 0; k < COUNT; k++) { + floor.placements.push({ + id: id(), + itemId: items[k % items.length]!.id, + floorId: 'f', + position: { x: 900 + (k % cols) * pitchMm, y: 900 + Math.floor(k / cols) * pitchMm }, + rotation: 0, + mount: { kind: 'floor' }, + elevation: 0, + }); + } + return { doc, floor }; +} + +for (const [label, pitchMm] of Object.entries(PITCHES)) { + describe(`${COUNT} placements, ${label}`, () => { + const { doc, floor } = floorOf(pitchMm); + const scene = buildScene(doc, floor); + const blockers = blockersOf(scene); + const walker = createWalker({ x: 20000, y: 16000 }, 45); + const world = { blockers, mode: 'walk' as const }; + + bench('buildScene', () => { + buildScene(doc, floor); + }); + + bench('buildStack', () => { + buildStack(doc, [floor], floor.id); + }); + + bench('blockersOf', () => { + blockersOf(scene); + }); + + bench('findCollisions', () => { + findCollisions(blockers); + }); + + bench('validateFloor', () => { + validateFloor(doc, floor); + }); + + // The frame. Everything above is per edit. + bench('stepWalker', () => { + stepWalker(walker, { ...NO_INPUT, forward: 1 }, 1 / 60, world); + }); + }); +} diff --git a/src/core/walk.test.ts b/src/core/walk.test.ts index 0dc3116..c15f011 100644 --- a/src/core/walk.test.ts +++ b/src/core/walk.test.ts @@ -15,12 +15,15 @@ import { lookTarget, normalizeHeading, rightVector, + slide, stepWalker, type WalkInput, type Walker, type WalkWorld, } from './walk'; import { blockersOf, buildScene } from './scene'; +import type { Volume } from './geometry/collision'; +import { polygon } from './geometry/polygon'; import { createOpening, type OpeningDefaults } from './openings'; import { createCatalogItem, type ItemDraft } from './catalog'; import { commitRoomRect } from './tools'; @@ -331,6 +334,62 @@ describe('sliding', () => { expect(after.position.x).toBeGreaterThan(2500); expect(after.position.y).toBeGreaterThan(0); }); + + /** + * A surface running north-east: the hypotenuse of a triangle filling everything + * south-east of the line y = x. The walker stands clear of it and walks due east, + * straight into it. + * + * This is the case neither axis can answer. Moving on x alone is the move that was + * blocked; moving on y alone is not the direction of travel and lands exactly where + * the walker already is — which is clear, so "stay put" wins and the walker sticks + * to the wall. The surface itself is the only thing that knows the answer. + */ + const diagonal: Volume = { + outline: polygon([ + { x: 0, y: 0 }, + { x: 5000, y: 5000 }, + { x: 5000, y: 0 }, + ]), + span: { bottom: 0, top: 2400 }, + }; + + it('slides along a diagonal wall rather than sticking to it', () => { + const from = { x: 1000, y: 1600 }; + const after = slide(from, { x: 400, y: 0 }, { bottom: 0, top: 1800 }, [diagonal]); + + expect(after).not.toEqual(from); + // Along the surface, which means both axes move — the east the walker asked for, + // and the north that keeping off the wall costs. + expect(after.x).toBeGreaterThan(from.x); + expect(after.y).toBeGreaterThan(from.y); + }); + + it('does not let the slide push through the surface', () => { + const from = { x: 1000, y: 1600 }; + const after = slide(from, { x: 400, y: 0 }, { bottom: 0, top: 1800 }, [diagonal]); + + expect(isClear(after, { bottom: 0, top: 1800 }, [diagonal])).toBe(true); + }); + + it('still stops dead when the move is straight into the surface', () => { + // Nothing tangential is left to keep: the whole move is the component that has + // to go. A wall you walk squarely at is a wall you stop at. + const from = { x: 1000, y: 1600 }; + const into = { x: 300, y: -300 }; + expect(slide(from, into, { bottom: 0, top: 1800 }, [diagonal])).toEqual(from); + }); + + it('leaves a walker who is already inside something free to get out', () => { + // Unchanged, and asserted here because the contact normal is computed from the + // blocked candidate: a walker standing inside a wall has candidates that are all + // blocked, and freezing them there is worse than briefly being in a wall. + const inWall = { x: 3000, y: 1000 }; + expect(slide(inWall, { x: 0, y: 500 }, { bottom: 0, top: 1800 }, [diagonal])).toEqual({ + x: 3000, + y: 1500, + }); + }); }); describe('the camera', () => { diff --git a/src/core/walk.ts b/src/core/walk.ts index db433b6..b1a067d 100644 --- a/src/core/walk.ts +++ b/src/core/walk.ts @@ -28,15 +28,32 @@ * * ## Sliding * - * Blocked moves are retried on each axis separately: full, then x-only, then y-only. - * That gives a clean slide along axis-aligned walls, which is most walls, and a - * stickier one along diagonals — a proper resolver would push out along the contact - * normal, and is not worth its bug surface here. Documented rather than hidden. + * A blocked move is retried along the surface it was blocked by: the component going + * into the contact normal is dropped and the rest is kept. On an axis-aligned wall + * that is exactly the old "try x, then try y" — the tangent of a north wall *is* the + * x axis — so the common case is unchanged. On a diagonal it is the case the axis + * retries could never answer, because moving on one axis alone is the move that was + * refused and moving on the other lands where the walker already stands. + * + * The axis retries are kept behind it. An inside corner has two normals and their + * average points out of the corner rather than along either wall, so the tangent + * there is a direction neither surface allows; falling back to one axis at a time is + * what still gets a walker along the wall they are actually pressed against. */ import { circleIntersects, spansOverlap, type Span, type Volume } from './geometry/collision'; import { containsPoint } from './geometry/polygon'; -import { toRadians, type Vec2 } from './geometry/vec'; +import { + closestPointOnSegment, + distanceToSegment, + dot, + length, + normalize, + scale, + sub, + toRadians, + type Vec2, +} from './geometry/vec'; import type { DocPoint3 } from './units'; // --------------------------------------------------------------------------- @@ -231,11 +248,55 @@ export function groundHeight( return ground; } +/** + * Which way the surfaces touching `at` face, averaged, or null if nothing touches it. + * + * Averaged rather than nearest-wins because a walker in an inside corner is against + * two surfaces at once, and one of them alone would send them straight into the other. + * The average points out of the corner, which is a direction that will fail `isClear` + * — and failing is the right answer there, because it is what hands the move to the + * axis retries that can still slide along one of the two walls. + * + * A body already *inside* a blocker takes the direction from itself to the nearest + * edge instead, so the normal still points at open air rather than deeper in. + */ +function contactNormal( + at: Vec2, + span: Span, + blockers: readonly Volume[], + radiusMm: number, +): Vec2 | null { + let sum: Vec2 = { x: 0, y: 0 }; + + for (const blocker of blockers) { + if (!spansOverlap(span, blocker.span)) continue; + if (!circleIntersects(blocker.outline, at, radiusMm)) continue; + + const inside = containsPoint(blocker.outline, at); + const pts = blocker.outline.pts; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]!; + const b = pts[(i + 1) % pts.length]!; + // Only the edges actually being touched. A long wall's far edge is part of the + // same polygon and points the opposite way; including it would cancel the + // normal out to nothing. + if (!inside && distanceToSegment(at, a, b) > radiusMm) continue; + + const closest = closestPointOnSegment(at, a, b); + const away = inside ? sub(closest, at) : sub(at, closest); + if (length(away) < 1e-6) continue; + sum = { x: sum.x + normalize(away).x, y: sum.y + normalize(away).y }; + } + } + + return length(sum) < 1e-6 ? null : normalize(sum); +} + /** * Move as far as the world allows, sliding along whatever is in the way. * - * Full move, then x-only, then y-only. Diagonal walls slide stickily; see the module - * comment. + * Full move, then along the surface, then x-only, then y-only. See the module comment + * for why the axis retries survive the surface one. */ export function slide( from: Vec2, @@ -252,11 +313,27 @@ export function slide( return { x: from.x + delta.x, y: from.y + delta.y }; } - const candidates: Vec2[] = [ - { x: from.x + delta.x, y: from.y + delta.y }, - { x: from.x + delta.x, y: from.y }, - { x: from.x, y: from.y + delta.y }, - ]; + const full = { x: from.x + delta.x, y: from.y + delta.y }; + if (isClear(full, span, blockers, radiusMm)) return full; + + const candidates: Vec2[] = []; + + // The normal is taken at the blocked position rather than at `from`, because `from` + // is clear by the check above and so touches nothing to take a normal from. + const normal = contactNormal(full, span, blockers, radiusMm); + if (normal) { + const into = dot(delta, normal); + // Only a move that goes *into* the surface has a component to lose. A move that + // is already leaving it was blocked by something else, and projecting would take + // away the escape. + if (into < 0) { + const along = sub(delta, scale(normal, into)); + candidates.push({ x: from.x + along.x, y: from.y + along.y }); + } + } + + candidates.push({ x: from.x + delta.x, y: from.y }, { x: from.x, y: from.y + delta.y }); + for (const candidate of candidates) { if (isClear(candidate, span, blockers, radiusMm)) return candidate; } diff --git a/src/server/lookup.test.ts b/src/server/lookup.test.ts index b5888c4..58f7e96 100644 --- a/src/server/lookup.test.ts +++ b/src/server/lookup.test.ts @@ -174,6 +174,74 @@ describe('resolving before connecting', () => { }); }); +describe('pinning the socket to what was checked', () => { + /** + * The window between the two lookups is DNS rebinding, and it is the guard with no + * visible output: a connection that re-resolves the name returns the same page as one + * that does not, right up until the day it returns the metadata endpoint instead. So + * what is asserted is the handover — the addresses the resolver validated are the ones + * the transport is given to connect to. + */ + it('hands the transport the addresses the resolver validated', async () => { + let pinned: string[] | null | undefined; + const result = await lookupProduct( + 'https://shop.example.com/p', + deps({ + resolve: () => Promise.resolve(['93.184.216.34', '93.184.216.35']), + fetch: (_url, _init, addresses) => { + pinned = addresses; + return Promise.resolve(html()); + }, + }), + ); + + expect(result.ok).toBe(true); + // Both, not just the first: a host with one dead address still has to fail over. + expect(pinned).toEqual(['93.184.216.34', '93.184.216.35']); + }); + + it('re-pins on the address of the host it was redirected to', async () => { + // A redirect changes the host, so a pin computed once for the URL the user typed + // would be the wrong pin for the hop that actually gets read. + const pinned: (string[] | null)[] = []; + await lookupProduct( + 'https://shop.example.com/p', + deps({ + resolve: (hostname) => + Promise.resolve(hostname === 'shop.example.com' ? ['93.184.216.34'] : ['93.184.216.99']), + fetch: (url, _init, addresses) => { + pinned.push(addresses); + return Promise.resolve( + url === 'https://shop.example.com/p' + ? new Response(null, { status: 302, headers: { location: 'https://cdn.example.com/p' } }) + : html(), + ); + }, + }), + ); + + expect(pinned).toEqual([['93.184.216.34'], ['93.184.216.99']]); + }); + + it('pins nothing where there was nothing to resolve', async () => { + // The edge runtime again. There is no second lookup to disagree with the first, so + // there is no window — and `null` is what says so rather than an empty list, which + // would read as "connect to none of these". + let pinned: string[] | null | undefined; + await lookupProduct( + 'https://shop.example.com/p', + deps({ + resolve: null, + fetch: (_url, _init, addresses) => { + pinned = addresses; + return Promise.resolve(html()); + }, + }), + ); + expect(pinned).toBeNull(); + }); +}); + describe('redirects', () => { it('revalidates every hop, not just the URL that was typed', async () => { // The standard way past a check that only looks at the first URL: answer the diff --git a/src/server/lookup.ts b/src/server/lookup.ts index 8ac9f8f..3a2317c 100644 --- a/src/server/lookup.ts +++ b/src/server/lookup.ts @@ -24,14 +24,17 @@ * at the URL the user typed. * - **A response size cap and a hard timeout**, so a hostile or merely enormous page * cannot hold a function open or exhaust its memory. - * - * The residual hole is stated rather than papered over: between the DNS check and the - * connection there is a window in which a record can change (DNS rebinding). Closing it - * needs the socket to be pinned to the address that was checked, which `fetch` does not - * expose. For a self-hosted planning tool with no internal network worth reaching, the - * trade is deliberate. + * - **The socket is pinned to the address that was checked.** A guard that resolves a + * name and a transport that resolves it again leave a window between them in which + * the record can change, which is the whole of DNS rebinding: the check sees a + * public address and the connection lands on a private one. `fetch` exposes no way + * to pin a socket, so where `node:https` is present the request goes through it + * with a `lookup` that returns the addresses already validated. */ +import type * as NodeHttps from 'node:https'; +import type * as NodeStream from 'node:stream'; + import { parseProduct, type ProductDraft } from '../core/product'; export const MAX_RESPONSE_BYTES = 2_000_000; @@ -159,12 +162,23 @@ export async function systemResolver(hostname: string): Promise { return found.map((entry) => entry.address); } -/** Resolve a hostname and refuse it if it points anywhere private. */ -async function assertPublicHost(hostname: string, resolve: Resolver | null): Promise { +/** + * Resolve a hostname, refuse it if it points anywhere private, and hand back the + * addresses the connection is allowed to use. + * + * Returning them rather than returning nothing is what turns a check into a guarantee: + * the caller connects to one of *these*, not to whatever the name resolves to a moment + * later. `null` means there is nothing to pin — a runtime with no resolver, where the + * literal checks are all there is. + */ +async function resolvePublicHost( + hostname: string, + resolve: Resolver | null, +): Promise { if (isPrivateAddress(hostname)) { throw new BlockedUrlError('That address is not a public web address.'); } - if (!resolve) return; + if (!resolve) return null; let addresses: string[]; try { @@ -173,7 +187,7 @@ async function assertPublicHost(hostname: string, resolve: Resolver | null): Pro // A runtime with no `node:dns` at all: skip the step rather than refusing every // lookup. A hostname that genuinely will not resolve fails at the fetch instead. if (err instanceof Error && /Cannot find module|ERR_MODULE_NOT_FOUND/.test(err.message)) { - return; + return null; } throw new BlockedUrlError(`Could not resolve ${hostname}.`); } @@ -183,6 +197,99 @@ async function assertPublicHost(hostname: string, resolve: Resolver | null): Pro throw new BlockedUrlError('That address resolves to a private network.'); } } + return addresses.length > 0 ? addresses : null; +} + +/** Which `dns.lookup` family a literal address belongs to. */ +function addressFamily(address: string): number { + return address.includes(':') ? 6 : 4; +} + +/** + * `fetch`, with the socket pinned to addresses that have already been checked. + * + * The one gap the guards above cannot close on their own. `resolvePublicHost` looks the + * name up and `fetch` looks it up again when it connects, and a record that changes + * between the two is DNS rebinding — the check passes on a public address, the + * connection lands on `169.254.169.254`. There is no `fetch` option for this, so the + * request is made through `node:https` with a `lookup` that returns what was validated + * instead of asking the resolver a second time. + * + * TLS is untouched: the hostname still drives SNI and certificate validation, so this + * pins *where* the connection goes without changing *who* it has to prove it is. All of + * the resolved addresses are offered rather than only the first, so a host with one + * dead address still fails over the way it would have. + * + * Two cases fall back to `fetch`, and neither is a hole: a runtime with no `node:https` + * is the same runtime that had no `node:dns`, and a call with no addresses is that same + * runtime arriving here. Where there is nothing to pin there was never a resolution to + * disagree with. + */ +async function pinnedFetch( + target: string, + init: RequestInit, + pinned: string[] | null, +): Promise { + if (!pinned || pinned.length === 0) return globalThis.fetch(target, init); + + let request: typeof NodeHttps.request; + let Readable: typeof NodeStream.Readable; + try { + ({ request } = await import('node:https')); + ({ Readable } = await import('node:stream')); + } catch { + return globalThis.fetch(target, init); + } + + const url = new URL(target); + const headers: Record = {}; + new Headers(init.headers).forEach((value, name) => { + headers[name] = value; + }); + // Written last so a caller cannot ask for something this transport cannot read. + // `https.request` does not decompress and `fetch` does; a compressed body here would + // reach `parseProduct` as gzip, with no error to explain why the page had no title. + headers['accept-encoding'] = 'identity'; + + return await new Promise((settle, fail) => { + const outgoing = request( + { + hostname: url.hostname, + port: url.port || 443, + path: `${url.pathname}${url.search}`, + method: 'GET', + headers, + ...(init.signal ? { signal: init.signal } : {}), + lookup: (_hostname, options, done) => { + if (options.all) { + done( + null, + pinned.map((address) => ({ address, family: addressFamily(address) })), + ); + } else { + done(null, pinned[0]!, addressFamily(pinned[0]!)); + } + }, + }, + (incoming) => { + const status = incoming.statusCode ?? 502; + const received = new Headers(); + for (const [name, value] of Object.entries(incoming.headers)) { + if (Array.isArray(value)) for (const one of value) received.append(name, one); + else if (value !== undefined) received.set(name, value); + } + // 204 and 304 are defined to carry no body, and `Response` refuses to be given + // one — a redirect chain through either would throw here rather than be read. + const empty = status === 204 || status === 304; + const body = empty + ? null + : (Readable.toWeb(incoming) as unknown as ReadableStream); + settle(new Response(body, { status, headers: received })); + }, + ); + outgoing.on('error', fail); + outgoing.end(); + }); } /** Validate the shape of a URL. Throws `BlockedUrlError` with a message to show. */ @@ -221,10 +328,24 @@ export function parseTargetUrl(raw: unknown): URL { return url; } -type FetchLike = (url: string, init: RequestInit) => Promise; +/** + * What actually makes the request: `fetch`, widened by the addresses the socket is to + * be pinned to. + * + * The third argument is here so a test can see it. Pinning is the guard with no + * observable output — a correct implementation and one that quietly calls plain + * `fetch` return the same page — so the addresses are passed rather than captured, and + * a stub can assert that the ones the resolver validated are the ones the connection + * was handed. A two-argument stub is still assignable and simply ignores them. + */ +export type Transport = ( + url: string, + init: RequestInit, + pinned: string[] | null, +) => Promise; export type LookupDeps = { - fetch?: FetchLike; + fetch?: Transport; /** Pass `null` to skip the DNS check — for a runtime that has no resolver. */ resolve?: Resolver | null; }; @@ -264,7 +385,7 @@ export async function lookupProduct( rawUrl: unknown, deps: LookupDeps = {}, ): Promise { - const fetchImpl = deps.fetch ?? ((url, init) => globalThis.fetch(url, init)); + const transport: Transport = deps.fetch ?? pinnedFetch; const resolve = deps.resolve === undefined ? systemResolver : deps.resolve; let url: URL; try { @@ -280,15 +401,21 @@ export async function lookupProduct( let response: Response | undefined; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { - await assertPublicHost(url.hostname, resolve); - - response = await fetchImpl(url.toString(), { - // Manual, so every hop is revalidated. `redirect: 'follow'` would let a - // retailer's shortlink land on a private address without this code seeing it. - redirect: 'manual', - signal: controller.signal, - headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml' }, - }); + // Checked *and* pinned: the addresses this returns are the only ones the + // connection below can reach, so the name cannot be re-pointed in between. + const pinned = await resolvePublicHost(url.hostname, resolve); + + response = await transport( + url.toString(), + { + // Manual, so every hop is revalidated. `redirect: 'follow'` would let a + // retailer's shortlink land on a private address without this code seeing it. + redirect: 'manual', + signal: controller.signal, + headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml' }, + }, + pinned, + ); if (response.status < 300 || response.status >= 400) break; diff --git a/src/styles/global.css b/src/styles/global.css index beeff7c..6fdcdb7 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -737,8 +737,15 @@ body { touch-action: none; } +/* Sized here as well as by the renderer. A `` with no width or height + attributes is 300 x 150 until something measures it, and React Three Fiber measures + from a ResizeObserver — a frame or two after the element exists. Without this the + 3D view opens as a small rectangle in the corner of its cell and jumps to full size + on the next frame. The renderer's own inline size still wins once it arrives. */ .space__canvas canvas { display: block; + width: 100%; + height: 100%; outline: none; }