alchemy 0.85.1 → 0.86.0

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 (40) hide show
  1. package/bin/alchemy.js +1 -1
  2. package/lib/cloudflare/index.d.ts +1 -0
  3. package/lib/cloudflare/index.d.ts.map +1 -1
  4. package/lib/cloudflare/index.js +1 -0
  5. package/lib/cloudflare/index.js.map +1 -1
  6. package/lib/cloudflare/sveltekit/plugin.d.ts.map +1 -1
  7. package/lib/cloudflare/sveltekit/plugin.js +11 -5
  8. package/lib/cloudflare/sveltekit/plugin.js.map +1 -1
  9. package/lib/cloudflare/vite/vite.d.ts.map +1 -1
  10. package/lib/cloudflare/vite/vite.js +2 -1
  11. package/lib/cloudflare/vite/vite.js.map +1 -1
  12. package/lib/cloudflare/vpc-service-ref.d.ts +38 -0
  13. package/lib/cloudflare/vpc-service-ref.d.ts.map +1 -0
  14. package/lib/cloudflare/vpc-service-ref.js +33 -0
  15. package/lib/cloudflare/vpc-service-ref.js.map +1 -0
  16. package/lib/cloudflare/vpc-service.d.ts +4 -4
  17. package/lib/cloudflare/vpc-service.d.ts.map +1 -1
  18. package/lib/cloudflare/vpc-service.js +44 -38
  19. package/lib/cloudflare/vpc-service.js.map +1 -1
  20. package/lib/docker/api.d.ts +33 -2
  21. package/lib/docker/api.d.ts.map +1 -1
  22. package/lib/docker/api.js +8 -4
  23. package/lib/docker/api.js.map +1 -1
  24. package/lib/docker/container.d.ts +1 -1
  25. package/lib/docker/container.d.ts.map +1 -1
  26. package/lib/docker/container.js +244 -15
  27. package/lib/docker/container.js.map +1 -1
  28. package/lib/test/bun.js +1 -1
  29. package/lib/test/vitest.js +1 -1
  30. package/package.json +1 -1
  31. package/src/cloudflare/index.ts +1 -0
  32. package/src/cloudflare/sveltekit/plugin.ts +17 -8
  33. package/src/cloudflare/vite/vite.ts +4 -2
  34. package/src/cloudflare/vpc-service-ref.ts +60 -0
  35. package/src/cloudflare/vpc-service.ts +53 -46
  36. package/src/docker/api.ts +47 -4
  37. package/src/docker/container.ts +318 -18
  38. package/src/test/bun.ts +1 -1
  39. package/src/test/vitest.ts +1 -1
  40. package/workers/tunnel-proxy.js +1 -1
package/src/docker/api.ts CHANGED
@@ -69,10 +69,44 @@ type VolumeInfo = {
69
69
  Scope: string;
70
70
  };
71
71
 
