vyft 0.2.0-alpha → 0.4.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.
Files changed (56) hide show
  1. package/README.md +5 -16
  2. package/dist/build.d.ts +1 -0
  3. package/dist/build.js +9 -4
  4. package/dist/cli.js +648 -43
  5. package/dist/context.d.ts +39 -0
  6. package/dist/context.js +101 -0
  7. package/dist/docker.d.ts +24 -12
  8. package/dist/docker.js +299 -389
  9. package/dist/exec.d.ts +1 -1
  10. package/dist/exec.js +2 -2
  11. package/dist/index.d.ts +4 -1
  12. package/dist/index.js +5 -1
  13. package/dist/init.js +19 -2
  14. package/dist/interpolate.d.ts +11 -0
  15. package/dist/interpolate.js +11 -0
  16. package/dist/local/dev.d.ts +31 -0
  17. package/dist/local/dev.js +109 -0
  18. package/dist/local/index.d.ts +2 -0
  19. package/dist/local/index.js +2 -0
  20. package/dist/local/runtime.d.ts +61 -0
  21. package/dist/local/runtime.js +391 -0
  22. package/dist/proxy.d.ts +16 -0
  23. package/dist/proxy.js +0 -0
  24. package/dist/resource.d.ts +104 -1
  25. package/dist/resource.js +11 -1
  26. package/dist/runtime.d.ts +11 -1
  27. package/dist/services/index.d.ts +26 -0
  28. package/dist/services/index.js +35 -0
  29. package/dist/services/minio.d.ts +36 -0
  30. package/dist/services/minio.js +53 -0
  31. package/dist/services/mongo.d.ts +28 -0
  32. package/dist/services/mongo.js +45 -0
  33. package/dist/services/mysql.d.ts +28 -0
  34. package/dist/services/mysql.js +44 -0
  35. package/dist/services/nats.d.ts +26 -0
  36. package/dist/services/nats.js +38 -0
  37. package/dist/services/postgres.d.ts +28 -0
  38. package/dist/services/postgres.js +45 -0
  39. package/dist/services/rabbitmq.d.ts +28 -0
  40. package/dist/services/rabbitmq.js +44 -0
  41. package/dist/services/redis.d.ts +28 -0
  42. package/dist/services/redis.js +49 -0
  43. package/dist/services/storage.d.ts +39 -0
  44. package/dist/services/storage.js +94 -0
  45. package/dist/swarm/factories.d.ts +9 -2
  46. package/dist/swarm/factories.js +9 -32
  47. package/dist/swarm/index.d.ts +11 -2
  48. package/dist/swarm/proxy.d.ts +24 -0
  49. package/dist/swarm/proxy.js +339 -0
  50. package/dist/swarm/types.d.ts +11 -21
  51. package/dist/symbols.d.ts +7 -0
  52. package/dist/symbols.js +3 -0
  53. package/package.json +4 -5
  54. package/templates/fullstack/package.json +2 -6
  55. package/templates/fullstack/vyft.config.ts +13 -28
  56. package/templates/fullstack/compose.yaml +0 -14
package/dist/docker.js CHANGED
@@ -1,25 +1,14 @@
1
- import { execSync } from "node:child_process";
2
1
  import { randomBytes } from "node:crypto";
2
+ import http from "node:http";
3
3
  import path from "node:path";
4
4
  import { PassThrough } from "node:stream";
5
- import { log } from "@clack/prompts";
6
5
  import Docker from "dockerode";
6
+ import { Client as SshClient } from "ssh2";
7
7
  import tar from "tar-fs";
8
8
  import { buildStatic } from "./build.js";
9
9
  import { logger as defaultLogger } from "./logger.js";
10
10
  import { isInterpolation, isReference } from "./resource.js";
