Skip to content

Add the virtual:nodespace-extensions build entry and the extension-api import alias #3311

Closed

Description

@mstomar125

Summary

Let a build inject extensions without core importing them (ADR-082 §2.1, proposed).

  • The virtual module. virtual:nodespace-extensions default-exports [] in core. With NODESPACE_EXTENSIONS=<module path> set, it re-exports that module's default export instead.
  • Registration. src/routes/+layout.svelte imports the virtual module and calls registerExtensions(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.ts files;
    • svelte and @tauri-apps/api resolve from core's node_modules (resolve.dedupe).
  • The alias. @nodespace/extension-api becomes an alias for src/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_EXTENSIONS is 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.js L17-22: plugins: [sveltekit()] and define. There is no plugin hook yet.
  • svelte.config.js: kit sets only adapter.
  • tailwind.config.js L23: content: ['./src/**/*.{html,js,svelte,ts}']. Classes used only in an out-of-tree component would therefore be purged.
  • src/vite-env.d.ts holds 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.ts is the unit tier.
    • In test mode it uses svelte(), not sveltekit(), so kit.alias does not apply to it.
    • Its test.include is src/tests/**.
  • vitest.browser.config.ts and vitest.webkit.config.ts use sveltekit().
  • vitest.e2e.config.ts has 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 Error that names NODESPACE_EXTENSIONS and the absolute path.
  • extensionContentGlobs(entry): [], or one glob for the {html,js,svelte,ts} files under dirname(entry).
  • extensionTestGlobs(entry, root): [], or a glob that Vitest's include accepts for the *.test.ts files under dirname(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 entry lies outside root, it also returns server.fs.allow: [searchForWorkspaceRoot(root), dirname(entry)]. Setting allow switches off Vite's default workspace root, so the plugin restates it. SvelteKit adds its own entries.
  • resolveId: claims only virtual:nodespace-extensions, and returns it prefixed with a NUL byte, as is Vite's convention for virtual modules.
  • load:
    • entry is null: export default [];;
    • otherwise: a re-export of the default export of normalizePath(entry), with the path JSON-quoted.
  • configResolved: when entry is set, logs injecting extensions from <path> once, through config.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') to resolve.alias.
  • Set test.include to ['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.ts also gets the alias.
  • Their include sets 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 types NodespaceExtension, Contribution, ChromeSlot, ChromeContribution and ViewerTabContribution, 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 /ui and /testing entries 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 AppShell renders;
    • this is the same timing the side-effect import gives today (ADR-082 §2.1);
    • core registers none.

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
fi

The 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-action has this guard in an earlier step of the same job;
  • no step or job sets NODESPACE_EXTENSIONS in an env block.

7. CLAUDE.md, frontend section

Add one sentence:

  • a build injects extensions with NODESPACE_EXTENSIONS=<module path>, and relative paths resolve against packages/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

Steps

  1. Start up as core's CLAUDE.md describes: a worktree named issue-<N>-extensions-build-entry, and self-assign at pickup.
  2. The entry helpers and the plugin, with their unit tests.
  3. The Vite, Svelte, Tailwind and Vitest wiring, vite-env.d.ts, and the minimal barrel.
  4. +layout.svelte.
  5. The release guard and its test.
  6. The CLAUDE.md sentence.
  7. 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 build succeeds, and the app is unchanged.
  • NODESPACE_EXTENSIONS=src/tests/fixtures/sample-extension-entry.ts bun run --cwd packages/desktop-app build succeeds.
  • NODESPACE_EXTENSIONS=does-not-exist.ts bun run --cwd packages/desktop-app build fails, 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.ts imports a type from @nodespace/extension-api, imports svelte and @tauri-apps/api/core, and lazily loads a .svelte component 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, run NODESPACE_EXTENSIONS=<tmp>/index.ts bunx vitest run <tmp>. It runs <tmp>/sample.test.ts under core's config, and that test:
    • resolves @nodespace/extension-api and @tauri-apps/api/core;
    • renders a .svelte component that uses $state.
  • Dev server. dev:tauri with the same variable serves the out-of-tree component, which checks fs.allow.

Checks

  • svelte-check resolves virtual:nodespace-extensions and @nodespace/extension-api.
  • The release guard is in both build jobs, and its test passes in bun run test:scripts.
  • bun run test:changed passes, quality:fix is clean, and the free app boots in dev:tauri.
  • bun run scripts/check-pro-boundary.ts exits 0, and BASELINES equal 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, and export default [];;
  • relative, absolute and missing paths;
  • resolveId ignores every other id;
  • load emits a normalized, quoted re-export;
  • config() always dedupes svelte and @tauri-apps/api, and extends fs.allow (the workspace root plus the entry's directory) only for an entry outside root;
  • the output of extensionContentGlobs and extensionTestGlobs;
  • a source assertion that +layout.svelte imports the default export of virtual:nodespace-extensions and passes it to registerExtensions.

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 call ssrLoadModule('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

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_EXTENSIONS set 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

  1. self-assigned this
    on Sep 30, 2026
  2. added
    pro-boundaryPro/core boundary: Pro code moves to nodespace-sync (ADR-081..085)
    on Sep 30, 2026
  3. mstomar125 commented on Sep 30, 2026

    @mstomar125
    ContributorAuthor

    Completed in #3392 (merged). Auto-close did not fire; closing manually.

  4. mstomar125 commented on Sep 30, 2026

    @mstomar125
    ContributorAuthor

    Correction to the closing comment above: this issue was completed in #3395 (merged), not the PR named there.

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

Metadata

Metadata

Assignees

Labels

pro-boundaryPro/core boundary: Pro code moves to nodespace-sync (ADR-081..085)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions