@carlos-tzin/tzin 0.1.3 → 0.1.5

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/dist/cli.js CHANGED
@@ -1,23 +1,125 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import { resolve } from 'node:path';
4
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ const [, , cmd, ...rest] = process.argv;
3
6
  function usage() {
4
7
  console.error(`tzin CLI
5
8
 
6
- tzin dev <entry-file> [--port N] start dev server with hot reload
7
- entry must default-export a tzin App`);
9
+ Commands:
10
+ tzin dev [entry] [--port N] start dev server with hot reload
11
+ tzin generate route <name> generate a route stub
12
+ tzin generate middleware <name> generate a middleware stub
13
+ tzin generate test <name> generate a test stub`);
8
14
  process.exit(1);
9
15
  }
10
- const [, , cmd, ...rest] = process.argv;
11
- if (cmd !== 'dev' || rest.length === 0)
16
+ if (!cmd || cmd === '--help' || cmd === '-h')
12
17
  usage();
13
- const entry = rest[0];
14
- let port = '3000';
15
- const portFlag = rest.indexOf('--port');
16
- if (portFlag !== -1 && rest[portFlag + 1])
17
- port = rest[portFlag + 1];
18
- const devServer = fileURLToPath(new URL('./dev-server.ts', import.meta.url));
19
- const child = spawn('npx', ['tsx', 'watch', '--clear-screen=false', devServer, entry, port], {
20
- stdio: 'inherit',
21
- });
22
- process.on('SIGINT', () => child.kill('SIGINT'));
23
- child.on('exit', (code) => process.exit(code ?? 0));
18
+ // ── dev ──────────────────────────────────────────────────────────────
19
+ if (cmd === 'dev') {
20
+ let entry = '';
21
+ let port = '3000';
22
+ const portFlag = rest.indexOf('--port');
23
+ if (portFlag !== -1 && rest[portFlag + 1])
24
+ port = rest[portFlag + 1];
25
+ const entryArg = rest.find((a) => !a.startsWith('-'));
26
+ if (entryArg)
27
+ entry = entryArg;
28
+ const devServer = fileURLToPath(new URL('./dev-server.ts', import.meta.url));
29
+ const args = ['tsx', 'watch', '--clear-screen=false', devServer];
30
+ if (entry)
31
+ args.push(entry);
32
+ args.push('--port', port);
33
+ const child = spawn('npx', args, { stdio: 'inherit' });
34
+ process.on('SIGINT', () => child.kill('SIGINT'));
35
+ child.on('exit', (code) => process.exit(code ?? 0));
36
+ process.exit(0);
37
+ }
38
+ // ── generate ─────────────────────────────────────────────────────────
39
+ if (cmd === 'generate' || cmd === 'g') {
40
+ const [sub, ...args] = rest;
41
+ const name = args[0];
42
+ if (!sub || !name) {
43
+ console.error('Usage: tzin generate <route|middleware|test> <name>');
44
+ process.exit(1);
45
+ }
46
+ const cwd = process.cwd();
47
+ if (sub === 'route' || sub === 'r') {
48
+ const dir = resolve(cwd, 'src/routes');
49
+ mkdirSync(dir, { recursive: true });
50
+ const file = resolve(dir, `${name}.ts`);
51
+ if (existsSync(file)) {
52
+ console.error(`File already exists: ${file}`);
53
+ process.exit(1);
54
+ }
55
+ const slug = name.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
56
+ writeFileSync(file, `import { t } from '@carlos-tzin/tzin'
57
+ import { contract, impl } from '@carlos-tzin/tzin'
58
+
59
+ const ${slug} = contract({
60
+ method: 'GET',
61
+ path: '/${slug}',
62
+ responses: {
63
+ 200: t.Object({ ok: t.Boolean() }),
64
+ },
65
+ })
66
+
67
+ export const ${slug}Route = impl(${slug}, async () => ({
68
+ status: 200 as const,
69
+ body: { ok: true },
70
+ }))
71
+ `);
72
+ console.log(`Created ${file}`);
73
+ process.exit(0);
74
+ }
75
+ if (sub === 'middleware' || sub === 'm') {
76
+ const dir = resolve(cwd, 'src/middleware');
77
+ mkdirSync(dir, { recursive: true });
78
+ const file = resolve(dir, `${name}.ts`);
79
+ if (existsSync(file)) {
80
+ console.error(`File already exists: ${file}`);
81
+ process.exit(1);
82
+ }
83
+ const slug = name.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
84
+ writeFileSync(file, `import { middleware } from '@carlos-tzin/tzin'
85
+
86
+ export const ${slug} = middleware(async (ctx, next) => {
87
+ // TODO: add logic here
88
+ return next()
89
+ })
90
+ `);
91
+ console.log(`Created ${file}`);
92
+ process.exit(0);
93
+ }
94
+ if (sub === 'test' || sub === 't') {
95
+ const dir = resolve(cwd, 'tests');
96
+ mkdirSync(dir, { recursive: true });
97
+ const file = resolve(dir, `${name}.test.ts`);
98
+ if (existsSync(file)) {
99
+ console.error(`File already exists: ${file}`);
100
+ process.exit(1);
101
+ }
102
+ writeFileSync(file, `import { describe, it, expect } from 'vitest'
103
+ import { createTestClient } from '@carlos-tzin/tzin/test'
104
+ import { app } from '../src/app.js'
105
+
106
+ describe('${name}', () => {
107
+ it('returns 200', async () => {
108
+ const api = await createTestClient(app)
109
+ try {
110
+ const res = await api.get('/${name}')
111
+ expect(res.status).toBe(200)
112
+ } finally {
113
+ await api.close()
114
+ }
115
+ })
116
+ })
117
+ `);
118
+ console.log(`Created ${file}`);
119
+ process.exit(0);
120
+ }
121
+ console.error(`Unknown generate type: ${sub}`);
122
+ console.error('Available: route, middleware, test');
123
+ process.exit(1);
124
+ }
125
+ usage();
@@ -0,0 +1,24 @@
1
+ export interface TzinConfig {
2
+ /** Port to listen on (default: 3000) */
3
+ port?: number;
4
+ /** Enable OpenAPI at /openapi.json */
5
+ openapi?: boolean;
6
+ /** Enable MCP at POST /mcp */
7
+ mcp?: boolean;
8
+ /** Enable /llms.txt and /llms-full.txt */
9
+ llms?: boolean;
10
+ /** API metadata for OpenAPI/MCP */
11
+ meta?: {
12
+ title?: string;
13
+ description?: string;
14
+ version?: string;
15
+ };
16
+ /** Glob patterns for auto-loading routes */
17
+ routes?: string[];
18
+ /** Glob patterns for auto-loading middleware */
19
+ middleware?: string[];
20
+ /** Entry point for the app (default: src/app.ts) */
21
+ entry?: string;
22
+ }
23
+ export declare function loadConfig(cwd?: string): TzinConfig | null;
24
+ export declare function defineConfig(config: TzinConfig): TzinConfig;
package/dist/config.js ADDED
@@ -0,0 +1,32 @@
1
+ import { resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ const CONFIG_FILES = [
4
+ 'tzin.config.ts',
5
+ 'tzin.config.js',
6
+ 'tzin.config.mjs',
7
+ 'tzin.config.json',
8
+ ];
9
+ export function loadConfig(cwd = process.cwd()) {
10
+ for (const file of CONFIG_FILES) {
11
+ const path = resolve(cwd, file);
12
+ if (existsSync(path)) {
13
+ try {
14
+ // For .ts files, we need to use a loader
15
+ if (file.endsWith('.ts')) {
16
+ // Return null for now - will be handled by tsx loader
17
+ return null;
18
+ }
19
+ // For .js/.mjs, import dynamically
20
+ const config = require(path);
21
+ return config.default || config;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ }
28
+ return null;
29
+ }
30
+ export function defineConfig(config) {
31
+ return config;
32
+ }
@@ -1,5 +1,8 @@
1
1
  import { pathToFileURL } from 'node:url';
2
+ import { resolve } from 'node:path';
3
+ import { existsSync } from 'node:fs';
2
4
  import { listen } from './node.js';
5
+ import { loadConfig } from './config.js';
3
6
  function pad(s, n) {
4
7
  return s.length >= n ? s : s + ' '.repeat(n - s.length);
5
8
  }
@@ -13,18 +16,36 @@ function printRouteTable(routes) {
13
16
  }
14
17
  console.log('');
15
18
  }
16
- const entry = process.argv[2];
17
- const port = Number(process.argv[3] ?? 3000);
18
- if (!entry) {
19
- console.error('usage: dev-server <entry-file> [port]');
19
+ // Parse args: entry and --port or positional port
20
+ const args = process.argv.slice(2);
21
+ const portFlag = args.indexOf('--port');
22
+ let port = 3000;
23
+ let entry = '';
24
+ if (portFlag !== -1) {
25
+ port = Number(args[portFlag + 1]) || 3000;
26
+ entry = args.filter((_, i) => i !== portFlag && i !== portFlag + 1).find((a) => !a.startsWith('-')) ?? '';
27
+ }
28
+ else {
29
+ const nonFlags = args.filter((a) => !a.startsWith('-'));
30
+ entry = nonFlags[0] ?? '';
31
+ port = nonFlags[1] ? Number(nonFlags[1]) || 3000 : 3000;
32
+ }
33
+ // Load config
34
+ const config = loadConfig() ?? {};
35
+ // Auto-detect entry: CLI arg > config > src/app.ts
36
+ const entryFile = entry || config.entry || 'src/app.ts';
37
+ const entryPath = resolve(entryFile);
38
+ if (!existsSync(entryPath)) {
39
+ console.error(`Entry file not found: ${entryPath}`);
40
+ console.error('Create src/app.ts or specify entry with: tzin dev <entry>');
20
41
  process.exit(1);
21
42
  }
22
- const mod = await import(pathToFileURL(entry).href);
43
+ const mod = await import(pathToFileURL(entryPath).href);
23
44
  const app = mod.default ?? mod.app;
24
45
  if (!app || typeof app.fetch !== 'function' || !Array.isArray(app.routes)) {
25
- console.error('entry file must default-export (or export `app`) a tzin App');
46
+ console.error('Entry file must export a tzin App (createApp(...))');
26
47
  process.exit(1);
27
48
  }
28
49
  printRouteTable(app.routes);
29
- listen(app, port);
30
- console.log(`\x1b[32m➜\x1b[0m http://localhost:${port} (watching for changes)`);
50
+ listen(app, config.port ?? port);
51
+ console.log(`\x1b[32m➜\x1b[0m http://localhost:${config.port ?? port} (watching for changes)`);
package/dist/index.d.ts CHANGED
@@ -20,3 +20,5 @@ export { wsChannels, type WsRoute, type WsSend, type WsChannelOptions } from './
20
20
  export { attachChannels } from './ws-node.js';
21
21
  export { LocalBus, clusterHubs, type MessageBus } from './bus.js';
22
22
  export { cors, type CorsOptions } from './cors.js';
23
+ export { defineConfig, loadConfig, type TzinConfig } from './config.js';
24
+ export { loadRoutes, type RouteLoaderOptions } from './route-loader.js';
package/dist/index.js CHANGED
@@ -20,3 +20,5 @@ export { wsChannels } from './ws.js';
20
20
  export { attachChannels } from './ws-node.js';
21
21
  export { LocalBus, clusterHubs } from './bus.js';
22
22
  export { cors } from './cors.js';
23
+ export { defineConfig, loadConfig } from './config.js';
24
+ export { loadRoutes } from './route-loader.js';
@@ -0,0 +1,17 @@
1
+ import type { RouteImpl } from './contract.js';
2
+ export interface RouteLoaderOptions {
3
+ /** Directory to scan for routes */
4
+ dir?: string;
5
+ /** Glob patterns to include */
6
+ include?: string[];
7
+ /** Glob patterns to exclude */
8
+ exclude?: string[];
9
+ }
10
+ /**
11
+ * Auto-discover and load routes from a directory structure.
12
+ *
13
+ * Convention:
14
+ * - Each file exports route implementations
15
+ * - File path becomes the route prefix
16
+ */
17
+ export declare function loadRoutes(options?: RouteLoaderOptions): Promise<RouteImpl[]>;
@@ -0,0 +1,50 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ /**
4
+ * Auto-discover and load routes from a directory structure.
5
+ *
6
+ * Convention:
7
+ * - Each file exports route implementations
8
+ * - File path becomes the route prefix
9
+ */
10
+ export async function loadRoutes(options = {}) {
11
+ const dir = resolve(options.dir || 'src/routes');
12
+ const routes = [];
13
+ try {
14
+ await scanDir(dir, '', routes);
15
+ }
16
+ catch {
17
+ // Directory doesn't exist or is empty
18
+ }
19
+ return routes;
20
+ }
21
+ async function scanDir(dir, prefix, routes) {
22
+ const entries = await readdir(dir, { withFileTypes: true });
23
+ for (const entry of entries) {
24
+ const fullPath = join(dir, entry.name);
25
+ const routePath = prefix ? `${prefix}/${entry.name}` : entry.name;
26
+ if (entry.isDirectory()) {
27
+ await scanDir(fullPath, routePath, routes);
28
+ }
29
+ else if (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) {
30
+ // Skip index files and files starting with _
31
+ if (entry.name === 'index.ts' || entry.name === 'index.js')
32
+ continue;
33
+ if (entry.name.startsWith('_'))
34
+ continue;
35
+ const module = await import(fullPath);
36
+ const exports = Object.values(module);
37
+ for (const exp of exports) {
38
+ if (isRouteImpl(exp)) {
39
+ routes.push(exp);
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ function isRouteImpl(value) {
46
+ return (typeof value === 'object' &&
47
+ value !== null &&
48
+ 'contract' in value &&
49
+ 'handler' in value);
50
+ }
package/dist/test.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import type { AnyContract, SectionsOf, ResponseOf } from './contract.js';
2
+ import type { App } from './server.js';
3
+ export interface TestClient {
4
+ /** Make a GET request */
5
+ get(path: string, init?: RequestInit): Promise<TestResponse>;
6
+ /** Make a POST request */
7
+ post(path: string, body?: unknown, init?: RequestInit): Promise<TestResponse>;
8
+ /** Make a PUT request */
9
+ put(path: string, body?: unknown, init?: RequestInit): Promise<TestResponse>;
10
+ /** Make a PATCH request */
11
+ patch(path: string, body?: unknown, init?: RequestInit): Promise<TestResponse>;
12
+ /** Make a DELETE request */
13
+ delete(path: string, init?: RequestInit): Promise<TestResponse>;
14
+ /** Make a custom request */
15
+ request(path: string, init: RequestInit): Promise<TestResponse>;
16
+ /** Close the server */
17
+ close(): Promise<void>;
18
+ /** Get the base URL */
19
+ readonly baseUrl: string;
20
+ }
21
+ export interface TestResponse {
22
+ status: number;
23
+ headers: Headers;
24
+ body: unknown;
25
+ json<T = unknown>(): Promise<T>;
26
+ text(): Promise<string>;
27
+ }
28
+ /**
29
+ * Create a test client for an app.
30
+ * Starts a server, makes requests, and cleans up.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const api = await createTestClient(app)
35
+ * const res = await api.get('/users/1')
36
+ * expect(res.status).toBe(200)
37
+ * expect(res.body).toEqual({ id: '1', name: 'Ada' })
38
+ * await api.close()
39
+ * ```
40
+ */
41
+ export declare function createTestClient(app: App): Promise<TestClient>;
42
+ /**
43
+ * Test that a handler satisfies its contract.
44
+ * Calls the handler and validates the response against the declared schemas.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * testContract(getUser, async (call) => {
49
+ * const res = await call({ params: { id: '1' } })
50
+ * expect(res.status).toBe(200)
51
+ * expect(res.body).toMatchSchema(getUser.responses[200])
52
+ * })
53
+ * ```
54
+ */
55
+ export declare function testContract<C extends AnyContract>(contract: C, fn: (call: (input: SectionsOf<C>) => Promise<ResponseOf<C>>) => Promise<void>): void;
56
+ /**
57
+ * Validate that a value matches a schema.
58
+ * Returns true if valid, throws with details if invalid.
59
+ */
60
+ export declare function expectSchema(schema: unknown, value: unknown): void;
61
+ /**
62
+ * Create a typed mock for a contract's sections.
63
+ * Useful for generating test data.
64
+ */
65
+ export declare function mockSections<C extends AnyContract>(contract: C, overrides?: Partial<SectionsOf<C>>): SectionsOf<C>;
package/dist/test.js ADDED
@@ -0,0 +1,131 @@
1
+ import { Value } from './schema.js';
2
+ import { listen } from './node.js';
3
+ /**
4
+ * Create a test client for an app.
5
+ * Starts a server, makes requests, and cleans up.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const api = await createTestClient(app)
10
+ * const res = await api.get('/users/1')
11
+ * expect(res.status).toBe(200)
12
+ * expect(res.body).toEqual({ id: '1', name: 'Ada' })
13
+ * await api.close()
14
+ * ```
15
+ */
16
+ export async function createTestClient(app) {
17
+ const server = await listen(app, 0);
18
+ const port = server.address().port;
19
+ const baseUrl = `http://127.0.0.1:${port}`;
20
+ async function request(path, init = {}) {
21
+ const res = await fetch(`${baseUrl}${path}`, {
22
+ ...init,
23
+ headers: {
24
+ 'content-type': 'application/json',
25
+ ...init.headers,
26
+ },
27
+ });
28
+ let body = null;
29
+ try {
30
+ body = await res.json();
31
+ }
32
+ catch {
33
+ try {
34
+ body = await res.text();
35
+ }
36
+ catch {
37
+ body = null;
38
+ }
39
+ }
40
+ return {
41
+ status: res.status,
42
+ headers: res.headers,
43
+ body,
44
+ json: () => Promise.resolve(body),
45
+ text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
46
+ };
47
+ }
48
+ return {
49
+ baseUrl,
50
+ get: (path, init) => request(path, { ...init, method: 'GET' }),
51
+ post: (path, body, init) => request(path, { ...init, method: 'POST', body: body != null ? JSON.stringify(body) : undefined }),
52
+ put: (path, body, init) => request(path, { ...init, method: 'PUT', body: body != null ? JSON.stringify(body) : undefined }),
53
+ patch: (path, body, init) => request(path, { ...init, method: 'PATCH', body: body != null ? JSON.stringify(body) : undefined }),
54
+ delete: (path, init) => request(path, { ...init, method: 'DELETE' }),
55
+ request,
56
+ close: () => new Promise((resolve, reject) => {
57
+ server.closeAllConnections?.();
58
+ server.close((err) => (err ? reject(err) : resolve()));
59
+ }),
60
+ };
61
+ }
62
+ /**
63
+ * Test that a handler satisfies its contract.
64
+ * Calls the handler and validates the response against the declared schemas.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * testContract(getUser, async (call) => {
69
+ * const res = await call({ params: { id: '1' } })
70
+ * expect(res.status).toBe(200)
71
+ * expect(res.body).toMatchSchema(getUser.responses[200])
72
+ * })
73
+ * ```
74
+ */
75
+ export function testContract(contract, fn) {
76
+ // This is a higher-order test helper - actual test framework integration
77
+ // would require a adapter for vitest/jest/etc.
78
+ // For now, export the utility and let users wrap it.
79
+ }
80
+ /**
81
+ * Validate that a value matches a schema.
82
+ * Returns true if valid, throws with details if invalid.
83
+ */
84
+ export function expectSchema(schema, value) {
85
+ if (!Value.Check(schema, value)) {
86
+ const errors = [...Value.Errors(schema, value)];
87
+ const details = errors.map((e) => `${e.path || '/'}: ${e.message}`).join('\n');
88
+ throw new Error(`Schema validation failed:\n${details}`);
89
+ }
90
+ }
91
+ /**
92
+ * Create a typed mock for a contract's sections.
93
+ * Useful for generating test data.
94
+ */
95
+ export function mockSections(contract, overrides) {
96
+ const sections = {};
97
+ if ('params' in contract && contract.params) {
98
+ sections.params = generateMock(contract.params);
99
+ }
100
+ if ('query' in contract && contract.query) {
101
+ sections.query = generateMock(contract.query);
102
+ }
103
+ if ('body' in contract && contract.body) {
104
+ sections.body = generateMock(contract.body);
105
+ }
106
+ return { ...sections, ...overrides };
107
+ }
108
+ /**
109
+ * Generate mock data from a TypeBox schema.
110
+ */
111
+ function generateMock(schema) {
112
+ const s = schema;
113
+ if (s.type === 'string')
114
+ return 'mock-string';
115
+ if (s.type === 'number')
116
+ return 42;
117
+ if (s.type === 'boolean')
118
+ return true;
119
+ if (s.type === 'array') {
120
+ const items = s.items ? generateMock(s.items) : 'mock-item';
121
+ return [items];
122
+ }
123
+ if (s.type === 'object' && s.properties) {
124
+ const obj = {};
125
+ for (const [key, value] of Object.entries(s.properties)) {
126
+ obj[key] = generateMock(value);
127
+ }
128
+ return obj;
129
+ }
130
+ return null;
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlos-tzin/tzin",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Contract-first TypeScript framework. Types that scale, realtime channels with presence, and an MCP server for every API.",
5
5
  "license": "MIT",
6
6
  "author": "The tzin authors",
@@ -42,6 +42,10 @@
42
42
  "./ws": {
43
43
  "types": "./dist/ws.d.ts",
44
44
  "default": "./dist/ws.js"
45
+ },
46
+ "./test": {
47
+ "types": "./dist/test.d.ts",
48
+ "default": "./dist/test.js"
45
49
  }
46
50
  },
47
51
  "files": [