Skip to content

feat(bundlers): opt-in app:// protocol for serving packaged renderers - #4352

Open
erickzhao wants to merge 13 commits into
nextfrom
claude/electron-app-protocol-templates-45kg8m
Open

feat(bundlers): opt-in app:// protocol for serving packaged renderers#4352
erickzhao wants to merge 13 commits into
nextfrom
claude/electron-app-protocol-templates-45kg8m

Conversation

@erickzhao

@erickzhao erickzhao commented Aug 27, 2026

Copy link
Copy Markdown
Member
  • I have read the contribution documentation for this project.
  • I agree to follow the code of conduct that this project follows, as appropriate.
  • The changes are appropriately documented (if applicable).
  • The changes have sufficient test coverage (if applicable).
  • The testsuite passes successfully on my local machine (if applicable).

Summarize your changes:

Adds an opt-in appProtocol option to plugin-vite and plugin-webpack that serves packaged renderer files over a privileged custom scheme (default app://) instead of file://, per Electron's security recommendationsfile:// pages get an opaque origin, which breaks fetch() of local resources and origin-scoped storage.

// forge.config.js
{
  name: '@electron-forge/plugin-vite', // or plugin-webpack
  config: {
    build: [/* ... */],
    renderer: [/* ... */],

    // simplest form: serve renderers over app:// in packaged apps
    appProtocol: true,

    // or the object form:
    appProtocol: {
      scheme: 'myapp', // default: 'app' — validated at build time
      additionalPrivilegedSchemes: [
        { scheme: 'media', privileges: { stream: true } },
      ],
    },
  },
}

The protocol boilerplate (scheme registration, protocol.handle with a renderer-name allowlist and path traversal guard) lives once in @electron-forge/core-utils and is injected into production main-process bundles as a banner, rather than being duplicated into every scaffolded app where it would drift. Templates stay minimal: the Vite templates collapse to a single mainWindow.loadURL(MAIN_WINDOW_VITE_ENTRY) (new define: dev-server URL in dev, app:// URL in prod); the webpack templates only add appProtocol: true.

Notes:

  • Opt-in only — existing apps are unaffected. Dev mode keeps dev-server URLs; webpack JS-only entries keep file://; the base template is untouched.
  • The banner runs before any user code, so registerSchemesAsPrivileged happens before ready and the handler is registered ahead of any user loadURL. Since that call is once-per-app, additionalPrivilegedSchemes folds an app's own privileged schemes into it.
  • scheme is validated at build time (lowercase RFC 3986 syntax, not a scheme Chromium/Electron claim). It becomes part of the renderer's origin, so renaming after release orphans origin-scoped data — documented accordingly.

Verified by unit specs, real builds through both pipelines, and a new Verdaccio e2e test that packages each bundler template, launches the binary, and asserts the renderer was served from app:// (it caught a real config-plumbing bug during development, fixed here). The custom-scheme path is also verified in a packaged asar app.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW

claude added 4 commits August 27, 2026 06:15
Prototype of serving built renderer files over a privileged custom
`app://` scheme instead of `file://` in packaged apps, per Electron's
security recommendations, implemented as a plugin-level feature so the
boilerplate lives in @electron-forge/plugin-vite rather than in every
scaffolded app.

- Add an opt-in `appProtocol` option to the Vite plugin config. When
  enabled, production main-process bundles are prefixed with a runtime
  banner that registers the privileged `app://` scheme and a
  `protocol.handle` serving `.vite/renderer/<name>` with a path
  traversal guard, via `net.fetch` on the resolved file URL.
- Add a `*_VITE_ENTRY` magic constant that resolves to the dev server
  URL during development and `app://<renderer-name>/index.html` in
  production builds, so app code can unconditionally call
  `mainWindow.loadURL(MAIN_WINDOW_VITE_ENTRY)`.
- Update the vite and vite-typescript templates to enable `appProtocol`
  and collapse the dev/prod loadURL/loadFile conditional to a single
  `loadURL(MAIN_WINDOW_VITE_ENTRY)` call.

The banner runs before user code, so the scheme registration happens
before app ready and the handler is registered ahead of any
`createWindow()` in a user 'ready' listener.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
Electron only allows a single protocol.registerSchemesAsPrivileged call
per app, and the runtime injected by `appProtocol` makes that call —
which previously meant the option could not be combined with app code
that needs its own privileged schemes.

Extend `appProtocol` to accept an object form with
`additionalPrivilegedSchemes`, folded into the injected runtime's
single registerSchemesAsPrivileged call alongside the `app` scheme.
The app still registers its own protocol.handle for those schemes —
Forge only registers their privileges. The `app` scheme itself is
reserved and rejected with a build-time error.

The scheme type is structurally compatible with Electron's CustomScheme
so values can be shared with app code without importing Electron types
into the Forge config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
…derers

Extend the appProtocol feature from plugin-vite to plugin-webpack, with
the shared runtime moved into @electron-forge/core-utils so both bundler
plugins inject identical protocol-serving code.

- Move the app:// runtime generator (scheme registration, protocol
  handler with renderer-name allowlist and path traversal guard,
  additional privileged scheme support) from plugin-vite to core-utils.
  plugin-vite now re-exports the shared types and imports the shared
  generator; the runtime's global guard is renamed accordingly.
- Add an opt-in `appProtocol` option to the webpack plugin config.
  When enabled, production builds inject the runtime via a raw
  entry-only BannerPlugin ahead of the webpack bootstrap, and
  `*_WEBPACK_ENTRY` defines for HTML entry points resolve to
  `app://<entry-name>/index.html` instead of a `file://` path.
  JS-only (no-window) entry points keep their `file://` paths, and
  development keeps dev server URLs, so existing
  `loadURL(MAIN_WINDOW_WEBPACK_ENTRY)` app code works unchanged in
  both modes.
- Enable `appProtocol: true` in the webpack and webpack-typescript
  templates. The template main files need no changes since they already
  call loadURL unconditionally.

The renderer output layout is identical across both plugins
(<out>/main bundle with ../renderer/<name>), so the shared runtime's
__dirname-relative lookup works for both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
Resolves a conflict in packages/plugin/vite/src/Config.ts where both
sides appended a new option to VitePluginConfig: keep appProtocol (ours)
and hotRestart (from next).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
@github-actions github-actions Bot added the next label Aug 27, 2026
@erickzhao erickzhao changed the title feat: opt-in app:// protocol for serving packaged renderers (Vite + webpack) feat: opt-in app:// protocol for serving packaged renderers Aug 27, 2026
knip flags it as an unused exported type: nothing references it since the
appProtocol config only names VitePluginAppProtocolConfig, and the type
was never released so there is no compatibility to preserve. Consumers
who need the scheme shape can use PrivilegedScheme from
@electron-forge/core-utils.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
@erickzhao erickzhao changed the title feat: opt-in app:// protocol for serving packaged renderers feat(bundlers): opt-in app:// protocol for serving packaged renderers Aug 27, 2026
claude added 5 commits August 27, 2026 07:33
Adds a packagedRendererProtocol option to testForgeTemplate: when set,
one extra test (npm only, to keep the packaging cost to a single run per
template) scaffolds the template against Verdaccio, injects a probe that
reports window.location.href from the preload over IPC, packages the app
with electron-forge package, launches the packaged binary, and asserts
the renderer window was served from that protocol.

This is the only coverage the injected app:// runtime gets in a real
packaged app — electron-forge start serves renderers from the dev
server, so the existing start-based template tests never exercise it.
All four bundler templates opt in with 'app:'.

The scaffold command, Forge-script environment (lockfile/user-agent
workarounds), and probe-file discovery are extracted into helpers shared
with the existing start test instead of being duplicated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
@electron/get downloads the Electron binary during start and package;
in environments that route outbound traffic through a proxy it needs
the proxy variables, which forgeScriptEnv otherwise strips. Unset
everywhere else, so this is a no-op on CI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
serializableConfig narrowed the plugin config to {build, renderer}
before handing it to the packaging build workers, silently dropping
appProtocol. The workers then built main bundles whose *_VITE_ENTRY
define resolved to undefined and injected no app:// runtime, so packaged
apps called loadURL(undefined) and never loaded a window. Found by the
new packaged-app Verdaccio test; add a unit regression test alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
webpack-typescript compiles the main entrypoint with ts-loader under
noImplicitAny, so the injected renderer-location probe's untyped
(_event, href) callback failed the packaging build with TS7006. Type
the parameters as unknown when the entrypoint is a .ts file; the .js
entrypoints keep the untyped form they require.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
Adds a scheme field to the appProtocol object form so apps can serve
their renderers over a scheme of their choosing instead of the default
app://, e.g. appProtocol: { scheme: 'myapp' }.

The scheme is validated at build time in a shared
resolveAppProtocolConfig() normalizer: it must be a syntactically valid
lowercase URI scheme (RFC 3986; Chromium lower-cases schemes at parse
time so uppercase registrations could never match), and must not be a
scheme Chromium/Electron already claim (http, file, devtools, ...). The
additional-privileged-schemes reservation check now applies to the
chosen scheme rather than the literal 'app' — which also means 'app'
itself becomes usable as an additional scheme when the serving scheme
differs.

The docs call out that the scheme is part of the renderer's origin, so
renaming it after an app has shipped orphans origin-scoped data
(localStorage, IndexedDB, service worker registrations) and should be
treated as a data migration.

Verified by unit specs across both plugins, a real subprocess build
carrying the custom scheme through the config round-trip, and a packaged
asar app loading its window over the renamed scheme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
@erickzhao
erickzhao marked this pull request as ready for review August 27, 2026 17:21
@erickzhao
erickzhao requested a review from a team as a code owner August 27, 2026 17:21
@erickzhao erickzhao mentioned this pull request Aug 27, 2026
20 tasks

@MarshallOfSound MarshallOfSound left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comments inline. The webpack publicPath one is the big one, I think packaged webpack template apps do not load their JS at all with this. The rest is smaller.

Comment thread packages/plugin/webpack/src/WebpackConfig.ts Outdated
Comment thread packages/plugin/vite/src/config/vite.main.config.ts Outdated
Comment thread packages/plugin/vite/src/config/vite.main.config.ts
Comment thread packages/plugin/webpack/src/WebpackConfig.ts Outdated
Comment thread packages/plugin/webpack/src/WebpackConfig.ts Outdated
Comment thread packages/plugin/vite/src/config/vite.base.config.ts Outdated
Comment thread packages/utils/core-utils/src/app-protocol.ts
Comment thread packages/utils/core-utils/src/app-protocol.ts
Comment thread packages/utils/core-utils/src/app-protocol.ts Outdated
Comment thread packages/utils/test-utils/src/template-tests.ts Outdated
Addresses MarshallOfSound's review of the appProtocol feature:

- webpack: serve all origins from the shared .webpack/renderer/ root
  with publicPath '/' for Web-target renderers, and carry the per-entry
  subdirectory in entry URLs (app://<name>/<name>/index.html). The
  previous per-name origin root broke every asset URL html-webpack-plugin
  emitted under publicPath 'auto', so packaged webpack apps loaded HTML
  but none of their JS or CSS.
- Make the packaged-app Verdaccio probe prove the renderer bundle
  actually executed: the renderer script posts a message, the preload
  forwards it with window.location.href over IPC, the main process logs
  it. Navigation alone no longer passes the test.
- Guard the injected runtime with process.type !== 'browser' so
  utility-process/forked-worker bundles built through main targets no-op
  instead of crashing on require('electron').app.
- vite: inject the runtime via a Forge-owned plugin's outputOptions hook
  instead of build.rollupOptions.output.banner, so a user's own banner
  composes with the runtime instead of replacing it; prefix the runtime
  with its own 'use strict' so the bundle's directive prologue stays
  effective.
- Emit a registration-only runtime in development for both plugins so
  the serving scheme and additionalPrivilegedSchemes carry the same
  privileges under electron-forge start as in the packaged app; this
  also makes webpack validate the config in dev, matching vite.
- webpack: keep nodeIntegration renderers on file:// — Electron only
  derives renderer __dirname from file: URLs, which AssetRelocatorPatch
  relies on for relocated native modules and assets in production.
- vite: resolve *_VITE_ENTRY to a file:// expression in builds without
  appProtocol so template-derived loadURL(MAIN_WINDOW_VITE_ENTRY) code
  cannot break only when packaged.
- Grant the serving scheme stream and codeCache by default and accept a
  privileges override in the object form (the runtime owns the app's
  single registerSchemesAsPrivileged call).
- Validate additionalPrivilegedSchemes entries against the scheme
  syntax, and validate renderer names as URL hosts.
- Wrap the handler's decodeURIComponent so malformed escapes 400 instead
  of failing with ERR_UNEXPECTED, and fix the mangled timeout comment in
  template-tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW

Copy link
Copy Markdown
Member Author

Thanks for the thorough review — all ten findings are addressed in c9d72b0.

The big one (webpack publicPath): confirmed real. Served renderers now share the .webpack/renderer/ origin root with publicPath: '/' (Web-target compilations only, mirroring how the dev server already serves them), and entry URLs carry the per-entry subdirectory (app://main_window/main_window/index.html). Per-entry publicPath can't work since one compilation serves multiple entries, so the shared root was the viable option of the two you sketched. The Verdaccio probe now proves the renderer bundle executed — the renderer script posts a message, the preload forwards it with window.location.href over IPC — so an app whose subresources 404 fails the test; all four bundler templates pass it packaged.

The rest, briefly:

  • Non-browser main-target bundles: if (process.type !== 'browser') return; guard (took the cheap fix — the plugin config can't distinguish the real main entry from workers).
  • output.banner fragility: moved to a Forge-owned Vite plugin whose outputOptions hook composes with a user's banner, and the runtime carries its own leading 'use strict'; so the bundle's prologue stays effective (also applies to the webpack BannerPlugin path).
  • Dev/prod privilege parity: both plugins now emit a registration-only runtime in dev (schemes registered, no handler), which also makes webpack validate the config under start like vite does.
  • nodeIntegration renderers stay on file:// (kept AssetRelocatorPatch untouched).
  • *_VITE_ENTRY resolves to a file:// expression in builds without appProtocol, so it's always a valid URL.
  • Serving-scheme privileges: defaults now include stream + codeCache, and the object form accepts a privileges override merged over them.
  • Validation: scheme syntax applies to additionalPrivilegedSchemes entries; renderer names are validated as URL hosts with a message pointing at the constraint.
  • decodeURIComponent wrapped, malformed escapes return 400; the mangled timeout comment is fixed (vitest options-object form so the formatter can't collapse it again).

Generated by Claude Code

*
* Notes:
* - `protocol.registerSchemesAsPrivileged` can only be called once per app,
* and the injected runtime makes that call. If your app needs its own

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I suspect this to end up being a little unergonomic and believe some apps will want to handle protocol registration themselves while still wanting app:// for Vite resources.

Not a blocker though, just something I'm noticing.

Copy link
Copy Markdown
Member Author

@felixrieseberg on apps wanting to own registration while keeping app:// serving — agreed that funneling every privileged scheme through Forge config is the least ergonomic part of this design. It follows from registerSchemesAsPrivileged being once-per-app and needing to run before ready, which forces a single owner; the injected runtime claimed that role so the common case stays zero-config.

A clean escape hatch would be an opt-out like appProtocol: { registerSchemes: false }: Forge injects only the protocol.handle serving part, and the app makes its own registerSchemesAsPrivileged call (which must then include the serving scheme — we'd export the default privileges so that's one spread rather than folklore). That splits ownership exactly along the line you're describing: the app owns the registration call, Forge owns serving. Happy to add it to this PR if you and @MarshallOfSound think it's worth the extra surface now, or leave it as a documented follow-up since the current object form covers the known cases.


Generated by Claude Code

@felixrieseberg felixrieseberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Seems fine. Some bike-shedding things that I don't think deserve blocking on:

  • I'd love an easy way for apps to not get locked out of using registerSchemesAsPrivileged themselves when using the plugin.
  • Some minor worries about the API contract but I doubt we'll change it anytime soon

Couldn't find any individual code I'd write differently!

Adds `appProtocol: { registerSchemes: false }` for apps that need to own
their single registerSchemesAsPrivileged call while still having Forge
serve renderers over the custom scheme. In this mode the injected
runtime carries only the protocol.handle serving part (and nothing at
all in development, where the dev server serves the renderers); the
app's own registration must include the serving scheme, and
APP_PROTOCOL_DEFAULT_PRIVILEGES is exported from core-utils so that is
one spread rather than folklore.

`privileges` and `additionalPrivilegedSchemes` are rejected in this
mode with pointed errors — both configure a registration call Forge no
longer makes.

Verified by unit specs across core-utils and both plugins, and a
packaged asar app that registers the scheme itself and loads its window
over the handler-only runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW

@MarshallOfSound MarshallOfSound left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Second pass on c9d72b0 and e57a3c5. The earlier stuff looks fixed. A few of these are new from the fix commit itself: the webpack publicPath: '/' on JS-only entries, the file-level 'use strict' on the webpack main bundle, and the *_VITE_ENTRY define breaking Vite < 8.

Comment on lines +394 to +399
private rendererPublicPath(target: RendererTarget) {
if (!this.isProd) return { publicPath: '/' };
return this.pluginConfig.appProtocol && target === RendererTarget.Web
? { publicPath: '/' }
: {};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This sets publicPath: '/' for the whole Web-target compilation in prod when appProtocol is on. That compilation also contains the JS-only (isNoWindow) entries, and rendererEntryPoint() keeps those on file:// on purpose. A JS-only bundle now gets __webpack_require__.p = '/', so any asset module, lazy import() chunk, or new URL(..., import.meta.url) in it resolves to file:///<hash>.ext at the filesystem root instead of the old 'auto' script-relative URL. Packaged only.

Either put JS-only entries on the scheme too, split served and unserved entries into separate compilations, or only set publicPath: '/' when every entry in the compilation is served.

Comment on lines +273 to +277
this.allPluginRendererOptions.flatMap((rendererOptions) =>
(rendererOptions.entryPoints ?? [])
.filter((entryPoint) => !isPreloadOnly(entryPoint))
.map((entryPoint) => entryPoint.name),
),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This list is every non-preload-only entry, and the banner validates each one as a URL host. But rendererEntryPoint() only puts isLocalWindow && !nodeIntegration entries on the scheme. JS-only and nodeIntegration entries stay on file://. So a config that was valid before, like { name: 'background worker', js: 'worker.js' } (toEnvironmentVariable supports names with spaces), now throws "cannot be used with appProtocol" in start and package for an entry the scheme never serves. Filter this with the same predicate rendererEntryPoint uses. That also keeps the handler host allowlist to origins that are actually served.

Comment thread packages/plugin/webpack/src/Config.ts Outdated
Comment on lines +155 to +161
* When enabled, the plugin injects the scheme registration and protocol
* handler into the production main-process bundle, and the `*_WEBPACK_ENTRY`
* magic constant for HTML entry points resolves to an
* `app://<entry-name>/index.html` URL in production (it is a dev server URL
* in development either way, so `mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY)`
* keeps working unchanged). JS-only (no-window) entry points keep their
* `file://` paths.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This still says app://<entry-name>/index.html. After c9d72b0 rendererEntryPoint() returns app://<name>/<name>/index.html because all origins share the .webpack/renderer/ root. It also does not say that nodeIntegration entries stay on file://. Someone writing a CSP or will-navigate allowlist from this gets the wrong path. Update it to the new URL shape and mention the nodeIntegration exception.

Comment on lines +365 to +367
return net.fetch(pathToFileURL(target).toString(), {
bypassCustomProtocolHandlers: true,
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

net.fetch(fileUrl, { bypassCustomProtocolHandlers: true }) does not forward request.headers, so the Range header from the media pipeline is dropped and every response is a full 200 with no Content-Range or Accept-Ranges. This is the known protocol.handle + net.fetch(file:) gap, electron/electron#38749. <video>/<audio> load but cannot seek. That is a regression from file:// and it contradicts the APP_PROTOCOL_DEFAULT_PRIVILEGES comment that says stream "keeps <video>/<audio> working". The serving handler is injected and apps cannot patch it. Either handle Range here (parse it, return a 206 with Content-Range) or drop the claim and document the limitation.

Comment on lines 336 to 353
/**
* Serializable snapshot of the plugin config to pass to subprocess workers.
* We only include build[] and renderer[] — the worker needs the full renderer
* list for defines even when building a single main target. `hotRestart` is
* moot here: workers only run when packaging.
* We include build[], renderer[], and appProtocol — the worker needs the
* full renderer list for defines even when building a single main target,
* and appProtocol drives both the `*_VITE_ENTRY` defines and the runtime
* injected into production main bundles. `hotRestart` is moot here: workers
* only run when packaging.
*/
private get serializableConfig(): Pick<
VitePluginConfig,
'build' | 'renderer'
'build' | 'renderer' | 'appProtocol'
> {
return {
build: this.config.build,
renderer: this.config.renderer,
appProtocol: this.config.appProtocol,
};
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pre-existing on next, but this PR rewrites the comment. It says hotRestart is moot because workers only run when packaging. The dev branch of build() also runs every main and preload target through spawnViteBuildWatch(this.serializableConfig, ...). hotRestart is not in the serialized config, so the worker vite.main.config.ts never installs pluginHotRestart, and the worker IPC has no restart message anyway. hotRestart: true is a silent no-op under start. At minimum the comment is wrong. Real fix is to include hotRestart in serializableConfig and bridge requestAppRestart from the worker to the parent like reload-renderers. Fine as a follow up.

Comment on lines +81 to +90
[VITE_ENTRY]:
command === 'serve'
? JSON.stringify(viteDevServerUrls[VITE_DEV_SERVER_URL])
: appProtocol
? JSON.stringify(getAppProtocolEntryUrl(name, appProtocol.scheme))
: // Keep the constant a valid URL without `appProtocol` too, so
// an app that calls `loadURL(MAIN_WINDOW_VITE_ENTRY)` and later
// turns the option off keeps working when packaged instead of
// failing only in production with `loadURL(undefined)`.
`\`file://\${require('node:path').join(__dirname, '../renderer/${name}/index.html')}\``,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The new fallback (no appProtocol, the default path) is a template literal with require('node:path').join(...) in it. Vite 5 to 7 do build-time define through esbuild. esbuild only accepts JSON literals or identifier/member expressions there and fails with "Invalid define value" for the whole define map as soon as any define key (e.g. MAIN_WINDOW_VITE_NAME) is used in a file. plugin-vite has no vite peer range and imports the project's Vite, so an existing Vite 7 project that upgrades Forge without opting in can no longer build its main target. Only Vite 8 (oxc define) is exercised by the specs.

Keep this JSON-serializable or undefined, or gate the expression on Vite >= 8, or declare Vite 8 as the minimum. If the expression stays, use require('node:url').pathToFileURL(...).href instead of 'file://' + path so install paths with #, ? or % are encoded the same way the loadFile call this replaces did.

});
});
});`;
return `'use strict';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The banner now starts with a file-level 'use strict';. On the Vite path that is a no-op, Rollup CJS output is already strict. plugin-webpack prepends the same string with BannerPlugin({ raw: true }) as the first statement of .webpack/main/index.js, in dev and prod. So the whole webpack main bundle is now strict, including the webpack runtime and every bundled sloppy-mode CJS dep. webpack keeps CJS modules sloppy on purpose unless every module is strict. With appProtocol on (the webpack template default now), one dep with a legacy octal escape or with makes the whole file a SyntaxError, and implicit globals and this change silently.

Keep the directive inside the IIFE for the webpack path and only add the file-level one in pluginAppProtocolRuntime, or inject via Rollup output.intro which lands after Rollup's own 'use strict'.

Comment on lines +239 to +244
return {
scheme,
registerSchemes,
privileges: { ...APP_PROTOCOL_DEFAULT_PRIVILEGES, ...config.privileges },
additionalPrivilegedSchemes,
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two configs pass resolveAppProtocolConfig and validateRendererNameForAppProtocol but cannot work at runtime, which breaks the "throws rather than emitting a runtime that could never serve a window" contract:

  • privileges is spread over the defaults (which now include codeCache: true) with no validation. privileges: { standard: false }, or an additionalPrivilegedSchemes entry with codeCache and no standard, emits a registerSchemesAsPrivileged call that Electron rejects ("Code cache can only be enabled when the custom scheme is registered as standard scheme"). That throws synchronously in the IIFE at the top of the main bundle. standard: false on the serving scheme cannot work with host-based renderer matching anyway. Force or reject it, and reject codeCache without standard.
  • RENDERER_NAME_AS_HOST accepts all-numeric names (1, 2024, 1.2, 0x10). standard: true schemes get Chromium IPv4 host canonicalisation, app://1/ becomes app://0.0.0.1/, so the handler rendererName.toLowerCase() === url.hostname check never matches and the window 404s, packaged only. Require at least one letter, or compare against the canonicalised host.

- Build served (local-window) and JS-only Web-target entries as separate
  compilations when packaging with appProtocol: only served entries get
  publicPath '/', so JS-only bundles keep webpack's 'auto'
  script-relative asset resolution under file://.
- Keep the runtime's 'use strict' scoped to its IIFE. A file-level
  directive from webpack's BannerPlugin would force deliberately-sloppy
  bundled CJS deps strict; the Vite path re-adds the file-level
  directive in pluginAppProtocolRuntime where the banner displaces
  Rollup's own prologue.
- Gate the *_VITE_ENTRY file:// fallback expression on Vite >= 8:
  esbuild-based define (Vite 5-7) rejects expression values and would
  fail the whole main build. Use pathToFileURL(...).href so install
  paths with '#', '?' or '%' stay encoded like loadFile did.
- Serve single-range requests from the file directly: net.fetch(file:)
  drops the Range header (electron/electron#38749), which media seeking
  needs and file:// supported. 206/Content-Range for satisfiable
  ranges, 416 otherwise, Accept-Ranges advertised on full responses.
- Only validate and allowlist renderer names the scheme actually serves
  (same predicate as rendererEntryPoint), so JS-only entries with names
  like 'background worker' keep working.
- Reject privileges that cannot work at runtime: standard: false on the
  serving scheme, and codeCache without standard on additional schemes.
  Round-trip renderer names through URL host canonicalisation so
  IPv4-like names ('1', '0x10') fail the build instead of 404ing only
  when packaged.
- Correct the serializableConfig comment about hotRestart and update the
  webpack appProtocol docs to the real production URL shape and the
  nodeIntegration exception.

Verified by unit specs and packaged-app repros (Range semantics
exercised over XHR from a served renderer; strict-mode and process-type
guards checked in emitted bundles).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHmXuuGMGg1bcCCYDNVSKW
tzh476 added a commit to tzh476/forge that referenced this pull request Sep 2, 2026
Five defects, all invisible on Vite 6 and all silent on Vite 8.

1. `output.freeze` is Rollup-only. Vite 8 bundles Rolldown, whose
   `OutputOptions` has no such key, so setting it is a type error
   (3x TS2339/TS2353) and a no-op. Rolldown never emits `Object.freeze`
   anywhere, so the opt-out is unnecessary there rather than merely
   unsupported; gate it on `vite.rolldownVersion`.

2. `resolveId` ignored its `importer`, so the shim's own
   `require("electron")` was re-claimed by the plugin and the virtual module
   resolved to itself:

     init_x = __esmMin(() => { moduleValue = (init_x(), ...) })

   `__esmMin`'s `fn = 0` guard swallows the self-call, so instead of
   recursing it yields `undefined` for every Electron export. Marking
   shim-internal requests external is what keeps a real `require` in the
   output -- simply declining them makes Rolldown resolve `electron` to the
   npm package, which outside Electron is the *installer stub*, bundling
   `getElectronPath()` and a "Downloading Electron binary..." branch into the
   renderer with `fs`/`child_process` stubbed to `module.exports = {}`.

3. The shim called `require` through an alias
   (`const runtimeRequire = require`). Rolldown only rewrites syntactically
   direct `require(...)` calls into its external-module interop; the aliased
   form is dropped. Call `require` directly.

4. `sharedTexture` was missing from `electronExportNames` -- a second
   instance of the `ServiceWorkerMain` bug. It is declared as a `const` in
   `CrossProcessExports`, so `MISSING_EXPORT` breaks the build for anyone
   importing it. Found by the export-list spec, which is what it is for.

5. The specs asserted a literal `runtimeRequire(...)` and `freeze: false` --
   Rollup's output shape rather than the behaviour. Assert the requested
   specifier plus a `require` mention (Rolldown reaches it via
   `require.apply(this, arguments)`, which a literal `require(` pattern
   cannot match), and branch the freeze assertion on the bundler so the spec
   keeps its teeth on Vite 6/7 instead of being loosened for both.

Also fixes a pre-existing lint error on this branch: the export-list spec
resolved `electron` as a bare specifier, but it is a devDependency of the
workspace root, not of this package, so `n/no-extraneous-require` rejected
it. The rule keys on the specifier, so `require.resolve(..., { paths })`
does not satisfy it; the typings are now located by path.

Verified in both directions, and the two version-conditional assertions were
mutation-checked so the branching did not turn them into no-ops:

  Vite 8.0.3 / Rolldown 1.0.0-rc.12 (on `next`, merged with electron#4352):
    tsc -b packages          0 errors
    vitest --project fast    50/50 pass
  Vite 6.4.3 / Rollup (this branch's base):
    tsc -b packages/plugin/vite   0 errors
    vitest --project fast         20/20 pass
    eslint                        0 problems
  Mutants killed: forcing the freeze gate off fails "keeps user dependency
  and Rollup settings"; pointing the typings path at a missing file fails
  "re-exports every Electron API in the shipped export list" with ENOENT.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants