@janux/vite 0.1.0
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 +3 -0
- package/package.json +49 -0
- package/src/api-stubs.test.ts +45 -0
- package/src/api-stubs.ts +59 -0
- package/src/app-config.ts +48 -0
- package/src/index.ts +4 -0
- package/src/plugin.ts +89 -0
- package/src/request-adapter.ts +26 -0
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@janux/vite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vite plugin for Janux: JSX config, SSR routes, api() client stubs (SWC) and the dev server bridge.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/aralroca/Janux.git"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Aral Roca Gomez",
|
|
12
|
+
"email": "contact@aralroca.com"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@swc/core": "^1.9.0",
|
|
21
|
+
"janux": "0.1.0",
|
|
22
|
+
"@janux/server": "0.1.0",
|
|
23
|
+
"@janux/agent": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"vite": ">=5"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/aralroca/Janux#readme",
|
|
29
|
+
"bugs": "https://github.com/aralroca/Janux/issues",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"janux",
|
|
32
|
+
"agentic-ui",
|
|
33
|
+
"mcp",
|
|
34
|
+
"webmcp",
|
|
35
|
+
"ai-agents",
|
|
36
|
+
"fullstack",
|
|
37
|
+
"framework",
|
|
38
|
+
"bun",
|
|
39
|
+
"vite",
|
|
40
|
+
"resumability",
|
|
41
|
+
"copilot"
|
|
42
|
+
],
|
|
43
|
+
"files": [
|
|
44
|
+
"src"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { apiModuleName, apiStubModule, exportedApiNames } from './api-stubs';
|
|
3
|
+
|
|
4
|
+
const serverCode = `
|
|
5
|
+
import { api, schema } from '@janux/server';
|
|
6
|
+
import { db } from './db';
|
|
7
|
+
|
|
8
|
+
const PAGE_SIZE = 20;
|
|
9
|
+
|
|
10
|
+
export const searchOrders = api({
|
|
11
|
+
input: schema({ q: str() }),
|
|
12
|
+
run: async ({ input }) => db.search(input.q, PAGE_SIZE),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const refundOrder = api({ guard: 'confirm', run: () => db.refund() });
|
|
16
|
+
|
|
17
|
+
function helper() {}
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
describe('api client stubs (SWC)', () => {
|
|
21
|
+
it('extracts only exported const names', () => {
|
|
22
|
+
expect(exportedApiNames(serverCode)).toEqual(['searchOrders', 'refundOrder']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('derives the module namespace from the filename', () => {
|
|
26
|
+
expect(apiModuleName('/app/src/server/shop.api.ts')).toBe('shop');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects unsupported export shapes instead of silently dropping them', () => {
|
|
30
|
+
expect(() => exportedApiNames('export default api({ run: () => 1 });')).toThrow(/only support/);
|
|
31
|
+
expect(() => exportedApiNames('export function foo() {}')).toThrow(/only support/);
|
|
32
|
+
expect(() => exportedApiNames('const a = 1; export { a };')).toThrow(/only support/);
|
|
33
|
+
expect(() => exportedApiNames('export const { a } = obj;')).toThrow(/destructured/);
|
|
34
|
+
expect(exportedApiNames('export type X = string; export const ok = api({run(){}});')).toEqual(['ok']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('emits fetch stubs and never leaks server code to the client', () => {
|
|
38
|
+
const stub = apiStubModule('/app/src/server/shop.api.ts', serverCode);
|
|
39
|
+
|
|
40
|
+
expect(stub).toContain(`export const searchOrders = clientApi("shop.searchOrders");`);
|
|
41
|
+
expect(stub).toContain(`export const refundOrder = clientApi("shop.refundOrder");`);
|
|
42
|
+
expect(stub).not.toContain('db.search');
|
|
43
|
+
expect(stub).not.toContain('PAGE_SIZE');
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/api-stubs.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { parseSync } from '@swc/core';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const UNSUPPORTED_EXPORTS = new Set([
|
|
5
|
+
'ExportDefaultDeclaration',
|
|
6
|
+
'ExportDefaultExpression',
|
|
7
|
+
'ExportNamedDeclaration',
|
|
8
|
+
'ExportAllDeclaration',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
function assertSupportedExport(node: any): void {
|
|
12
|
+
if (UNSUPPORTED_EXPORTS.has(node.type)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Janux: *.api.ts modules only support \`export const name = api({...})\` — found ${node.type}`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
if (node.type !== 'ExportDeclaration') return;
|
|
18
|
+
if (node.declaration?.type?.startsWith('Ts')) return;
|
|
19
|
+
if (node.declaration?.type !== 'VariableDeclaration') {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Janux: *.api.ts modules only support \`export const name = api({...})\` — found exported ${node.declaration?.type}`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
node.declaration.declarations.forEach((declaration: any) => {
|
|
25
|
+
if (declaration.id?.type !== 'Identifier') {
|
|
26
|
+
throw new Error('Janux: destructured exports are not supported in *.api.ts modules');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Extracts exported const names from a `*.api.ts` module using SWC (no Babel). */
|
|
32
|
+
export function exportedApiNames(code: string): string[] {
|
|
33
|
+
const module = parseSync(code, { syntax: 'typescript', tsx: false });
|
|
34
|
+
|
|
35
|
+
return module.body.flatMap((node: any) => {
|
|
36
|
+
assertSupportedExport(node);
|
|
37
|
+
if (node.type !== 'ExportDeclaration') return [];
|
|
38
|
+
if (node.declaration?.type !== 'VariableDeclaration') return [];
|
|
39
|
+
|
|
40
|
+
return node.declaration.declarations.map((declaration: any) => declaration.id.value);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function apiModuleName(filePath: string): string {
|
|
45
|
+
return basename(filePath).replace(/\.api\.[tj]s$/, '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Client projection of a server api module: every export becomes a typed
|
|
50
|
+
* fetch stub. Server code never reaches the browser bundle (RFC §7.1).
|
|
51
|
+
*/
|
|
52
|
+
export function apiStubModule(filePath: string, code: string): string {
|
|
53
|
+
const moduleName = apiModuleName(filePath);
|
|
54
|
+
const stubs = exportedApiNames(code)
|
|
55
|
+
.map((name) => `export const ${name} = clientApi(${JSON.stringify(`${moduleName}.${name}`)});`)
|
|
56
|
+
.join('\n');
|
|
57
|
+
|
|
58
|
+
return `import { clientApi } from 'janux/client';\n${stubs}\n`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface JanuxAppConfig {
|
|
5
|
+
root: string;
|
|
6
|
+
routesDir: string;
|
|
7
|
+
serverDir: string;
|
|
8
|
+
clientEntry: string;
|
|
9
|
+
agentModule?: string;
|
|
10
|
+
storesModule?: string;
|
|
11
|
+
stylesheet?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface JanuxPluginOptions {
|
|
16
|
+
routesDir?: string;
|
|
17
|
+
serverDir?: string;
|
|
18
|
+
clientEntry?: string;
|
|
19
|
+
agentModule?: string;
|
|
20
|
+
storesModule?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function optional(path: string): string | undefined {
|
|
25
|
+
return existsSync(path) ? path : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Resolves the conventional app layout: src/routes, src/server, src/client.ts, src/agent.ts, src/stores.ts. */
|
|
29
|
+
export function resolveAppConfig(root: string, options: JanuxPluginOptions = {}): JanuxAppConfig {
|
|
30
|
+
return {
|
|
31
|
+
root,
|
|
32
|
+
routesDir: resolve(root, options.routesDir ?? 'src/routes'),
|
|
33
|
+
serverDir: resolve(root, options.serverDir ?? 'src/server'),
|
|
34
|
+
clientEntry: options.clientEntry ?? optional(resolve(root, 'src/client.ts')) ?? '',
|
|
35
|
+
agentModule: options.agentModule ?? optional(resolve(root, 'src/agent.ts')),
|
|
36
|
+
storesModule: options.storesModule ?? optional(resolve(root, 'src/stores.ts')),
|
|
37
|
+
stylesheet: optional(resolve(root, 'src/styles.css')),
|
|
38
|
+
title: options.title,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function apiFiles(serverDir: string): string[] {
|
|
43
|
+
if (!existsSync(serverDir)) return [];
|
|
44
|
+
|
|
45
|
+
return readdirSync(serverDir)
|
|
46
|
+
.filter((entry) => /\.api\.[tj]s$/.test(entry))
|
|
47
|
+
.map((entry) => join(serverDir, entry));
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { janux } from './plugin';
|
|
2
|
+
export { resolveAppConfig, apiFiles, type JanuxPluginOptions, type JanuxAppConfig } from './app-config';
|
|
3
|
+
export { apiStubModule, exportedApiNames, apiModuleName } from './api-stubs';
|
|
4
|
+
export { toFetchRequest, sendFetchResponse } from './request-adapter';
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Plugin, ViteDevServer } from 'vite';
|
|
2
|
+
import { createJanuxServer, type ServerOptions } from '@janux/server';
|
|
3
|
+
import { defineAgent } from '@janux/agent';
|
|
4
|
+
import { apiFiles, resolveAppConfig, type JanuxPluginOptions } from './app-config';
|
|
5
|
+
import { apiModuleName, apiStubModule } from './api-stubs';
|
|
6
|
+
import { sendFetchResponse, toFetchRequest } from './request-adapter';
|
|
7
|
+
|
|
8
|
+
const SSR_PACKAGES = ['janux', '@janux/server', '@janux/agent'];
|
|
9
|
+
|
|
10
|
+
async function loadServerOptions(vite: ViteDevServer, options: JanuxPluginOptions): Promise<ServerOptions> {
|
|
11
|
+
const app = resolveAppConfig(vite.config.root, options);
|
|
12
|
+
const apiModules = Object.fromEntries(
|
|
13
|
+
await Promise.all(
|
|
14
|
+
apiFiles(app.serverDir).map(async (file) => [
|
|
15
|
+
apiModuleName(file),
|
|
16
|
+
(await vite.ssrLoadModule(file)) as Record<string, unknown>,
|
|
17
|
+
]),
|
|
18
|
+
),
|
|
19
|
+
);
|
|
20
|
+
const agentModule = app.agentModule ? await vite.ssrLoadModule(app.agentModule) : undefined;
|
|
21
|
+
const storesModule = app.storesModule ? await vite.ssrLoadModule(app.storesModule) : undefined;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
routesDir: app.routesDir,
|
|
25
|
+
loadRoute: (filePath) => vite.ssrLoadModule(filePath),
|
|
26
|
+
apis: apiModules,
|
|
27
|
+
agent: (agentModule?.default as ServerOptions['agent']) ?? defineAgent(),
|
|
28
|
+
storeDefs: (storesModule ?? {}) as ServerOptions['storeDefs'],
|
|
29
|
+
runtimeUrl: app.clientEntry ? `/${relativeToRoot(vite.config.root, app.clientEntry)}` : undefined,
|
|
30
|
+
stylesheets: app.stylesheet ? [`/${relativeToRoot(vite.config.root, app.stylesheet)}`] : [],
|
|
31
|
+
title: app.title,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function relativeToRoot(root: string, absolute: string): string {
|
|
36
|
+
return absolute.startsWith(root) ? absolute.slice(root.length + 1) : absolute;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The Janux Vite plugin: JSX runtime config, api() client stubs (SWC) and the SSR dev bridge. */
|
|
40
|
+
export function janux(options: JanuxPluginOptions = {}): Plugin {
|
|
41
|
+
return {
|
|
42
|
+
name: 'janux',
|
|
43
|
+
|
|
44
|
+
config() {
|
|
45
|
+
return {
|
|
46
|
+
appType: 'custom',
|
|
47
|
+
esbuild: { jsx: 'automatic', jsxImportSource: 'janux' },
|
|
48
|
+
resolve: { dedupe: ['janux'] },
|
|
49
|
+
ssr: { noExternal: SSR_PACKAGES },
|
|
50
|
+
optimizeDeps: { exclude: SSR_PACKAGES },
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
transform(code, id, transformOptions) {
|
|
55
|
+
if (!/\.api\.[tj]s($|\?)/.test(id)) return undefined;
|
|
56
|
+
if (transformOptions?.ssr) return undefined;
|
|
57
|
+
|
|
58
|
+
return { code: apiStubModule(id, code), map: null };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
configureServer(vite) {
|
|
62
|
+
let cached: Promise<ReturnType<typeof createJanuxServer>> | undefined;
|
|
63
|
+
|
|
64
|
+
const januxServer = () => {
|
|
65
|
+
cached ??= loadServerOptions(vite, options).then(createJanuxServer);
|
|
66
|
+
|
|
67
|
+
return cached;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
vite.watcher.on('change', () => {
|
|
71
|
+
cached = undefined;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
vite.middlewares.use((req, res, next) => {
|
|
76
|
+
const handle = async () => {
|
|
77
|
+
const server = await januxServer();
|
|
78
|
+
const response = await server.fetch(await toFetchRequest(req));
|
|
79
|
+
|
|
80
|
+
if (response.status === 404 && !req.url?.startsWith('/_janux/')) return next();
|
|
81
|
+
await sendFetchResponse(res, response);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
handle().catch(next);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
|
|
3
|
+
async function readBody(req: IncomingMessage): Promise<Buffer | undefined> {
|
|
4
|
+
if (req.method === 'GET' || req.method === 'HEAD') return undefined;
|
|
5
|
+
const chunks: Buffer[] = [];
|
|
6
|
+
|
|
7
|
+
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
8
|
+
|
|
9
|
+
return Buffer.concat(chunks);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function toFetchRequest(req: IncomingMessage): Promise<Request> {
|
|
13
|
+
const host = req.headers.host ?? 'localhost';
|
|
14
|
+
const body = await readBody(req);
|
|
15
|
+
|
|
16
|
+
return new Request(`http://${host}${req.url ?? '/'}`, {
|
|
17
|
+
method: req.method,
|
|
18
|
+
headers: Object.entries(req.headers).map(([name, value]) => [name, String(value)] as [string, string]),
|
|
19
|
+
body: body && body.length > 0 ? new Uint8Array(body) : undefined,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function sendFetchResponse(res: ServerResponse, response: Response): Promise<void> {
|
|
24
|
+
res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
|
|
25
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
26
|
+
}
|