tova 0.7.0 → 0.8.2

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 (38) hide show
  1. package/bin/tova.js +192 -10
  2. package/package.json +2 -7
  3. package/src/analyzer/analyzer.js +134 -2
  4. package/src/analyzer/deploy-analyzer.js +44 -0
  5. package/src/codegen/base-codegen.js +1159 -10
  6. package/src/codegen/codegen.js +20 -0
  7. package/src/codegen/deploy-codegen.js +49 -0
  8. package/src/codegen/shared-codegen.js +5 -0
  9. package/src/codegen/wasm-codegen.js +6 -0
  10. package/src/config/edit-toml.js +6 -2
  11. package/src/config/git-resolver.js +128 -0
  12. package/src/config/lock-file.js +57 -0
  13. package/src/config/module-cache.js +58 -0
  14. package/src/config/module-entry.js +37 -0
  15. package/src/config/module-path.js +31 -0
  16. package/src/config/pkg-errors.js +62 -0
  17. package/src/config/resolve.js +17 -0
  18. package/src/config/resolver.js +139 -0
  19. package/src/config/search.js +28 -0
  20. package/src/config/semver.js +72 -0
  21. package/src/config/toml.js +48 -5
  22. package/src/deploy/deploy.js +217 -0
  23. package/src/deploy/infer.js +218 -0
  24. package/src/deploy/provision.js +311 -0
  25. package/src/lsp/server.js +482 -0
  26. package/src/parser/ast.js +24 -0
  27. package/src/parser/concurrency-ast.js +15 -0
  28. package/src/parser/concurrency-parser.js +236 -0
  29. package/src/parser/deploy-ast.js +37 -0
  30. package/src/parser/deploy-parser.js +132 -0
  31. package/src/parser/parser.js +21 -3
  32. package/src/parser/select-ast.js +39 -0
  33. package/src/registry/plugins/concurrency-plugin.js +32 -0
  34. package/src/registry/plugins/deploy-plugin.js +33 -0
  35. package/src/registry/register-all.js +4 -0
  36. package/src/stdlib/inline.js +35 -3
  37. package/src/stdlib/runtime-bridge.js +152 -0
  38. package/src/version.js +1 -1
@@ -0,0 +1,218 @@
1
+ // Infrastructure Inference Engine for the Tova language
2
+ // Walks the entire program AST and produces a complete infrastructure manifest
3
+ // by combining explicit deploy block config with inferred requirements from
4
+ // server, browser, and security blocks.
5
+
6
+ import { DeployCodegen } from '../codegen/deploy-codegen.js';
7
+
8
+ const MANIFEST_DEFAULTS = {
9
+ name: null,
10
+ server: null,
11
+ domain: null,
12
+ instances: 1,
13
+ memory: '512mb',
14
+ branch: 'main',
15
+ health: '/healthz',
16
+ health_interval: 30,
17
+ health_timeout: 5,
18
+ restart_on_failure: true,
19
+ keep_releases: 5,
20
+ env: {},
21
+ databases: [],
22
+ requires: { bun: false, caddy: false, ufw: false },
23
+ hasWebSocket: false,
24
+ hasSSE: false,
25
+ hasBrowser: false,
26
+ requiredSecrets: [],
27
+ blockTypes: [],
28
+ };
29
+
30
+ /**
31
+ * Walk an AST node tree recursively and invoke a visitor callback on each node.
32
+ */
33
+ function walkNode(node, visitor) {
34
+ if (!node || typeof node !== 'object') return;
35
+ if (node.type) visitor(node);
36
+
37
+ // Walk arrays (e.g., body, entries, arguments)
38
+ for (const key of Object.keys(node)) {
39
+ const val = node[key];
40
+ if (Array.isArray(val)) {
41
+ for (const item of val) {
42
+ walkNode(item, visitor);
43
+ }
44
+ } else if (val && typeof val === 'object' && val.type) {
45
+ walkNode(val, visitor);
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Collect all env() call arguments from a node tree.
52
+ * env("JWT_SECRET") => CallExpression with callee.name === "env" and args[0].value
53
+ */
54
+ function collectEnvCalls(node) {
55
+ const secrets = [];
56
+ walkNode(node, (n) => {
57
+ if (
58
+ n.type === 'CallExpression' &&
59
+ n.callee &&
60
+ n.callee.type === 'Identifier' &&
61
+ n.callee.name === 'env' &&
62
+ n.arguments &&
63
+ n.arguments.length > 0
64
+ ) {
65
+ const arg = n.arguments[0];
66
+ if (arg && (arg.value !== undefined)) {
67
+ secrets.push(typeof arg.value === 'string' ? arg.value : String(arg.value));
68
+ }
69
+ }
70
+ });
71
+ return secrets;
72
+ }
73
+
74
+ /**
75
+ * Infer infrastructure requirements from the full program AST.
76
+ *
77
+ * @param {Object} ast - Program AST with ast.body array of top-level blocks
78
+ * @returns {Object} Complete infrastructure manifest
79
+ */
80
+ export function inferInfrastructure(ast) {
81
+ const manifest = JSON.parse(JSON.stringify(MANIFEST_DEFAULTS));
82
+ const blockTypes = new Set();
83
+ const deployBlocks = [];
84
+ const inferredDatabases = [];
85
+ const secretsSet = new Set();
86
+
87
+ if (!ast || !ast.body) return manifest;
88
+
89
+ for (const node of ast.body) {
90
+ switch (node.type) {
91
+ case 'DeployBlock': {
92
+ blockTypes.add('deploy');
93
+ deployBlocks.push(node);
94
+ break;
95
+ }
96
+
97
+ case 'ServerBlock': {
98
+ blockTypes.add('server');
99
+ manifest.requires.bun = true;
100
+ manifest.requires.caddy = true;
101
+ manifest.requires.ufw = true;
102
+
103
+ // Scan server block body for specific declarations
104
+ if (node.body && Array.isArray(node.body)) {
105
+ for (const stmt of node.body) {
106
+ if (stmt.type === 'DbDeclaration') {
107
+ // Server-block db is always SQLite (bun:sqlite)
108
+ const dbConfig = {};
109
+ if (stmt.config && typeof stmt.config === 'object') {
110
+ for (const [k, v] of Object.entries(stmt.config)) {
111
+ dbConfig[k] = v && v.value !== undefined ? v.value : v;
112
+ }
113
+ }
114
+ inferredDatabases.push({ engine: 'sqlite', config: dbConfig });
115
+ }
116
+ if (stmt.type === 'WebSocketDeclaration') {
117
+ manifest.hasWebSocket = true;
118
+ }
119
+ if (stmt.type === 'SseDeclaration') {
120
+ manifest.hasSSE = true;
121
+ }
122
+ // Also check inside route groups
123
+ if (stmt.type === 'RouteGroupDeclaration' && stmt.body) {
124
+ for (const inner of stmt.body) {
125
+ if (inner.type === 'WebSocketDeclaration') {
126
+ manifest.hasWebSocket = true;
127
+ }
128
+ if (inner.type === 'SseDeclaration') {
129
+ manifest.hasSSE = true;
130
+ }
131
+ if (inner.type === 'DbDeclaration') {
132
+ const dbConfig = {};
133
+ if (inner.config && typeof inner.config === 'object') {
134
+ for (const [k, v] of Object.entries(inner.config)) {
135
+ dbConfig[k] = v && v.value !== undefined ? v.value : v;
136
+ }
137
+ }
138
+ inferredDatabases.push({ engine: 'sqlite', config: dbConfig });
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ break;
145
+ }
146
+
147
+ case 'BrowserBlock': {
148
+ blockTypes.add('browser');
149
+ manifest.hasBrowser = true;
150
+ break;
151
+ }
152
+
153
+ case 'SecurityBlock': {
154
+ blockTypes.add('security');
155
+ // Scan for env() calls to find required secrets
156
+ if (node.body && Array.isArray(node.body)) {
157
+ for (const stmt of node.body) {
158
+ if (stmt.type === 'SecurityAuthDeclaration') {
159
+ // Walk the config looking for env() calls
160
+ if (stmt.config && typeof stmt.config === 'object') {
161
+ for (const [, value] of Object.entries(stmt.config)) {
162
+ const secrets = collectEnvCalls(value);
163
+ for (const s of secrets) secretsSet.add(s);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+ break;
170
+ }
171
+ }
172
+ }
173
+
174
+ // Merge explicit deploy config via DeployCodegen
175
+ if (deployBlocks.length > 0) {
176
+ const deployConfig = DeployCodegen.mergeDeployBlocks(deployBlocks);
177
+ // Apply deploy config fields to manifest
178
+ if (deployConfig.name) manifest.name = deployConfig.name;
179
+ if (deployConfig.server) manifest.server = deployConfig.server;
180
+ if (deployConfig.domain) manifest.domain = deployConfig.domain;
181
+ if (deployConfig.instances !== undefined) manifest.instances = deployConfig.instances;
182
+ if (deployConfig.memory) manifest.memory = deployConfig.memory;
183
+ if (deployConfig.branch) manifest.branch = deployConfig.branch;
184
+ if (deployConfig.health) manifest.health = deployConfig.health;
185
+ if (deployConfig.health_interval !== undefined) manifest.health_interval = deployConfig.health_interval;
186
+ if (deployConfig.health_timeout !== undefined) manifest.health_timeout = deployConfig.health_timeout;
187
+ if (deployConfig.restart_on_failure !== undefined) manifest.restart_on_failure = deployConfig.restart_on_failure;
188
+ if (deployConfig.keep_releases !== undefined) manifest.keep_releases = deployConfig.keep_releases;
189
+ if (deployConfig.env && Object.keys(deployConfig.env).length > 0) {
190
+ manifest.env = { ...manifest.env, ...deployConfig.env };
191
+ }
192
+ // Declared databases from deploy block
193
+ if (deployConfig.databases && deployConfig.databases.length > 0) {
194
+ manifest.databases = [...deployConfig.databases];
195
+ }
196
+ }
197
+
198
+ // Merge inferred databases with declared ones (avoid duplicates by engine name)
199
+ const declaredEngines = new Set(manifest.databases.map(d => d.engine));
200
+ for (const inferred of inferredDatabases) {
201
+ if (!declaredEngines.has(inferred.engine)) {
202
+ manifest.databases.push(inferred);
203
+ declaredEngines.add(inferred.engine);
204
+ }
205
+ }
206
+
207
+ // If server block is present, ensure bun/caddy/ufw are required
208
+ if (blockTypes.has('server')) {
209
+ manifest.requires.bun = true;
210
+ manifest.requires.caddy = true;
211
+ manifest.requires.ufw = true;
212
+ }
213
+
214
+ manifest.requiredSecrets = [...secretsSet].sort();
215
+ manifest.blockTypes = [...blockTypes].sort();
216
+
217
+ return manifest;
218
+ }
@@ -0,0 +1,311 @@
1
+ // Provisioning Script Generator for the Tova language
2
+ // Generates idempotent bash scripts from an infrastructure manifest.
3
+
4
+ /**
5
+ * Generate a complete provisioning shell script from an infrastructure manifest.
6
+ *
7
+ * The script is idempotent — it checks before installing (command -v, dpkg, etc.)
8
+ * and is organized in layers:
9
+ * Layer 1: System (Bun, Caddy, UFW, tova user)
10
+ * Layer 2: Databases (PostgreSQL, Redis — conditional)
11
+ * Layer 3: App directories
12
+ * Layer 5: Caddy config
13
+ * Layer 6: systemd services
14
+ *
15
+ * @param {Object} manifest - Infrastructure manifest from inferInfrastructure()
16
+ * @returns {string} Complete provisioning bash script
17
+ */
18
+ export function generateProvisionScript(manifest) {
19
+ const appName = manifest.name || 'tova-app';
20
+ const lines = [];
21
+
22
+ lines.push('#!/bin/bash');
23
+ lines.push('set -euo pipefail');
24
+ lines.push('');
25
+ lines.push(`# Provisioning script for ${appName}`);
26
+ lines.push(`# Generated by Tova deploy — idempotent, safe to re-run`);
27
+ lines.push('');
28
+
29
+ // ── Layer 1: System ────────────────────────────────────────
30
+ lines.push('# ═══════════════════════════════════════════════════════════');
31
+ lines.push('# Layer 1: System dependencies');
32
+ lines.push('# ═══════════════════════════════════════════════════════════');
33
+ lines.push('');
34
+
35
+ if (manifest.requires && manifest.requires.bun) {
36
+ lines.push('# Install Bun runtime');
37
+ lines.push('if ! command -v bun &>/dev/null; then');
38
+ lines.push(' echo "Installing Bun..."');
39
+ lines.push(' curl -fsSL https://bun.sh/install | bash');
40
+ lines.push(' export PATH="$HOME/.bun/bin:$PATH"');
41
+ lines.push('fi');
42
+ lines.push('');
43
+ }
44
+
45
+ if (manifest.requires && manifest.requires.caddy) {
46
+ lines.push('# Install Caddy web server');
47
+ lines.push('if ! command -v caddy &>/dev/null; then');
48
+ lines.push(' echo "Installing Caddy..."');
49
+ lines.push(' apt-get update -qq');
50
+ lines.push(' apt-get install -y -qq debian-keyring debian-archive-keyring apt-transport-https curl');
51
+ lines.push(' curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/gpg.key" | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg');
52
+ lines.push(' curl -1sLf "https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt" | tee /etc/apt/sources.list.d/caddy-stable.list');
53
+ lines.push(' apt-get update -qq');
54
+ lines.push(' apt-get install -y -qq caddy');
55
+ lines.push('fi');
56
+ lines.push('');
57
+ }
58
+
59
+ if (manifest.requires && manifest.requires.ufw) {
60
+ lines.push('# Configure UFW firewall');
61
+ lines.push('if command -v ufw &>/dev/null; then');
62
+ lines.push(' ufw allow 22/tcp');
63
+ lines.push(' ufw allow 80/tcp');
64
+ lines.push(' ufw allow 443/tcp');
65
+ lines.push(' echo "y" | ufw enable || true');
66
+ lines.push('fi');
67
+ lines.push('');
68
+ }
69
+
70
+ // Create tova system user
71
+ lines.push('# Create tova system user');
72
+ lines.push('if ! id "tova" &>/dev/null; then');
73
+ lines.push(' useradd --system --create-home --shell /bin/bash tova');
74
+ lines.push('fi');
75
+ lines.push('');
76
+
77
+ // ── Layer 2: Databases ─────────────────────────────────────
78
+ const databases = manifest.databases || [];
79
+ const hasPostgres = databases.some(d => d.engine === 'postgres');
80
+ const hasRedis = databases.some(d => d.engine === 'redis');
81
+
82
+ if (hasPostgres || hasRedis) {
83
+ lines.push('# ═══════════════════════════════════════════════════════════');
84
+ lines.push('# Layer 2: Databases');
85
+ lines.push('# ═══════════════════════════════════════════════════════════');
86
+ lines.push('');
87
+ }
88
+
89
+ if (hasPostgres) {
90
+ const pgDb = databases.find(d => d.engine === 'postgres');
91
+ const dbName = (pgDb && pgDb.config && pgDb.config.name) || `${appName}_db`;
92
+ lines.push('# Install PostgreSQL');
93
+ lines.push('if ! command -v psql &>/dev/null; then');
94
+ lines.push(' echo "Installing PostgreSQL..."');
95
+ lines.push(' apt-get update -qq');
96
+ lines.push(' apt-get install -y -qq postgresql postgresql-contrib');
97
+ lines.push(' systemctl enable postgresql');
98
+ lines.push(' systemctl start postgresql');
99
+ lines.push('fi');
100
+ lines.push('');
101
+ lines.push(`# Create database: ${dbName}`);
102
+ lines.push(`sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = '${dbName}'" | grep -q 1 || sudo -u postgres createdb "${dbName}"`);
103
+ lines.push('');
104
+ }
105
+
106
+ if (hasRedis) {
107
+ lines.push('# Install Redis');
108
+ lines.push('if ! command -v redis-server &>/dev/null; then');
109
+ lines.push(' echo "Installing Redis..."');
110
+ lines.push(' apt-get update -qq');
111
+ lines.push(' apt-get install -y -qq redis-server');
112
+ lines.push(' systemctl enable redis-server');
113
+ lines.push(' systemctl start redis-server');
114
+ lines.push('fi');
115
+ lines.push('');
116
+ }
117
+
118
+ // ── Layer 3: App directories ───────────────────────────────
119
+ lines.push('# ═══════════════════════════════════════════════════════════');
120
+ lines.push('# Layer 3: App directories');
121
+ lines.push('# ═══════════════════════════════════════════════════════════');
122
+ lines.push('');
123
+ lines.push('mkdir -p /opt/tova/apps');
124
+ lines.push(`APP_DIR="/opt/tova/apps/${appName}"`);
125
+ lines.push('mkdir -p "$APP_DIR/releases"');
126
+ lines.push('mkdir -p "$APP_DIR/shared/logs"');
127
+ lines.push('mkdir -p "$APP_DIR/shared/data"');
128
+ lines.push('chown -R tova:tova /opt/tova');
129
+ lines.push('');
130
+
131
+ // ── Layer 5: Caddy config ──────────────────────────────────
132
+ if (manifest.requires && manifest.requires.caddy && manifest.domain) {
133
+ lines.push('# ═══════════════════════════════════════════════════════════');
134
+ lines.push('# Layer 5: Caddy config');
135
+ lines.push('# ═══════════════════════════════════════════════════════════');
136
+ lines.push('');
137
+ const caddyConfig = generateCaddyConfig(appName, {
138
+ domain: manifest.domain,
139
+ instances: manifest.instances || 1,
140
+ health: manifest.health,
141
+ hasWebSocket: manifest.hasWebSocket,
142
+ });
143
+ lines.push(`cat > /etc/caddy/Caddyfile <<'CADDY_EOF'`);
144
+ lines.push(caddyConfig);
145
+ lines.push('CADDY_EOF');
146
+ lines.push('');
147
+ lines.push('systemctl reload caddy || systemctl restart caddy');
148
+ lines.push('');
149
+ }
150
+
151
+ // ── Layer 6: systemd services ──────────────────────────────
152
+ lines.push('# ═══════════════════════════════════════════════════════════');
153
+ lines.push('# Layer 6: systemd services');
154
+ lines.push('# ═══════════════════════════════════════════════════════════');
155
+ lines.push('');
156
+ const serviceUnit = generateSystemdService(appName, {
157
+ memory: manifest.memory || '512mb',
158
+ restart_on_failure: manifest.restart_on_failure !== false,
159
+ env: manifest.env || {},
160
+ });
161
+ lines.push(`cat > /etc/systemd/system/${appName}@.service <<'SYSTEMD_EOF'`);
162
+ lines.push(serviceUnit);
163
+ lines.push('SYSTEMD_EOF');
164
+ lines.push('');
165
+ lines.push('systemctl daemon-reload');
166
+
167
+ // Enable and start instances
168
+ const instances = manifest.instances || 1;
169
+ for (let i = 0; i < instances; i++) {
170
+ const port = 3000 + i;
171
+ lines.push(`systemctl enable ${appName}@${port}`);
172
+ }
173
+ lines.push('');
174
+
175
+ lines.push(`echo "Provisioning complete for ${appName}"`);
176
+ return lines.join('\n');
177
+ }
178
+
179
+ /**
180
+ * Parse a memory string like "512mb", "1gb" into bytes for systemd MemoryMax.
181
+ */
182
+ function parseMemory(mem) {
183
+ if (typeof mem === 'number') return mem;
184
+ const str = String(mem).toLowerCase().trim();
185
+ const match = str.match(/^(\d+(?:\.\d+)?)\s*(mb|gb|m|g|kb|k)?$/);
186
+ if (!match) return str;
187
+ const num = parseFloat(match[1]);
188
+ const unit = match[2] || 'mb';
189
+ switch (unit) {
190
+ case 'kb': case 'k': return `${Math.round(num)}K`;
191
+ case 'mb': case 'm': return `${Math.round(num)}M`;
192
+ case 'gb': case 'g': return `${Math.round(num * 1024)}M`;
193
+ default: return str;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Generate a systemd unit template for the application.
199
+ *
200
+ * Uses %i for the instance port (template unit: appName@.service).
201
+ *
202
+ * @param {string} appName - Application name
203
+ * @param {Object} config - { memory, restart_on_failure, env }
204
+ * @returns {string} systemd unit file content
205
+ */
206
+ export function generateSystemdService(appName, config = {}) {
207
+ const memLimit = parseMemory(config.memory || '512mb');
208
+ const restart = config.restart_on_failure !== false ? 'on-failure' : 'no';
209
+ const env = config.env || {};
210
+
211
+ const lines = [];
212
+ lines.push('[Unit]');
213
+ lines.push(`Description=${appName} instance on port %i`);
214
+ lines.push('After=network.target');
215
+ lines.push('');
216
+ lines.push('[Service]');
217
+ lines.push('Type=simple');
218
+ lines.push('User=tova');
219
+ lines.push('Group=tova');
220
+ lines.push(`WorkingDirectory=/opt/tova/apps/${appName}/current`);
221
+ lines.push(`ExecStart=/home/tova/.bun/bin/bun run server.js --port %i`);
222
+ lines.push(`Restart=${restart}`);
223
+ lines.push('RestartSec=5');
224
+ lines.push(`MemoryMax=${memLimit}`);
225
+ lines.push('');
226
+
227
+ // Environment — load secrets from .env.production, then set inline defaults
228
+ lines.push(`EnvironmentFile=-/opt/tova/apps/${appName}/.env.production`);
229
+ lines.push('Environment=NODE_ENV=production');
230
+ lines.push('Environment=PORT=%i');
231
+ for (const [key, value] of Object.entries(env)) {
232
+ if (key !== 'NODE_ENV' && key !== 'PORT') {
233
+ lines.push(`Environment=${key}=${value}`);
234
+ }
235
+ }
236
+ lines.push('');
237
+
238
+ // Logging
239
+ lines.push('StandardOutput=journal');
240
+ lines.push('StandardError=journal');
241
+ lines.push(`SyslogIdentifier=${appName}-%i`);
242
+ lines.push('');
243
+ lines.push('[Install]');
244
+ lines.push('WantedBy=multi-user.target');
245
+
246
+ return lines.join('\n');
247
+ }
248
+
249
+ /**
250
+ * Generate a Caddy site block configuration.
251
+ *
252
+ * @param {string} appName - Application name
253
+ * @param {Object} opts - { domain, instances, health, hasWebSocket }
254
+ * @returns {string} Caddy config block
255
+ */
256
+ export function generateCaddyConfig(appName, opts = {}) {
257
+ const domain = opts.domain || 'localhost';
258
+ const instances = opts.instances || 1;
259
+ const health = opts.health || '/healthz';
260
+ const hasWebSocket = opts.hasWebSocket || false;
261
+
262
+ const lines = [];
263
+ lines.push(`${domain} {`);
264
+
265
+ // Upstream / reverse proxy
266
+ if (instances === 1) {
267
+ lines.push(' reverse_proxy localhost:3000 {');
268
+ } else {
269
+ // Multiple instances with round-robin load balancing
270
+ const upstreams = [];
271
+ for (let i = 0; i < instances; i++) {
272
+ upstreams.push(`localhost:${3000 + i}`);
273
+ }
274
+ lines.push(` reverse_proxy ${upstreams.join(' ')} {`);
275
+ lines.push(' lb_policy round_robin');
276
+ }
277
+
278
+ // Health check
279
+ lines.push(` health_uri ${health}`);
280
+ lines.push(' health_interval 30s');
281
+ lines.push(' health_timeout 5s');
282
+
283
+ lines.push(' }');
284
+
285
+ // WebSocket support
286
+ if (hasWebSocket) {
287
+ lines.push('');
288
+ lines.push(' @websocket {');
289
+ lines.push(' header Connection *Upgrade*');
290
+ lines.push(' header Upgrade websocket');
291
+ lines.push(' }');
292
+ if (instances === 1) {
293
+ lines.push(' reverse_proxy @websocket localhost:3000');
294
+ } else {
295
+ const upstreams = [];
296
+ for (let i = 0; i < instances; i++) {
297
+ upstreams.push(`localhost:${3000 + i}`);
298
+ }
299
+ lines.push(` reverse_proxy @websocket ${upstreams.join(' ')}`);
300
+ }
301
+ }
302
+
303
+ // Logging
304
+ lines.push('');
305
+ lines.push(' log {');
306
+ lines.push(` output file /var/log/caddy/${appName}.log`);
307
+ lines.push(' }');
308
+
309
+ lines.push('}');
310
+ return lines.join('\n');
311
+ }