@zerct/zerct 0.1.13 → 0.1.14

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.
Files changed (3) hide show
  1. package/README.md +10 -2
  2. package/bin/zerct.js +392 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Deploy Rust backends and static frontends to Zerct.
4
4
 
5
5
  ```sh
6
+ npx @zerct/zerct init my-app --template fullstack-rust-tanstack
7
+ cd my-app/web && bun install && cd ..
6
8
  npx @zerct/zerct deploy
7
9
  ```
8
10
 
@@ -14,12 +16,18 @@ npx zerct doctor
14
16
  npx zerct deploy
15
17
  ```
16
18
 
17
- Zerct expects `Cargo.toml`, `Cargo.lock`, and `zerct.toml`. The app must listen
18
- on `0.0.0.0:$PORT` and expose the configured health endpoint.
19
+ Rust backends expect `Cargo.toml`, `Cargo.lock`, and `zerct.toml`. They must
20
+ listen on `0.0.0.0:$PORT` and expose the configured health endpoint.
19
21
 
20
22
  From a full-stack repo root, the same deploy command discovers nested
21
23
  `zerct.toml` files and deploys the whole workspace in one command.
22
24
 
25
+ Preview before deploying:
26
+
27
+ ```sh
28
+ npx @zerct/zerct preview
29
+ ```
30
+
23
31
  Managed Postgres apps receive `DATABASE_URL`, `ZERCT_DATABASE_URL`, and
24
32
  `ZERCT_DATABASE_CONNECTION_LIMIT`. Use that limit as the max size for your
25
33
  database pool.
package/bin/zerct.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from 'node:child_process'
3
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
4
+ import { createServer } from 'node:http'
4
5
  import { homedir } from 'node:os'
5
6
  import path from 'node:path'
6
7
 
7
- const VERSION = '0.1.13'
8
+ const VERSION = '0.1.14'
8
9
  const DEFAULT_API_URL = 'https://api.zerct.com'
9
10
  const ARCHIVE_LIMIT_BYTES = 48 * 1024 * 1024
10
11
  const DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS = 900
@@ -18,6 +19,18 @@ const DEFAULT_LOGIN_INTERVAL_SECONDS = 5
18
19
  const DEFAULT_RUST_CHECK_COMMAND = 'cargo check --locked && cargo clippy --locked --all-targets --all-features -- -D warnings'
19
20
  const DEFAULT_NPM_FRONTEND_CHECK_COMMAND = 'npm ci --prefer-offline --no-audit --fund=false && npm run typecheck && npm run lint'
20
21
  const DEFAULT_BUN_FRONTEND_CHECK_COMMAND = 'bun ci && bun run typecheck && bun run lint'
22
+ const PROJECT_KINDS = new Set(['rust_backend', 'static_frontend'])
23
+ const PROJECT_TEMPLATES = new Set(['rust-api', 'tanstack-static-frontend', 'fullstack-rust-tanstack'])
24
+ const FRONTEND_TEMPLATE_FILES = new Set([
25
+ 'index.html',
26
+ 'package.json',
27
+ 'src/main.tsx',
28
+ 'src/styles.css',
29
+ 'src/vite-env.d.ts',
30
+ 'tsconfig.json',
31
+ 'vite.config.ts',
32
+ 'zerct.toml'
33
+ ])
21
34
  const ARCHIVE_EXCLUDES = [
22
35
  '.git',
23
36
  'target',
@@ -61,9 +74,10 @@ const WORKSPACE_EXCLUDED_DIRS = new Set([
61
74
  const HELP = `Zerct ${VERSION}
62
75
 
63
76
  Usage:
64
- zerct init [path]
77
+ zerct init [path] [--template rust-api|tanstack-static-frontend|fullstack-rust-tanstack]
65
78
  zerct install [path]
66
79
  zerct doctor [path] [--json]
80
+ zerct preview [path] [--port <port>]
67
81
  zerct login [--token <token>] [--api <url>]
68
82
  zerct deploy [path] [--database] [--wait] [--wait-timeout <seconds>] [--api <url>] [--json]
69
83
  zerct capabilities [--api <url>] [--json]
@@ -90,7 +104,9 @@ Usage:
90
104
  Agent contract:
91
105
  - Rust backends keep Cargo.lock committed, listen on 0.0.0.0:$PORT, and return HTTP 200 from health.
92
106
  - Static frontends set kind = "static_frontend", keep TypeScript source, a package lockfile, and typecheck + lint scripts.
