Repository navigation
Description
Summary
Let a build inject extensions without core importing them (ADR-082 §2.1, proposed).
- The virtual module.
virtual:nodespace-extensionsdefault-exports[]in core. WithNODESPACE_EXTENSIONS=<module path>set, it re-exports that module's default export instead. - Registration.
src/routes/+layout.svelteimports the virtual module and callsregisterExtensions(extensions)before the app shell renders. - Support for an injected entry:
- Tailwind scans its directory;
- Vite's dev server may serve it;
- Vitest picks up its
*.test.tsfiles; svelteand@tauri-apps/apiresolve from core'snode_modules(resolve.dedupe).
- The alias.
@nodespace/extension-apibecomes an alias forsrc/lib/extension-api/. This PR creates that directory with the registration types; Publish the extension host API: services, ui and testing entries, version policy #3315 completes it. - The release guard. Core's release workflow fails if
NODESPACE_EXTENSIONSis set.
With the variable unset, nothing changes. The transitional import './pro-plugin' stays until #3316, so the Pro build is unaffected.
Context
Paths are under packages/desktop-app/, at af8ddb61.
Build configuration:
vite.config.jsL17-22:plugins: [sveltekit()]anddefine. There is no plugin hook yet.svelte.config.js:kitsets onlyadapter.tailwind.config.jsL23:content: ['./src/**/*.{html,js,svelte,ts}']. Classes used only in an out-of-tree component would therefore be purged.src/vite-env.d.tsholds a single ambient declaration.
The layout. src/routes/+layout.svelte is the only layout, and it renders AppShell. +layout.ts sets ssr = false and prerender = true.
The Vitest configs. Each has its own resolve.alias:
vitest.config.tsis the unit tier.- In test mode it uses
svelte(), notsveltekit(), sokit.aliasdoes not apply to it. - Its
test.includeissrc/tests/**.
- In test mode it uses
vitest.browser.config.tsandvitest.webkit.config.tsusesveltekit().vitest.e2e.config.tshas only an alias.
Core's release workflow. In .github/workflows/release.yml, build-tauri-macos-arm and the other-platforms build job each run tauri-apps/tauri-action with projectPath: packages/desktop-app. Tauri's beforeBuildCommand (bun run build) inherits the job's environment.
The Pro GUI build. nodespace-sync's build-pro-dmg.sh and release.yml run tauri build in this directory, at core HEAD today. They will set NODESPACE_EXTENSIONS after #3316.
Scope
1. Entry helpers: new packages/desktop-app/vite-plugins/nodespace-extensions-entry.js
Write this as plain ESM with JSDoc types, importing only node:fs and node:path, so that Vite's, Vitest's and Tailwind's config loaders can all load it.
EXTENSIONS_ENV = 'NODESPACE_EXTENSIONS'.resolveExtensionsEntry(raw, root):- undefined or blank: returns
null; - a relative path: resolved against
root(packages/desktop-app); - an absolute path: kept;
- a missing file: throws an
Errorthat namesNODESPACE_EXTENSIONSand the absolute path.
- undefined or blank: returns
extensionContentGlobs(entry):[], or one glob for the{html,js,svelte,ts}files underdirname(entry).extensionTestGlobs(entry, root):[], or a glob that Vitest'sincludeaccepts for the*.test.tsfiles underdirname(entry). Use an absolute glob, or a root-relative one (../…) if Vitest's scanner rejects absolute patterns. The acceptance check below decides which.
2. The Vite plugin: new packages/desktop-app/vite-plugins/nodespace-extensions.js
nodespaceExtensions({ root, entry }), with name nodespace-extensions and enforce: 'pre'.
config():- Always returns
resolve.dedupe: ['svelte', '@tauri-apps/api']. - When
entrylies outsideroot, it also returnsserver.fs.allow: [searchForWorkspaceRoot(root), dirname(entry)]. Settingallowswitches off Vite's default workspace root, so the plugin restates it. SvelteKit adds its own entries.
- Always returns
resolveId: claims onlyvirtual:nodespace-extensions, and returns it prefixed with a NUL byte, as is Vite's convention for virtual modules.load:entryis null:export default [];;- otherwise: a re-export of the default export of
normalizePath(entry), with the path JSON-quoted.
configResolved: whenentryis set, logsinjecting extensions from <path>once, throughconfig.logger.info.
3. Wiring
vite.config.js. Compute root from import.meta.url, then:
plugins: [
nodespaceExtensions({ root, entry: resolveExtensionsEntry(process.env.NODESPACE_EXTENSIONS, root) }),
sveltekit()
]svelte.config.js. Add kit.alias: { '@nodespace/extension-api': 'src/lib/extension-api' }. SvelteKit writes the alias into its generated tsconfig, so svelte-check resolves it and its subpaths.
tailwind.config.js. Append ...extensionContentGlobs(resolveExtensionsEntry(process.env.NODESPACE_EXTENSIONS, <this directory>)) to content, after core's own glob.
vitest.config.ts:
- Add the plugin after
injectRuneMocks(). - Add
'@nodespace/extension-api': path.resolve(__dirname, 'src/lib/extension-api')toresolve.alias. - Set
test.includeto['src/tests/**/*.{test,spec}.{js,ts}', ...extensionTestGlobs(entry, __dirname)]. - Injected tests therefore run under core's setup files and rune mocks.
vitest.browser.config.ts, vitest.webkit.config.ts and vitest.e2e.config.ts:
- Add the plugin, so every tier resolves the virtual module.
vitest.e2e.config.tsalso gets the alias.- Their
includesets stay unchanged.
src/vite-env.d.ts. Add this declaration, with a doc comment: extensions are provided by the build, and the list is empty in core (ADR-082 §2.1).
declare module 'virtual:nodespace-extensions' {
const extensions: readonly import('@nodespace/extension-api').NodespaceExtension[];
export default extensions;
}4. Minimal host API barrel: new src/lib/extension-api/index.ts
- Re-export
EXTENSION_API_VERSION, and the typesNodespaceExtension,Contribution,ChromeSlot,ChromeContributionandViewerTabContribution, from$lib/plugins/ui-extensions. - Use explicit export lists, with no
export *. - Module doc:
- this is the host API for build-time extensions (ADR-082 §2.6);
- core modules never import it;
- the services, the
/uiand/testingentries and the compatibility policy are added before any extension relies on them.
5. Bootstrap: src/routes/+layout.svelte
-
After the CSS imports, add:
import extensions from 'virtual:nodespace-extensions'; import { registerExtensions } from '$lib/plugins/ui-extensions';
-
At the top of the script body, add
registerExtensions(extensions);, with a comment:- the import is static and registration is synchronous, so contributions exist before
AppShellrenders; - this is the same timing the side-effect import gives today (ADR-082 §2.1);
- core registers none.
- the import is static and registration is synchronous, so contributions exist before
While the transitional self-registration in pro-plugin.ts still exists, a build that sets NODESPACE_EXTENSIONS=src/lib/plugins/pro-plugin.ts registers the same object twice. #3310's identical-object rule makes the second registration a no-op.
6. Core release guard
The step. In .github/workflows/release.yml, add a step before "Build Tauri app" in both build jobs. Give it shell: bash, so that it also runs on Windows. It fails the job whenever the variable is present:
if printenv NODESPACE_EXTENSIONS > /dev/null; then
echo '::error::NODESPACE_EXTENSIONS is set; core releases ship no extensions (ADR-082)'
exit 1
fiThe test. New scripts/release-workflow-extensions-guard.test.ts, using bun:test and no DOM. It asserts that:
- every job that uses
tauri-apps/tauri-actionhas this guard in an earlier step of the same job; - no step or job sets
NODESPACE_EXTENSIONSin anenvblock.
7. CLAUDE.md, frontend section
Add one sentence:
- a build injects extensions with
NODESPACE_EXTENSIONS=<module path>, and relative paths resolve againstpackages/desktop-app; - core ships none, and its release workflow refuses the variable;
- core's own test runs must not set it (ADR-082).
8. Pro-boundary ratchet
Paste the printed BASELINES. No marker is expected to change.
Out of scope
- Removing
import './pro-plugin'or the self-registration (Stop importing the Pro extension in core; register it only through the build entry #3316). - The host services, the
/uiand/testingentries, the policy and the snapshot (Publish the extension host API: services, ui and testing entries, version policy #3315). - Setting the variable in nodespace-sync. That happens in its pin-bump PR after Stop importing the Pro extension in core; register it only through the build entry #3316.
- An ESLint import rule for extension code. That belongs to nodespace-sync's gate (ADR-082 §2.6).
Steps
- Start up as core's
CLAUDE.mddescribes: a worktree namedissue-<N>-extensions-build-entry, and self-assign at pickup. - The entry helpers and the plugin, with their unit tests.
- The Vite, Svelte, Tailwind and Vitest wiring,
vite-env.d.ts, and the minimal barrel. +layout.svelte.- The release guard and its test.
- The
CLAUDE.mdsentence. - The verification, including the out-of-tree checks. Record their output in the PR.
Acceptance criteria
Resolution
- Unset: the virtual module is
export default [];,bun run --cwd packages/desktop-app buildsucceeds, and the app is unchanged. -
NODESPACE_EXTENSIONS=src/tests/fixtures/sample-extension-entry.ts bun run --cwd packages/desktop-app buildsucceeds. -
NODESPACE_EXTENSIONS=does-not-exist.ts bun run --cwd packages/desktop-app buildfails, with a message naming the variable and the absolute path.
An out-of-tree entry. Use a temporary directory outside the repository, and record the results in the PR.
- Build. Its
index.tsimports a type from@nodespace/extension-api, importssvelteand@tauri-apps/api/core, and lazily loads a.sveltecomponent that uses a Tailwind class core does not use. The build succeeds, and the class appears in the emitted CSS. - Tests. From
packages/desktop-app, runNODESPACE_EXTENSIONS=<tmp>/index.ts bunx vitest run <tmp>. It runs<tmp>/sample.test.tsunder core's config, and that test:- resolves
@nodespace/extension-apiand@tauri-apps/api/core; - renders a
.sveltecomponent that uses$state.
- resolves
- Dev server.
dev:tauriwith the same variable serves the out-of-tree component, which checksfs.allow.
Checks
- svelte-check resolves
virtual:nodespace-extensionsand@nodespace/extension-api. - The release guard is in both build jobs, and its test passes in
bun run test:scripts. -
bun run test:changedpasses,quality:fixis clean, and the free app boots indev:tauri. -
bun run scripts/check-pro-boundary.tsexits 0, andBASELINESequal its printed counts.
Tests and verification
New src/tests/build/nodespace-extensions-plugin.test.ts (with // @vitest-environment node):
- unset and blank both give
null, andexport default [];; - relative, absolute and missing paths;
resolveIdignores every other id;loademits a normalized, quoted re-export;config()always dedupessvelteand@tauri-apps/api, and extendsfs.allow(the workspace root plus the entry's directory) only for an entry outsideroot;- the output of
extensionContentGlobsandextensionTestGlobs; - a source assertion that
+layout.svelteimports the default export ofvirtual:nodespace-extensionsand passes it toregisterExtensions.
New src/tests/build/virtual-extensions-module.test.ts (with // @vitest-environment node):
- Start Vite with
createServer({ configFile: false, root, appType: 'custom', server: { middlewareMode: true }, plugins: [nodespaceExtensions(…)] }), then callssrLoadModule('virtual:nodespace-extensions'). - With the fixture entry, the default export is the fixture's array.
- With no entry, it is
[]. - Close the server in
afterAll.
New src/tests/fixtures/sample-extension-entry.ts: export default [{ id: 'sample-extension', apiVersion: 1 }] satisfies NodespaceExtension[];, with a type-only import from @nodespace/extension-api.
New scripts/release-workflow-extensions-guard.test.ts, as described in section 6.
bun run test:changed
bun run test:scripts
bun run --cwd packages/desktop-app test src/tests/build
bun run --cwd packages/desktop-app build
NODESPACE_EXTENSIONS=src/tests/fixtures/sample-extension-entry.ts bun run --cwd packages/desktop-app build
bun run --cwd packages/desktop-app quality:fix
bun run scripts/check-pro-boundary.ts
Dependencies
- Adopt ADR-082's contribution contract: ids, when() predicates, keyed and isolated outlets #3310:
NodespaceExtension,EXTENSION_API_VERSIONandregisterExtensions(), which the declaration, the barrel and the layout use.
Risks and rollback
- Config loaders. The helpers are plain JS with only Node imports, so Tailwind's and Vite's config loaders never have to load TypeScript.
- Windows paths. Every emitted path goes through Vite's
normalizePath. fs.allow. Restating the workspace root is required; the unit test pins it.- The SSR build. With
ssr = false, the layout is never rendered on the server. The server build still bundles the entry, though, so extension modules must not do any work at import time (ADR-082 §2.7). - A developer shell with
NODESPACE_EXTENSIONSset changes core's own test runs. In that case Stop importing the Pro extension in core; register it only through the build entry #3316's boundary test fails loudly. - Rollback. Revert the PR. A nodespace-sync build pinned to a core revision that includes this change is unaffected.
Plan key: C3.2 (pro-boundary DAG).
🤖 Generated with Claude Code
Activity
mstomar125 commentedon Sep 30, 2026
Completed in #3392 (merged). Auto-close did not fire; closing manually.
mstomar125 commentedon Sep 30, 2026
Correction to the closing comment above: this issue was completed in #3395 (merged), not the PR named there.