@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.
- package/README.md +10 -2
- package/bin/zerct.js +392 -13
- 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
|
-
|
|
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.
|
|
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
|
|
278
|
-
const source =
|
|
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
|
-
|
|
295
|
-
|
|
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 (!
|
|
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)
|
|
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
|
}
|