@carlos-tzin/tzin 1.0.6 → 1.0.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,107 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.8 — 2026-10-01
4
+
5
+ - **`tzin.config.ts` was silently ignored.** `loadConfig()` bailed out with
6
+ `null` for `.ts` files, so the `tzin.config.ts` that every scaffolded project
7
+ ships never applied — changing `port` there did nothing while the dev server
8
+ kept listening on the default. Added `loadConfigAsync()`, which reads a
9
+ TypeScript config through a dynamic import, and wired the dev server (which
10
+ runs under `tsx`) to it. The sync `loadConfig()` keeps its signature and
11
+ still handles `.js` / `.mjs` / `.json`.
12
+ - Tests: `tzin.config.ts` is now covered end to end (the dev server is spawned
13
+ and asserted to serve on the configured port).
14
+ - `vitest.config.ts`: exclude `.kilo/` and `.kilocode/`. Agent tooling leaves
15
+ git worktrees inside the repo holding stale copies of the suite, which the
16
+ default glob picked up (9 test files became 17).
17
+
18
+ ## 1.0.7 — 2026-09-27
19
+
20
+ The `tzin` CLI was announced as a headline feature but was **not reachable** in
21
+ a published install. `npm run dev` in a freshly scaffolded app failed with
22
+ `sh: 1: tzin: not found`, and `npx tzin dev` in the docs 404'd because no
23
+ unscoped `tzin` package exists on npm. Fixed here.
24
+
25
+ ### Fixed
26
+
27
+ - **`bin` field added to `package.json`** — `@carlos-tzin/tzin` now installs a
28
+ `tzin` executable into `node_modules/.bin`, so the `create-tzin` node
29
+ template (`"dev": "tzin dev"`, `"build": "tzin build"`) works after
30
+ `npm install`.
31
+ - **Shebang in `src/cli.ts`** — `dist/cli.js` is now a runnable binary. `tsc`
32
+ preserves it on emit.
33
+ - **`dev` no longer resolves a `.ts` file from `dist/`** — the CLI pointed
34
+ `tsx watch` at `./dev-server.ts` relative to itself, which only exists in
35
+ `src/`. In the published package the watcher died with
36
+ `ERR_MODULE_NOT_FOUND: .../dist/dev-server.ts`. It now resolves the sibling
37
+ module for both layouts (`.js` when built, `.ts` when run from source).
38
+ - **`tzin build` / `tzin dev` no longer return before their child exits** —
39
+ both spawned `tsc` / `tsx watch` and then called `process.exit(0)`, so
40
+ `tzin build` reported success (exit 0) even with type errors and the dev
41
+ server was orphaned. The CLI now waits and forwards the child's exit code,
42
+ breaking a CI build on a type error instead of shipping stale output.
43
+ - **Tool resolution** — `tsx` / `tsc` / `wrangler` are now taken from the
44
+ project's own `node_modules/.bin` (walking up from cwd). The npx fallback
45
+ pins the right package (`--package typescript tsc`); previously `npx tsc`
46
+ without a local install ran an unrelated third-party `tsc` package.
47
+
48
+ ### Also fixed (found while verifying the above — both shipped broken in 1.0.6)
49
+
50
+ - **`signJwt` / `verifyJwt` threw in every consumer install.**
51
+ `src/auth.ts` used a bare `require('node:crypto')` inside an ESM package,
52
+ so `dist/auth.js` shipped as `require` + top-level `await` and blew up with
53
+ `ReferenceError: Cannot determine intended module format` /
54
+ `ERR_AMBIGUOUS_MODULE_SYNTAX` on the first call. The unit tests passed
55
+ because Vitest's module runner injects a `require` shim, so nothing
56
+ exercised the published artifact. Now a static `import`.
57
+ - **`loadConfig()` silently ignored every config file.** Same bare `require`
58
+ in `src/config.ts`, swallowed by the surrounding `try/catch` and turned into
59
+ a `null` return, so `port` / `openapi` / `routes` from `tzin.config.*` never
60
+ applied. Now uses `createRequire(import.meta.url)`, which also lets Node load
61
+ ESM `.js` and `.mjs` configs.
62
+
63
+ ### Docs
64
+
65
+ - Replaced every `npx tzin dev` / `npx tzin build` (which 404) with
66
+ `npm run dev` / `npm run build`, plus `npx --package @carlos-tzin/tzin tzin dev`
67
+ for one-off runs without a local install.
68
+
69
+ ### Tests
70
+
71
+ - `test/cli.test.ts`: asserts the `bin` mapping, the shebang in source and
72
+ build output, that `build` waits for `tsc` and propagates a non-zero exit
73
+ code, and that `dev` from the built layout finds `dev-server` instead of
74
+ crashing.
75
+ - `test/cli.test.ts`: runs the built `dist/` in a plain node process to assert
76
+ JWT signing, `loadConfig`, and a served app work outside Vitest's transform
77
+ (the gap that let the `require` bugs reach npm).
78
+
79
+ ## 1.0.6 — 2026-09-22
80
+
81
+ Deployment fixes after the 1.0.5 release. No API changes.
82
+
83
+ ### Templates
84
+
85
+ - `create-tzin/templates/node/src/bus.ts`: `import('ioredis' as string)`
86
+ — the dynamic import was being resolved by `tsc` at compile time, so a
87
+ freshly scaffolded app (without `ioredis` installed) failed to build with
88
+ `Cannot find module 'ioredis'`. Now it typechecks and runs with zero new
89
+ dependencies; `ioredis` is only fetched at runtime when `REDIS_URL` is set.
90
+
91
+ ### Docker
92
+
93
+ - `Dockerfile` HEALTHCHECK now hits `/openapi.json` instead of `/health`.
94
+ The node template mounts `bearerAuth` on every route, so `/health` returns
95
+ 401 and the healthcheck would always fail. `/openapi.json` is public.
96
+
97
+ ### Compose / docs
98
+
99
+ - `docker-compose.yml`: removed the `TZIN_BUS=redis` env var (the framework
100
+ never read it — clustering is enabled by `REDIS_URL` via `src/bus.ts`).
101
+ Added a comment that `build: .` points at the app root, not the framework
102
+ repo.
103
+ - `docs/deployment.md` env-var table: `TZIN_BUS` → `REDIS_URL`.
104
+
3
105
  ## 1.0.5 — 2026-09-21
