@janux/vite 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@janux/vite",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Vite plugin for Janux: JSX config, SSR routes, api() client stubs (SWC) and the dev server bridge.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { resolveAppConfig } from './app-config';
6
+
7
+ function appWithPackageJson(json: unknown): string {
8
+ const root = mkdtempSync(join(tmpdir(), 'janux-app-'));
9
+
10
+ writeFileSync(join(root, 'package.json'), JSON.stringify(json));
11
+
12
+ return root;
13
+ }
14
+
15
+ describe('resolveAppConfig package.json "janux" field', () => {
16
+ it('reads llmsTxt and title from the app package.json', () => {
17
+ const root = appWithPackageJson({
18
+ name: 'x',
19
+ janux: { title: 'My App', llmsTxt: { description: 'An app.' } },
20
+ });
21
+ const app = resolveAppConfig(root);
22
+
23
+ expect(app.title).toBe('My App');
24
+ expect(app.llmsTxt).toEqual({ description: 'An app.' });
25
+ });
26
+
27
+ it('lets explicit plugin options win over package.json', () => {
28
+ const root = appWithPackageJson({ name: 'x', janux: { title: 'From pkg' } });
29
+
30
+ expect(resolveAppConfig(root, { title: 'From plugin' }).title).toBe('From plugin');
31
+ });
32
+
33
+ it('defaults output to "bun" and reads "static" from the config', () => {
34
+ expect(resolveAppConfig(appWithPackageJson({ name: 'x' })).output).toBe('bun');
35
+ expect(resolveAppConfig(appWithPackageJson({ name: 'x', janux: { output: 'static' } })).output).toBe('static');
36
+ expect(resolveAppConfig(appWithPackageJson({ name: 'x' }), { output: 'static' }).output).toBe('static');
37
+ });
38
+
39
+ it('tolerates apps without a package.json or without the field', () => {
40
+ const root = mkdtempSync(join(tmpdir(), 'janux-app-'));
41
+
42
+ expect(resolveAppConfig(root).llmsTxt).toBeUndefined();
43
+ expect(resolveAppConfig(appWithPackageJson({ name: 'x' })).llmsTxt).toBeUndefined();
44
+ });
45
+ });
package/src/app-config.ts CHANGED
@@ -1,6 +1,8 @@
1
- import { existsSync, readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
 
4
+ export type JanuxOutput = 'bun' | 'static';
5
+
4
6
  export interface JanuxAppConfig {
5
7
  root: string;
6
8
  routesDir: string;
@@ -9,7 +11,10 @@ export interface JanuxAppConfig {
9
11
  agentModule?: string;
10
12
  storesModule?: string;
11
13
  stylesheet?: string;
14
+ favicon?: string;
12
15
  title?: string;
16
+ llmsTxt?: { title?: string; description?: string };
17
+ output: JanuxOutput;
13
18
  }
14
19
 
15
20
  export interface JanuxPluginOptions {
@@ -19,14 +24,27 @@ export interface JanuxPluginOptions {
19
24
  agentModule?: string;
20
25
  storesModule?: string;
21
26
  title?: string;
27
+ llmsTxt?: { title?: string; description?: string };
28
+ output?: JanuxOutput;
22
29
  }
23
30
 
24
31
  function optional(path: string): string | undefined {
25
32
  return existsSync(path) ? path : undefined;
26
33
  }
27
34
 
35
+ /** Optional per-app config: a `"janux"` field in the app's package.json (plugin options win over it). */
36
+ function packageJsonOptions(root: string): JanuxPluginOptions {
37
+ try {
38
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).janux ?? {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
28
44
  /** 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 {
45
+ export function resolveAppConfig(root: string, pluginOptions: JanuxPluginOptions = {}): JanuxAppConfig {
46
+ const options = { ...packageJsonOptions(root), ...pluginOptions };
47
+
30
48
  return {
31
49
  root,
32
50
  routesDir: resolve(root, options.routesDir ?? 'src/routes'),
@@ -35,7 +53,10 @@ export function resolveAppConfig(root: string, options: JanuxPluginOptions = {})
35
53
  agentModule: options.agentModule ?? optional(resolve(root, 'src/agent.ts')),
36
54
  storesModule: options.storesModule ?? optional(resolve(root, 'src/stores.ts')),
37
55
  stylesheet: optional(resolve(root, 'src/styles.css')),
56
+ favicon: optional(resolve(root, 'public/favicon.svg')) ? '/favicon.svg' : undefined,
38
57
  title: options.title,
58
+ llmsTxt: options.llmsTxt,
59
+ output: options.output ?? 'bun',
39
60
  };
40
61
  }
41
62
 
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { janux } from './plugin';
2
- export { resolveAppConfig, apiFiles, type JanuxPluginOptions, type JanuxAppConfig } from './app-config';
2
+ export { resolveAppConfig, apiFiles, type JanuxPluginOptions, type JanuxAppConfig, type JanuxOutput } from './app-config';
3
3
  export { apiStubModule, exportedApiNames, apiModuleName } from './api-stubs';
4
4
  export { toFetchRequest, sendFetchResponse } from './request-adapter';
package/src/plugin.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import type { Plugin, ViteDevServer } from 'vite';
2
3
  import { createJanuxServer, type ServerOptions } from '@janux/server';
3
4
  import { defineAgent } from '@janux/agent';
5
+ import { mimeFor, resolvePublicFile } from './static-files';
4
6
  import { apiFiles, resolveAppConfig, type JanuxPluginOptions } from './app-config';
5
7
  import { apiModuleName, apiStubModule } from './api-stubs';
6
8
  import { sendFetchResponse, toFetchRequest } from './request-adapter';
@@ -28,7 +30,9 @@ async function loadServerOptions(vite: ViteDevServer, options: JanuxPluginOption
28
30
  storeDefs: (storesModule ?? {}) as ServerOptions['storeDefs'],
29
31
  runtimeUrl: app.clientEntry ? `/${relativeToRoot(vite.config.root, app.clientEntry)}` : undefined,
30
32
  stylesheets: app.stylesheet ? [`/${relativeToRoot(vite.config.root, app.stylesheet)}`] : [],
33
+ favicon: app.favicon,
31
34
  title: app.title,
35
+ llmsTxt: app.llmsTxt,
32
36
  };
33
37
  }
34
38
 
@@ -74,6 +78,14 @@ export function janux(options: JanuxPluginOptions = {}): Plugin {
74
78
  return () => {
75
79
  vite.middlewares.use((req, res, next) => {
76
80
  const handle = async () => {
81
+ const publicFile = resolvePublicFile(vite.config.root, req.url?.split('?')[0] ?? '/');
82
+
83
+ if (publicFile) {
84
+ res.writeHead(200, { 'content-type': mimeFor(publicFile) });
85
+ res.end(readFileSync(publicFile));
86
+
87
+ return;
88
+ }
77
89
  const server = await januxServer();
78
90
  const response = await server.fetch(await toFetchRequest(req));
79
91
 
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { mkdirSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { mimeFor, resolvePublicFile } from './static-files';
5
+
6
+ const root = '/tmp/janux-static-test';
7
+
8
+ mkdirSync(join(root, 'public'), { recursive: true });
9
+ writeFileSync(join(root, 'public/logo.svg'), '<svg/>');
10
+
11
+ describe('public/ static files', () => {
12
+ it('resolves existing files inside public/', () => {
13
+ expect(resolvePublicFile(root, '/logo.svg')).toBe(join(root, 'public/logo.svg'));
14
+ expect(resolvePublicFile(root, '/missing.png')).toBeUndefined();
15
+ });
16
+
17
+ it('refuses path traversal outside public/', () => {
18
+ expect(resolvePublicFile(root, '/../package.json')).toBeUndefined();
19
+ expect(resolvePublicFile(root, '/%2e%2e/secret')).toBeUndefined();
20
+ });
21
+
22
+ it('maps mime types with a safe fallback', () => {
23
+ expect(mimeFor('a.svg')).toBe('image/svg+xml');
24
+ expect(mimeFor('a.woff2')).toBe('font/woff2');
25
+ expect(mimeFor('a.unknown')).toBe('application/octet-stream');
26
+ });
27
+ });
@@ -0,0 +1,41 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { extname, join, relative } from 'node:path';
3
+
4
+ const MIME_TYPES: Record<string, string> = {
5
+ '.svg': 'image/svg+xml',
6
+ '.png': 'image/png',
7
+ '.jpg': 'image/jpeg',
8
+ '.jpeg': 'image/jpeg',
9
+ '.gif': 'image/gif',
10
+ '.webp': 'image/webp',
11
+ '.ico': 'image/x-icon',
12
+ '.css': 'text/css; charset=utf-8',
13
+ '.js': 'text/javascript; charset=utf-8',
14
+ '.json': 'application/json',
15
+ '.txt': 'text/plain; charset=utf-8',
16
+ '.woff2': 'font/woff2',
17
+ '.webmanifest': 'application/manifest+json',
18
+ };
19
+
20
+ export function mimeFor(filePath: string): string {
21
+ return MIME_TYPES[extname(filePath)] ?? 'application/octet-stream';
22
+ }
23
+
24
+ /** Resolves a request path inside `<root>/public`, refusing traversal outside it. */
25
+ export function resolvePublicFile(root: string, pathname: string): string | undefined {
26
+ const publicDir = join(root, 'public');
27
+ let decoded: string;
28
+
29
+ try {
30
+ decoded = decodeURIComponent(pathname);
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ const candidate = join(publicDir, decoded);
35
+ const rel = relative(publicDir, candidate);
36
+
37
+ if (rel.startsWith('..') || rel === '') return undefined;
38
+ if (!existsSync(candidate) || !statSync(candidate).isFile()) return undefined;
39
+
40
+ return candidate;
41
+ }