Skip to content

fix(portal): allow the preview to serve the installation its dependencies link to - #418

Merged
MuHamza30 merged 4 commits into
devfrom
fix/portal-serve-dependency-allow-list
Oct 6, 2026
Merged

MuHamza30 merged 4 commits into
devfrom
fix/portal-serve-dependency-allow-list

Conversation

@MuHamza30

Copy link
Copy Markdown
Contributor

Problem

On macOS, apimatic quickstart opens the portal preview on a blank page in Safari with Unhandled Promise Rejection: TypeError: Importing a module script failed. Pasting the same address into a new tab loads the portal. Reported in apimatic/apimatic-io#2287.

Root cause

The prepared project's node_modules are links into the CLI's installation, which the dev server's server.fs.allow list does not name. Vite serves a file from outside that list only after the module importing it has been transformed, which marks the file safe. Browsers normally fetch the importer first, so the side effect holds.

Safari breaks the order. The preview always runs on port 23513, so Safari keeps the entry module cached across runs, executes the cached copy without asking the fresh server, and its first request is the TanStack dev entry inside the CLI's installation. Nothing has marked it safe, so the server answers 403, the entry's top-level await import rejects, and the page stays blank. Captured on a failing run:

The request id "/opt/homebrew/lib/node_modules/@apimatic/cli/node_modules/@tanstack/react-start/dist/plugin/default-entry/client.tsx" is outside of Vite serving allow list.

This also explains why the issue's module crawls never reproduced it: a crawler has no cache, always fetches the importer first, and so always sees 200.

Fix

The template now resolves the real directories its dependencies link to, npm and pnpm layouts alike, and names them in server.fs.allow, so every dependency is servable whatever the browser asks for first.

Testing

  • test/e2e/portal-serve.test.ts replays Safari: cold dev server, the /@fs/ URL of the TanStack entry requested before anything else. Against the old config it fails with the same 403 and message as the Mac; with the fix it gets 200.
  • test/portal-template/dependency-directories.test.ts covers the directory resolution, including the pnpm store layout, a copied dependency, and a dangling link.
  • Full suite, the portal build end-to-end test, lint and build all pass.

🤖 Generated with Claude Code

…cies link to

The prepared project's node_modules are links into the CLI's installation,
which the dev server's allow list does not name. A file from there is served
only once the module importing it has been transformed, an order a browser
cache skips: Safari keeps the entry cached across previews on the fixed port,
executes it without asking the fresh server, and is refused the TanStack dev
entry with a 403, leaving the page blank (apimatic-io#2287).

Naming the real directories the links lead to keeps every dependency served
whatever the browser asks for first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MuHamza30 and others added 3 commits October 5, 2026 11:35
Addresses the review of the allow-list fix:

- Keep the workspace root Vite trusts by default, which naming the allow
  list by hand had dropped, and add the installations to it.
- Trust only the installation the dependencies are linked from: the
  node_modules holding them, or the pnpm store around them. The template's
  walk to the outermost node_modules trusted every global npm package.
- Resolve the installations in the CLI, where the links are made, and pass
  them in portal.config.json instead of rediscovering them in the template
  on every config load, build and prerender included.
- Spell them with the native real path, so a Windows 8.3 short name never
  leaves the entry and the request spelt apart.
- Keep the new comments to one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…may serve"

This reverts commit 09bcb54. The build with it left the preview blank in
Safari on macOS, where the build before it loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page imports its images from the static directory, which sits outside the
project and was missing from the allow list. Like the dependencies, an image
was served only once the page importing it had been transformed, so a browser
running that page from its cache was refused it.

Vite already serves the directory at the site root as publicDir, so naming it
exposes nothing new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented Oct 5, 2026

Copy link
Copy Markdown

@mrafnadeem-apimatic mrafnadeem-apimatic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on the condition that a follow-up PR addresses the comments in this review, the inline one included.

Reviewed at 1ded182f on 2026-10-05 and rechecked at 6b249331.

The PR's effective change is 0d15e2da and 6b249331. The first scans the prepared project's node_modules and adds the outermost node_modules above each symlinked package to server.fs.allow; the second adds paths.staticDir. The CLI-computed list in 09bcb54b was reverted because Safari on macOS still showed a blank page with it. projectDirectory in vite.config.ts is the temporary copy of portal-template/ that the CLI runs Vite in, never the CLI repo root.

Findings

# Finding Status Evidence
1 The outermost node_modules is the wrong boundary: too wide for npm global installs, too narrow for pnpm global installs Confirmed Table below
2 Images a page imports from the static directory get a 403 when asked for before the page Fixed in 6b249331 The page imports /@fs/<source>/static/images/logo.png?import. Image first: 403. Page first: 200. With paths.staticDir in the allow list: 200.
3 Starting the CLI through a Windows 8.3 short path blanks the page in any browser, in normal request order Confirmed with an npm global install Vite 8.2.2 refuses short-name paths before it reads the allow list (isFileLoadingAllowed, node.js:20501). TanStack's entry imports client.tsx by a short /@fs/ URL. Pointing the symlinks and viteBinary at canonical() paths loads all 120 /@fs/ files. Not tried on dev.
4 The allow list doesn't explain why 09bcb54b failed in Safari Confirmed In every layout 09bcb54b allows at least as much as the PR, so the Safari result points to cache state or another difference between the test runs
5 Setting server.fs.allow drops Vite's default, searchForWorkspaceRoot(root), and vite.config.ts:60-61 wrongly calls that default "the project alone" Confirmed in Vite's docs It matters when the Windows cross-drive fallback puts the project inside a user's workspace
6 The PR's premise needs Safari to run a cached no-cache module without revalidating it Partly The page (/) is sent with no Cache-Control; modules with no-cache and an ETag; pre-bundled dependencies immutable. A stale ?v= gets 504. A browser that revalidates gets 304 then 200 and never hits the 403.
7 The e2e tests' bare 200 checks pass for a TanStack file or an image that has moved Confirmed A missing /@fs/ file returns 200 text/html, the portal's fallback page (portal-serve.test.ts:74 and :86)
8 The Windows CI job likely passes the e2e tests without the fix Inferred test.yml runs them on windows-latest. The fallback puts the project inside the repo, whose pnpm-workspace.yaml already lets Vite's default serve node_modules and the fixture's static directory.
9 entries and realPath ignore every file-system error, not just "file not found" Read in the code dependency-directories.ts:29-43
10 after calls fs.rmSync(root) without checking that before set root Read in the diff portal-serve.test.ts:58
11 Rescanning on every config load Negligible One scan per serve; four per build, about 60 ms of a 21 s build

Allow-list coverage, counted as the page's 120 /@fs/ files that fall outside each version's list:

Layout PR (0d15e2da) allows Missing 09bcb54b allows Missing
pnpm dev checkout <cli>/node_modules 0 <cli>/node_modules/.pnpm 0
npm global <prefix>/node_modules, which includes every global package 0 <prefix>/node_modules/@apimatic/cli/node_modules 0
pnpm 10 global 20 per-package global/5/.pnpm/<pkg>/node_modules 69 global/5/.pnpm 0
pnpm 11 global per-package links/…/<hash>/node_modules 69 the same 69

The 69 include @tanstack/react-start-client and router-core. These counts measure coverage only: a file is still served once its importer has been transformed.

Over npm global, the extra width exposes every non-denied file of every global package to any local process while the preview runs. Vite still refuses .env*, key and certificate files, .npmrc, .yarnrc.yml and .git/**, and its CORS and host checks keep websites out.

.ai/instructions.md

The PR targets dev, and the tests follow the mkdtemp/afterEach and mirrored-location rules. The comments break the one-line-of-why rule:

Where Replace with
dependency-directories.ts:4-10 // Safari runs a cached entry before its importer is transformed (apimatic-io#2287).
vite.config.ts:60-61 No comment, with allow: [searchForWorkspaceRoot(projectDirectory), ...]
portal-serve.test.ts:62-67 The test name 'serves a dependency file asked for before its importer (apimatic-io#2287)'
portal-serve.test.ts:17-18 // Opt-in: a real dev server needs a minute and every runtime dependency.
dependency-directories.test.ts:7-10 Delete; the test names say it

Recommendations

  1. Change the boundary so it covers the pnpm 10 and pnpm 11 global stores, where 69 of the page's files fall outside it, without taking in every npm global package (finding 1).
  2. Return canonical() paths from packageDirectory, which linkDependencies and viteBinary both take their paths from (finding 3). The template's realPath then follows the symlinks to long paths, so the allow list needs no change of its own.
  3. Have entries and realPath catch only ENOENT, so a permission or symlink-loop error stops the config load with its message instead of silently dropping packages from the allow list and bringing the 403 back (finding 9).
  4. Make the e2e tests check that a JavaScript module and an image came back, not just a 200 (finding 7).
  5. Try Cache-Control: no-store in Safari on macOS, a fix that would need no allow-list change (finding 6). no-cache, which Vite sends on modules, lets a browser keep a copy as long as it asks the server before reusing it; no-store forbids keeping one at all. With no stored entry to run, Safari has to request it, the fresh server transforms it, and client.tsx is then served under the allow list dev already has.
    • The page (/) is sent with no Cache-Control, so a middleware can set no-store on it. Whether that alone stops Safari reusing the entry is unknown. It is the cheaper change: modules keep their 304s and pre-bundled dependencies stay immutable.
    • A middleware can't reach modules, because Vite's transform middleware sets their Cache-Control after it. server.headers can, because Vite applies it last, but it covers every module and pre-bundled dependency, so each preview load downloads all of them again.
    • Test both in the scenario that blanked the page, two previews in a row on the same port, with Web Inspector's Network tab open. The entry should no longer show as "(memory cache)".
    • If either works, it replaces the scan in dependency-directories.ts, and findings 1, 5, 9 and 11 go with it. The e2e test would then check the header, since client.tsx asked for first gets a 403 again.

Findings 2, 3, 4, 6, 7 and 11 come from local runs on Windows, replaying requests over HTTP. Nothing was run in Safari.

const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]);
const pages = await prerenderPages(paths, identity.siteUrl);
const publicDir: string | false = paths.staticDir ?? false;
const projectDirectory = fileURLToPath(new URL('.', import.meta.url));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename variable to portalProjectDirectory to match CONTEXT.md.

@MuHamza30
MuHamza30 merged commit 35947ee into dev Oct 6, 2026
15 of 16 checks passed
saeedjamshaid added a commit that referenced this pull request Oct 6, 2026
Brings in #419, #418, #423 and #425. The one conflict, in
routes/$.tsx, is resolved with #419's line: its copy button gets the
page's path and adds the base itself, while the popover keeps the full
address this branch gives it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.0.0-beta.4 🎉

The release is available on:

Your semantic-release bot 📦🚀

MuHamza30 added a commit that referenced this pull request Oct 8, 2026
Follow-up to #418, which was approved on the condition that its review
comments are addressed in a separate PR. Every comment is addressed
here.

## Changes

- **Real paths for the dependencies and the Vite binary** (finding 3).
The package lookup answers canonical paths, so a Windows 8.3 short name
or a symlinked install can no longer leave the entry's `/@fs/` import
refused in every browser. The new unit test fails without the change on
a pnpm checkout.
- **The narrowest directory holding the dependencies** (finding 1). The
allow list took the outermost `node_modules` above each linked package:
every global package under an npm global install, and only each
package's own directory under a pnpm global install, where 69 of the
page's 120 `/@fs/` files fell outside it. It now takes the directory all
the linked installations share: the CLI's own `node_modules` under npm,
the `.pnpm` or `links` store under pnpm. Where the installations share
nothing short of the disk's root, as with an `npm link`ed dependency on
another drive, each is listed on its own rather than widening to the
root. Unit tests cover the four layouts in the review's table and the
divergent case.
- **Vite's default workspace root is kept** (finding 5), and the project
directory is `portalProjectDirectory` after the glossary (inline
comment).
- **Nothing is skipped** (finding 9, tightened in review). A missing
`node_modules` or a link leading nowhere stops the config load with its
own error, naming the path, rather than surfacing later as a blank page.
- **Serve tests check what came back** (finding 7): a JavaScript content
type for the dependency file and `image/png` for the static image, since
a missing file answers 200 with the fallback page. Cleanup only removes
what set-up made (finding 10).
- **One-line comments**, as the review's table asks.

## Recommendation 5: `Cache-Control: no-store`, tested in Safari

Tested on macOS, Safari, from the `dev` commit before #418 so the bug
could show. Control: the first preview loaded and the next preview on
the same port went blank. With `no-store` on the page: every run blank.
With `no-store` on every module too: first run loaded, every later run
blank, Safari restarted between. In Web Inspector the entry module,
`virtual:tanstack-start-dev-client-entry`, showed `(memory)` as its
transfer size in every case, on reload too. Safari serves the module
from its memory cache whatever the header says, which confirms finding
6's premise and rules out the header as a fix. The allow list stays.

## Verification

- Full suite, both end-to-end suites, lint and type check pass.
- Replaying the review's measurement: the page's 120 `/@fs/` files,
asked of a fresh server before anything else, are all served from a real
`npm install -g` and a real `pnpm add -g` (pnpm 11, `links` store). The
old boundary refused 69 under pnpm.
- A build of this branch at 7754fb5 on macOS, Safari, with the stale
cache from earlier previews in place: quickstart, two repeat previews, a
page with a static image over two previews, and a second quickstart all
loaded. The commits since restructure the fold, add the root guard and
let the lookup throw; the unit and serve tests cover them, and on a
project linked like the CLI links it the old and new lookup answer the
same allow list.

## Note on Windows CI

As finding 8 says, on `windows-latest` the test project lands inside the
repository, which Vite's default already trusts, so the serve tests pass
there with or without the allow list. They guard it on macOS and Linux.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants