vyft 0.1.0-alpha → 0.2.0-alpha

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 CHANGED
@@ -1,189 +1,56 @@
1
1
  # Vyft
2
2
 
3
- Deploy apps with TypeScript.
3
+ Deploy apps with TypeScript
4
4
 
5
- ## Install
5
+ ## Getting Started
6
6
 
7
7
  ```bash
8
- npm install vyft
8
+ npx vyft init
9
9
  ```
10
10
 
11
- ## Quick Start
11
+ This scaffolds a fullstack project with a Hono API, React SPA, and Postgres — all wired up and ready to deploy.
12
12
 
13
- Create `vyft.config.ts`:
13
+ ## Example
14
14
 
15
15
  ```typescript
16
+ // vyft.config.ts
16
17
  import { interpolate } from 'vyft';
17
18
  import { swarm } from 'vyft/swarm';
18
19
 
19
- const { secret, volume, service, site } = swarm();
20
+ const { service, secret, volume, site } = swarm();
20
21
 
21
- export const dbPassword = secret('db-password');
22
- export const dbData = volume('db-data', { size: '20GB' });
22
+ export const dbPassword = secret('db-password', { length: 32 });
23
+ export const dbData = volume('db-data', { size: '10GB' });
23
24
 
24
25
  export const db = service('db', {
25
- image: 'postgres:16',
26
+ image: 'postgres:17',
27
+ volumes: [{ volume: dbData, mount: '/var/lib/postgresql/data' }],
26
28
  env: {
27
29
  POSTGRES_PASSWORD: dbPassword,
28
- POSTGRES_DB: 'myapp'
29
- },
30
- volumes: [
31
- { volume: dbData, mount: '/var/lib/postgresql/data' }
32
- ]
30
+ POSTGRES_DB: 'myapp',
31
+ }
33
32
  });
34
33
 
35
34
  export const api = service('api', {
36
- route: 'api.example.com',
37
- image: { context: './api' },
35
+ route: 'example.com/api/*',
36
+ image: { dockerfile: './apps/api/Dockerfile' },
38
37
  env: {
39
- DATABASE_URL: interpolate`postgres://postgres:${dbPassword}@${db.host}:5432/myapp`
38
+ DATABASE_URL: interpolate`postgres://postgres:${dbPassword}@${db.host}:5432/myapp`,
40
39
  }
41
40
  });
42
41
 
43
- export const app = site('app', {
42
+ export const web = site('web', {
44
43
  route: 'example.com',
45
44
  spa: true,
46
- build: { context: './frontend' }
45
+ build: { cwd: './apps/web' }
47
46
  });
48
47
  ```
49
48
 
50
- Deploy:
51
-
52
49
  ```bash
53
50
  vyft deploy
54
51
  ```
55
52
 
56
- Tear down:
57
-
58
- ```bash
59
- vyft destroy
60
- ```
61
-
62
- ## Runtime
63
-
64
- Resources are created through a runtime. The `swarm` runtime manages containers, networking, and routing.
65
-
66
- ```typescript
67
- import { swarm } from 'vyft/swarm';
68
-
69
- const { secret, volume, service, site } = swarm();
70
- ```
71
-
72
- Volumes support size limits (requires xfs with project quotas on the host):
73
-
74
- ```typescript
75
- const { volume } = swarm();
76
-
77
- export const data = volume('data', { size: '50GB' });
78
- ```
79
-
80
- ## Primitives
81
-
82
- ### volume
83
-
84
- Persistent storage.
85
-
86
- ```typescript
87
- volume('data');
88
- ```
89
-
90
- ### secret
91
-
92
- Auto-generated secure values. Mounted as files at `/run/secrets/<id>`.
93
-
94
- ```typescript
95
- const apiKey = secret('api-key');
96
- const jwtSecret = secret('jwt-secret', { length: 64 });
97
- ```
98
-
99
- Pass a secret directly as an env value to mount it. The `_FILE` suffix is added automatically:
100
-
101
- ```typescript
102
- env: {
103
- API_KEY: apiKey
104
- }
105
- // → sets API_KEY_FILE=/run/secrets/api-key
106
- ```
107
-
108
- ### interpolate
109
-
110
- Compose env values that contain secrets. Imported from `vyft`.
111
-
112
- ```typescript
113
- import { interpolate } from 'vyft';
114
-
115
- env: {
116
- DATABASE_URL: interpolate`postgres://user:${dbPassword}@${db.host}:5432/mydb`
117
- }
118
- // → sets DATABASE_URL_FILE=/run/secrets/<derived-secret>
119
- ```
120
-
121
- The result is stored as a derived secret and mounted as a file.
122
-
123
- ### service
124
-
125
- Run containers.
126
-
127
- ```typescript
128
- const api = service('api', {
129
- image: { context: './api' }, // Build from directory
130
- // or: image: 'node:20', // Use existing image
131
- route: 'api.example.com',
132
- port: 3000,
133
- env: { NODE_ENV: 'production' },
134
- command: ['node', 'server.js'],
135
- volumes: [{ volume: data, mount: '/app/data' }],
136
- replicas: 3,
137
- healthCheck: {
138
- command: ['curl', '-f', 'http://localhost:3000/health'],
139
- interval: '30s',
140
- timeout: '10s',
141
- retries: 3
142
- },
143
- resources: {
144
- memory: '512MB',
145
- cpus: 0.5
146
- },
147
- restartPolicy: 'on-failure'
148
- });
149
- ```
150
-
151
- Services expose output properties for referencing elsewhere:
152
-
153
- ```typescript
154
- api.host // internal hostname
155
- api.port // internal port (default 3000)
156
- api.url // full URL (https if routed, http otherwise)
157
- ```
158
-
159
- ### site
160
-
161
- Serve static sites with automatic builds.
162
-
163
- ```typescript
164
- const app = site('app', {
165
- route: 'example.com',
166
- spa: true,
167
- build: {
168
- context: './frontend',
169
- output: './dist',
170
- command: 'npm run build'
171
- }
172
- });
173
-
174
- app.url // https://example.com
175
- ```
176
-
177
- ## Routing
178
-
179
- Routes go through a reverse proxy with automatic SSL.
180
-
181
- ```typescript
182
- 'example.com' // Root domain
183
- 'example.com/api/*' // Path prefix
184
- 'api.example.com' // Subdomain
185
- '*.example.com' // Wildcard subdomain
186
- ```
53
+ Secrets are auto-generated, images are built, static sites are compiled, and everything is routed with automatic SSL.
187
54
 
188
55
  ## Requirements
189
56
 
package/dist/cli.js CHANGED
@@ -46,6 +46,27 @@ function isResource(value) {
46
46
  typeof value.type === "string" &&
47
47
  typeof value.id === "string");
48
48
  }
49
+ function collectResources(exports) {
50
+ const seen = new Set();
51
+ const resources = [];
52
+ for (const value of Object.values(exports)) {
53
+ if (isResource(value)) {
54
+ if (!seen.has(value.id)) {
55
+ seen.add(value.id);
56
+ resources.push(value);
57
+ }
58
+ }
59
+ else if (typeof value === "object" && value !== null) {
60
+ for (const nested of Object.values(value)) {
61
+ if (isResource(nested) && !seen.has(nested.id)) {
62
+ seen.add(nested.id);
63
+ resources.push(nested);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return resources;
69
+ }
49
70
  function hasRuntime(value) {
50
71
  return (typeof value === "object" &&
51
72
  value !== null &&
@@ -69,9 +90,10 @@ async function deploy(configFile, verbose) {
69
90
  const sessionLog = logger.child({ sessionId, command: "deploy" });
70
91
  const start = performance.now();
71
92
  const project = await findProjectName(configFile);
93
+ process.env.VYFT_PROJECT = project;
72
94
  intro(`Deploying ${project}`);
73
95
  const config = await import(configFile);
74
- const resources = Object.values(config).filter(isResource);
96
+ const resources = collectResources(config);
75
97
  if (resources.length === 0) {
76
98
  log.warn("No resources found");
77
99
  outro();
@@ -87,7 +109,7 @@ async function deploy(configFile, verbose) {
87
109
  let skipped = 0;
88
110
  for (const resource of resources) {
89
111
  const exists = await docker.exists(resource);
90
- if (exists) {
112
+ if (exists && resource.type !== "service" && resource.type !== "site") {
91
113
  log.success(resource.id);
92
114
  skipped++;
93
115
  }
@@ -142,6 +164,7 @@ async function destroy(searchDir) {
142
164
  removed++;
143
165
  }
144
166
  }
167
+ await docker.removeProjectNetwork();
145
168
  const durationMs = Math.round(performance.now() - start);
146
169
  sessionLog.info({ project, removed, durationMs }, "destroy completed");
147
170
  outro("Destroy complete");
@@ -181,6 +204,20 @@ program
181
204
  throw err;
182
205
  }
183
206
  });
207
+ const proxy = program.command("proxy").description("Manage the proxy");
208
+ proxy
209
+ .command("logs")
210
+ .description("View proxy request logs")
211
+ .option("-f, --follow", "stream logs continuously", false)
212
+ .option("--tail <n>", "number of lines to show", "100")
213
+ .action(async (opts) => {
214
+ const project = await findProjectName(path.join(process.cwd(), "package.json"));
215
+ const docker = new DockerClient(project);
216
+ await docker.proxyLogs({
217
+ follow: opts.follow,
218
+ tail: parseInt(opts.tail, 10),
219
+ });
220
+ });
184
221
  program
185
222
  .command("destroy")
186
223
  .description("Destroy all deployed resources")
package/dist/docker.d.ts CHANGED
@@ -5,7 +5,7 @@ export declare function parseRoute(route: string): {
5
5
  host: string;
6
6
  path?: string;
7
7
  };
8
- export declare function buildCaddyRoute(project: string, resourceId: string, route: string, handler: Record<string, unknown>): Record<string, unknown>;
8
+ export declare function buildCaddyRoute(resourceId: string, route: string, handler: Record<string, unknown>): Record<string, unknown>;
9
9
  export interface ManagedResource {
10
10
  id: string;
11
11
  type: ResourceType;
@@ -15,11 +15,14 @@ export interface ManagedResource {
15
15
  }
16
16
  export declare class DockerClient implements Runtime {
17
17
  private docker;
18
+ private localDocker;
19
+ private isRemote;
18
20
  private project;
19
21
  private secretValues;
20
22
  private log;
21
23
  verbose: boolean;
22
24
  constructor(project: string, parentLogger?: Logger);
25
+ private ensureNetwork;
23
26
  ensureInfrastructure(): Promise<void>;
24
27
  private findProxyContainer;
25
28
  private caddyApiRequest;
@@ -43,6 +46,12 @@ export declare class DockerClient implements Runtime {
43
46
  private createService;
44
47
  private serviceExists;
45
48
  private removeService;
49
+ private pullImage;
46
50
  private createStatic;
47
51
  private removeStatic;
52
+ proxyLogs(options: {
53
+ follow?: boolean;
54
+ tail?: number;
55
+ }): Promise<void>;
56
+ removeProjectNetwork(): Promise<void>;
48
57
  }
package/dist/docker.js CHANGED
@@ -67,13 +67,13 @@ export function parseRoute(route) {
67
67
  path: route.slice(slashIndex),
68
68
  };
69
69
  }
70
- export function buildCaddyRoute(project, resourceId, route, handler) {
70
+ export function buildCaddyRoute(resourceId, route, handler) {
71
71
  const { host, path } = parseRoute(route);
72
72
  const match = { host: [host] };
73
73
  if (path)
74
74
  match.path = [path];
75
75
  return {
76
- "@id": `vyft-${project}-${resourceId}`,
76
+ "@id": `vyft-${resourceId}`,
77
77
  match: [match],
78
78
  terminal: true,
79
79
  handle: [handler],
@@ -81,15 +81,19 @@ export function buildCaddyRoute(project, resourceId, route, handler) {
81
81
  }
82
82
  export class DockerClient {
83
83
  docker;
84
+ localDocker;
85
+ isRemote;
84
86
  project;
85
87
  secretValues = new Map();
86
88
  log;
87
89
  verbose = false;
88
90
  constructor(project, parentLogger) {
91
+ this.localDocker = new Docker({ socketPath: "/var/run/docker.sock" });
89
92
  const contextHost = resolveDockerHost();
90
93
  if (contextHost)
91
94
  process.env.DOCKER_HOST = contextHost;
92
95
  this.docker = new Docker();
96
+ this.isRemote = !!(contextHost || process.env.DOCKER_HOST);
93
97
  this.project = project;
94
98
  this.log = (parentLogger ?? defaultLogger).child({
95
99
  component: "docker",
@@ -98,27 +102,49 @@ export class DockerClient {
98
102
  if (contextHost)
99
103
  this.log.debug({ host: contextHost }, "using docker context endpoint");
100
104
  }
101
- async ensureInfrastructure() {
102
- const start = performance.now();
103
- this.log.debug("checking proxy existence");
104
- const exists = await this.serviceExists(PROXY_SERVICE_NAME);
105
- if (exists) {
106
- this.log.debug("proxy already exists");
107
- return;
108
- }
109
- // Create network for services to communicate
105
+ async ensureNetwork(name, labels = {}) {
110
106
  const networks = await this.docker.listNetworks({
111
- filters: JSON.stringify({ name: ["vyft-network"] }),
107
+ filters: JSON.stringify({ name: [name] }),
112
108
  });
113
- if (networks.length === 0) {
114
- this.log.debug("creating network");
109
+ const exactMatch = networks.some((n) => n.Name === name);
110
+ if (!exactMatch) {
111
+ this.log.debug({ network: name }, "creating network");
115
112
  await this.docker.createNetwork({
116
- Name: "vyft-network",
113
+ Name: name,
117
114
  Driver: "overlay",
118
115
  Attachable: true,
119
- Labels: { "vyft.infrastructure": "true" },
116
+ Labels: labels,
120
117
  });
121
118
  }
119
+ }
120
+ async ensureInfrastructure() {
121
+ const start = performance.now();
122
+ // Ensure per-project network for inter-service DNS
123
+ await this.ensureNetwork(`${this.project}-internal`, {
124
+ "com.docker.stack.namespace": this.project,
125
+ "vyft.managed": "true",
126
+ "vyft.project": this.project,
127
+ });
128
+ this.log.debug("checking proxy existence");
129
+ const exists = await this.serviceExists(PROXY_SERVICE_NAME);
130
+ if (exists) {
131
+ this.log.debug("proxy already exists, verifying config structure");
132
+ try {
133
+ const raw = await this.caddyApiRequest("GET", "/config/");
134
+ const config = JSON.parse(raw);
135
+ if (!config?.apps?.http?.servers?.main) {
136
+ this.log.debug("proxy config missing servers.main, re-seeding");
137
+ await this.seedCaddyConfig();
138
+ }
139
+ }
140
+ catch {
141
+ this.log.debug("proxy config check failed, re-seeding");
142
+ await this.seedCaddyConfig();
143
+ }
144
+ return;
145
+ }
146
+ // Create shared proxy network
147
+ await this.ensureNetwork("vyft-network", { "vyft.infrastructure": "true" });
122
148
  this.log.debug("creating proxy service");
123
149
  await this.docker.createService({
124
150
  Name: PROXY_SERVICE_NAME,
@@ -266,12 +292,20 @@ export class DockerClient {
266
292
  }
267
293
  }
268
294
  const baseConfig = {
295
+ logging: {
296
+ logs: {
297
+ default: {
298
+ level: "INFO",
299
+ },
300
+ },
301
+ },
269
302
  apps: {
270
303
  http: {
271
304
  servers: {
272
305
  main: {
273
306
  listen: [":443", ":80"],
274
307
  routes: [],
308
+ logs: {},
275
309
  },
276
310
  },
277
311
  },
@@ -298,10 +332,10 @@ export class DockerClient {
298
332
  }
299
333
  async addRoute(resourceId, route, handler) {
300
334
  this.log.debug({ resourceId, route }, "adding route");
301
- const caddyRoute = buildCaddyRoute(this.project, resourceId, route, handler);
335
+ const caddyRoute = buildCaddyRoute(resourceId, route, handler);
302
336
  // Delete existing route for idempotency
303
337
  try {
304
- await this.caddyApiRequest("DELETE", `/id/vyft-${this.project}-${resourceId}`);
338
+ await this.caddyApiRequest("DELETE", `/id/vyft-${resourceId}`);
305
339
  }
306
340
  catch (err) {
307
341
  this.log.debug({ err, resourceId }, "idempotent route delete failed");
@@ -326,7 +360,7 @@ export class DockerClient {
326
360
  async removeRoute(resourceId) {
327
361
  this.log.debug({ resourceId }, "removing route");
328
362
  try {
329
- await this.caddyApiRequest("DELETE", `/id/vyft-${this.project}-${resourceId}`);
363
+ await this.caddyApiRequest("DELETE", `/id/vyft-${resourceId}`);
330
364
  this.log.debug({ resourceId }, "route removed");
331
365
  }
332
366
  catch (err) {
@@ -419,6 +453,7 @@ export class DockerClient {
419
453
  this.log.debug({ volumeName: volume.id }, "creating volume");
420
454
  const config = volume.config;
421
455
  const labels = {
456
+ "com.docker.stack.namespace": this.project,
422
457
  "vyft.managed": "true",
423
458
  "vyft.project": this.project,
424
459
  "vyft.type": "volume",
@@ -497,6 +532,7 @@ export class DockerClient {
497
532
  Name: name,
498
533
  Data: Buffer.from(value).toString("base64"),
499
534
  Labels: {
535
+ "com.docker.stack.namespace": this.project,
500
536
  "vyft.managed": "true",
501
537
  "vyft.project": this.project,
502
538
  "vyft.type": "secret",
@@ -531,25 +567,39 @@ export class DockerClient {
531
567
  envList.push(`${envKey}=/run/secrets/${value.id}`);
532
568
  }
533
569
  else if (isInterpolation(value)) {
534
- const parts = [];
535
- for (let i = 0; i < value.strings.length; i++) {
536
- parts.push(value.strings[i]);
537
- if (i < value.values.length) {
538
- const v = value.values[i];
539
- if (isReference(v)) {
540
- const secretValue = this.secretValues.get(v.id);
541
- if (secretValue === undefined) {
542
- throw new Error(`Secret "${v.id}" value not available — it must be created in the same deploy session`);
570
+ const derivedName = `${serviceId}-${key.toLowerCase().replace(/_/g, "-")}`;
571
+ // Check if all referenced secrets have their values available
572
+ let allValuesAvailable = true;
573
+ for (const v of value.values) {
574
+ if (isReference(v) && !this.secretValues.has(v.id)) {
575
+ allValuesAvailable = false;
576
+ break;
577
+ }
578
+ }
579
+ if (allValuesAvailable) {
580
+ // Build the interpolated value and create/update the derived secret
581
+ const parts = [];
582
+ for (let i = 0; i < value.strings.length; i++) {
583
+ parts.push(value.strings[i]);
584
+ if (i < value.values.length) {
585
+ const v = value.values[i];
586
+ if (isReference(v)) {
587
+ parts.push(this.secretValues.get(v.id));
588
+ }
589
+ else {
590
+ parts.push(v);
543
591
  }
544
- parts.push(secretValue);
545
- }
546
- else {
547
- parts.push(v);
548
592
  }
549
593
  }
594
+ await this.createDerivedSecret(derivedName, parts.join(""), serviceId);
595
+ }
596
+ else if (!(await this.secretExists(derivedName))) {
597
+ // Source secret values not in memory and derived secret doesn't exist
598
+ const missing = value.values
599
+ .filter((v) => isReference(v) && !this.secretValues.has(v.id))
600
+ .map((v) => v.id);
601
+ throw new Error(`Secret(s) ${missing.join(", ")} value not available and derived secret "${derivedName}" does not exist — destroy and redeploy to regenerate`);
550
602
  }
551
- const derivedName = `${serviceId}-${key.toLowerCase().replace(/_/g, "-")}`;
552
- await this.createDerivedSecret(derivedName, parts.join(""), serviceId);
553
603
  const derivedId = await this.lookupSecretId(derivedName);
554
604
  secrets.push(secretMount(derivedId, derivedName));
555
605
  const envKey = key.endsWith("_FILE") ? key : `${key}_FILE`;
@@ -561,20 +611,25 @@ export class DockerClient {
561
611
  }
562
612
  async createService(service) {
563
613
  const start = performance.now();
614
+ await this.ensureNetwork(`${this.project}-internal`, {
615
+ "com.docker.stack.namespace": this.project,
616
+ "vyft.managed": "true",
617
+ "vyft.project": this.project,
618
+ });
564
619
  const config = service.config;
565
620
  let imageName;
566
621
  if (typeof config.image === "object") {
567
622
  const { context = ".", dockerfile = "Dockerfile" } = config.image;
568
- imageName = `${service.id}:latest`;
623
+ const buildTag = `${service.id}:latest`;
569
624
  this.log.debug({ resourceId: service.id, context, dockerfile }, "image build started");
570
625
  const buildStart = performance.now();
571
626
  const tarStream = tar.pack(path.resolve(context));
572
- const stream = await this.docker.buildImage(tarStream, {
573
- t: imageName,
627
+ const stream = await this.localDocker.buildImage(tarStream, {
628
+ t: buildTag,
574
629
  dockerfile,
575
630
  });
576
631
  await new Promise((resolve, reject) => {
577
- this.docker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
632
+ this.localDocker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
578
633
  if (event.error)
579
634
  log.error(event.error);
580
635
  else if (this.verbose && event.stream)
@@ -585,14 +640,29 @@ export class DockerClient {
585
640
  const buildDurationMs = Math.round(performance.now() - buildStart);
586
641
  this.log.debug({
587
642
  resourceId: service.id,
588
- image: imageName,
643
+ image: buildTag,
589
644
  durationMs: buildDurationMs,
590
645
  }, "image build completed");
591
- const images = await this.docker.listImages({
592
- filters: JSON.stringify({ reference: [imageName] }),
646
+ const images = await this.localDocker.listImages({
647
+ filters: JSON.stringify({ reference: [buildTag] }),
593
648
  });
594
649
  if (images.length === 0) {
595
- throw new Error(`Image ${imageName} was not built successfully`);
650
+ throw new Error(`Image ${buildTag} was not built successfully`);
651
+ }
652
+ // Tag with content hash so the orchestrator detects the new image
653
+ const shortHash = images[0].Id.replace("sha256:", "").slice(0, 12);
654
+ imageName = `${service.id}:${shortHash}`;
655
+ await this.localDocker.getImage(buildTag).tag({
656
+ repo: service.id,
657
+ tag: shortHash,
658
+ });
659
+ if (this.isRemote) {
660
+ this.log.debug({ image: imageName }, "transferring image to remote");
661
+ const transferStart = performance.now();
662
+ const imageStream = await this.localDocker.getImage(imageName).get();
663
+ await this.docker.loadImage(imageStream);
664
+ const transferDurationMs = Math.round(performance.now() - transferStart);
665
+ this.log.debug({ image: imageName, durationMs: transferDurationMs }, "image transferred to remote");
596
666
  }
597
667
  }
598
668
  else {
@@ -600,11 +670,13 @@ export class DockerClient {
600
670
  }
601
671
  // Build labels for service and container
602
672
  const serviceLabels = {
673
+ "com.docker.stack.namespace": this.project,
603
674
  "vyft.managed": "true",
604
675
  "vyft.project": this.project,
605
676
  "vyft.type": "service",
606
677
  };
607
678
  const containerLabels = {
679
+ "com.docker.stack.namespace": this.project,
608
680
  "vyft.managed": "true",
609
681
  "vyft.project": this.project,
610
682
  };
@@ -641,9 +713,15 @@ export class DockerClient {
641
713
  : undefined,
642
714
  };
643
715
  }
716
+ const networks = [
717
+ { Target: `${this.project}-internal` },
718
+ ];
719
+ if (config.route) {
720
+ networks.push({ Target: "vyft-network" });
721
+ }
644
722
  const taskTemplate = {
645
723
  ContainerSpec: containerSpec,
646
- Networks: config.route ? [{ Target: "vyft-network" }] : undefined,
724
+ Networks: networks,
647
725
  };
648
726
  if (config.resources) {
649
727
  taskTemplate.Resources = {
@@ -687,8 +765,20 @@ export class DockerClient {
687
765
  : undefined,
688
766
  };
689
767
  this.log.trace({ serviceSpec }, "docker create service spec");
690
- this.log.debug({ resourceId: service.id }, "docker.createService called");
691
- await this.docker.createService(serviceSpec);
768
+ const existing = await this.serviceExists(service.id);
769
+ if (existing) {
770
+ this.log.debug({ resourceId: service.id }, "updating existing service");
771
+ const svc = this.docker.getService(service.id);
772
+ const info = await svc.inspect();
773
+ await svc.update({
774
+ ...serviceSpec,
775
+ version: info.Version.Index,
776
+ });
777
+ }
778
+ else {
779
+ this.log.debug({ resourceId: service.id }, "creating new service");
780
+ await this.docker.createService(serviceSpec);
781
+ }
692
782
  if (config.route) {
693
783
  const port = config.port || 3000;
694
784
  await this.addRoute(service.id, config.route, {
@@ -702,8 +792,8 @@ export class DockerClient {
702
792
  image: imageName,
703
793
  hasRoute: !!config.route,
704
794
  durationMs,
705
- }, "service created");
706
- log.step(`Created ${service.id}`);
795
+ }, existing ? "service updated" : "service created");
796
+ log.step(`${existing ? "Updated" : "Created"} ${service.id}`);
707
797
  }
708
798
  async serviceExists(id) {
709
799
  try {
@@ -743,8 +833,21 @@ export class DockerClient {
743
833
  this.log.info({ resourceId: id, durationMs }, "service removed");
744
834
  log.step(`Removed ${id}`);
745
835
  }
836
+ async pullImage(image) {
837
+ this.log.debug({ image }, "pulling image");
838
+ const stream = await this.docker.pull(image);
839
+ await new Promise((resolve, reject) => {
840
+ this.docker.modem.followProgress(stream, (err) => err ? reject(err) : resolve());
841
+ });
842
+ this.log.debug({ image }, "image pulled");
843
+ }
746
844
  async createStatic(static_) {
747
845
  const start = performance.now();
846
+ await this.ensureNetwork(`${this.project}-internal`, {
847
+ "com.docker.stack.namespace": this.project,
848
+ "vyft.managed": "true",
849
+ "vyft.project": this.project,
850
+ });
748
851
  this.log.debug({ resourceId: static_.id, route: static_.config.route }, "creating static site");
749
852
  this.log.debug({ resourceId: static_.id }, "static build starting");
750
853
  const { outputPath } = await buildStatic(static_.config.build.cwd, {
@@ -753,17 +856,21 @@ export class DockerClient {
753
856
  env: static_.config.build.env,
754
857
  }, this.log);
755
858
  const volumeName = `${static_.id}-files`;
756
- this.log.debug({ volumeName }, "creating static volume");
757
- await this.docker.createVolume({
758
- Name: volumeName,
759
- Labels: {
760
- "vyft.managed": "true",
761
- "vyft.project": this.project,
762
- "vyft.type": "volume",
763
- "vyft.static": static_.id,
764
- },
765
- });
859
+ if (!(await this.volumeExists(volumeName))) {
860
+ this.log.debug({ volumeName }, "creating static volume");
861
+ await this.docker.createVolume({
862
+ Name: volumeName,
863
+ Labels: {
864
+ "com.docker.stack.namespace": this.project,
865
+ "vyft.managed": "true",
866
+ "vyft.project": this.project,
867
+ "vyft.type": "volume",
868
+ "vyft.static": static_.id,
869
+ },
870
+ });
871
+ }
766
872
  this.log.debug({ resourceId: static_.id, outputPath }, "tar copy started");
873
+ await this.pullImage("alpine:latest");
767
874
  const tarStream = tar.pack(outputPath);
768
875
  const container = await this.docker.createContainer({
769
876
  Image: "alpine:latest",
@@ -773,16 +880,24 @@ export class DockerClient {
773
880
  },
774
881
  });
775
882
  await container.start();
883
+ // Clear old files before copying new ones
884
+ await container.exec({
885
+ Cmd: ["sh", "-c", "rm -rf /data/*"],
886
+ AttachStdout: true,
887
+ AttachStderr: true,
888
+ });
776
889
  await container.putArchive(tarStream, { path: "/data" });
777
890
  await container.remove({ force: true });
778
891
  this.log.debug({ resourceId: static_.id }, "tar copy completed");
779
892
  const serviceLabels = {
893
+ "com.docker.stack.namespace": this.project,
780
894
  "vyft.managed": "true",
781
895
  "vyft.project": this.project,
782
896
  "vyft.type": "site",
783
897
  "vyft.route": static_.config.route,
784
898
  };
785
899
  const containerLabels = {
900
+ "com.docker.stack.namespace": this.project,
786
901
  "vyft.managed": "true",
787
902
  "vyft.project": this.project,
788
903
  };
@@ -793,28 +908,50 @@ export class DockerClient {
793
908
  "printf ':80 {\\nroot * /srv\\ntry_files {path} /index.html\\nfile_server\\n}\\n' > /etc/caddy/Caddyfile && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile",
794
909
  ]
795
910
  : ["caddy", "file-server", "--root", "/srv", "--listen", ":80"];
796
- this.log.debug({ resourceId: static_.id }, "creating caddy service for static site");
797
- await this.docker.createService({
911
+ const serviceSpec = {
798
912
  Name: static_.id,
799
913
  Labels: serviceLabels,
800
914
  TaskTemplate: {
801
915
  ContainerSpec: {
802
916
  Image: "caddy:latest",
803
917
  Command: command,
804
- Mounts: [{ Type: "volume", Source: volumeName, Target: "/srv" }],
918
+ Mounts: [
919
+ { Type: "volume", Source: volumeName, Target: "/srv" },
920
+ ],
805
921
  Labels: containerLabels,
806
922
  },
807
- Networks: [{ Target: "vyft-network" }],
923
+ Networks: [
924
+ { Target: `${this.project}-internal` },
925
+ { Target: "vyft-network" },
926
+ ],
808
927
  },
809
928
  Mode: { Replicated: { Replicas: 1 } },
810
- });
929
+ };
930
+ const existing = await this.serviceExists(static_.id);
931
+ if (existing) {
932
+ this.log.debug({ resourceId: static_.id }, "updating static site service");
933
+ const svc = this.docker.getService(static_.id);
934
+ const info = await svc.inspect();
935
+ await svc.update({
936
+ ...serviceSpec,
937
+ version: info.Version.Index,
938
+ TaskTemplate: {
939
+ ...serviceSpec.TaskTemplate,
940
+ ForceUpdate: (info.Spec?.TaskTemplate?.ForceUpdate ?? 0) + 1,
941
+ },
942
+ });
943
+ }
944
+ else {
945
+ this.log.debug({ resourceId: static_.id }, "creating caddy service for static site");
946
+ await this.docker.createService(serviceSpec);
947
+ }
811
948
  await this.addRoute(static_.id, static_.config.route, {
812
949
  handler: "reverse_proxy",
813
950
  upstreams: [{ dial: `${static_.id}:80` }],
814
951
  });
815
952
  const durationMs = Math.round(performance.now() - start);
816
- this.log.info({ resourceId: static_.id, route: static_.config.route, durationMs }, "site created");
817
- log.step(`Created ${static_.id}`);
953
+ this.log.info({ resourceId: static_.id, route: static_.config.route, durationMs }, existing ? "site updated" : "site created");
954
+ log.step(`${existing ? "Updated" : "Created"} ${static_.id}`);
818
955
  }
819
956
  async removeStatic(id) {
820
957
  const start = performance.now();
@@ -852,4 +989,60 @@ export class DockerClient {
852
989
  this.log.info({ resourceId: id, durationMs }, "site removed");
853
990
  log.step(`Removed ${id}`);
854
991
  }
992
+ async proxyLogs(options) {
993
+ const container = await this.findProxyContainer();
994
+ const tail = options.tail ?? 100;
995
+ if (options.follow) {
996
+ const stream = await container.logs({
997
+ stdout: true,
998
+ stderr: true,
999
+ follow: true,
1000
+ tail,
1001
+ timestamps: true,
1002
+ });
1003
+ const stdoutStream = new PassThrough();
1004
+ const stderrStream = new PassThrough();
1005
+ stdoutStream.pipe(process.stdout);
1006
+ stderrStream.pipe(process.stderr);
1007
+ this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
1008
+ await new Promise((resolve) => {
1009
+ stream.on("end", resolve);
1010
+ });
1011
+ }
1012
+ else {
1013
+ const buf = await container.logs({
1014
+ stdout: true,
1015
+ stderr: true,
1016
+ follow: false,
1017
+ tail,
1018
+ timestamps: true,
1019
+ });
1020
+ // Demux the multiplexed buffer: each frame has an 8-byte header
1021
+ // [stream_type(1), padding(3), size(4)] followed by the payload.
1022
+ let offset = 0;
1023
+ while (offset + 8 <= buf.length) {
1024
+ const type = buf[offset];
1025
+ const size = buf.readUInt32BE(offset + 4);
1026
+ offset += 8;
1027
+ const payload = buf.subarray(offset, offset + size);
1028
+ if (type === 2) {
1029
+ process.stderr.write(payload);
1030
+ }
1031
+ else {
1032
+ process.stdout.write(payload);
1033
+ }
1034
+ offset += size;
1035
+ }
1036
+ }
1037
+ }
1038
+ async removeProjectNetwork() {
1039
+ const name = `${this.project}-internal`;
1040
+ try {
1041
+ await this.docker.getNetwork(name).remove();
1042
+ this.log.debug({ network: name }, "project network removed");
1043
+ }
1044
+ catch {
1045
+ this.log.debug({ network: name }, "project network removal skipped");
1046
+ }
1047
+ }
855
1048
  }
@@ -1,8 +1,9 @@
1
1
  import type { Secret, SecretConfig, Service, Site, SiteConfig, Volume } from "../resource.js";
2
- import type { StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig } from "./types.js";
2
+ import type { Postgres, PostgresConfig, StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig } from "./types.js";
3
3
  export declare function createFactories<D extends StorageDriver = "local">(swarmConfig: SwarmConfig<D>): {
4
- volume(id: string, config?: SwarmVolumeConfig<D>): Volume;
5
- service(id: string, config: SwarmServiceConfig): Service;
6
- secret(id: string, config?: SecretConfig): Secret;
7
- site(id: string, config: SiteConfig): Site;
4
+ volume: (id: string, config?: SwarmVolumeConfig<D>) => Volume;
5
+ service: (id: string, config: SwarmServiceConfig) => Service;
6
+ secret: (id: string, config?: SecretConfig) => Secret;
7
+ site: (id: string, config: SiteConfig) => Site;
8
+ postgres: (id: string, config?: PostgresConfig) => Postgres;
8
9
  };
@@ -1,3 +1,4 @@
1
+ import { interpolate } from "../interpolate.js";
1
2
  import { validateId, validateRoute } from "../resource.js";
2
3
  import { VYFT_RUNTIME } from "../symbols.js";
3
4
  function attachRuntime(obj, config) {
@@ -6,45 +7,80 @@ function attachRuntime(obj, config) {
6
7
  });
7
8
  }
8
9
  export function createFactories(swarmConfig) {
9
- return {
10
- volume(id, config = {}) {
11
- validateId(id);
12
- return attachRuntime({ type: "volume", id, config }, swarmConfig);
13
- },
14
- service(id, config) {
15
- validateId(id);
16
- if (config.route) {
17
- validateRoute(config.route);
18
- }
19
- const port = config.port || 3000;
20
- return attachRuntime({
21
- type: "service",
22
- id,
23
- config,
24
- host: id,
25
- port,
26
- url: config.route
27
- ? `https://${config.route}`
28
- : `http://${id}:${port}`,
29
- }, swarmConfig);
30
- },
31
- secret(id, config = {}) {
32
- validateId(id);
33
- return attachRuntime({
34
- type: "secret",
35
- id,
36
- config,
37
- }, swarmConfig);
38
- },
39
- site(id, config) {
40
- validateId(id);
10
+ const project = process.env.VYFT_PROJECT;
11
+ const prefix = project ? `${project}-` : "";
12
+ function volume(id, config = {}) {
13
+ validateId(id);
14
+ const fullId = `${prefix}${id}`;
15
+ return attachRuntime({ type: "volume", id: fullId, config }, swarmConfig);
16
+ }
17
+ function service(id, config) {
18
+ validateId(id);
19
+ if (config.route) {
41
20
  validateRoute(config.route);
42
- return attachRuntime({
43
- type: "site",
44
- id,
45
- config,
46
- url: `https://${config.route}`,
47
- }, swarmConfig);
48
- },
49
- };
21
+ }
22
+ const fullId = `${prefix}${id}`;
23
+ const port = config.port || 3000;
24
+ return attachRuntime({
25
+ type: "service",
26
+ id: fullId,
27
+ config,
28
+ host: fullId,
29
+ port,
30
+ url: config.route
31
+ ? `https://${config.route}`
32
+ : `http://${fullId}:${port}`,
33
+ }, swarmConfig);
34
+ }
35
+ function secret(id, config = {}) {
36
+ validateId(id);
37
+ const fullId = `${prefix}${id}`;
38
+ return attachRuntime({
39
+ type: "secret",
40
+ id: fullId,
41
+ config,
42
+ }, swarmConfig);
43
+ }
44
+ function site(id, config) {
45
+ validateId(id);
46
+ validateRoute(config.route);
47
+ const fullId = `${prefix}${id}`;
48
+ return attachRuntime({
49
+ type: "site",
50
+ id: fullId,
51
+ config,
52
+ url: `https://${config.route}`,
53
+ }, swarmConfig);
54
+ }
55
+ function postgres(id, config = {}) {
56
+ validateId(id);
57
+ const version = config.version ?? 18;
58
+ const mount = version >= 18 ? "/var/lib/postgresql" : "/var/lib/postgresql/data";
59
+ const password = secret(`${id}-password`, { length: 32 });
60
+ const vol = volume(`${id}-data`);
61
+ const svc = service(id, {
62
+ image: `postgres:${version}`,
63
+ port: 5432,
64
+ volumes: [{ volume: vol, mount }],
65
+ env: {
66
+ POSTGRES_PASSWORD: password,
67
+ },
68
+ healthCheck: {
69
+ command: ["pg_isready", "-U", "postgres"],
70
+ interval: "5s",
71
+ timeout: "5s",
72
+ retries: 5,
73
+ startPeriod: "10s",
74
+ },
75
+ });
76
+ return {
77
+ service: svc,
78
+ volume: vol,
79
+ password,
80
+ host: svc.host,
81
+ port: 5432,
82
+ connectionUrl: interpolate `postgres://postgres:${password}@${svc.host}:5432/postgres`,
83
+ };
84
+ }
85
+ return { volume, service, secret, site, postgres };
50
86
  }