4
106
 
5
107
  Deployment polish + release automation. No API changes to the framework core.
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  [![CI](https://github.com/Tzinny-dev/tzin/actions/workflows/ci.yml/badge.svg)](https://github.com/Tzinny-dev/tzin/actions/workflows/ci.yml)
9
9
  [![npm](https://img.shields.io/npm/v/@carlos-tzin/tzin)](https://www.npmjs.com/package/@carlos-tzin/tzin)
10
10
  [![Donate](https://img.shields.io/badge/Donate-PayPal-00457C?logo=paypal&logoColor=white)](https://paypal.me/carlostzin)
11
+ [![Buy Me A Coffee](https://img.shields.io/badge/Buy_Me_A_Coffee-FFDD00?logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/tzinny)
11
12
 
12
13
  **Status: stable (1.0.x).** The core works end-to-end (see [Benchmarks](#benchmarks));
13
14
  1.0.0 shipped the stable API, and 1.0.2 adds test coverage gates, a wider CI
@@ -25,6 +26,8 @@ npx create-tzin my-app
25
26
 
26
27
  ## Documentation
27
28
 
29
+ Hosted site: **[tzin.tzinny.com](https://tzin.tzinny.com)**
30
+
28
31
  - **[API Reference](docs/api-reference.md)** — all exports, types, and options
29
32
  - **[Architecture Guide](docs/architecture.md)** — request pipeline, design decisions, internals
30
33
  - **[Deployment Guide](docs/deployment.md)** — Docker, Cloudflare Workers, multi-node clustering
@@ -203,8 +206,15 @@ export const app = createApp([getUserRoute], {
203
206
  ### 4. Run
204
207
 
205
208
  ```bash
206
- npx tzin dev # dev server at http://localhost:3000
207
- npx tzin build # production build
209
+ npm run dev # tzin dev -> dev server at http://localhost:3000
210
+ npm run build # tzin build -> production build
211
+ ```
212
+
213
+ The `tzin` binary ships with the package, so these scripts work after `npm install`. Without
214
+ a local install, run it through npm exec:
215
+
216
+ ```bash
217
+ npx --package @carlos-tzin/tzin tzin dev
208
218
  ```
209
219
 
210
220
  ## CLI
package/dist/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHmac } from 'node:crypto';
1
2
  import { defineContext } from './context.js';
2
3
  // ── Context Key ──────────────────────────────────────────────────────
3
4
  /** Typed key for the authenticated user */
@@ -10,7 +11,6 @@ function base64UrlDecode(data) {
10
11
  return Buffer.from(data, 'base64url').toString();
11
12
  }
12
13
  function hmacSign(data, secret) {
13
- const { createHmac } = require('node:crypto');
14
14
  return createHmac('sha256', secret).update(data).digest('base64url');
15
15
  }
16
16
  /**
package/dist/cli.d.ts CHANGED
@@ -1 +1,2 @@
1
+ #!/usr/bin/env node
1
2
  export {};
package/dist/cli.js CHANGED
@@ -1,8 +1,51 @@
1
+ #!/usr/bin/env node
1
2
  import { spawn } from 'node:child_process';
2
3
  import { fileURLToPath } from 'node:url';
3
- import { resolve } from 'node:path';
4
+ import { resolve, dirname } from 'node:path';
4
5
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
5
6
  const [, , cmd, ...rest] = process.argv;
7
+ // Published package ships dist/*.js, repo runs from src/*.ts — pick the one that exists.
8
+ function siblingModule(name) {
9
+ const js = fileURLToPath(new URL(`./${name}.js`, import.meta.url));
10
+ if (existsSync(js))
11
+ return js;
12
+ const ts = fileURLToPath(new URL(`./${name}.ts`, import.meta.url));
13
+ if (existsSync(ts))
14
+ return ts;
15
+ console.error(`tzin: cannot locate ${name} (looked for ${js} and ${ts})`);
16
+ process.exit(1);
17
+ }
18
+ // npm package that provides each binary (never assume bin name == package name:
19
+ // `npx tsc` without typescript installed runs an unrelated third-party `tsc`).
20
+ const TOOL_PACKAGE = { tsc: 'typescript' };
21
+ const isWin = process.platform === 'win32';
22
+ function localBin(name, cwd) {
23
+ const exe = isWin ? `${name}.cmd` : name;
24
+ let dir = cwd;
25
+ for (;;) {
26
+ const candidate = resolve(dir, 'node_modules', '.bin', exe);
27
+ if (existsSync(candidate))
28
+ return candidate;
29
+ const parent = dirname(dir);
30
+ if (parent === dir)
31
+ return null;
32
+ dir = parent;
33
+ }
34
+ }
35
+ // Prefer the project's own binary; only fall back to npx (pinned to the right
36
+ // package) when the tool is not installed locally.
37
+ function runTool(name, args, cwd) {
38
+ const local = localBin(name, cwd);
39
+ if (local)
40
+ return spawn(local, args, { cwd, stdio: 'inherit' });
41
+ const pkg = TOOL_PACKAGE[name] ?? name;
42
+ console.error(`tzin: ${name} not found in ${cwd} (install it to pin the version)`);
43
+ return spawn('npx', ['--yes', '--package', pkg, name, ...args], {
44
+ cwd,
45
+ stdio: 'inherit',
46
+ shell: isWin,
47
+ });
48
+ }
6
49
  let matched = false;
7
50
  function usage() {
8
51
  console.error(`tzin CLI
@@ -22,6 +65,7 @@ if (!cmd || cmd === '--help' || cmd === '-h')
22
65
  // ── dev ──────────────────────────────────────────────────────────────
23
66
  if (cmd === 'dev') {
24
67
  matched = true;
68
+ const cwd = process.cwd();
25
69
  let entry = '';
26
70
  let port = '3000';
27
71
  const portFlag = rest.indexOf('--port');
@@ -30,15 +74,17 @@ if (cmd === 'dev') {
30
74
  const entryArg = rest.find((a) => !a.startsWith('-'));
31
75
  if (entryArg)
32
76
  entry = entryArg;
33
- const devServer = fileURLToPath(new URL('./dev-server.ts', import.meta.url));
34
- const args = ['tsx', 'watch', '--clear-screen=false', devServer];
77
+ const devServer = siblingModule('dev-server');
78
+ const args = ['watch', '--clear-screen=false', devServer];
35
79
  if (entry)
36
80
  args.push(entry);
37
81
  args.push('--port', port);
38
- const child = spawn('npx', args, { stdio: 'inherit' });
82
+ const child = runTool('tsx', args, cwd);
39
83
  process.on('SIGINT', () => child.kill('SIGINT'));
40
- child.on('exit', (code) => process.exit(code ?? 0));
41
- process.exit(0);
84
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
85
+ // Stay alive until the watcher exits — the parent must not return the
86
+ // prompt (or npm's exit code) while the dev server is still running.
87
+ child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)));
42
88
  }
43
89
  // ── build ─────────────────────────────────────────────────────────────
44
90
  if (cmd === 'build') {
@@ -55,17 +101,15 @@ if (cmd === 'build') {
55
101
  process.exit(1);
56
102
  }
57
103
  console.log('Building...');
58
- const child = spawn('npx', ['tsc', '-p', 'tsconfig.json'], {
59
- cwd,
60
- stdio: 'inherit',
61
- });
104
+ const child = runTool('tsc', ['-p', 'tsconfig.json'], cwd);
62
105
  child.on('exit', (code) => {
63
106
  if (code === 0) {
64
107
  console.log('\n✓ Build complete → dist/');
65
108
  }
66
109
  process.exit(code ?? 1);
67
110
  });
68
- process.exit(0);
111
+ // NOTE: no trailing process.exit(0) — it would return success to the
112
+ // caller before tsc finishes (and mask type errors).
69
113
  }
70
114
  // ── deploy ────────────────────────────────────────────────────────────
71
115
  if (cmd === 'deploy') {
@@ -86,105 +130,102 @@ if (cmd === 'deploy') {
86
130
  console.error('No wrangler.toml found. Run: npx wrangler init');
87
131
  process.exit(1);
88
132
  }
89
- const child = spawn('npx', ['wrangler', 'deploy'], {
90
- cwd,
91
- stdio: 'inherit',
92
- });
133
+ const child = runTool('wrangler', ['deploy'], cwd);
93
134
  child.on('exit', (code) => process.exit(code ?? 1));
94
- process.exit(0);
95
135
  }
96
- // Default: Node
97
- if (target !== 'node') {
136
+ else if (target === 'node') {
137
+ nodeDeploy();
138
+ }
139
+ else {
98
140
  console.error(`Unknown deploy target: ${target}`);
99
141
  console.error('Available: node, workers');
100
142
  process.exit(1);
101
143
  }
102
- // Check for tsconfig
103
- if (!existsSync(resolve(cwd, 'tsconfig.json'))) {
104
- console.error('No tsconfig.json found');
105
- process.exit(1);
106
- }
107
- console.log('Building for production...');
108
- const build = spawn('npx', ['tsc', '-p', 'tsconfig.json'], {
109
- cwd,
110
- stdio: 'inherit',
111
- });
112
- build.on('exit', (code) => {
113
- if (code !== 0)
114
- process.exit(code ?? 1);
115
- console.log('\n✓ Build complete');
116
- afterBuild();
117
- });
118
- // NOTE: no trailing process.exit(0) here — the parent must stay alive
119
- // until the build child exits and afterBuild() runs. Calling
120
- // process.exit() here would kill the process (and the child listener)
121
- // before the build finishes.
122
- function afterBuild() {
123
- const pkgPath = resolve(cwd, 'package.json');
124
- let name = 'app';
125
- let version = '0.0.0';
126
- try {
127
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
128
- name = String(pkg.name ?? 'app').replace(/^@/, '').replace(/\//g, '-');
129
- version = String(pkg.version ?? '0.0.0');
130
- }
131
- catch {
132
- console.error('No package.json found — is this a tzin project?');
144
+ function nodeDeploy() {
145
+ // Check for tsconfig
146
+ if (!existsSync(resolve(cwd, 'tsconfig.json'))) {
147
+ console.error('No tsconfig.json found');
133
148
  process.exit(1);
134
149
  }
135
- // --pack: runnable tarball (dist + manifest + container files when present).
136
- if (pack) {
137
- const out = resolve(cwd, `${name}-${version}.tgz`);
138
- const files = ['dist', 'package.json'];
139
- for (const f of ['Dockerfile', 'docker-compose.yml', '.dockerignore']) {
140
- if (existsSync(resolve(cwd, f)))
141
- files.push(f);
150
+ console.log('Building for production...');
151
+ const build = runTool('tsc', ['-p', 'tsconfig.json'], cwd);
152
+ build.on('exit', (code) => {
153
+ if (code !== 0)
154
+ process.exit(code ?? 1);
155
+ console.log('\n✓ Build complete');
156
+ afterBuild();
157
+ });
158
+ // NOTE: no trailing process.exit(0) here — the parent must stay alive
159
+ // until the build child exits and afterBuild() runs. Calling
160
+ // process.exit() here would kill the process (and the child listener)
161
+ // before the build finishes.
162
+ function afterBuild() {
163
+ const pkgPath = resolve(cwd, 'package.json');
164
+ let name = 'app';
165
+ let version = '0.0.0';
166
+ try {
167
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
168
+ name = String(pkg.name ?? 'app').replace(/^@/, '').replace(/\//g, '-');
169
+ version = String(pkg.version ?? '0.0.0');
142
170
  }
143
- console.log(`Packing ${files.join(', ')} → ${out}`);
144
- const tar = spawn('tar', ['-czf', out, ...files], { cwd, stdio: 'inherit' });
145
- tar.on('exit', (tarCode) => {
146
- if (tarCode !== 0)
147
- process.exit(tarCode ?? 1);
148
- console.log(`\n✓ Packed ${out}`);
149
- console.log(`Unpack + run: tar -xzf ${name}-${version}.tgz && node dist/index.js`);
150
- if (dockerTag)
151
- dockerBuild();
152
- else
153
- process.exit(0);
154
- });
155
- return;
156
- }
157
- if (dockerTag) {
158
- dockerBuild();
159
- return;
160
- }
161
- console.log('Run: node dist/index.js');
162
- console.log('Tip: tzin deploy --target node --pack [--docker <tag> [--push]] for a runnable artifact');
163
- process.exit(0);
164
- }
165
- function dockerBuild() {
166
- if (!dockerTag)
167
- return;
168
- if (!existsSync(resolve(cwd, 'Dockerfile'))) {
169
- console.error('No Dockerfile found. Copy the reference Dockerfile from the tzin repo (see docs/deployment.md).');
170
- process.exit(1);
171
+ catch {
172
+ console.error('No package.json found — is this a tzin project?');
173
+ process.exit(1);
174
+ }
175
+ // --pack: runnable tarball (dist + manifest + container files when present).
176
+ if (pack) {
177
+ const out = resolve(cwd, `${name}-${version}.tgz`);
178
+ const files = ['dist', 'package.json'];
179
+ for (const f of ['Dockerfile', 'docker-compose.yml', '.dockerignore']) {
180
+ if (existsSync(resolve(cwd, f)))
181
+ files.push(f);
182
+ }
183
+ console.log(`Packing ${files.join(', ')} → ${out}`);
184
+ const tar = spawn('tar', ['-czf', out, ...files], { cwd, stdio: 'inherit' });
185
+ tar.on('exit', (tarCode) => {
186
+ if (tarCode !== 0)
187
+ process.exit(tarCode ?? 1);
188
+ console.log(`\n✓ Packed ${out}`);
189
+ console.log(`Unpack + run: tar -xzf ${name}-${version}.tgz && node dist/index.js`);
190
+ if (dockerTag)
191
+ dockerBuild();
192
+ else
193
+ process.exit(0);
194
+ });
195
+ return;
196
+ }
197
+ if (dockerTag) {
198
+ dockerBuild();
199
+ return;
200
+ }
201
+ console.log('Run: node dist/index.js');
202
+ console.log('Tip: tzin deploy --target node --pack [--docker <tag> [--push]] for a runnable artifact');
203
+ process.exit(0);
171
204
  }
172
- console.log(`Building Docker image ${dockerTag}...`);
173
- const args = ['build', '-t', dockerTag, '.'];
174
- const child = spawn('docker', args, { cwd, stdio: 'inherit' });
175
- child.on('exit', (buildCode) => {
176
- if (buildCode !== 0)
177
- process.exit(buildCode ?? 1);
178
- console.log(`\n✓ Image built: ${dockerTag}`);
179
- if (!push) {
180
- console.log(`Run: docker run -p 3000:3000 --env PORT=3000 ${dockerTag}`);
181
- process.exit(0);
205
+ function dockerBuild() {
206
+ if (!dockerTag)
182
207
  return;
208
+ if (!existsSync(resolve(cwd, 'Dockerfile'))) {
209
+ console.error('No Dockerfile found. Copy the reference Dockerfile from the tzin repo (see docs/deployment.md).');
210
+ process.exit(1);
183
211
  }
184
- console.log(`Pushing ${dockerTag}...`);
185
- const pushChild = spawn('docker', ['push', dockerTag], { cwd, stdio: 'inherit' });
186
- pushChild.on('exit', (pushCode) => process.exit(pushCode ?? 1));
187
- });
212
+ console.log(`Building Docker image ${dockerTag}...`);
213
+ const args = ['build', '-t', dockerTag, '.'];
214
+ const child = spawn('docker', args, { cwd, stdio: 'inherit' });
215
+ child.on('exit', (buildCode) => {
216
+ if (buildCode !== 0)
217
+ process.exit(buildCode ?? 1);
218
+ console.log(`\n✓ Image built: ${dockerTag}`);
219
+ if (!push) {
220
+ console.log(`Run: docker run -p 3000:3000 --env PORT=3000 ${dockerTag}`);
221
+ process.exit(0);
222
+ return;
223
+ }
224
+ console.log(`Pushing ${dockerTag}...`);
225
+ const pushChild = spawn('docker', ['push', dockerTag], { cwd, stdio: 'inherit' });
226
+ pushChild.on('exit', (pushCode) => process.exit(pushCode ?? 1));
227
+ });
228
+ }
188
229
  }
189
230
  }
190
231
  // ── generate ─────────────────────────────────────────────────────────
package/dist/config.d.ts CHANGED
@@ -21,4 +21,10 @@ export interface TzinConfig {
21
21
  entry?: string;
22
22
  }
23
23
  export declare function loadConfig(cwd?: string): TzinConfig | null;
24
+ /**
25
+ * Same as {@link loadConfig}, but also reads a TypeScript `tzin.config.ts`
26
+ * through a dynamic import. Needs a TS loader in the process (the dev server
27
+ * runs under `tsx`).
28
+ */
29
+ export declare function loadConfigAsync(cwd?: string): Promise<TzinConfig | null>;
24
30
  export declare function defineConfig(config: TzinConfig): TzinConfig;
package/dist/config.js CHANGED
@@ -1,32 +1,58 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { pathToFileURL } from 'node:url';
3
5
  const CONFIG_FILES = [
4
6
  'tzin.config.ts',
5
7
  'tzin.config.js',
6
8
  'tzin.config.mjs',
7
9
  'tzin.config.json',
8
10
  ];
9
- export function loadConfig(cwd = process.cwd()) {
11
+ function findConfigFile(cwd) {
10
12
  for (const file of CONFIG_FILES) {
11
13
  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
- }
14
+ if (existsSync(path))
15
+ return { file, path };
27
16
  }
28
17
  return null;
29
18
  }
19
+ export function loadConfig(cwd = process.cwd()) {
20
+ const found = findConfigFile(cwd);
21
+ if (!found)
22
+ return null;
23
+ // A .ts config needs a TS loader; only loadConfigAsync can do that.
24
+ if (found.file.endsWith('.ts'))
25
+ return null;
26
+ try {
27
+ // createRequire, not a bare require(): the package is ESM, where a
28
+ // bare require() is undefined and would silently drop the config.
29
+ const config = createRequire(import.meta.url)(found.path);
30
+ return config.default || config;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ /**
37
+ * Same as {@link loadConfig}, but also reads a TypeScript `tzin.config.ts`
38
+ * through a dynamic import. Needs a TS loader in the process (the dev server
39
+ * runs under `tsx`).
40
+ */
41
+ export async function loadConfigAsync(cwd = process.cwd()) {
42
+ const found = findConfigFile(cwd);
43
+ if (!found)
44
+ return null;
45
+ if (!found.file.endsWith('.ts'))
46
+ return loadConfig(cwd);
47
+ try {
48
+ const mod = await import(pathToFileURL(found.path).href);
49
+ return (mod.default ?? mod);
50
+ }
51
+ catch (err) {
52
+ console.error(`tzin: failed to load ${found.file}: ${err.message}`);
53
+ return null;
54
+ }
55
+ }
30
56
  export function defineConfig(config) {
31
57
  return config;
32
58
  }
@@ -2,7 +2,7 @@ import { pathToFileURL } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { listen } from './node.js';
5
- import { loadConfig } from './config.js';
5
+ import { loadConfigAsync } from './config.js';
6
6
  function pad(s, n) {
7
7
  return s.length >= n ? s : s + ' '.repeat(n - s.length);
8
8
  }
@@ -31,7 +31,7 @@ else {
31
31
  port = nonFlags[1] ? Number(nonFlags[1]) || 3000 : 3000;
32
32
  }
33
33
  // Load config
34
- const config = loadConfig() ?? {};
34
+ const config = (await loadConfigAsync()) ?? {};
35
35
  // Auto-detect entry: CLI arg > config > src/app.ts
36
36
  const entryFile = entry || config.entry || 'src/app.ts';
37
37
  const entryPath = resolve(entryFile);
package/dist/index.d.ts CHANGED
@@ -20,5 +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';
23
+ export { defineConfig, loadConfig, loadConfigAsync, type TzinConfig } from './config.js';
24
24
  export { loadRoutes, type RouteLoaderOptions } from './route-loader.js';
package/dist/index.js CHANGED
@@ -20,5 +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';
23
+ export { defineConfig, loadConfig, loadConfigAsync } from './config.js';
24
24
  export { loadRoutes } from './route-loader.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carlos-tzin/tzin",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
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",
@@ -26,6 +26,9 @@
26
26
  "type": "module",
27
27
  "main": "./dist/index.js",
28
28
  "types": "./dist/index.d.ts",
29
+ "bin": {
30
+ "tzin": "./dist/cli.js"
31
+ },
29
32
  "exports": {
30
33
  ".": {
31
34
  "types": "./dist/index.d.ts",