11
- const PROXY_SERVICE_NAME = "vyft-proxy";
12
- function resolveDockerHost() {
13
- if (process.env.DOCKER_HOST)
14
- return undefined;
15
- try {
16
- const host = execSync("docker context inspect --format '{{.Endpoints.docker.Host}}'", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
17
- if (host && host !== `unix:///var/run/docker.sock`)
18
- return host;
19
- }
20
- catch { }
21
- return undefined;
22
- }
11
+ import { CaddyProxy } from "./swarm/proxy.js";
23
12
  function secretMount(id, name) {
24
13
  return {
25
14
  SecretID: id,
@@ -67,40 +56,88 @@ export function parseRoute(route) {
67
56
  path: route.slice(slashIndex),
68
57
  };
69
58
  }
70
- export function buildCaddyRoute(resourceId, route, handler) {
71
- const { host, path } = parseRoute(route);
72
- const match = { host: [host] };
73
- if (path)
74
- match.path = [path];
59
+ /**
60
+ * Create a Docker client that reuses a single SSH connection instead of
61
+ * opening a new one for every API call (the docker-modem default).
62
+ */
63
+ function createSshDocker(sshUrl) {
64
+ const parsed = new URL(sshUrl);
65
+ const conn = new SshClient();
66
+ let connected = false;
67
+ const ready = new Promise((resolve, reject) => {
68
+ conn.once("ready", () => {
69
+ connected = true;
70
+ resolve();
71
+ });
72
+ conn.once("error", reject);
73
+ conn.connect({
74
+ host: parsed.hostname,
75
+ port: parsed.port ? parseInt(parsed.port, 10) : 22,
76
+ username: parsed.username || undefined,
77
+ agent: process.env.SSH_AUTH_SOCK,
78
+ });
79
+ });
80
+ const agent = new http.Agent({ keepAlive: false });
81
+ // biome-ignore lint/suspicious/noExplicitAny: monkey-patching internal Agent method for SSH tunneling
82
+ agent.createConnection = (_opts, fn) => {
83
+ const exec = () => conn.exec("docker system dial-stdio", (err, stream) => {
84
+ if (err)
85
+ return fn(err);
86
+ fn(null, stream);
87
+ });
88
+ if (connected)
89
+ exec();
90
+ else
91
+ ready.then(exec).catch((err) => fn(err));
92
+ };
75
93
  return {
76
- "@id": `vyft-${resourceId}`,
77
- match: [match],
78
- terminal: true,
79
- handle: [handler],
94
+ docker: new Docker({
95
+ protocol: "http",
96
+ host: parsed.hostname,
97
+ agent,
98
+ // biome-ignore lint/suspicious/noExplicitAny: dockerode typing doesn't expose agent option
99
+ }),
100
+ sshClient: conn,
80
101
  };
81
102
  }
82
103
  export class DockerClient {
83
104
  docker;
84
105
  localDocker;
85
106
  isRemote;
107
+ sshClient = null;
86
108
  project;
87
109
  secretValues = new Map();
110
+ builtImages = new Map();
88
111
  log;
112
+ proxy;
89
113
  verbose = false;
90
- constructor(project, parentLogger) {
114
+ constructor(project, opts) {
91
115
  this.localDocker = new Docker({ socketPath: "/var/run/docker.sock" });
92
- const contextHost = resolveDockerHost();
93
- if (contextHost)
94
- process.env.DOCKER_HOST = contextHost;
95
- this.docker = new Docker();
96
- this.isRemote = !!(contextHost || process.env.DOCKER_HOST);
116
+ const host = opts?.host;
117
+ if (host?.startsWith("ssh://")) {
118
+ const { docker, sshClient } = createSshDocker(host);
119
+ this.docker = docker;
120
+ this.sshClient = sshClient;
121
+ }
122
+ else {
123
+ if (host)
124
+ process.env.DOCKER_HOST = host;
125
+ this.docker = new Docker();
126
+ }
127
+ this.isRemote = !!host;
97
128
  this.project = project;
98
- this.log = (parentLogger ?? defaultLogger).child({
129
+ this.log = (opts?.parentLogger ?? defaultLogger).child({
99
130
  component: "docker",
100
131
  project,
101
132
  });
102
- if (contextHost)
103
- this.log.debug({ host: contextHost }, "using docker context endpoint");
133
+ this.proxy = new CaddyProxy(this.docker, this.log);
134
+ if (host)
135
+ this.log.debug({ host }, "using remote endpoint");
136
+ }
137
+ /** Close the SSH connection (if any), allowing the process to exit. */
138
+ destroy() {
139
+ this.sshClient?.end();
140
+ this.sshClient = null;
104
141
  }
105
142
  async ensureNetwork(name, labels = {}) {
106
143
  const networks = await this.docker.listNetworks({
@@ -125,247 +162,9 @@ export class DockerClient {
125
162
  "vyft.managed": "true",
126
163
  "vyft.project": this.project,
127
164
  });
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" });
148
- this.log.debug("creating proxy service");
149
- await this.docker.createService({
150
- Name: PROXY_SERVICE_NAME,
151
- Labels: { "vyft.infrastructure": "true" },
152
- TaskTemplate: {
153
- ContainerSpec: {
154
- Image: "caddy:latest",
155
- Command: ["caddy", "run", "--resume"],
156
- Mounts: [
157
- {
158
- Type: "volume",
159
- Source: "vyft-proxy-config",
160
- Target: "/config",
161
- },
162
- {
163
- Type: "volume",
164
- Source: "vyft-proxy-data",
165
- Target: "/data",
166
- },
167
- ],
168
- Labels: { "vyft.infrastructure": "true" },
169
- },
170
- Networks: [{ Target: "vyft-network" }],
171
- },
172
- EndpointSpec: {
173
- Ports: [
174
- {
175
- Protocol: "tcp",
176
- TargetPort: 80,
177
- PublishedPort: 80,
178
- PublishMode: "ingress",
179
- },
180
- {
181
- Protocol: "tcp",
182
- TargetPort: 443,
183
- PublishedPort: 443,
184
- PublishMode: "ingress",
185
- },
186
- ],
187
- },
188
- Mode: { Replicated: { Replicas: 1 } },
189
- });
190
- log.step("Created vyft-proxy");
191
- await this.seedCaddyConfig();
165
+ await this.proxy.ensure();
192
166
  const durationMs = Math.round(performance.now() - start);
193
- this.log.info({ proxyCreated: true, durationMs }, "infrastructure ready");
194
- }
195
- async findProxyContainer() {
196
- const containers = await this.docker.listContainers({
197
- filters: JSON.stringify({
198
- label: [`com.docker.swarm.service.name=${PROXY_SERVICE_NAME}`],
199
- }),
200
- });
201
- if (containers.length === 0) {
202
- throw new Error("vyft-proxy container not found");
203
- }
204
- return this.docker.getContainer(containers[0].Id);
205
- }
206
- async caddyApiRequest(method, path, body) {
207
- const start = performance.now();
208
- this.log.debug({ method, path }, "caddy request started");
209
- const container = await this.findProxyContainer();
210
- let cmd;
211
- if (method === "POST") {
212
- const bodyStr = body ? JSON.stringify(body) : "";
213
- cmd = [
214
- "wget",
215
- "-q",
216
- "-O",
217
- "-",
218
- "--timeout=10",
219
- "--header=Content-Type: application/json",
220
- `--post-data=${bodyStr}`,
221
- `http://127.0.0.1:2019${path}`,
222
- ];
223
- }
224
- else if (method === "DELETE") {
225
- cmd = [
226
- "curl",
227
- "-s",
228
- "--connect-timeout",
229
- "5",
230
- "--max-time",
231
- "10",
232
- "-X",
233
- "DELETE",
234
- `http://127.0.0.1:2019${path}`,
235
- ];
236
- }
237
- else {
238
- cmd = [
239
- "wget",
240
- "-q",
241
- "-O",
242
- "-",
243
- "--timeout=10",
244
- `http://127.0.0.1:2019${path}`,
245
- ];
246
- }
247
- this.log.trace({ cmd }, "caddy exec command");
248
- const exec = await container.exec({
249
- Cmd: cmd,
250
- AttachStdout: true,
251
- AttachStderr: true,
252
- });
253
- const stream = await exec.start({});
254
- return new Promise((resolve, reject) => {
255
- const stdout = [];
256
- const stderr = [];
257
- const stdoutStream = new PassThrough();
258
- const stderrStream = new PassThrough();
259
- stdoutStream.on("data", (chunk) => stdout.push(chunk));
260
- stderrStream.on("data", (chunk) => stderr.push(chunk));
261
- this.docker.modem.demuxStream(stream, stdoutStream, stderrStream);
262
- stream.on("end", () => {
263
- const output = Buffer.concat(stdout).toString();
264
- const errOutput = Buffer.concat(stderr).toString();
265
- const durationMs = Math.round(performance.now() - start);
266
- this.log.trace({ stdoutBytes: output.length, stderrBytes: errOutput.length }, "caddy exec output");
267
- if (errOutput) {
268
- this.log.debug({ method, path, durationMs, stderr: errOutput }, "caddy request failed");
269
- reject(new Error(`Caddy API ${method} ${path} failed: ${errOutput}`));
270
- }
271
- else {
272
- this.log.debug({ method, path, durationMs }, "caddy request completed");
273
- resolve(output);
274
- }
275
- });
276
- });
277
- }
278
- async seedCaddyConfig() {
279
- const maxWait = 30000;
280
- const interval = 2000;
281
- let waited = 0;
282
- this.log.debug("waiting for proxy container");
283
- while (waited < maxWait) {
284
- try {
285
- await this.findProxyContainer();
286
- break;
287
- }
288
- catch (err) {
289
- this.log.debug({ err, waited, maxWait }, "proxy container not ready");
290
- await new Promise((r) => setTimeout(r, interval));
291
- waited += interval;
292
- }
293
- }
294
- const baseConfig = {
295
- logging: {
296
- logs: {
297
- default: {
298
- level: "INFO",
299
- },
300
- },
301
- },
302
- apps: {
303
- http: {
304
- servers: {
305
- main: {
306
- listen: [":443", ":80"],
307
- routes: [],
308
- logs: {},
309
- },
310
- },
311
- },
312
- },
313
- };
314
- const maxAttempts = 5;
315
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
316
- try {
317
- this.log.debug({ attempt: attempt + 1, max: maxAttempts }, "seeding caddy config");
318
- await this.caddyApiRequest("POST", "/load", baseConfig);
319
- this.log.debug("caddy config seeded");
320
- return;
321
- }
322
- catch (err) {
323
- this.log.debug({ err, attempt: attempt + 1, max: maxAttempts }, "seed attempt failed");
324
- if (attempt >= Math.floor(maxAttempts * 0.8)) {
325
- this.log.warn({ attempt: attempt + 1, max: maxAttempts }, "seed retry nearing limit");
326
- }
327
- await new Promise((r) => setTimeout(r, 2000));
328
- }
329
- }
330
- this.log.error("caddy config seed exhausted retries");
331
- throw new Error("Failed to seed Caddy config after retries");
332
- }
333
- async addRoute(resourceId, route, handler) {
334
- this.log.debug({ resourceId, route }, "adding route");
335
- const caddyRoute = buildCaddyRoute(resourceId, route, handler);
336
- // Delete existing route for idempotency
337
- try {
338
- await this.caddyApiRequest("DELETE", `/id/vyft-${resourceId}`);
339
- }
340
- catch (err) {
341
- this.log.debug({ err, resourceId }, "idempotent route delete failed");
342
- }
343
- const maxAttempts = 5;
344
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
345
- try {
346
- await this.caddyApiRequest("POST", "/config/apps/http/servers/main/routes", caddyRoute);
347
- this.log.debug({ resourceId, route }, "route added");
348
- return;
349
- }
350
- catch (err) {
351
- this.log.debug({ err, resourceId, attempt: attempt + 1, max: maxAttempts }, "route add attempt failed");
352
- if (attempt === maxAttempts - 1) {
353
- this.log.error({ resourceId, route }, "route add exhausted retries");
354
- throw new Error(`Failed to add route for ${resourceId}`);
355
- }
356
- await new Promise((r) => setTimeout(r, 2000));
357
- }
358
- }
359
- }
360
- async removeRoute(resourceId) {
361
- this.log.debug({ resourceId }, "removing route");
362
- try {
363
- await this.caddyApiRequest("DELETE", `/id/vyft-${resourceId}`);
364
- this.log.debug({ resourceId }, "route removed");
365
- }
366
- catch (err) {
367
- this.log.debug({ err, resourceId }, "route removal failed (may not exist)");
368
- }
167
+ this.log.info({ durationMs }, "infrastructure ready");
369
168
  }
370
169
  async listManagedResources() {
371
170
  const resources = [];
@@ -413,7 +212,7 @@ export class DockerClient {
413
212
  }
414
213
  return resources;
415
214
  }
416
- async create(resource) {
215
+ async create(resource, options) {
417
216
  switch (resource.type) {
418
217
  case "volume":
419
218
  return this.createVolume(resource);
@@ -422,7 +221,7 @@ export class DockerClient {
422
221
  case "service":
423
222
  return this.createService(resource);
424
223
  case "site":
425
- return this.createStatic(resource);
224
+ return this.createStatic(resource, options?.silent);
426
225
  }
427
226
  }
428
227
  async exists(resource) {
@@ -465,9 +264,9 @@ export class DockerClient {
465
264
  Name: volume.id,
466
265
  Labels: labels,
467
266
  });
468
- const durationMs = Math.round(performance.now() - start);
469
- this.log.debug({ volumeName: volume.id, durationMs }, "volume created");
470
- log.step(`Created ${volume.id}`);
267
+ const duration = Math.round(performance.now() - start);
268
+ this.log.debug({ volumeName: volume.id, durationMs: duration }, "volume created");
269
+ return { action: "created", duration };
471
270
  }
472
271
  async volumeExists(id) {
473
272
  const start = performance.now();
@@ -489,7 +288,6 @@ export class DockerClient {
489
288
  await this.docker.getVolume(id).remove();
490
289
  const durationMs = Math.round(performance.now() - start);
491
290
  this.log.debug({ volumeName: id, durationMs }, "volume removed");
492
- log.step(`Removed ${id}`);
493
291
  }
494
292
  async createSecret(secret) {
495
293
  const start = performance.now();
@@ -500,9 +298,9 @@ export class DockerClient {
500
298
  await this.storeSecretData(secret.id, value, {
501
299
  "vyft.length": length.toString(),
502
300
  });
503
- const durationMs = Math.round(performance.now() - start);
504
- this.log.debug({ secretName: secret.id, length, durationMs }, "secret created");
505
- log.step(`Created ${secret.id}`);
301
+ const duration = Math.round(performance.now() - start);
302
+ this.log.debug({ secretName: secret.id, length, durationMs: duration }, "secret created");
303
+ return { action: "created", duration };
506
304
  }
507
305
  async secretExists(id) {
508
306
  const start = performance.now();
@@ -524,7 +322,6 @@ export class DockerClient {
524
322
  await this.docker.getSecret(id).remove();
525
323
  const durationMs = Math.round(performance.now() - start);
526
324
  this.log.debug({ secretName: id, durationMs }, "secret removed");
527
- log.step(`Removed ${id}`);
528
325
  }
529
326
  async storeSecretData(name, value, labels = {}) {
530
327
  this.log.debug({ secretName: name }, "storing secret data");
@@ -551,7 +348,6 @@ export class DockerClient {
551
348
  "vyft.derived": "true",
552
349
  "vyft.parent-service": parentService,
553
350
  });
554
- log.step(`Created ${name}`);
555
351
  }
556
352
  async resolveEnv(serviceId, env) {
557
353
  const envList = [];
@@ -609,60 +405,70 @@ export class DockerClient {
609
405
  this.log.debug({ serviceId, envCount: envList.length, secretMountCount: secrets.length }, "env resolved");
610
406
  return { envList, secrets };
611
407
  }
408
+ async buildImage(service) {
409
+ const config = service.config;
410
+ if (typeof config.image !== "object")
411
+ return null;
412
+ const { context = ".", dockerfile = "Dockerfile" } = config.image;
413
+ const buildTag = `${service.id}:latest`;
414
+ this.log.debug({ resourceId: service.id, context, dockerfile }, "image build started");
415
+ const buildStart = performance.now();
416
+ const tarStream = tar.pack(path.resolve(context));
417
+ const stream = await this.localDocker.buildImage(tarStream, {
418
+ t: buildTag,
419
+ dockerfile,
420
+ });
421
+ await new Promise((resolve, reject) => {
422
+ this.localDocker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
423
+ if (event.error)
424
+ this.log.error({ error: event.error }, "build error");
425
+ else if (this.verbose && event.stream)
426
+ process.stdout.write(event.stream);
427
+ this.log.trace({ event }, "build progress");
428
+ });
429
+ });
430
+ const buildDurationMs = Math.round(performance.now() - buildStart);
431
+ this.log.debug({
432
+ resourceId: service.id,
433
+ image: buildTag,
434
+ durationMs: buildDurationMs,
435
+ }, "image build completed");
436
+ const images = await this.localDocker.listImages({
437
+ filters: JSON.stringify({ reference: [buildTag] }),
438
+ });
439
+ if (images.length === 0) {
440
+ throw new Error(`Image ${buildTag} was not built successfully`);
441
+ }
442
+ // Tag with content hash so the orchestrator detects the new image
443
+ const shortHash = images[0].Id.replace("sha256:", "").slice(0, 12);
444
+ const imageName = `${service.id}:${shortHash}`;
445
+ await this.localDocker.getImage(buildTag).tag({
446
+ repo: service.id,
447
+ tag: shortHash,
448
+ });
449
+ if (this.isRemote) {
450
+ this.log.debug({ image: imageName }, "transferring image to remote");
451
+ const transferStart = performance.now();
452
+ const imageStream = await this.localDocker.getImage(imageName).get();
453
+ await this.docker.loadImage(imageStream);
454
+ const transferDurationMs = Math.round(performance.now() - transferStart);
455
+ this.log.debug({ image: imageName, durationMs: transferDurationMs }, "image transferred to remote");
456
+ }
457
+ this.builtImages.set(service.id, imageName);
458
+ return imageName;
459
+ }
612
460
  async createService(service) {
613
461
  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
- });
619
462
  const config = service.config;
620
463
  let imageName;
621
464
  if (typeof config.image === "object") {
622
- const { context = ".", dockerfile = "Dockerfile" } = config.image;
623
- const buildTag = `${service.id}:latest`;
624
- this.log.debug({ resourceId: service.id, context, dockerfile }, "image build started");
625
- const buildStart = performance.now();
626
- const tarStream = tar.pack(path.resolve(context));
627
- const stream = await this.localDocker.buildImage(tarStream, {
628
- t: buildTag,
629
- dockerfile,
630
- });
631
- await new Promise((resolve, reject) => {
632
- this.localDocker.modem.followProgress(stream, (err) => (err ? reject(err) : resolve()), (event) => {
633
- if (event.error)
634
- log.error(event.error);
635
- else if (this.verbose && event.stream)
636
- process.stdout.write(event.stream);
637
- this.log.trace({ event }, "build progress");
638
- });
639
- });
640
- const buildDurationMs = Math.round(performance.now() - buildStart);
641
- this.log.debug({
642
- resourceId: service.id,
643
- image: buildTag,
644
- durationMs: buildDurationMs,
645
- }, "image build completed");
646
- const images = await this.localDocker.listImages({
647
- filters: JSON.stringify({ reference: [buildTag] }),
648
- });
649
- if (images.length === 0) {
650
- throw new Error(`Image ${buildTag} was not built successfully`);
465
+ const prebuilt = this.builtImages.get(service.id);
466
+ if (prebuilt) {
467
+ imageName = prebuilt;
651
468
  }
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");
469
+ else {
470
+ await this.buildImage(service);
471
+ imageName = this.builtImages.get(service.id);
666
472
  }
667
473
  }
668
474
  else {
@@ -781,19 +587,23 @@ export class DockerClient {
781
587
  }
782
588
  if (config.route) {
783
589
  const port = config.port || 3000;
784
- await this.addRoute(service.id, config.route, {
785
- handler: "reverse_proxy",
786
- upstreams: [{ dial: `${service.id}:${port}` }],
590
+ await this.proxy.addRoute(service.id, config.route, {
591
+ host: service.id,
592
+ port,
787
593
  });
788
594
  }
789
- const durationMs = Math.round(performance.now() - start);
595
+ const duration = Math.round(performance.now() - start);
790
596
  this.log.info({
791
597
  resourceId: service.id,
792
598
  image: imageName,
793
599
  hasRoute: !!config.route,
794
- durationMs,
600
+ durationMs: duration,
795
601
  }, existing ? "service updated" : "service created");
796
- log.step(`${existing ? "Updated" : "Created"} ${service.id}`);
602
+ return {
603
+ action: existing ? "updated" : "created",
604
+ route: config.route,
605
+ duration,
606
+ };
797
607
  }
798
608
  async serviceExists(id) {
799
609
  try {
@@ -805,10 +615,27 @@ export class DockerClient {
805
615
  return false;
806
616
  }
807
617
  }
618
+ async waitForHealthy(id, timeoutMs = 120000) {
619
+ const start = performance.now();
620
+ const interval = 2000;
621
+ this.log.debug({ resourceId: id }, "waiting for service to be healthy");
622
+ while (performance.now() - start < timeoutMs) {
623
+ const tasks = await this.docker.listTasks({
624
+ filters: { service: [id], "desired-state": ["running"] },
625
+ });
626
+ const healthy = tasks.some((t) => t.Status.State === "running");
627
+ if (healthy) {
628
+ this.log.debug({ resourceId: id }, "service is healthy");
629
+ return;
630
+ }
631
+ await new Promise((r) => setTimeout(r, interval));
632
+ }
633
+ throw new Error(`Service ${id} did not become healthy within ${timeoutMs}ms`);
634
+ }
808
635
  async removeService(id) {
809
636
  const start = performance.now();
810
637
  this.log.debug({ resourceId: id }, "removing service");
811
- await this.removeRoute(id);
638
+ await this.proxy.removeRoute(id);
812
639
  await this.docker.getService(id).remove();
813
640
  const maxWait = 60000;
814
641
  const interval = 2000;
@@ -831,7 +658,6 @@ export class DockerClient {
831
658
  }
832
659
  const durationMs = Math.round(performance.now() - start);
833
660
  this.log.info({ resourceId: id, durationMs }, "service removed");
834
- log.step(`Removed ${id}`);
835
661
  }
836
662
  async pullImage(image) {
837
663
  this.log.debug({ image }, "pulling image");
@@ -841,19 +667,15 @@ export class DockerClient {
841
667
  });
842
668
  this.log.debug({ image }, "image pulled");
843
669
  }
844
- async createStatic(static_) {
670
+ async createStatic(static_, silent) {
845
671
  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
- });
851
672
  this.log.debug({ resourceId: static_.id, route: static_.config.route }, "creating static site");
852
673
  this.log.debug({ resourceId: static_.id }, "static build starting");
853
674
  const { outputPath } = await buildStatic(static_.config.build.cwd, {
854
675
  output: static_.config.build.output,
855
676
  command: static_.config.build.command,
856
677
  env: static_.config.build.env,
678
+ silent,
857
679
  }, this.log);
858
680
  const volumeName = `${static_.id}-files`;
859
681
  if (!(await this.volumeExists(volumeName))) {
@@ -901,13 +723,13 @@ export class DockerClient {
901
723
  "vyft.managed": "true",
902
724
  "vyft.project": this.project,
903
725
  };
904
- const command = static_.config.spa
905
- ? [
726
+ const command = static_.config.spa === false
727
+ ? ["caddy", "file-server", "--root", "/srv", "--listen", ":80"]
728
+ : [
906
729
  "sh",
907
730
  "-c",
908
731
  "printf ':80 {\\nroot * /srv\\ntry_files {path} /index.html\\nfile_server\\n}\\n' > /etc/caddy/Caddyfile && caddy run --config /etc/caddy/Caddyfile --adapter caddyfile",
909
- ]
910
- : ["caddy", "file-server", "--root", "/srv", "--listen", ":80"];
732
+ ];
911
733
  const serviceSpec = {
912
734
  Name: static_.id,
913
735
  Labels: serviceLabels,
@@ -945,18 +767,26 @@ export class DockerClient {
945
767
  this.log.debug({ resourceId: static_.id }, "creating caddy service for static site");
946
768
  await this.docker.createService(serviceSpec);
947
769
  }
948
- await this.addRoute(static_.id, static_.config.route, {
949
- handler: "reverse_proxy",
950
- upstreams: [{ dial: `${static_.id}:80` }],
770
+ await this.proxy.addRoute(static_.id, static_.config.route, {
771
+ host: static_.id,
772
+ port: 80,
951
773
  });
952
- const durationMs = Math.round(performance.now() - start);
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}`);
774
+ const duration = Math.round(performance.now() - start);
775
+ this.log.info({
776
+ resourceId: static_.id,
777
+ route: static_.config.route,
778
+ durationMs: duration,
779
+ }, existing ? "site updated" : "site created");
780
+ return {
781
+ action: existing ? "updated" : "created",
782
+ route: static_.config.route,
783
+ duration,
784
+ };
955
785
  }
956
786
  async removeStatic(id) {
957
787
  const start = performance.now();
958
788
  this.log.debug({ resourceId: id }, "removing static site");
959
- await this.removeRoute(id);
789
+ await this.proxy.removeRoute(id);
960
790
  await this.docker.getService(id).remove();
961
791
  const maxWait = 30000;
962
792
  const interval = 2000;
@@ -987,12 +817,92 @@ export class DockerClient {
987
817
  }
988
818
  const durationMs = Math.round(performance.now() - start);
989
819
  this.log.info({ resourceId: id, durationMs }, "site removed");
990
- log.step(`Removed ${id}`);
991
820
  }
992
- async proxyLogs(options) {
993
- const container = await this.findProxyContainer();
821
+ async *serviceLogs(options) {
822
+ const allServices = await this.docker.listServices({
823
+ filters: JSON.stringify({
824
+ label: ["vyft.managed=true", `vyft.project=${this.project}`],
825
+ }),
826
+ });
827
+ const prefix = `${this.project}-`;
828
+ let matched = allServices;
829
+ if (options.services && options.services.length > 0) {
830
+ const filter = new Set(options.services);
831
+ matched = allServices.filter((svc) => {
832
+ const name = svc.Spec?.Name || "";
833
+ const short = name.startsWith(prefix)
834
+ ? name.slice(prefix.length)
835
+ : name;
836
+ return filter.has(short) || filter.has(name);
837
+ });
838
+ }
839
+ if (matched.length === 0) {
840
+ this.log.warn("no matching services found");
841
+ return;
842
+ }
994
843
  const tail = options.tail ?? 100;
995
- if (options.follow) {
844
+ // Collect all containers with their short service name (parallel)
845
+ const targets = (await Promise.all(matched.map(async (svc) => {
846
+ const name = svc.Spec?.Name || "";
847
+ const short = name.startsWith(prefix)
848
+ ? name.slice(prefix.length)
849
+ : name;
850
+ const containers = await this.docker.listContainers({
851
+ filters: JSON.stringify({
852
+ label: [`com.docker.swarm.service.name=${name}`],
853
+ }),
854
+ });
855
+ return containers.map((cInfo) => ({
856
+ container: this.docker.getContainer(cInfo.Id),
857
+ short,
858
+ }));
859
+ }))).flat();
860
+ if (!options.follow) {
861
+ // Fetch all logs in parallel
862
+ const results = await Promise.all(targets.map(async ({ container, short }) => {
863
+ const buf = await container.logs({
864
+ stdout: true,
865
+ stderr: true,
866
+ follow: false,
867
+ tail,
868
+ timestamps: true,
869
+ });
870
+ const entries = [];
871
+ let offset = 0;
872
+ while (offset + 8 <= buf.length) {
873
+ const type = buf[offset];
874
+ const size = buf.readUInt32BE(offset + 4);
875
+ offset += 8;
876
+ const text = buf.subarray(offset, offset + size).toString();
877
+ entries.push({
878
+ service: short,
879
+ stream: type === 2 ? "stderr" : "stdout",
880
+ text,
881
+ });
882
+ offset += size;
883
+ }
884
+ return entries;
885
+ }));
886
+ for (const entries of results) {
887
+ for (const entry of entries) {
888
+ yield entry;
889
+ }
890
+ }
891
+ return;
892
+ }
893
+ // Follow mode: merge all container streams concurrently
894
+ const buffer = [];
895
+ let notify = null;
896
+ let active = targets.length;
897
+ const push = (entry) => {
898
+ buffer.push(entry);
899
+ if (notify) {
900
+ const n = notify;
901
+ notify = null;
902
+ n();
903
+ }
904
+ };
905
+ await Promise.all(targets.map(async ({ container, short }) => {
996
906
  const stream = await container.logs({
997
907
  stdout: true,
998
908
  stderr: true,
@@ -1000,38 +910,38 @@ export class DockerClient {
1000
910
  tail,
1001
911
  timestamps: true,
1002
912
  });
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,
913
+ const stdoutPT = new PassThrough();
914
+ const stderrPT = new PassThrough();
915
+ stdoutPT.on("data", (chunk) => {
916
+ for (const line of chunk.toString().split("\n")) {
917
+ if (line)
918
+ push({ service: short, stream: "stdout", text: line });
919
+ }
1019
920
  });
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);
921
+ stderrPT.on("data", (chunk) => {
922
+ for (const line of chunk.toString().split("\n")) {
923
+ if (line)
924
+ push({ service: short, stream: "stderr", text: line });
1030
925
  }
1031
- else {
1032
- process.stdout.write(payload);
926
+ });
927
+ this.docker.modem.demuxStream(stream, stdoutPT, stderrPT);
928
+ stream.on("end", () => {
929
+ active--;
930
+ if (notify && active === 0) {
931
+ const n = notify;
932
+ notify = null;
933
+ n();
1033
934
  }
1034
- offset += size;
935
+ });
936
+ }));
937
+ while (active > 0 || buffer.length > 0) {
938
+ if (buffer.length === 0) {
939
+ await new Promise((r) => {
940
+ notify = r;
941
+ });
942
+ }
943
+ while (buffer.length > 0) {
944
+ yield buffer.shift();
1035
945
  }
1036
946
  }
1037
947
  }