107
+ - Frontends call Rust backends for APIs, managed Postgres, and server-side logic.
93
108
  - Run deploy from a repo root with nested zerct.toml files to deploy the whole workspace in one command.
109
+ - When a frontend calls a backend on another hostname, configure backend CORS or use a same-origin custom domain.
94
110
  - Keep direct unsafe out of Rust source.
95
111
  `
96
112
 
@@ -109,14 +125,17 @@ async function main() {
109
125
 
110
126
  switch (cli.command) {
111
127
  case 'init':
112
- initProject(projectPath(cli.args[0]))
128
+ initProject(projectPath(cli.args[0]), cli.template)
113
129
  break
114
130
  case 'install':
115
- installProject(projectPath(cli.args[0]))
131
+ installProject(projectPath(cli.args[0]), cli.template)
116
132
  break
117
133
  case 'doctor':
118
134
  doctorProject(projectPath(cli.args[0]), cli.json)
119
135
  break
136
+ case 'preview':
137
+ previewProject(projectPath(cli.args[0]), cli.port)
138
+ break
120
139
  case 'login':
121
140
  await login(cli)
122
141
  break
@@ -185,6 +204,8 @@ function parseArgs(argv) {
185
204
  limit: '',
186
205
  cursor: '',
187
206
  token: '',
207
+ template: '',
208
+ port: 0,
188
209
  waitTimeoutSeconds: DEFAULT_DEPLOY_WAIT_TIMEOUT_SECONDS,
189
210
  json: false,
190
211
  database: false,
@@ -232,6 +253,12 @@ function parseArgs(argv) {
232
253
  } else if (arg === '--token') {
233
254
  cli.token = requireValue(argv, index, '--token')
234
255
  index += 1
256
+ } else if (arg === '--template') {
257
+ cli.template = requireValue(argv, index, '--template')
258
+ index += 1
259
+ } else if (arg === '--port') {
260
+ cli.port = parsePositiveInteger(requireValue(argv, index, '--port'), '--port')
261
+ index += 1
235
262
  } else {
236
263
  positional.push(arg)
237
264
  }
@@ -266,18 +293,128 @@ function projectPath(value) {
266
293
  return path.resolve(value || process.cwd())
267
294
  }
268
295
 
269
- function initProject(projectDir) {
296
+ function initProject(projectDir, template = '') {
297
+ if (template) {
298
+ mkdirSync(projectDir, { recursive: true, mode: 0o755 })
299
+ createTemplate(projectDir, template)
300
+ return
301
+ }
270
302
  ensureDirectory(projectDir)
303
+
271
304
  const configPath = path.join(projectDir, 'zerct.toml')
272
305
  if (existsSync(configPath)) {
273
306
  console.log('zerct.toml already exists')
274
307
  return
275
308
  }
276
309
 
277
- const name = serviceNameFromDir(projectDir)
278
- const source = `name = "${name}"
310
+ const kind = inferProjectKind(projectDir)
311
+ const source = kind === 'static_frontend'
312
+ ? frontendConfig(projectDir)
313
+ : rustBackendConfig(projectDir)
314
+
315
+ writeFileSync(configPath, source, { mode: 0o644 })
316
+ console.log(`created ${path.relative(process.cwd(), configPath)}`)
317
+ console.log(`detected ${kind}`)
318
+ }
319
+
320
+ function createTemplate(projectDir, template) {
321
+ if (!PROJECT_TEMPLATES.has(template)) {
322
+ throw agentError('invalid_template', 'Zerct template is unknown.', `Use one of: ${[...PROJECT_TEMPLATES].join(', ')}.`, false)
323
+ }
324
+ if (template === 'rust-api') {
325
+ writeRustApiTemplate(projectDir, serviceNameFromDir(projectDir))
326
+ } else if (template === 'tanstack-static-frontend') {
327
+ writeFrontendTemplate(projectDir, serviceNameFromDir(projectDir), '/api')
328
+ } else {
329
+ const apiDir = path.join(projectDir, 'api')
330
+ const webDir = path.join(projectDir, 'web')
331
+ writeRustApiTemplate(apiDir, 'api')
332
+ writeFrontendTemplate(webDir, 'web', 'http://localhost:3000')
333
+ }
334
+ console.log(`created ${template} template`)
335
+ }
336
+
337
+ function writeRustApiTemplate(projectDir, name) {
338
+ mkdirSync(path.join(projectDir, 'src'), { recursive: true, mode: 0o755 })
339
+ writeNewFile(path.join(projectDir, 'Cargo.toml'), `[package]
340
+ name = "${name}"
341
+ version = "0.1.0"
342
+ edition = "2024"
343
+ publish = false
344
+
345
+ [lints.rust]
346
+ unsafe_code = "forbid"
347
+ warnings = "deny"
348
+ `)
349
+ writeNewFile(path.join(projectDir, 'Cargo.lock'), `# This file is automatically @generated by Cargo.
350
+ version = 4
351
+
352
+ [[package]]
353
+ name = "${name}"
354
+ version = "0.1.0"
355
+ `)
356
+ writeNewFile(path.join(projectDir, 'src', 'main.rs'), rustApiSource())
357
+ writeNewFile(path.join(projectDir, 'zerct.toml'), rustBackendConfig(projectDir))
358
+ }
359
+
360
+ function writeFrontendTemplate(projectDir, name, apiBaseUrl) {
361
+ mkdirSync(path.join(projectDir, 'src'), { recursive: true, mode: 0o755 })
362
+ writeNewFile(path.join(projectDir, 'package.json'), `{
363
+ "name": "${name}",
364
+ "private": true,
365
+ "type": "module",
366
+ "scripts": {
367
+ "typecheck": "tsgo --noEmit",
368
+ "lint": "oxlint src vite.config.ts --deny-warnings",
369
+ "build": "vite build",
370
+ "preview": "vite preview --host 0.0.0.0"
371
+ },
372
+ "dependencies": {
373
+ "react": "^19.2.1",
374
+ "react-dom": "^19.2.1",
375
+ "@tanstack/react-router": "^1.140.0"
376
+ },
377
+ "devDependencies": {
378
+ "@types/react": "^19.2.7",
379
+ "@types/react-dom": "^19.2.3",
380
+ "@typescript/native-preview": "^7.0.0-dev.20251126.1",
381
+ "@vitejs/plugin-react": "^5.1.1",
382
+ "oxlint": "^1.30.0",
383
+ "typescript": "^5.9.3",
384
+ "vite": "^7.2.4"
385
+ }
386
+ }
387
+ `)
388
+ writeFrontendTemplateFile(projectDir, 'index.html', '<div id="root"></div><script type="module" src="/src/main.tsx"></script>\n')
389
+ writeFrontendTemplateFile(projectDir, 'src/styles.css', 'body{margin:0;font-family:system-ui,sans-serif}main{min-height:100svh;display:grid;place-items:center;padding:2rem}code{font-family:ui-monospace,monospace}\n')
390
+ writeFrontendTemplateFile(projectDir, 'src/vite-env.d.ts', '/// <reference types="vite/client" />\n')
391
+ writeFrontendTemplateFile(projectDir, 'src/main.tsx', frontendSource(apiBaseUrl))
392
+ writeFrontendTemplateFile(projectDir, 'tsconfig.json', '{"compilerOptions":{"strict":true,"jsx":"react-jsx","module":"ESNext","moduleResolution":"Bundler","target":"ES2022","noEmit":true,"skipLibCheck":true},"include":["src","vite.config.ts"]}\n')
393
+ writeFrontendTemplateFile(projectDir, 'vite.config.ts', 'import react from "@vitejs/plugin-react";\nimport { defineConfig } from "vite";\n\nexport default defineConfig({ plugins: [react()] });\n')
394
+ writeFrontendTemplateFile(projectDir, 'zerct.toml', frontendConfig(projectDir))
395
+ console.log('run package install in the frontend directory before doctor: bun install or npm install')
396
+ }
397
+
398
+ function writeFrontendTemplateFile(projectDir, relative, source) {
399
+ if (!FRONTEND_TEMPLATE_FILES.has(relative)) {
400
+ throw new Error(`unexpected template file: ${relative}`)
401
+ }
402
+ writeNewFile(path.join(projectDir, relative), source)
403
+ }
404
+
405
+ function writeNewFile(file, source) {
406
+ if (existsSync(file)) {
407
+ throw agentError('file_exists', `Refusing to overwrite ${path.relative(process.cwd(), file)}.`, 'Move the existing file or choose an empty directory, then retry.', false)
408
+ }
409
+ writeFileSync(file, source, { mode: 0o644 })
410
+ }
411
+
412
+ function rustBackendConfig(projectDir) {
413
+ const name = serviceNameFromCargo(projectDir) || serviceNameFromDir(projectDir)
414
+ return `name = "${name}"
279
415
 
280
416
  [build]
417
+ check = "${DEFAULT_RUST_CHECK_COMMAND}"
281
418
  command = "cargo build --release"
282
419
 
283
420
  [run]
@@ -290,13 +427,22 @@ memory = "512mb"
290
427
  cpu = "0.25"
291
428
  idle_timeout_minutes = 15
292
429
  `
430
+ }
293
431
 
