Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { type Plugin, defineConfig } from 'vite';

// Force every importer (host, workspace module, wheel-installed module)
// to resolve to one React copy + a single Inertia hook context. Without
Expand Down Expand Up @@ -36,26 +36,40 @@ const fsRoot = findNodeModulesRoot(__dirname);
// optimizeDeps.entries so its dependency scanner discovers bare imports
// from wheel-installed pages and pre-bundles them.
//
// We also collect each module's package.json (one level up from pages/,
// where Hatch's force-include drops it) so the dependency walk in
// `collectOptimizeIncludes` reaches packages a wheel-installed page imports
// directly (`sonner`, `lucide-react`, ...). Without this seed, Vite's
// pre-bundler never sees those bare specifiers and Node module resolution
// walks up from inside .venv/site-packages — never reaching
// host/client_app/node_modules.
// We also collect each module's package.json — wheels embed it next to
// the Python package (one level up from pages/, force-included by Hatch),
// while editable/workspace installs leave it at the source-tree module
// root (two levels up). We accept either. The dep walk in
// `collectOptimizeIncludes` uses it to reach packages a module's pages
// import directly (`sonner`, `lucide-react`, `maplibre-gl`, …). Without
// this seed, Vite's pre-bundler never sees those bare specifiers and Node
// module resolution walks up from inside .venv/site-packages — never
// reaching host/client_app/node_modules.
const manifestPath = path.resolve(__dirname, 'modules.manifest.json');
const moduleFsAllow: string[] = [];
const moduleOptimizeEntries: string[] = [];
const modulePkgJsonPaths: string[] = [];
const modulePagesPrefixes: string[] = [];
if (fs.existsSync(manifestPath)) {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as Record<string, string>;
for (const pagesDir of Object.values(manifest)) {
moduleFsAllow.push(path.dirname(pagesDir));
const pkgDir = path.dirname(pagesDir);
moduleFsAllow.push(pkgDir);
moduleOptimizeEntries.push(path.join(pagesDir, '**/*.tsx'));
const modulePkgJson = path.join(path.dirname(pagesDir), 'package.json');
if (fs.existsSync(modulePkgJson)) modulePkgJsonPaths.push(modulePkgJson);
modulePagesPrefixes.push(pagesDir + path.sep);
for (const candidate of [
path.join(pkgDir, 'package.json'),
path.join(path.dirname(pkgDir), 'package.json'),
]) {
if (fs.existsSync(candidate)) {
modulePkgJsonPaths.push(candidate);
break;
}
}
}
}
const fsRootPrefix = fsRoot + path.sep;
const fakeWorkspaceImporter = path.join(fsRoot, 'package.json');

// CJS-only deps like `clsx`, `tailwind-merge`, `class-variance-authority`
// expose named exports only after esbuild's CJS→ESM transform. Vite's
Expand All @@ -70,6 +84,7 @@ type Pkg = {
module?: string;
exports?: unknown;
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};

const pkgCache = new Map<string, Pkg | null>();
Expand Down Expand Up @@ -117,26 +132,72 @@ function collectOptimizeIncludes(): string[] {
visited.add(pkgJsonPath);
const pkg = readPackageJSON(pkgJsonPath);
if (!pkg) continue;
for (const name of Object.keys(pkg.dependencies ?? {})) {
if (name.startsWith('@types/')) continue;
const nested = findPackageJSON(name);
if (!nested) continue;
const nestedPkg = readPackageJSON(nested);
if (!nestedPkg) continue;
// Skip packages that ship only sub-paths (`@babel/runtime`); vite
// refuses to pre-bundle them and bare imports against them resolve
// naturally through Node's normal module-walk anyway.
if (hasTopLevelEntry(nestedPkg)) {
includes.add(name);
// Walk both `dependencies` and `peerDependencies`: a module's pages
// routinely import host-provided peer deps (`@inertiajs/react`,
// `@simple-module-py/ui`, …) as bare specifiers and we need those
// pre-bundled too, not just the deps the module ships its own copy of.
for (const block of [pkg.dependencies, pkg.peerDependencies]) {
for (const name of Object.keys(block ?? {})) {
if (name.startsWith('@types/')) continue;
const nested = findPackageJSON(name);
if (!nested) continue;
const nestedPkg = readPackageJSON(nested);
if (!nestedPkg) continue;
// Skip packages that ship only sub-paths (`@babel/runtime`); vite
// refuses to pre-bundle them and bare imports against them resolve
// naturally through Node's normal module-walk anyway.
if (hasTopLevelEntry(nestedPkg)) {
includes.add(name);
}
queue.push(nested);
}
queue.push(nested);
}
}
return [...includes];
}

// Cross-package bare imports from module pages (`maplibre-gl`, `pmtiles`,
// `@inertiajs/react`, …) live in fsRoot/node_modules after `npm install`.
// But when a module's pages sit outside fsRoot — under
// `.venv/.../site-packages/<pkg>/pages/` for wheel installs — Vite's
// resolver walks up from the importer looking for node_modules and never
// reaches fsRoot/node_modules. Resolution fails with: "Failed to resolve
// import … Does the file exist?".
//
// This plugin recovers by retrying any unresolved bare import from a
// module-pages importer as if the importer lived at fsRoot, which puts
// fsRoot/node_modules back on the resolver's path. Combined with the
// `optimizeDeps.include` walk above (module deps + peer deps), dev,
// dep-scan, and production builds all converge on the host's hoisted copy.
function moduleBareImportResolver(): Plugin {
return {
name: 'simple-module:resolve-module-bare-imports',
enforce: 'pre',
async resolveId(source, importer) {
if (!importer) return null;
if (
source.startsWith('.') ||
source.startsWith('/') ||
source.startsWith('\0') ||
source.startsWith('virtual:')
) {
return null;
}
const importerPath = importer.split('?')[0];
if (importerPath.startsWith(fsRootPrefix)) return null;
if (!modulePagesPrefixes.some((prefix) => importerPath.startsWith(prefix))) {
return null;
}
const resolved = await this.resolve(source, fakeWorkspaceImporter, {
skipSelf: true,
});
return resolved ?? null;
},
};
}

export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [moduleBareImportResolver(), react(), tailwindcss()],
root: __dirname,
resolve: {
dedupe: [...REACT_CORE_DEPS, '@simple-module-py/ui', '@simple-module-py/i18n'],
Expand Down
107 changes: 106 additions & 1 deletion framework/hosting/tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@

from __future__ import annotations

import importlib.util
import json
import sys
from pathlib import Path

from simple_module_hosting.manifest import repo_root_from_client_app
import pytest
from simple_module_core import ModuleBase, ModuleMeta
from simple_module_hosting.manifest import (
collect_module_js_deps,
read_module_package_json,
repo_root_from_client_app,
)


def test_repo_root_finds_workspace_root_in_framework_layout(tmp_path: Path) -> None:
Expand Down Expand Up @@ -58,3 +66,100 @@ def test_repo_root_falls_back_to_two_levels_up_if_no_package_json(tmp_path: Path
deep.mkdir(parents=True)

assert repo_root_from_client_app(deep) == (tmp_path / "outer").resolve()


@pytest.fixture
def fake_module_factory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Build a fake installed module with a configurable on-disk layout.

Returns a callable that takes a ``layout`` ∈ {"wheel", "source"} plus
optional ``dependencies`` and ``peer_dependencies`` maps, lays the
files down under ``tmp_path``, registers the package in ``sys.modules``,
and returns a ``ModuleBase`` subclass pinned to it.
"""
counter = {"n": 0}

def _build(
layout: str,
dependencies: dict[str, str] | None = None,
peer_dependencies: dict[str, str] | None = None,
) -> type[ModuleBase]:
counter["n"] += 1
pkg_name = f"fake_module_{counter['n']}"
if layout == "wheel":
# Wheel: <pkg>/ contains code + package.json (Hatch force-include).
pkg_root = tmp_path / pkg_name
pkg_root.mkdir()
pkg_json_path = pkg_root / "package.json"
elif layout == "source":
# Source-tree / editable: package.json sits above the Python pkg.
module_root = tmp_path / f"{pkg_name}_repo"
module_root.mkdir()
pkg_root = module_root / pkg_name
pkg_root.mkdir()
pkg_json_path = module_root / "package.json"
else:
raise ValueError(f"unknown layout: {layout}")
init_py = pkg_root / "__init__.py"
init_py.write_text("")
pkg_json: dict[str, object] = {
"name": f"@fake/{pkg_name}",
"dependencies": dependencies or {},
}
if peer_dependencies is not None:
pkg_json["peerDependencies"] = peer_dependencies
pkg_json_path.write_text(json.dumps(pkg_json))

# Register the package with a real importlib spec so that
# importlib.resources.files() can locate the on-disk pkg_root.
spec = importlib.util.spec_from_file_location(
pkg_name, init_py, submodule_search_locations=[str(pkg_root)]
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
monkeypatch.setitem(sys.modules, pkg_name, mod)

class FakeMod(ModuleBase):
meta = ModuleMeta(name=pkg_name.title().replace("_", ""))

FakeMod.__module__ = pkg_name
return FakeMod

return _build


def test_read_module_package_json_finds_wheel_layout(fake_module_factory) -> None:
"""Wheel install: package.json sits next to the Python package."""
fake_cls = fake_module_factory(
"wheel",
{"dep-a": "^1.0.0"},
peer_dependencies={"@host/peer": "^2.0.0"},
)
pkg = read_module_package_json(fake_cls())
assert pkg is not None
assert pkg["dependencies"] == {"dep-a": "^1.0.0"}
# The TS-side vite.config.ts also reads peerDependencies for its
# optimizeDeps walk — make sure the raw dict surfaces both blocks.
assert pkg["peerDependencies"] == {"@host/peer": "^2.0.0"}


def test_read_module_package_json_finds_source_layout(fake_module_factory) -> None:
"""Source-tree / workspace: package.json sits at the module repo root."""
fake_cls = fake_module_factory("source", {"dep-b": "^2.0.0"})
pkg = read_module_package_json(fake_cls())
assert pkg is not None
assert pkg["dependencies"] == {"dep-b": "^2.0.0"}


def test_collect_module_js_deps_aggregates_across_layouts(fake_module_factory) -> None:
"""Mixed-layout modules all contribute their declared deps."""
wheel_cls = fake_module_factory("wheel", {"cmdk": "^1.0.0"})
source_cls = fake_module_factory("source", {"maplibre-gl": "^4.7.0", "pmtiles": "^3.2.0"})
empty_cls = fake_module_factory("wheel", {})

deps = collect_module_js_deps([wheel_cls(), source_cls(), empty_cls()])
# Empty deps are dropped; both populated modules appear by their meta.name.
assert empty_cls.meta.name not in deps
assert deps[wheel_cls.meta.name] == {"cmdk": "^1.0.0"}
assert deps[source_cls.meta.name] == {"maplibre-gl": "^4.7.0", "pmtiles": "^3.2.0"}
Loading
Loading