Skip to content

fix(vrowzer): isolate projects that share a Service Worker registration - #73

Merged
kazupon merged 10 commits into
mainfrom
fix/project-isolation
Oct 2, 2026
Merged

kazupon merged 10 commits into
mainfrom
fix/project-isolation

Conversation

@kazupon

@kazupon kazupon commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Closes #45

Two tabs of the same application can now use separate Vrowzer projects while they share one Service Worker registration. Each tab sees only its own files, transforms and HMR updates, and keeps working when the other tab is disposed or closed.

Cause

The Service Worker held the state of one project only:

  • one virtual filesystem, which the V_FS_* messages of every tab wrote to;
  • one Web Worker channel for transforms and HMR ports, from the runtime that connected last;
  • one public file list and one set of safe module paths.

In a browser, two tabs with the same file paths overwrote each other's files, all transforms and HMR ports went to the Web Worker that connected last, and disposing that Web Worker made the other tab's preview HTML fail with a 500 after the 30-second RPC timeout.

Fix

The change has two stages, and each commit can be read on its own.

Stage A: the Web Worker answers the preview requests (commits 1–5)

The Web Worker already has the project files, the module graph and the plugins, so it now answers whole requests:

  • It runs the Vite middlewares (Hono) in the order of Vite's dev server (createRequestPipeline()), and answers each request through a new handleRequest RPC.
    • Plugins get these middlewares as server.middlewares in configureServer. The functions that the hooks return add their middlewares after the internal ones, as in Vite.
    • Request and response bodies cross the port as transferred ArrayBuffers instead of copies.
  • The Service Worker only forwards the requests within the base path, adds the cross-origin isolation headers, and forwards the HMR ports. It no longer resolves the Vite config, runs no plugins, and has no files, public file list or safe module paths.
  • The runtime gives the project files to the Web Worker only. The copies of the project go from three to one: the Service Worker's copy and the page's copy for the recovery of Recover project files and channels after Service Worker process restart #46 are gone.
  • The recovery after a Service Worker restart (Recover project files and channels after Service Worker process restart #46) only connects the Web Worker channel again. File operations no longer wait for it.
  • Vite's error page now comes from the Web Worker, so it names the failed plugin (plugin, pluginCode), and the HMR error payload is sent as in Vite.

Stage B: route by owner (commits 6–10)

  • Each Vrowzer() instance gets an ID of 12 hexadecimal digits. Its previews load from basePath followed by the ID, e.g. /__preview__/0123456789ab/, which the new read-only previewBasePath returns. The path is also the Vite base of its Web Worker.
  • The Service Worker keeps the Web Worker channels by runtime ID (V_WW_CONNECT_PORT.runtimeId), and routes each request by the ID in its URL.
    • A request for a runtime that has not connected, e.g. right after a restart, waits for up to 10 seconds (ownerWaitTimeout), then gets a 503.
    • A URL without a runtime ID, or of a released runtime, gets a 404 right away.
    • A new channel of the same runtime replaces its previous one.
  • vite:mc:init carries the Vite base of the preview, and the Service Worker forwards the HMR port to the Web Worker of that runtime.
  • V_WW_CONNECT_PORT_ACK names the runtime, and each runtime takes only its own acknowledgement. Runtimes in the same page receive each other's.
  • dispose() sends the new V_WW_DISCONNECT_PORT. A closed tab cannot, so when a runtime connects, the Service Worker releases the runtimes whose pages clients.matchAll() no longer lists.

Performance

Measured locally with a temporary benchmark test, two runs each. Each value is the mean of 200 reads from the preview iframe.

Read main (ms) This PR (ms)
HTML 0.29 / 0.30 0.27 / 0.27
Module 0.24 / 0.24 0.23 / 0.23
Public file 0.23 / 0.21 0.22 / 0.22
Static file, 2 KB 0.22 / 0.21 0.22 / 0.22
Static file, 64 KB 0.35 / 0.34 0.28 / 0.26
  • Bundles: the Service Worker goes from 608 KB to 57 KB. The Web Worker transformer grows by 33 KB (Hono and the middlewares).
  • After a Service Worker restart, the first response came 6–9 ms after the waking request, instead of 68–72 ms. The Service Worker no longer resolves the Vite config, and the recovery only connects the channel.
  • E2E: 81.9 s, against 82.0 s on main.
  • A trade-off: the Web Worker now answers static files as well, so they wait while it runs a long synchronous transform. During the transform of a 1 MB TypeScript module (20,000 functions, 105–128 ms), a static read waited for up to 62–82 ms. On main, where the Service Worker served static files, it waited for up to 34–39 ms. Usual modules transform in well under a millisecond.

Acceptance criteria

The new isolation.integration-test.ts opens two pages in one browser context, which share the Service Worker registration as tabs do.

Criterion Test
Two tabs have /index.html and /src/main.ts at the same paths, initialized in both orders serves each tab its own project at the same paths (A first / B first)
Updates, additions and deletions in either tab stay in its project after both previews reload keeps the file changes of each tab in its own project
Delayed transforms and HMR updates stay with their owners while both tabs edit delivers delayed transforms and HMR updates to their own tabs
After one owner is disposed or closed, the other keeps loading assets, editing and reloading (also #43) keeps a tab working after the other tab is disposed / closed
The previews of one owner share its updates shares the updates of a tab among its previews
Two clients sharing the registration each restore their own project after a restart (from #46) restores the project of each tab after the Service Worker restarts

Breaking change

  • Preview URLs: previews load from basePath followed by an instance ID (previewBasePath), not from basePath itself, and import.meta.env.BASE_URL in the preview is previewBasePath. Hosts that build preview URLs from basePath should use previewBasePath.
  • initServiceWorker() of vrowzer/service-worker-core no longer takes plugins. The Service Worker runs no plugins; give them to the Web Worker, as @vrowzer/vite-plugin does with the plugins of vite.config.ts.
  • Plugin middlewares that plugins add in configureServer run in the Web Worker. Up to 0.4.x, the Web Worker's dev server had no middlewares, so they ran only in the Service Worker, for the plugins given to it.
  • createServer() of @vrowzer/vite-dev-server/service-worker takes (scope, { version, basePath, ownerWaitTimeout }) and forwards requests to the Web Workers. It no longer takes a Vite config, plugins, watcherFactory, beforeRequest or onWorkerChannelReady, and the module no longer exports the config, HMR, middleware and module graph APIs.

The README has a NOTE for the first three. A footer for the squash commit:

BREAKING CHANGE: Previews load from basePath followed by an instance ID, which the new previewBasePath returns, and import.meta.env.BASE_URL in the preview is that path. initServiceWorker() of vrowzer/service-worker-core no longer takes plugins, and the middlewares that plugins add in configureServer run in the Web Worker. createServer() of @vrowzer/vite-dev-server/service-worker only forwards requests, and takes the version, the base path and ownerWaitTimeout.

Other changes

Tests

  • Unit tests:

    • requestTransport.test.ts (new): serializing requests and responses, and the transfer list of birpc messages, including through a MessageChannel.
    • requestPipeline.test.ts (new): the order of the middlewares, the post hooks, errors, and requests outside the base path.
    • service-worker.test.ts (new): forwarding by runtime ID, two owners kept apart, the waits, 404 and 503, HMR ports, replaced channels, V_WW_DISCONNECT_PORT, and closed pages.
    • web-worker.test.ts: the pipeline around the configureServer hooks, and handleRequest.
    • index.test.ts, index-file-sync.test.ts, index-recovery.test.ts, index-dispose.test.ts: previewBasePath, the files sent to the Web Worker only, the channel-only recovery, the own acknowledgement, and the release on dispose().

    The new and changed tests failed before the changes.

  • Integration tests:

    • isolation.integration-test.ts (new), as in the table above.
    • vrowzer.integration-test.ts: plugin middlewares in the Web Worker, before and after the internal middlewares, and the plugin name on the error page. The tests read the preview files under previewBasePath.
  • I checked that tests fail without parts of the fix:

    • Without the middlewares in the Web Worker, 66 integration tests fail.
    • Without the post hooks, the middleware added after the internal ones gets index.html.
    • Without the routing by runtime ID, the first isolation test fails in both orders.

I ran these checks locally:

  • vp run test:unit: 122 files, 1,974 tests (3 skipped)
  • vp run test:integration:vrowzer: 85 passed and 1 conditional skip, in five runs
  • vp run test:e2e: 46 passed and 6 existing test.skip, in 81.9 s
  • oxlint (no new warnings, 8 fewer from removed files), knip, the format check of the changed files, and typecheck

kazupon added 10 commits October 2, 2026 11:59
The Web Worker has the project files, the module graph and the plugins, so
it can answer a whole request with the Vite middlewares. This is the first
step of #45, where the Service Worker only forwards requests to the Web
Worker that owns the project.

- createRequestPipeline() builds the Vite middlewares in the Web Worker,
  in the order of Vite's dev server: base, public files, transform, raw
  fs, static files, the HTML fallback, the functions that the
  configureServer hooks return, index.html, 404 and the error handler.
- The configureServer hooks get these middlewares as server.middlewares.
- setupHMR() returns the public file list that its watcher handlers keep
  up to date, and the public file middleware uses it.
- The new handleRequest RPC answers a forwarded request. Requests and
  responses cross the port as plain data, and their bodies are
  transferred instead of copied.
- The test plugins of the integration fixtures find the Web Worker's dev
  server by its environments, since it has middlewares now.

The Service Worker still answers requests itself. It forwards them in the
next commit.
…o the Web Worker

The Service Worker no longer runs the Vite middlewares. It forwards each
request within the base path to the Web Worker with handleRequest, and
answers with the Web Worker's response and the cross-origin isolation
headers. It still forwards the HMR ports of the previews.

createServer() of @vrowzer/vite-dev-server/service-worker takes only the
version, the base path and ownerWaitTimeout now. The Service Worker does
not resolve the Vite config, and has neither plugins nor the public file
list:

- A request that comes before a Web Worker connects, e.g. right after the
  browser restarted the Service Worker, waits for ownerWaitTimeout (10 s
  by default), then gets a 503. This replaces the beforeRequest and
  onWorkerChannelReady hooks of #46, and vrowzer's wait for the project
  files and the channel.
- An error on the way to the Web Worker gets a plain 500. The Web Worker
  answers the errors of the project with Vite's error page. Only the Web
  Worker, which has environments, uses the error middleware now, so
  logError() is back to Vite's form.
- The Web Worker no longer sends the safe module paths to the Service
  Worker, which serves no files.
- initServiceWorker() of vrowzer/service-worker-core no longer takes
  plugins. Plugins run in the Web Worker.
- The old browser test of the Service Worker server, which no test
  project ran, is removed.

The Service Worker still receives the project files. The next commit stops
sending them.
The Service Worker forwards the preview requests to the Web Worker, so it
needs no copy of the project. The project files now live only in the Web
Worker:

- ready() gives the files to the Web Worker only. The Service Worker gets
  no V_FS_INIT, no later V_FS_* messages, and no virtual file system.
- A file operation resolves once the Web Worker acknowledges it, and times
  out waiting for the Web Worker only.
- The recovery after a Service Worker restart (#46) only connects the Web
  Worker channel again. The runtime no longer keeps a copy of the files
  for it, and no longer holds the file operations called in the
  meantime, since the Web Worker can apply them right away.
- The error page test checks that the Web Worker names the failed plugin.

serviceWorkerRecovered and serviceWorkerRecoveryError are emitted as
before, when the channel is connected again or cannot be within
fileSyncTimeout.
The Web Worker now runs the middlewares that plugins add in
configureServer. A test plugin of the playground adds one in the hook and
one in the function that the hook returns. The test checks that the first
runs before the internal middlewares, and the second after them and
before the SPA fallback to index.html.
- README: the Architecture section describes how the Service Worker
  forwards the requests within basePath to the Web Worker, which has the
  project files and runs the Vite middlewares. A NOTE describes the change
  from vrowzer 0.4.x: the Service Worker no longer takes plugins with
  initServiceWorker(), and the middlewares that plugins add in
  configureServer run in the Web Worker.
- README and JSDoc: file operations wait for the Web Worker only, and a
  restarted Service Worker only gets the Web Worker channel again, while
  file operations go on.
- The architecture diagram drops the Service Worker's file subscriber,
  virtual file system and Vite dev server, and shows the V_FS_* messages
  going to the Web Worker only.
- The recovery error message says that the restarted Service Worker could
  not be connected to the Web Worker.
- The API references are regenerated.
Each Vrowzer instance now has an ID, 12 hexadecimal digits from 48 random
bits, created when Vrowzer() is called. Its previews load from its own
base path, basePath followed by the ID, e.g. /__preview__/0123456789ab/.
The path is also the Vite base of its Web Worker, so
import.meta.env.BASE_URL in the preview returns it.

- The new read-only previewBasePath returns the path. It is set right
  after Vrowzer() and stays the same after dispose().
- The ID replaces the runtimeId that V_WW_CONNECT_PORT has carried since
  #46. It no longer uses crypto.randomUUID(), so it does not change the
  order of the preview load tokens.
- The integration and e2e tests read preview files under the
  previewBasePath of their instance.

The Service Worker still forwards every request within basePath to the
Web Worker that connected last. The next commit routes the requests by
the ID in the path.
…Worker

The Service Worker kept one Web Worker channel, the last one connected,
so two runtimes that share it, e.g. in two tabs, got each other's
previews, transforms and HMR updates (#45).

The Service Worker now keeps the Web Worker channels by runtime ID, from
V_WW_CONNECT_PORT, and routes by the ID in the preview URL:

- A request goes to the Web Worker of the runtime named by the first path
  segment within the base path, e.g. /__preview__/0123456789ab/.
- A request for a runtime that has not connected waits up to
  ownerWaitTimeout, e.g. right after a restart, then gets a 503. A
  request without a runtime ID in its URL gets a 404 right away.
- A new channel of the same runtime replaces and closes its previous one.
- The HMR client sends the Vite base of the preview with vite:mc:init,
  and the Service Worker forwards the port to the Web Worker of that
  runtime. A port without a runtime in its base is closed.
- V_WW_CONNECT_PORT_ACK names the runtime, and a runtime takes only its
  own acknowledgement, since runtimes in the same page receive each
  other's. A connection without a valid runtime ID is closed.
A disposed runtime left its Web Worker channel in the Service Worker,
which forwarded the requests of its previews to a terminated Web Worker
until they timed out.

- dispose() sends the new V_WW_DISCONNECT_PORT with the runtime ID, once
  the instance has sent its Web Worker channel. The Service Worker closes
  the channel, and answers the requests and HMR ports of the runtime's
  previews with 404 and a closed port from then on. Requests that wait for
  the runtime to connect get the 404 as well.
- A closed page cannot send the message, so when a runtime connects, the
  Service Worker releases the runtimes whose pages clients.matchAll() no
  longer lists.
- A released runtime that connects again is accepted.
The new isolation tests open two pages in one browser context, which
share the Service Worker registration as tabs do. Each test checks a
criterion of #45, and those carried over from #43 and #46:

- Both tabs have /index.html and /src/main.ts with different contents,
  initialized in both orders, and each preview shows its own.
- Files that one tab updates, adds or deletes stay in its project, also
  after both previews reload.
- While the Web Worker of one tab holds a transform, the other tab edits,
  and the held response and the HMR updates reach their own tabs.
- After one tab is disposed or closed, the other one keeps loading assets,
  editing and reloading. The previews of the disposed tab get 404, and so
  do those of the closed tab once another runtime connects.
- The previews of one tab share its updates.
- After the Service Worker restarts, each tab restores its own project.

Without the routing by runtime ID, the first test fails.
- README: basePath is the start of the preview URLs. Each instance loads
  its previews from its own path under it, which the new previewBasePath
  returns, so several instances, e.g. in two tabs, can share a Service
  Worker. A NOTE describes the change from vrowzer 0.4.x: the preview
  URLs and import.meta.env.BASE_URL now have the instance ID, and hosts
  that build preview URLs from basePath should use previewBasePath.
- README: dispose() releases the previews of the instance in the Service
  Worker, and a closed page's previews are released when another instance
  connects.
- JSDoc of basePath and dispose(), and the architecture diagram.
- The API references are regenerated, with previewBasePath.
@kazupon
kazupon merged commit c232436 into main Oct 2, 2026
4 checks passed
@kazupon
kazupon deleted the fix/project-isolation branch October 2, 2026 05:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Isolate projects sharing a Service Worker registration

1 participant