@typed/virtual-modules-vite 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # @typed/virtual-modules-vite
2
+
3
+ Vite plugin that integrates [@typed/virtual-modules](../virtual-modules) for virtual module resolution and loading in both dev and build. Enables compile-time code generation—imports like `virtual:config` or `api:./routes` resolve to generated TypeScript at build time.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @typed/virtual-modules @typed/virtual-modules-vite
9
+ ```
10
+
11
+ Peer: `vite` (>=5).
12
+
13
+ ## Overview
14
+
15
+ **Virtual modules** are imports that resolve to generated code at dev/build time rather than to files on disk. This plugin plugs the `@typed/virtual-modules` resolver into Vite's `resolveId` and `load` hooks, so any `VirtualModulePlugin` registered with a `PluginManager` can serve content when that module specifier is imported.
16
+
17
+ - Supports **static** virtual modules (pure code generation from id/importer).
18
+ - Supports **type-aware** virtual modules via the optional TypeInfo API (`api.file()`, `api.directory()` when `createTypeInfoApiSession` is provided).
19
+
20
+ ## Usage
21
+
22
+ ### Basic static virtual module
23
+
24
+ 1. Create a resolver (`PluginManager`) and register your virtual module plugins.
25
+ 2. Add the Vite plugin with that resolver.
26
+
27
+ ```ts
28
+ import { defineConfig } from "vite";
29
+ import { PluginManager } from "@typed/virtual-modules";
30
+ import { virtualModulesVitePlugin } from "@typed/virtual-modules-vite";
31
+
32
+ const manager = new PluginManager([
33
+ {
34
+ name: "my-virtual",
35
+ shouldResolve(id) {
36
+ return id === "virtual:config";
37
+ },
38
+ build(id) {
39
+ return `export const config = { env: "dev" };`;
40
+ },
41
+ },
42
+ ]);
43
+
44
+ export default defineConfig({
45
+ plugins: [virtualModulesVitePlugin({ resolver: manager })],
46
+ });
47
+ ```
48
+
49
+ Then in app code:
50
+
51
+ ```ts
52
+ import { config } from "virtual:config";
53
+ ```
54
+
55
+ ### Type-aware virtual modules
56
+
57
+ When plugins use the TypeInfo API (e.g. `api.file()`, `api.directory()`) for type-aware code generation, pass `createTypeInfoApiSession` so the resolver can create a session with the project's TypeScript program:
58
+
59
+ ```ts
60
+ import { dirname } from "node:path";
61
+ import ts from "typescript";
62
+ import { createTypeInfoApiSession, PluginManager } from "@typed/virtual-modules";
63
+ import { virtualModulesVitePlugin } from "@typed/virtual-modules-vite";
64
+
65
+ // Build program from your project (e.g. from tsconfig)
66
+ const program = ts.createProgram(["./src/main.ts"], { /* ... */ });
67
+
68
+ const createSession = () => createTypeInfoApiSession({ ts, program });
69
+ const manager = new PluginManager([
70
+ {
71
+ name: "file-snapshot",
72
+ shouldResolve(id) {
73
+ return id === "virtual:file-snapshot";
74
+ },
75
+ build(_id, importer, api) {
76
+ const baseDir = dirname(importer);
77
+ const result = api.file("types.ts", { baseDir });
78
+ if (!result.ok) return `export const names = [];`;
79
+ const names = result.snapshot.exports.map((e) => e.name);
80
+ return `export const names = ${JSON.stringify(names)};`;
81
+ },
82
+ },
83
+ ]);
84
+
85
+ export default defineConfig({
86
+ plugins: [
87
+ virtualModulesVitePlugin({
88
+ resolver: manager,
89
+ createTypeInfoApiSession: createSession,
90
+ }),
91
+ ],
92
+ });
93
+ ```
94
+
95
+ See [@typed/virtual-modules](../virtual-modules) for the full TypeInfo API.
96
+
97
+ ### Integrating with @typed/vite-plugin
98
+
99
+ If you want router (`virtual:router`) and HttpApi (`api:./<dir>`) virtual modules without wiring this plugin manually, use [@typed/vite-plugin](../vite-plugin). It includes `virtualModulesVitePlugin` and pre-registers the router and HttpApi plugins from [@typed/app](../app):
100
+
101
+ ```ts
102
+ import { defineConfig } from "vite";
103
+ import { typedVitePlugin } from "@typed/vite-plugin";
104
+
105
+ export default defineConfig({
106
+ plugins: [
107
+ typedVitePlugin({
108
+ createTypeInfoApiSession: createSession, // required for router type-checking
109
+ routerVmOptions: { /* ... */ },
110
+ apiVmOptions: { /* ... */ }, // optional
111
+ }),
112
+ ],
113
+ });
114
+ ```
115
+
116
+ ## API
117
+
118
+ ### `virtualModulesVitePlugin(options)`
119
+
120
+ Returns a Vite plugin. Uses `enforce: "pre"` so virtual resolution runs before other resolvers.
121
+
122
+ | Option | Type | Description |
123
+ |--------|------|-------------|
124
+ | `resolver` | `VirtualModuleResolver` | Resolver (e.g. `PluginManager`) that handles virtual module resolution and loading. |
125
+ | `createTypeInfoApiSession` | `CreateTypeInfoApiSession` | Optional. Session factory for TypeInfo API when plugins use `api.file()` / `api.directory()`. |
126
+ | `warnOnError` | `boolean` | Log resolution/load errors to console (default `true`). Errors include plugin name and message. |
127
+
128
+ ### Encoding helpers
129
+
130
+ The plugin uses `\0virtual:` + base64url encoding to carry `(id, importer)` through Vite's resolution. Exported for consumers (e.g. URL encoding in dev):
131
+
132
+ - **`encodeVirtualId(id, importer)`** — Encode a virtual id and importer into the internal format.
133
+ - **`decodeVirtualId(resolvedId)`** — Parse an encoded id; returns `{ id, importer }` or `null` if not a virtual id.
134
+ - **`isVirtualId(resolvedId)`** — Returns `true` if the string is an encoded virtual id.
135
+
136
+ Payload validation: decoded `id` and `importer` must not contain null bytes, be empty, or exceed 4096 characters.
137
+
138
+ ## Errors
139
+
140
+ The plugin does **not** throw. When resolution or load fails, `resolveId` / `load` return `null` and the import fails at build/dev (module not found). When `warnOnError` is true (default), errors are logged to `console.warn`. See [virtual-modules-errors-and-gotchas](../virtual-modules/.docs/virtual-modules-errors-and-gotchas.md) for full reference.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Encode (id, importer) into a single string for Vite's resolveId return value.
3
+ * Uses base64url so path characters (e.g. `:`, `\`) don't break parsing.
4
+ */
5
+ export declare function encodeVirtualId(id: string, importer: string): string;
6
+ /**
7
+ * Parse a virtual id produced by encodeVirtualId. Returns null if not a virtual id.
8
+ */
9
+ export declare function decodeVirtualId(resolvedId: string): {
10
+ id: string;
11
+ importer: string;
12
+ } | null;
13
+ export declare function isVirtualId(resolvedId: string): boolean;
14
+ //# sourceMappingURL=encodeVirtualId.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"encodeVirtualId.d.ts","sourceRoot":"","sources":["../src/encodeVirtualId.ts"],"names":[],"mappings":"AAUA;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAmB3F;AAED,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAEvD"}
@@ -0,0 +1,41 @@
1
+ const PREFIX = "\0virtual:";
2
+ function toBase64Url(s) {
3
+ return Buffer.from(s, "utf8").toString("base64url");
4
+ }
5
+ function fromBase64Url(s) {
6
+ return Buffer.from(s, "base64url").toString("utf8");
7
+ }
8
+ /**
9
+ * Encode (id, importer) into a single string for Vite's resolveId return value.
10
+ * Uses base64url so path characters (e.g. `:`, `\`) don't break parsing.
11
+ */
12
+ export function encodeVirtualId(id, importer) {
13
+ return `${PREFIX}${toBase64Url(id)}:${toBase64Url(importer)}`;
14
+ }
15
+ /**
16
+ * Parse a virtual id produced by encodeVirtualId. Returns null if not a virtual id.
17
+ */
18
+ export function decodeVirtualId(resolvedId) {
19
+ if (!resolvedId.startsWith(PREFIX)) {
20
+ return null;
21
+ }
22
+ const rest = resolvedId.slice(PREFIX.length);
23
+ const colonIndex = rest.indexOf(":");
24
+ if (colonIndex === -1) {
25
+ return null;
26
+ }
27
+ const encodedId = rest.slice(0, colonIndex);
28
+ const encodedImporter = rest.slice(colonIndex + 1);
29
+ try {
30
+ return {
31
+ id: fromBase64Url(encodedId),
32
+ importer: fromBase64Url(encodedImporter),
33
+ };
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function isVirtualId(resolvedId) {
40
+ return resolvedId.startsWith(PREFIX);
41
+ }
@@ -0,0 +1,3 @@
1
+ export { virtualModulesVitePlugin, type VirtualModulesVitePluginOptions } from "./vitePlugin.js";
2
+ export { encodeVirtualId, decodeVirtualId, isVirtualId } from "./encodeVirtualId.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,KAAK,+BAA+B,EAAE,MAAM,iBAAiB,CAAC;AACjG,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { virtualModulesVitePlugin } from "./vitePlugin.js";
2
+ export { encodeVirtualId, decodeVirtualId, isVirtualId } from "./encodeVirtualId.js";
@@ -0,0 +1,22 @@
1
+ import type { Plugin } from "vite";
2
+ import type { CreateTypeInfoApiSession, VirtualModuleResolver } from "@typed/virtual-modules";
3
+ export interface VirtualModulesVitePluginOptions {
4
+ /**
5
+ * Resolver that handles virtual module resolution (e.g. a PluginManager instance).
6
+ */
7
+ readonly resolver: VirtualModuleResolver;
8
+ /**
9
+ * Optional session factory for TypeInfo API when plugins need type information.
10
+ */
11
+ readonly createTypeInfoApiSession?: CreateTypeInfoApiSession;
12
+ /**
13
+ * When true, resolution errors are logged with console.warn. Default true.
14
+ */
15
+ readonly warnOnError?: boolean;
16
+ }
17
+ /**
18
+ * Vite plugin that integrates @typed/virtual-modules: resolves and loads virtual
19
+ * modules via the given resolver (e.g. PluginManager) in both dev and build.
20
+ */
21
+ export declare function virtualModulesVitePlugin(options: VirtualModulesVitePluginOptions): Plugin;
22
+ //# sourceMappingURL=vitePlugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vitePlugin.d.ts","sourceRoot":"","sources":["../src/vitePlugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AACnC,OAAO,KAAK,EAAE,wBAAwB,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAK9F,MAAM,WAAW,+BAA+B;IAC9C;;OAEG;IACH,QAAQ,CAAC,QAAQ,EAAE,qBAAqB,CAAC;IACzC;;OAEG;IACH,QAAQ,CAAC,wBAAwB,CAAC,EAAE,wBAAwB,CAAC;IAC7D;;OAEG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;CAChC;AAUD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,+BAA+B,GAAG,MAAM,CAoDzF"}
@@ -0,0 +1,62 @@
1
+ import { encodeVirtualId, decodeVirtualId, isVirtualId } from "./encodeVirtualId.js";
2
+ const PLUGIN_NAME = "virtual-modules";
3
+ /** Validate decoded id/importer before passing to resolver (defense in depth). */
4
+ function validateDecodedPayload(id, importer) {
5
+ if (typeof id !== "string" || id.length === 0 || id.includes("\0"))
6
+ return false;
7
+ if (typeof importer !== "string" || importer.length === 0 || importer.includes("\0"))
8
+ return false;
9
+ if (id.length > 4096 || importer.length > 4096)
10
+ return false;
11
+ return true;
12
+ }
13
+ /**
14
+ * Vite plugin that integrates @typed/virtual-modules: resolves and loads virtual
15
+ * modules via the given resolver (e.g. PluginManager) in both dev and build.
16
+ */
17
+ export function virtualModulesVitePlugin(options) {
18
+ const { resolver, createTypeInfoApiSession, warnOnError = true } = options;
19
+ return {
20
+ name: PLUGIN_NAME,
21
+ enforce: "pre",
22
+ resolveId(id, importer) {
23
+ if (!importer) {
24
+ return null;
25
+ }
26
+ const result = resolver.resolveModule({
27
+ id,
28
+ importer,
29
+ createTypeInfoApiSession,
30
+ });
31
+ if (result.status === "resolved") {
32
+ return encodeVirtualId(id, importer);
33
+ }
34
+ if (result.status === "error" && warnOnError) {
35
+ console.warn(`[${PLUGIN_NAME}] ${result.diagnostic.pluginName}: ${result.diagnostic.message}`);
36
+ }
37
+ return null;
38
+ },
39
+ load(resolvedId) {
40
+ if (!isVirtualId(resolvedId)) {
41
+ return null;
42
+ }
43
+ const parsed = decodeVirtualId(resolvedId);
44
+ if (!parsed || !validateDecodedPayload(parsed.id, parsed.importer)) {
45
+ return null;
46
+ }
47
+ const { id, importer } = parsed;
48
+ const result = resolver.resolveModule({
49
+ id,
50
+ importer,
51
+ createTypeInfoApiSession,
52
+ });
53
+ if (result.status === "resolved") {
54
+ return { code: result.sourceText };
55
+ }
56
+ if (result.status === "error" && warnOnError) {
57
+ console.warn(`[${PLUGIN_NAME}] load ${result.diagnostic.pluginName}: ${result.diagnostic.message}`);
58
+ }
59
+ return null;
60
+ },
61
+ };
62
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@typed/virtual-modules-vite",
3
+ "version": "1.0.0-beta.1",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "import": "./dist/index.js"
9
+ }
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "scripts": {
15
+ "build": "[ -d dist ] || rm -f tsconfig.tsbuildinfo; tsc",
16
+ "test": "vitest run --passWithNoTests"
17
+ },
18
+ "dependencies": {
19
+ "@typed/virtual-modules": "workspace:*"
20
+ },
21
+ "devDependencies": {
22
+ "@typed/virtual-modules": "workspace:*",
23
+ "@types/node": "^25.3.0",
24
+ "typescript": "catalog:",
25
+ "vite": "^7.3.1",
26
+ "vitest": "catalog:"
27
+ },
28
+ "peerDependencies": {
29
+ "vite": ">=5.0.0"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src"
34
+ ]
35
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { encodeVirtualId, decodeVirtualId, isVirtualId } from "./encodeVirtualId.js";
3
+
4
+ describe("encodeVirtualId / decodeVirtualId", () => {
5
+ it("round-trips id and importer", () => {
6
+ const id = "virtual:config";
7
+ const importer = "/Users/app/src/main.ts";
8
+ const encoded = encodeVirtualId(id, importer);
9
+ expect(isVirtualId(encoded)).toBe(true);
10
+ const decoded = decodeVirtualId(encoded);
11
+ expect(decoded).toEqual({ id, importer });
12
+ });
13
+
14
+ it("returns null for non-virtual id", () => {
15
+ expect(decodeVirtualId("/some/path.ts")).toBeNull();
16
+ expect(isVirtualId("/some/path.ts")).toBe(false);
17
+ });
18
+
19
+ it("handles Windows-style paths", () => {
20
+ const id = "virtual:env";
21
+ const importer = "C:\\Users\\app\\src\\main.ts";
22
+ const encoded = encodeVirtualId(id, importer);
23
+ const decoded = decodeVirtualId(encoded);
24
+ expect(decoded).toEqual({ id, importer });
25
+ });
26
+ });
@@ -0,0 +1,45 @@
1
+ const PREFIX = "\0virtual:";
2
+
3
+ function toBase64Url(s: string): string {
4
+ return Buffer.from(s, "utf8").toString("base64url");
5
+ }
6
+
7
+ function fromBase64Url(s: string): string {
8
+ return Buffer.from(s, "base64url").toString("utf8");
9
+ }
10
+
11
+ /**
12
+ * Encode (id, importer) into a single string for Vite's resolveId return value.
13
+ * Uses base64url so path characters (e.g. `:`, `\`) don't break parsing.
14
+ */
15
+ export function encodeVirtualId(id: string, importer: string): string {
16
+ return `${PREFIX}${toBase64Url(id)}:${toBase64Url(importer)}`;
17
+ }
18
+
19
+ /**
20
+ * Parse a virtual id produced by encodeVirtualId. Returns null if not a virtual id.
21
+ */
22
+ export function decodeVirtualId(resolvedId: string): { id: string; importer: string } | null {
23
+ if (!resolvedId.startsWith(PREFIX)) {
24
+ return null;
25
+ }
26
+ const rest = resolvedId.slice(PREFIX.length);
27
+ const colonIndex = rest.indexOf(":");
28
+ if (colonIndex === -1) {
29
+ return null;
30
+ }
31
+ const encodedId = rest.slice(0, colonIndex);
32
+ const encodedImporter = rest.slice(colonIndex + 1);
33
+ try {
34
+ return {
35
+ id: fromBase64Url(encodedId),
36
+ importer: fromBase64Url(encodedImporter),
37
+ };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function isVirtualId(resolvedId: string): boolean {
44
+ return resolvedId.startsWith(PREFIX);
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { virtualModulesVitePlugin, type VirtualModulesVitePluginOptions } from "./vitePlugin.js";
2
+ export { encodeVirtualId, decodeVirtualId, isVirtualId } from "./encodeVirtualId.js";
@@ -0,0 +1,224 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import ts from "typescript";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import {
7
+ createTypeInfoApiSession,
8
+ PluginManager,
9
+ type VirtualModulePlugin,
10
+ } from "@typed/virtual-modules";
11
+ import { createServer } from "vite";
12
+ import { encodeVirtualId } from "./encodeVirtualId.js";
13
+ import { virtualModulesVitePlugin } from "./vitePlugin.js";
14
+
15
+ const tempDirs: string[] = [];
16
+
17
+ function createTempDir(): string {
18
+ const dir = mkdtempSync(join(tmpdir(), "virtual-modules-vite-"));
19
+ tempDirs.push(dir);
20
+ return dir;
21
+ }
22
+
23
+ afterEach(() => {
24
+ while (tempDirs.length > 0) {
25
+ const dir = tempDirs.pop();
26
+ if (dir) {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ }
29
+ }
30
+ });
31
+
32
+ /**
33
+ * Virtual module that uses TypeInfoApi.file() to read a single file's type snapshot
34
+ * and exports the list of export names.
35
+ */
36
+ function fileSnapshotPlugin(): VirtualModulePlugin {
37
+ return {
38
+ name: "file-snapshot",
39
+ shouldResolve(id: string): boolean {
40
+ return id === "virtual:file-snapshot";
41
+ },
42
+ build(_id: string, importer: string, api): string {
43
+ const baseDir = dirname(importer);
44
+ const result = api.file("types.ts", { baseDir });
45
+ if (!result.ok) {
46
+ return `export const fileExportNames = []; export const fileError = ${JSON.stringify(result.error)};`;
47
+ }
48
+ const names = result.snapshot.exports.map((e) => e.name);
49
+ return `export const fileExportNames = ${JSON.stringify(names)};`;
50
+ },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Virtual module that uses TypeInfoApi.directory() to list type snapshots in a directory
56
+ * and exports the relative file paths.
57
+ */
58
+ function dirSnapshotPlugin(): VirtualModulePlugin {
59
+ return {
60
+ name: "dir-snapshot",
61
+ shouldResolve(id: string): boolean {
62
+ return id === "virtual:dir-snapshot";
63
+ },
64
+ build(_id: string, importer: string, api): string {
65
+ const srcDir = dirname(importer);
66
+ const baseDir = join(srcDir, "features");
67
+ const snapshots = api.directory("*.ts", {
68
+ baseDir,
69
+ recursive: true,
70
+ });
71
+ const filePaths = snapshots.map((s) => s.filePath);
72
+ return `export const dirFilePaths = ${JSON.stringify(filePaths)};`;
73
+ },
74
+ };
75
+ }
76
+
77
+ describe("virtualModulesVitePlugin integration", () => {
78
+ it("serves virtual module content in dev (static virtual module)", async () => {
79
+ const projectRoot = createTempDir();
80
+ const srcDir = join(projectRoot, "src");
81
+ mkdirSync(srcDir, { recursive: true });
82
+ writeFileSync(
83
+ join(projectRoot, "index.html"),
84
+ `<!DOCTYPE html><html><body><script type="module" src="/src/main.ts"></script></body></html>`,
85
+ "utf8",
86
+ );
87
+ writeFileSync(
88
+ join(srcDir, "main.ts"),
89
+ 'import { value } from "virtual:static";\nexport const out = value;',
90
+ "utf8",
91
+ );
92
+ const manager = new PluginManager([
93
+ {
94
+ name: "static",
95
+ shouldResolve: (id) => id === "virtual:static",
96
+ build: () => 'export const value = "from-virtual";',
97
+ },
98
+ ]);
99
+ const server = await createServer({
100
+ root: projectRoot,
101
+ plugins: [virtualModulesVitePlugin({ resolver: manager })],
102
+ server: { port: 0 },
103
+ logLevel: "warn",
104
+ });
105
+ await server.listen();
106
+ try {
107
+ const base = `http://localhost:${server.config.server.port}`;
108
+ const mainRes = await fetch(`${base}/src/main.ts`);
109
+ expect(mainRes.ok).toBe(true);
110
+ const mainText = await mainRes.text();
111
+ expect(mainText).toContain("out");
112
+ const importer = join(projectRoot, "src", "main.ts");
113
+ const resolvedId = encodeVirtualId("virtual:static", importer);
114
+ const virtualPath = "/@id/" + resolvedId.split(String.fromCharCode(0)).join("__x00__");
115
+ const virtualRes = await fetch(base + virtualPath);
116
+ expect(virtualRes.ok).toBe(true);
117
+ const virtualText = await virtualRes.text();
118
+ expect(virtualText).toContain("from-virtual");
119
+ await server.waitForRequestsIdle();
120
+ } finally {
121
+ await server.close();
122
+ }
123
+ });
124
+
125
+ it("serves virtual modules backed by api.file() and api.directory() in dev", async () => {
126
+ const projectRoot = createTempDir();
127
+ const srcDir = join(projectRoot, "src");
128
+ const featuresDir = join(projectRoot, "src", "features");
129
+ mkdirSync(featuresDir, { recursive: true });
130
+
131
+ writeFileSync(
132
+ join(projectRoot, "index.html"),
133
+ `<!DOCTYPE html><html><head><meta charset="utf-8"/></head><body><script type="module" src="/src/main.ts"></script></body></html>`,
134
+ "utf8",
135
+ );
136
+ writeFileSync(
137
+ join(projectRoot, "tsconfig.json"),
138
+ JSON.stringify({
139
+ compilerOptions: {
140
+ target: "ESNext",
141
+ module: "ESNext",
142
+ moduleResolution: "bundler",
143
+ strict: true,
144
+ skipLibCheck: true,
145
+ },
146
+ include: ["src"],
147
+ }),
148
+ "utf8",
149
+ );
150
+ writeFileSync(join(srcDir, "types.ts"), `export type X = string; export const y = 42;`, "utf8");
151
+ writeFileSync(
152
+ join(srcDir, "main.ts"),
153
+ [
154
+ 'import { fileExportNames } from "virtual:file-snapshot";',
155
+ 'import { dirFilePaths } from "virtual:dir-snapshot";',
156
+ "export const fileExportNamesFromFile = fileExportNames;",
157
+ "export const dirFilePathsFromDir = dirFilePaths;",
158
+ ].join("\n"),
159
+ "utf8",
160
+ );
161
+ writeFileSync(join(featuresDir, "one.ts"), `export const one = "one";`, "utf8");
162
+ writeFileSync(join(featuresDir, "two.ts"), `export const two = "two";`, "utf8");
163
+
164
+ const rootFiles = [
165
+ join(srcDir, "main.ts"),
166
+ join(srcDir, "types.ts"),
167
+ join(featuresDir, "one.ts"),
168
+ join(featuresDir, "two.ts"),
169
+ ];
170
+ const program = ts.createProgram(rootFiles, {
171
+ strict: true,
172
+ target: ts.ScriptTarget.ESNext,
173
+ module: ts.ModuleKind.ESNext,
174
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
175
+ skipLibCheck: true,
176
+ noEmit: true,
177
+ });
178
+
179
+ const createSession = () => createTypeInfoApiSession({ ts, program });
180
+ const manager = new PluginManager([fileSnapshotPlugin(), dirSnapshotPlugin()]);
181
+
182
+ const server = await createServer({
183
+ root: projectRoot,
184
+ plugins: [
185
+ virtualModulesVitePlugin({
186
+ resolver: manager,
187
+ createTypeInfoApiSession: createSession,
188
+ warnOnError: true,
189
+ }),
190
+ ],
191
+ server: { port: 0 },
192
+ logLevel: "warn",
193
+ });
194
+ await server.listen();
195
+ try {
196
+ const base = `http://localhost:${server.config.server.port}`;
197
+ const mainRes = await fetch(`${base}/src/main.ts`);
198
+ expect(mainRes.ok).toBe(true);
199
+ const mainText = await mainRes.text();
200
+ expect(mainText).toContain("fileExportNamesFromFile");
201
+ expect(mainText).toContain("dirFilePathsFromDir");
202
+ const importer = join(projectRoot, "src", "main.ts");
203
+ const fileResolvedId = encodeVirtualId("virtual:file-snapshot", importer);
204
+ const dirResolvedId = encodeVirtualId("virtual:dir-snapshot", importer);
205
+ const toUrl = (id: string) =>
206
+ base + "/@id/" + id.split(String.fromCharCode(0)).join("__x00__");
207
+ const fileSnapshotRes = await fetch(toUrl(fileResolvedId));
208
+ expect(fileSnapshotRes.ok).toBe(true);
209
+ const fileSnapshotText = await fileSnapshotRes.text();
210
+ expect(fileSnapshotText).toContain("fileExportNames");
211
+ expect(fileSnapshotText).toMatch(/"X".*"y"/);
212
+
213
+ const dirSnapshotRes = await fetch(toUrl(dirResolvedId));
214
+ expect(dirSnapshotRes.ok).toBe(true);
215
+ const dirSnapshotText = await dirSnapshotRes.text();
216
+ expect(dirSnapshotText).toContain("dirFilePaths");
217
+ expect(dirSnapshotText).toContain("one.ts");
218
+ expect(dirSnapshotText).toContain("two.ts");
219
+ await server.waitForRequestsIdle();
220
+ } finally {
221
+ await server.close();
222
+ }
223
+ });
224
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { PluginManager } from "@typed/virtual-modules";
3
+ import { virtualModulesVitePlugin } from "./vitePlugin.js";
4
+
5
+ type ResolveId = (specifier: string, importer: string | undefined) => string | null;
6
+ type Load = (specifier: string) => string | { code: string } | null;
7
+
8
+ describe("virtualModulesVitePlugin", () => {
9
+ it("returns a plugin with name and enforce pre", () => {
10
+ const manager = new PluginManager();
11
+ const plugin = virtualModulesVitePlugin({ resolver: manager });
12
+ expect(plugin.name).toBe("virtual-modules");
13
+ expect(plugin.enforce).toBe("pre");
14
+ });
15
+
16
+ it("resolveId returns null when importer is undefined", () => {
17
+ const manager = new PluginManager([
18
+ {
19
+ name: "test",
20
+ shouldResolve: () => true,
21
+ build: () => "export {};",
22
+ },
23
+ ]);
24
+ const plugin = virtualModulesVitePlugin({ resolver: manager });
25
+ const resolveId = plugin.resolveId! as ResolveId;
26
+ expect(resolveId("virtual:x", undefined)).toBeNull();
27
+ });
28
+
29
+ it("resolveId returns encoded id when resolver resolves", () => {
30
+ const manager = new PluginManager([
31
+ {
32
+ name: "test",
33
+ shouldResolve: (id) => id === "virtual:config",
34
+ build: () => "export const x = 1;",
35
+ },
36
+ ]);
37
+ const plugin = virtualModulesVitePlugin({ resolver: manager });
38
+ const resolveId = plugin.resolveId! as ResolveId;
39
+ const out = resolveId("virtual:config", "/app/main.ts");
40
+ expect(out).not.toBeNull();
41
+ expect(typeof out).toBe("string");
42
+ expect((out as string).startsWith("\0virtual:")).toBe(true);
43
+ });
44
+
45
+ it("load returns sourceText for encoded virtual id", () => {
46
+ const manager = new PluginManager([
47
+ {
48
+ name: "test",
49
+ shouldResolve: (id) => id === "virtual:config",
50
+ build: () => "export const x = 1;",
51
+ },
52
+ ]);
53
+ const plugin = virtualModulesVitePlugin({ resolver: manager });
54
+ const resolveId = plugin.resolveId! as ResolveId;
55
+ const load = plugin.load! as Load;
56
+ const resolvedId = resolveId("virtual:config", "/app/main.ts") as string;
57
+ const result = load(resolvedId);
58
+ const code = typeof result === "string" ? result : result?.code;
59
+ expect(code).toBe("export const x = 1;");
60
+ });
61
+ });
@@ -0,0 +1,86 @@
1
+ import type { Plugin } from "vite";
2
+ import type { CreateTypeInfoApiSession, VirtualModuleResolver } from "@typed/virtual-modules";
3
+ import { encodeVirtualId, decodeVirtualId, isVirtualId } from "./encodeVirtualId.js";
4
+
5
+ const PLUGIN_NAME = "virtual-modules";
6
+
7
+ export interface VirtualModulesVitePluginOptions {
8
+ /**
9
+ * Resolver that handles virtual module resolution (e.g. a PluginManager instance).
10
+ */
11
+ readonly resolver: VirtualModuleResolver;
12
+ /**
13
+ * Optional session factory for TypeInfo API when plugins need type information.
14
+ */
15
+ readonly createTypeInfoApiSession?: CreateTypeInfoApiSession;
16
+ /**
17
+ * When true, resolution errors are logged with console.warn. Default true.
18
+ */
19
+ readonly warnOnError?: boolean;
20
+ }
21
+
22
+ /** Validate decoded id/importer before passing to resolver (defense in depth). */
23
+ function validateDecodedPayload(id: string, importer: string): boolean {
24
+ if (typeof id !== "string" || id.length === 0 || id.includes("\0")) return false;
25
+ if (typeof importer !== "string" || importer.length === 0 || importer.includes("\0")) return false;
26
+ if (id.length > 4096 || importer.length > 4096) return false;
27
+ return true;
28
+ }
29
+
30
+ /**
31
+ * Vite plugin that integrates @typed/virtual-modules: resolves and loads virtual
32
+ * modules via the given resolver (e.g. PluginManager) in both dev and build.
33
+ */
34
+ export function virtualModulesVitePlugin(options: VirtualModulesVitePluginOptions): Plugin {
35
+ const { resolver, createTypeInfoApiSession, warnOnError = true } = options;
36
+
37
+ return {
38
+ name: PLUGIN_NAME,
39
+ enforce: "pre",
40
+
41
+ resolveId(id: string, importer: string | undefined): string | null {
42
+ if (!importer) {
43
+ return null;
44
+ }
45
+ const result = resolver.resolveModule({
46
+ id,
47
+ importer,
48
+ createTypeInfoApiSession,
49
+ });
50
+ if (result.status === "resolved") {
51
+ return encodeVirtualId(id, importer);
52
+ }
53
+ if (result.status === "error" && warnOnError) {
54
+ console.warn(
55
+ `[${PLUGIN_NAME}] ${result.diagnostic.pluginName}: ${result.diagnostic.message}`,
56
+ );
57
+ }
58
+ return null;
59
+ },
60
+
61
+ load(resolvedId: string): string | { code: string } | null {
62
+ if (!isVirtualId(resolvedId)) {
63
+ return null;
64
+ }
65
+ const parsed = decodeVirtualId(resolvedId);
66
+ if (!parsed || !validateDecodedPayload(parsed.id, parsed.importer)) {
67
+ return null;
68
+ }
69
+ const { id, importer } = parsed;
70
+ const result = resolver.resolveModule({
71
+ id,
72
+ importer,
73
+ createTypeInfoApiSession,
74
+ });
75
+ if (result.status === "resolved") {
76
+ return { code: result.sourceText };
77
+ }
78
+ if (result.status === "error" && warnOnError) {
79
+ console.warn(
80
+ `[${PLUGIN_NAME}] load ${result.diagnostic.pluginName}: ${result.diagnostic.message}`,
81
+ );
82
+ }
83
+ return null;
84
+ },
85
+ };
86
+ }