72
- type ContainerInfo = {
72
+ export type ContainerInfo = {
73
73
  Id: string;
74
74
  State: { Status: "created" | "running" | "paused" | "stopped" | "exited" };
75
75
  Created: string;
76
+ Config: {
77
+ Image: string;
78
+ Cmd: string[] | null;
79
+ Env: string[] | null;
80
+ Healthcheck?: {
81
+ Test: string[] | null;
82
+ Interval?: number;
83
+ Timeout?: number;
84
+ Retries?: number;
85
+ StartPeriod?: number;
86
+ StartInterval?: number;
87
+ } | null;
88
+ };
89
+ HostConfig: {
90
+ PortBindings: Record<
91
+ string,
92
+ Array<{ HostIp: string; HostPort: string }> | null
93
+ > | null;
94
+ Binds: string[] | null;
95
+ RestartPolicy: {
96
+ Name: string;
97
+ MaximumRetryCount: number;
98
+ };
99
+ AutoRemove: boolean;
100
+ };
101
+ NetworkSettings: {
102
+ Networks: Record<
103
+ string,
104
+ {
105
+ NetworkID: string;
106
+ Aliases: string[] | null;
107
+ }
108
+ > | null;
109
+ };
76
110
  };
77
111
 
78
112
  /**
@@ -103,6 +137,7 @@ export class DockerApi {
103
137
  async exec(
104
138
  args: string[],
105
139
  remainingAttempts = 3,
140
+ quiet = false,
106
141
  ): Promise<{ stdout: string; stderr: string }> {
107
142
  // If a custom config directory is provided, ensure all commands use it by
108
143
  // setting the DOCKER_CONFIG env variable for the spawned process.
@@ -124,13 +159,17 @@ export class DockerApi {
124
159
 
125
160
  // Stream stdout in real-time
126
161
  subprocess.stdout?.on("data", (chunk: string) => {
127
- process.stdout.write(chunk);
162
+ if (!quiet) {
163
+ process.stdout.write(chunk);
164
+ }
128
165
  stdout += chunk;
129
166
  });
130
167
 
131
168
  // Stream stderr in real-time
132
169
  subprocess.stderr?.on("data", (chunk: string) => {
133
- process.stderr.write(chunk);
170
+ if (!quiet) {
171
+ process.stderr.write(chunk);
172
+ }
134
173
  stderr += chunk;
135
174
  });
136
175
 
@@ -364,7 +403,11 @@ export class DockerApi {
364
403
  * @returns Container details in JSON format
365
404
  */
366
405
  async inspectContainer(containerId: string): Promise<ContainerInfo[]> {
367
- const { stdout } = await this.exec(["container", "inspect", containerId]);
406
+ const { stdout } = await this.exec(
407
+ ["container", "inspect", containerId],
408
+ undefined,
409
+ true,
410
+ );
368
411
  try {
369
412
  return JSON.parse(stdout.trim()) as ContainerInfo[];
370
413
  } catch (_error) {
@@ -1,6 +1,6 @@
1
1
  import type { Context } from "../context.ts";
2
2
  import { Resource } from "../resource.ts";
3
- import { DockerApi } from "./api.ts";
3
+ import { DockerApi, normalizeDuration, type ContainerInfo } from "./api.ts";
4
4
  import type { Image } from "./image.ts";
5
5
  import type { RemoteImage } from "./remote-image.ts";
6
6
 
@@ -201,7 +201,7 @@ export interface Container extends ContainerProps {
201
201
  /**
202
202
  * Container state
203
203
  */
204
- state?: "created" | "running" | "paused" | "stopped" | "exited";
204
+ state: "created" | "running" | "paused" | "stopped" | "exited";
205
205
 
206
206
  /**
207
207
  * Time when the container was created
@@ -277,6 +277,7 @@ export interface Container extends ContainerProps {
277
277
  */
278
278
  export const Container = Resource(
279
279
  "docker::Container",
280
+ { alwaysUpdate: true },
280
281
  async function (
281
282
  this: Context<Container>,
282
283
  id: string,
@@ -311,39 +312,63 @@ export const Container = Resource(
311
312
  return this.destroy();
312
313
  }
313
314
 
314
- let containerState: NonNullable<Container["state"]> = "created";
315
+ let containerState: Container["state"] = "created";
315
316
 
316
317
  // Check if container already exists
317
318
  const containerExists = await api.containerExists(containerName);
318
319
 
319
320
  if (containerExists) {
320
- if (this.phase === "update") {
321
- // Remove existing container for update
322
- await api.removeContainer(containerName, true);
321
+ // Create phase - check for adoption
322
+ if (this.phase === "create" && !props.adopt) {
323
+ throw new Error(
324
+ `Container "${containerName}" already exists. Use adopt: true to adopt it.`,
325
+ );
326
+ }
327
+
328
+ const [containerInfo] = await api.inspectContainer(containerName);
329
+
330
+ // Compute what changes are needed
331
+ if (shouldReplace(imageRef, props, containerInfo)) {
332
+ // Need to recreate - remove existing container
333
+ if (this.phase === "update") {
334
+ // In update phase, we can replace the resource
335
+ // Force because we need to delete the old one first if the name is the same
336
+ return this.replace(true);
337
+ } else {
338
+ // In create phase, we cannot replace the resource, so manually delete instead
339
+ await api.removeContainer(containerName, true);
340
+ }
323
341
  } else {
324
- // Create phase - check for adoption
325
- if (!props.adopt) {
326
- throw new Error(
327
- `Container "${containerName}" already exists. Use adopt: true to adopt it.`,
328
- );
342
+ // Apply incremental changes without recreating the container
343
+
344
+ const { toConnect, toDisconnect } = getNetworkChanges(
345
+ props,
346
+ containerInfo,
347
+ );
348
+
349
+ // Apply network disconnections
350
+ for (const network of toDisconnect) {
351
+ await api.disconnectNetwork(containerInfo.Id, network);
329
352
  }
330
353
 
331
- // Adopt existing container
332
- const containerInfos = await api.inspectContainer(containerName);
333
- const containerInfo = containerInfos[0];
334
- let adoptedState = containerInfo.State.Status;
354
+ // Apply network connections
355
+ for (const network of toConnect) {
356
+ await api.connectNetwork(containerInfo.Id, network.name, {
357
+ aliases: network.aliases,
358
+ });
359
+ }
335
360
 
336
361
  // Optionally start the container if requested
337
362
  if (props.start && containerInfo.State.Status !== "running") {
338
- await api.startContainer(containerName);
339
- adoptedState = "running";
363
+ await api.startContainer(containerInfo.Id);
364
+ containerState = "running";
340
365
  }
341
366
 
342
367
  return {
343
368
  ...props,
344
369
  id: containerInfo.Id,
345
370
  name: containerName,
346
- state: adoptedState,
371
+ state: containerState,
347
372
  createdAt: new Date(containerInfo.Created).getTime(),
348
373
  };
349
374
  }
@@ -402,3 +427,278 @@ export const Container = Resource(
402
427
  };
403
428
  },
404
429
  );
430
+
431
+ function getNetworkChanges(
432
+ props: ContainerProps,
433
+ containerInfo: ContainerInfo,
434
+ ): { toConnect: NetworkMapping[]; toDisconnect: string[] } {
435
+ const currentNetworks = new Set(
436
+ Object.keys(containerInfo.NetworkSettings.Networks || {}),
437
+ );
438
+ const desiredNetworks = new Map(
439
+ (props.networks || []).map((n) => [n.name, n]),
440
+ );
441
+ const toConnect: NetworkMapping[] = [];
442
+ const toDisconnect: string[] = [];
443
+ for (const network of currentNetworks) {
444
+ if (!desiredNetworks.has(network) && network !== "bridge") {
445
+ toDisconnect.push(network);
446
+ }
447
+ }
448
+ for (const [name, config] of desiredNetworks) {
449
+ if (!currentNetworks.has(name)) {
450
+ toConnect.push(config);
451
+ }
452
+ }
453
+ return { toConnect, toDisconnect };
454
+ }
455
+
456
+ function shouldReplace(
457
+ imageRef: string,
458
+ props: ContainerProps,
459
+ containerInfo: ContainerInfo,
460
+ ): boolean {
461
+ // Check immutable properties that require recreation
462
+
463
+ // Image change - compare the image ID/digest if available
464
+ // The container stores the resolved image ID, so we compare against imageRef
465
+ if (containerInfo.Config.Image !== imageRef) {
466
+ return true;
467
+ }
468
+
469
+ // Command change
470
+ const containerCmd = containerInfo.Config.Cmd || [];
471
+ if (
472
+ props.command && // only compare if command is set; otherwise we'd be comparing against the image's default command
473
+ (props.command.length !== containerCmd.length ||
474
+ !props.command.every((c, i) => c === containerCmd[i]))
475
+ ) {
476
+ return true;
477
+ }
478
+
479
+ // Environment variables
480
+ if (!compareEnv(props.environment, containerInfo.Config.Env)) {
481
+ return true;
482
+ }
483
+
484
+ // Port bindings
485
+ if (!comparePorts(props.ports, containerInfo.HostConfig.PortBindings)) {
486
+ return true;
487
+ }
488
+
489
+ // Volume bindings
490
+ if (!compareVolumes(props.volumes, containerInfo.HostConfig.Binds)) {
491
+ return true;
492
+ }
493
+
494
+ // Healthcheck
495
+ if (
496
+ !compareHealthcheck(props.healthcheck, containerInfo.Config.Healthcheck)
497
+ ) {
498
+ return true;
499
+ }
500
+
501
+ // Restart policy
502
+ if (
503
+ !compareRestartPolicy(props.restart, containerInfo.HostConfig.RestartPolicy)
504
+ ) {
505
+ return true;
506
+ }
507
+
508
+ // AutoRemove (removeOnExit)
509
+ if ((props.removeOnExit || false) !== containerInfo.HostConfig.AutoRemove) {
510
+ return true;
511
+ }
512
+
513
+ return false;
514
+ }
515
+
516
+ /**
517
+ * Normalize port mappings to a comparable format
518
+ * @internal
519
+ */
520
+ function normalizePortMappings(
521
+ ports: PortMapping[] | undefined,
522
+ ): Map<string, string> {
523
+ const map = new Map<string, string>();
524
+ if (!ports) return map;
525
+ for (const port of ports) {
526
+ const protocol = port.protocol || "tcp";
527
+ map.set(`${port.external}`, `${port.internal}/${protocol}`);
528
+ }
529
+ return map;
530
+ }
531
+
532
+ /**
533
+ * Normalize volume mappings to a comparable format
534
+ * @internal
535
+ */
536
+ function normalizeVolumeMappings(
537
+ volumes: VolumeMapping[] | undefined,
538
+ ): Set<string> {
539
+ const set = new Set<string>();
540
+ if (!volumes) return set;
541
+ for (const volume of volumes) {
542
+ const readOnlyFlag = volume.readOnly ? ":ro" : "";
543
+ set.add(`${volume.hostPath}:${volume.containerPath}${readOnlyFlag}`);
544
+ }
545
+ return set;
546
+ }
547
+
548
+ /**
549
+ * Compare environment variables
550
+ * @internal
551
+ */
552
+ function compareEnv(
553
+ propsEnv: Record<string, string> | undefined,
554
+ containerEnv: string[] | null,
555
+ ): boolean {
556
+ const propsEntries = Object.entries(propsEnv || {}).sort(([a], [b]) =>
557
+ a.localeCompare(b),
558
+ );
559
+ const containerEntries = (containerEnv || [])
560
+ .map((e) => {
561
+ const idx = e.indexOf("=");
562
+ return idx >= 0 ? ([e.slice(0, idx), e.slice(idx + 1)] as const) : null;
563
+ })
564
+ .filter((e): e is [string, string] => e !== null)
565
+ // Filter out PATH and other system env vars that Docker adds
566
+ .filter(([key]) => propsEnv && key in propsEnv)
567
+ .sort(([a], [b]) => a.localeCompare(b));
568
+
569
+ if (propsEntries.length !== containerEntries.length) return false;
570
+ for (let i = 0; i < propsEntries.length; i++) {
571
+ if (
572
+ propsEntries[i][0] !== containerEntries[i][0] ||
573
+ propsEntries[i][1] !== containerEntries[i][1]
574
+ ) {
575
+ return false;
576
+ }
577
+ }
578
+ return true;
579
+ }
580
+
581
+ /**
582
+ * Compare port bindings
583
+ * @internal
584
+ */
585
+ function comparePorts(
586
+ propsPorts: PortMapping[] | undefined,
587
+ containerPorts: Record<
588
+ string,
589
+ Array<{ HostIp: string; HostPort: string }> | null
590
+ > | null,
591
+ ): boolean {
592
+ const propsMap = normalizePortMappings(propsPorts);
593
+
594
+ // Extract container port mappings
595
+ const containerMap = new Map<string, string>();
596
+ if (containerPorts) {
597
+ for (const [containerPort, bindings] of Object.entries(containerPorts)) {
598
+ if (bindings && bindings.length > 0) {
599
+ containerMap.set(bindings[0].HostPort, containerPort);
600
+ }
601
+ }
602
+ }
603
+
604
+ if (propsMap.size !== containerMap.size) return false;
605
+ for (const [hostPort, containerPort] of propsMap) {
606
+ if (containerMap.get(hostPort) !== containerPort) return false;
607
+ }
608
+ return true;
609
+ }
610
+
611
+ /**
612
+ * Compare volume bindings
613
+ * @internal
614
+ */
615
+ function compareVolumes(
616
+ propsVolumes: VolumeMapping[] | undefined,
617
+ containerBinds: string[] | null,
618
+ ): boolean {
619
+ const propsSet = normalizeVolumeMappings(propsVolumes);
620
+ const containerSet = new Set(containerBinds || []);
621
+
622
+ if (propsSet.size !== containerSet.size) return false;
623
+ for (const bind of propsSet) {
624
+ if (!containerSet.has(bind)) return false;
625
+ }
626
+ return true;
627
+ }
628
+
629
+ /**
630
+ * Compare healthcheck configuration
631
+ * @internal
632
+ */
633
+ function compareHealthcheck(
634
+ propsHc: HealthcheckConfig | undefined,
635
+ containerHc:
636
+ | {
637
+ Test: string[] | null;
638
+ Interval?: number;
639
+ Timeout?: number;
640
+ Retries?: number;
641
+ StartPeriod?: number;
642
+ StartInterval?: number;
643
+ }
644
+ | null
645
+ | undefined,
646
+ ): boolean {
647
+ // Both undefined/null
648
+ if (!propsHc && !containerHc) return true;
649
+ // One defined, one not
650
+ if (!propsHc || !containerHc) return false;
651
+
652
+ // Compare command
653
+ const propsCmd = Array.isArray(propsHc.cmd)
654
+ ? propsHc.cmd.join(" ")
655
+ : propsHc.cmd;
656
+ const containerCmd = containerHc.Test
657
+ ? containerHc.Test.slice(1).join(" ")
658
+ : "";
659
+ if (propsCmd !== containerCmd) return false;
660
+
661
+ // Helper to convert Duration to nanoseconds for comparison
662
+ const toNanos = (d: Duration | undefined): number => {
663
+ if (d === undefined) return 0;
664
+ const str = normalizeDuration(d);
665
+ const match = str.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/);
666
+ if (!match) return 0;
667
+ const value = parseFloat(match[1]);
668
+ const unit = match[2];
669
+ switch (unit) {
670
+ case "ms":
671
+ return value * 1_000_000;
672
+ case "s":
673
+ return value * 1_000_000_000;
674
+ case "m":
675
+ return value * 60 * 1_000_000_000;
676
+ case "h":
677
+ return value * 3600 * 1_000_000_000;
678
+ default:
679
+ return 0;
680
+ }
681
+ };
682
+
683
+ if (toNanos(propsHc.interval) !== (containerHc.Interval || 0)) return false;
684
+ if (toNanos(propsHc.timeout) !== (containerHc.Timeout || 0)) return false;
685
+ if ((propsHc.retries || 0) !== (containerHc.Retries || 0)) return false;
686
+ if (toNanos(propsHc.startPeriod) !== (containerHc.StartPeriod || 0))
687
+ return false;
688
+ if (toNanos(propsHc.startInterval) !== (containerHc.StartInterval || 0))
689
+ return false;
690
+
691
+ return true;
692
+ }
693
+
694
+ /**
695
+ * Compare restart policy
696
+ * @internal
697
+ */
698
+ function compareRestartPolicy(
699
+ propsRestart: ContainerProps["restart"] | undefined,
700
+ containerRestart: { Name: string; MaximumRetryCount: number },
701
+ ): boolean {
702
+ const propsPolicy = propsRestart || "no";
703
+ return propsPolicy === containerRestart.Name;
704
+ }
package/src/test/bun.ts CHANGED
@@ -157,7 +157,7 @@ export function test(meta: ImportMeta, defaultOptions?: TestOptions): test {
157
157
  const timeout =
158
158
  typeof args[args.length - 1] === "number"
159
159
  ? (args[args.length - 1] as number)
160
- : 120000;
160
+ : 150000;
161
161
  const spread = (obj: any) =>
162
162
  obj && typeof obj === "object"
163
163
  ? Object.fromEntries(
@@ -196,7 +196,7 @@ export function test(
196
196
  const timeout =
197
197
  typeof args[args.length - 1] === "number"
198
198
  ? (args[args.length - 1] as number)
199
- : 120000;
199
+ : 150000;
200
200
  const spread = (obj: any) =>
201
201
  obj && typeof obj === "object"
202
202
  ? Object.fromEntries(
@@ -83,7 +83,7 @@ const renderErrorHtml = (props) => `
83
83
  Alchemy</a>.</p>
84
84
  </div>
85
85
  <div class="bg-slate-200 px-5 py-3 flex flex-col w-full max-w-lg">
86
- <p class="text-sm text-slate-500">Alchemy 0.85.1</p>
86
+ <p class="text-sm text-slate-500">Alchemy 0.86.0</p>
87
87
  </div>
88
88
  </div>
89
89
  </body>