294
- writeFileSync(configPath, source, { mode: 0o644 })
295
- console.log(`created ${path.relative(process.cwd(), configPath)}`)
432
+ function frontendConfig(projectDir) {
433
+ const name = serviceNameFromPackage(projectDir) || serviceNameFromDir(projectDir)
434
+ return `name = "${name}"
435
+ kind = "static_frontend"
436
+
437
+ [build]
438
+ check = "${frontendCheckCommand(projectDir)}"
439
+ command = "${frontendBuildCommand(projectDir)}"
440
+ output = "dist"
441
+ `
296
442
  }
297
443
 
298
- function installProject(projectDir) {
299
- initProject(projectDir)
444
+ function installProject(projectDir, template = '') {
445
+ initProject(projectDir, template)
300
446
  doctorProject(projectDir, false)
301
447
  }
302
448
 
@@ -332,6 +478,106 @@ function doctorProject(projectDir, json) {
332
478
  }
333
479
  }
334
480
 
481
+ function previewProject(projectDir, port) {
482
+ const report = runDoctorWorkspace(projectDir)
483
+ if (Array.isArray(report.projects)) {
484
+ throw agentError('workspace_preview_unsupported', 'Preview one project at a time.', 'Run `npx @zerct/zerct preview api` or `npx @zerct/zerct preview web` from the workspace root.', false)
485
+ }
486
+ if (!report.ok) {
487
+ const firstFailure = report.checks.find((check) => !check.ok)
488
+ throw agentError('doctor_failed', 'Zerct doctor failed.', firstFailure?.agent_instruction || 'Fix the failed checks and retry `npx @zerct/zerct preview`.', false)
489
+ }
490
+
491
+ const config = parseZerctToml(readFileSync(path.join(projectDir, 'zerct.toml'), 'utf8'), projectDir)
492
+ validateConfig(config)
493
+ runShell(config.build.command, projectDir, 'Build failed before preview.')
494
+ if (config.kind === 'static_frontend') {
495
+ serveStatic(path.join(projectDir, config.build.output), port || 4173)
496
+ return
497
+ }
498
+
499
+ const runtimePort = port || config.run.port
500
+ console.log(`preview http://127.0.0.1:${runtimePort}`)
501
+ const result = spawnSync(config.run.command, {
502
+ cwd: projectDir,
503
+ env: { ...process.env, PORT: String(runtimePort) },
504
+ shell: true,
505
+ stdio: 'inherit'
506
+ })
507
+ if (result.error) {
508
+ throw agentError('preview_failed', 'Preview command failed.', result.error.message, false)
509
+ }
510
+ if (result.status !== 0) {
511
+ throw agentError('preview_failed', 'Preview command exited with an error.', 'Fix the local runtime command and retry `npx @zerct/zerct preview`.', false)
512
+ }
513
+ }
514
+
515
+ function runShell(command, projectDir, failureMessage) {
516
+ console.log(command)
517
+ const result = spawnSync(command, {
518
+ cwd: projectDir,
519
+ env: process.env,
520
+ shell: true,
521
+ stdio: 'inherit'
522
+ })
523
+ if (result.error) {
524
+ throw agentError('command_failed', failureMessage, result.error.message, false)
525
+ }
526
+ if (result.status !== 0) {
527
+ throw agentError('command_failed', failureMessage, 'Fix the command output above, then retry.', false)
528
+ }
529
+ }
530
+
531
+ function serveStatic(root, port) {
532
+ ensureDirectory(root)
533
+ const server = createServer((request, response) => {
534
+ const pathname = decodeURIComponent(new URL(request.url || '/', `http://127.0.0.1:${port}`).pathname)
535
+ const target = staticTarget(root, pathname)
536
+ if (!target) {
537
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
538
+ response.end('not found')
539
+ return
540
+ }
541
+ response.writeHead(200, { 'content-type': contentType(target) })
542
+ response.end(readFileSync(target))
543
+ })
544
+ server.listen(port, '127.0.0.1', () => {
545
+ console.log(`preview http://127.0.0.1:${port}`)
546
+ })
547
+ }
548
+
549
+ function staticTarget(root, pathname) {
550
+ const safePath = pathname.replace(/^\/+/u, '')
551
+ const candidate = path.resolve(root, safePath || 'index.html')
552
+ if (!candidate.startsWith(path.resolve(root) + path.sep) && candidate !== path.resolve(root)) {
553
+ return ''
554
+ }
555
+ if (existsSync(candidate) && statSync(candidate).isFile()) {
556
+ return candidate
557
+ }
558
+ const index = path.join(root, 'index.html')
559
+ return existsSync(index) ? index : ''
560
+ }
561
+
562
+ function contentType(file) {
563
+ if (file.endsWith('.html')) {
564
+ return 'text/html; charset=utf-8'
565
+ }
566
+ if (file.endsWith('.css')) {
567
+ return 'text/css; charset=utf-8'
568
+ }
569
+ if (file.endsWith('.js') || file.endsWith('.mjs')) {
570
+ return 'text/javascript; charset=utf-8'
571
+ }
572
+ if (file.endsWith('.json')) {
573
+ return 'application/json; charset=utf-8'
574
+ }
575
+ if (file.endsWith('.svg')) {
576
+ return 'image/svg+xml'
577
+ }
578
+ return 'application/octet-stream'
579
+ }
580
+
335
581
  function runDoctorWorkspace(projectDir) {
336
582
  if (existsSync(path.join(projectDir, 'zerct.toml'))) {
337
583
  return runDoctor(projectDir)
@@ -1206,7 +1452,7 @@ function validateConfig(config) {
1206
1452
  if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/u.test(config.name || '')) {
1207
1453
  throw new Error('name must be lowercase DNS-safe text up to 48 characters')
1208
1454
  }
1209
- if (!['rust_backend', 'static_frontend'].includes(config.kind)) {
1455
+ if (!PROJECT_KINDS.has(config.kind)) {
1210
1456
  throw new Error('kind must be rust_backend or static_frontend')
1211
1457
  }
1212
1458
  if (typeof config.build.command !== 'string' || !config.build.command.trim()) {
@@ -1222,6 +1468,9 @@ function validateConfig(config) {
1222
1468
  }
1223
1469
  return
1224
1470
  }
1471
+ if (config.build.output) {
1472
+ throw new Error('[build].output is only valid for static_frontend')
1473
+ }
1225
1474
  if (!config.run.command || typeof config.run.command !== 'string') {
1226
1475
  throw new Error('[run].command is required')
1227
1476
  }
@@ -1496,10 +1745,140 @@ function ensureDirectory(dir) {
1496
1745
  }
1497
1746
 
1498
1747
  function serviceNameFromDir(projectDir) {
1499
- const name = path.basename(projectDir).toLowerCase().replace(/[^a-z0-9-]+/gu, '-').replace(/^-+|-+$/gu, '')
1748
+ const name = serviceNameFromValue(path.basename(projectDir))
1500
1749
  return name || 'api'
1501
1750
  }
1502
1751
 
1752
+ function serviceNameFromCargo(projectDir) {
1753
+ try {
1754
+ const source = readFileSync(path.join(projectDir, 'Cargo.toml'), 'utf8')
1755
+ return serviceNameFromValue(source.match(/^\s*name\s*=\s*"([^"]+)"/mu)?.[1] || '')
1756
+ } catch (_error) {
1757
+ return ''
1758
+ }
1759
+ }
1760
+
1761
+ function serviceNameFromPackage(projectDir) {
1762
+ const manifest = readPackageJson(projectDir)
1763
+ return serviceNameFromValue(typeof manifest?.name === 'string' ? manifest.name : '')
1764
+ }
1765
+
1766
+ function serviceNameFromValue(value) {
1767
+ return value.toLowerCase().replace(/[^a-z0-9-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 48)
1768
+ }
1769
+
1770
+ function inferProjectKind(projectDir) {
1771
+ if (existsSync(path.join(projectDir, 'Cargo.toml'))) {
1772
+ return 'rust_backend'
1773
+ }
1774
+ if (existsSync(path.join(projectDir, 'package.json'))) {
1775
+ return 'static_frontend'
1776
+ }
1777
+ return 'rust_backend'
1778
+ }
1779
+
1780
+ function rustApiSource() {
1781
+ return `use std::{
1782
+ io::{Read, Write},
1783
+ net::{TcpListener, TcpStream},
1784
+ };
1785
+
1786
+ fn main() -> std::io::Result<()> {
1787
+ let port = std::env::var("PORT").unwrap_or_else(|_error| "3000".to_owned());
1788
+ let listener = TcpListener::bind(format!("0.0.0.0:{port}"))?;
1789
+
1790
+ for stream in listener.incoming() {
1791
+ handle(stream?)?;
1792
+ }
1793
+
1794
+ Ok(())
1795
+ }
1796
+
1797
+ fn handle(mut stream: TcpStream) -> std::io::Result<()> {
1798
+ let mut buffer = [0_u8; 2048];
1799
+ let size = stream.read(&mut buffer)?;
1800
+ let request = String::from_utf8_lossy(&buffer[..size]);
1801
+ let mut parts = request
1802
+ .lines()
1803
+ .next()
1804
+ .unwrap_or_default()
1805
+ .split_whitespace();
1806
+ let method = parts.next().unwrap_or_default();
1807
+ let path = parts.next().unwrap_or("/");
1808
+ let origin = request
1809
+ .lines()
1810
+ .find_map(|line| line.strip_prefix("Origin: "))
1811
+ .unwrap_or("*");
1812
+ let cors_origin = allowed_origin(origin);
1813
+
1814
+ if method == "OPTIONS" {
1815
+ return write_response(&mut stream, "204 No Content", "", &cors_origin);
1816
+ }
1817
+
1818
+ let body = if path == "/healthz" {
1819
+ r#"{"ok":true}"#
1820
+ } else {
1821
+ r#"{"message":"hello from zerct","backend":"rust"}"#
1822
+ };
1823
+ write_response(&mut stream, "200 OK", body, &cors_origin)
1824
+ }
1825
+
1826
+ fn allowed_origin(request_origin: &str) -> String {
1827
+ let configured = std::env::var("FRONTEND_ORIGIN").unwrap_or_else(|_error| request_origin.to_owned());
1828
+ if configured == "*" || configured == request_origin {
1829
+ configured
1830
+ } else {
1831
+ "null".to_owned()
1832
+ }
1833
+ }
1834
+
1835
+ fn write_response(
1836
+ stream: &mut TcpStream,
1837
+ status: &str,
1838
+ body: &str,
1839
+ origin: &str,
1840
+ ) -> std::io::Result<()> {
1841
+ write!(
1842
+ stream,
1843
+ "HTTP/1.1 {status}\\r\\ncontent-type: application/json\\r\\ncontent-length: {}\\r\\naccess-control-allow-origin: {origin}\\r\\naccess-control-allow-methods: GET, OPTIONS\\r\\naccess-control-allow-headers: content-type, authorization\\r\\nconnection: close\\r\\n\\r\\n{body}",
1844
+ body.len()
1845
+ )
1846
+ }
1847
+ `
1848
+ }
1849
+
1850
+ function frontendSource(apiBaseUrl) {
1851
+ return `import { createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router'
1852
+ import { createRoot } from 'react-dom/client'
1853
+ import './styles.css'
1854
+
1855
+ const apiBaseUrl = import.meta.env.VITE_API_URL ?? '${apiBaseUrl}'
1856
+
1857
+ function App() {
1858
+ return (
1859
+ <main>
1860
+ <section>
1861
+ <h1>Zerct TanStack Frontend</h1>
1862
+ <p>Static runtime, dynamic Rust backend calls.</p>
1863
+ <code>{apiBaseUrl}</code>
1864
+ </section>
1865
+ </main>
1866
+ )
1867
+ }
1868
+
1869
+ const rootRoute = createRootRoute({ component: App })
1870
+ const router = createRouter({ routeTree: rootRoute })
1871
+
1872
+ declare module '@tanstack/react-router' {
1873
+ interface Register {
1874
+ router: typeof router
1875
+ }
1876
+ }
1877
+
1878
+ createRoot(document.getElementById('root')!).render(<RouterProvider router={router} />)
1879
+ `
1880
+ }
1881
+
1503
1882
  function printJsonOrPretty(cli, value) {
1504
1883
  console.log(JSON.stringify(value, null, cli.json ? 2 : 2))
1505
1884
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerct/zerct",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Deploy Rust backends and static frontends to Zerct.",
5
5
  "type": "module",
6
6
  "bin": {