@@ -1,10 +1,11 @@
1
1
  export { createFactories } from "./factories.js";
2
- export type { DriverVolumeOptions, StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig, } from "./types.js";
2
+ export type { DriverVolumeOptions, Postgres, PostgresConfig, StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig, } from "./types.js";
3
3
  import type { Secret, SecretConfig, Service, Site, SiteConfig, Volume } from "../resource.js";
4
- import type { StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig } from "./types.js";
4
+ import type { Postgres, PostgresConfig, StorageDriver, SwarmConfig, SwarmServiceConfig, SwarmVolumeConfig } from "./types.js";
5
5
  export declare function swarm<D extends StorageDriver = "local">(config?: SwarmConfig<D>): {
6
6
  volume: (id: string, config?: SwarmVolumeConfig<D>) => Volume;
7
7
  service: (id: string, config: SwarmServiceConfig) => Service;
8
8
  secret: (id: string, config?: SecretConfig) => Secret;
9
9
  site: (id: string, config: SiteConfig) => Site;
10
+ postgres: (id: string, config?: PostgresConfig) => Postgres;
10
11
  };
@@ -1,4 +1,4 @@
1
- import type { HealthCheckConfig, ResourceLimits, ServiceConfig, VolumeConfig } from "../resource.js";
1
+ import type { HealthCheckConfig, Interpolation, ResourceLimits, Secret, Service, ServiceConfig, Volume, VolumeConfig } from "../resource.js";
2
2
  export interface DriverVolumeOptions {
3
3
  local: {
4
4
  size?: string;
@@ -23,3 +23,14 @@ export interface SwarmServiceConfig extends ServiceConfig {
23
23
  resources?: ResourceLimits;
24
24
  restartPolicy?: "none" | "on-failure" | "any";
25
25
  }
26
+ export interface PostgresConfig {
27
+ version?: number;
28
+ }
29
+ export interface Postgres {
30
+ service: Service;
31
+ volume: Volume;
32
+ password: Secret;
33
+ host: string;
34
+ port: number;
35
+ connectionUrl: Interpolation;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vyft",
3
- "version": "0.1.0-alpha",
3
+ "version": "0.2.0-alpha",
4
4
  "description": "Deploy apps to Docker Swarm with TypeScript",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,11 +8,12 @@ COPY apps/api/package.json ./apps/api/
8
8
  RUN pnpm install --frozen-lockfile
9
9
 
10
10
  FROM deps AS build
11
+ COPY tsconfig.json ./
11
12
  COPY apps/api/ ./apps/api/
12
13
  RUN pnpm --filter {{name}}-api build
13
14
 
14
15
  FROM build AS deploy
15
- RUN pnpm --filter {{name}}-api --prod deploy --legacy /prod/api
16
+ RUN pnpm --filter {{name}}-api --prod deploy /prod/api
16
17
 
17
18
  FROM base
18
19
  WORKDIR /app
@@ -2,7 +2,8 @@
2
2
  "name": "{{name}}",
3
3
  "private": true,
4
4
  "type": "module",
5
- "dependencies": {
5
+ "packageManager": "pnpm@9.15.9",
6
+ "devDependencies": {
6
7
  "vyft": "latest"
7
8
  },
8
9
  "scripts": {