alchemy 0.60.2 → 0.62.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 (174) hide show
  1. package/README.md +1 -2
  2. package/bin/alchemy.js +1480 -1498
  3. package/bin/commands/init.ts +94 -25
  4. package/bin/services/execute-alchemy.ts +32 -23
  5. package/lib/alchemy.d.ts +2 -2
  6. package/lib/alchemy.d.ts.map +1 -1
  7. package/lib/alchemy.js +1 -0
  8. package/lib/alchemy.js.map +1 -1
  9. package/lib/aws/client-props.d.ts +149 -0
  10. package/lib/aws/client-props.d.ts.map +1 -0
  11. package/lib/aws/client-props.js +1 -0
  12. package/lib/aws/client-props.js.map +1 -0
  13. package/lib/aws/credentials.d.ts +68 -5
  14. package/lib/aws/credentials.d.ts.map +1 -1
  15. package/lib/aws/credentials.js +148 -0
  16. package/lib/aws/credentials.js.map +1 -1
  17. package/lib/aws/ec2/internet-gateway-attachment.d.ts +57 -3
  18. package/lib/aws/ec2/internet-gateway-attachment.d.ts.map +1 -1
  19. package/lib/aws/ec2/internet-gateway-attachment.js +57 -3
  20. package/lib/aws/ec2/internet-gateway-attachment.js.map +1 -1
  21. package/lib/aws/ec2/internet-gateway.d.ts +68 -40
  22. package/lib/aws/ec2/internet-gateway.d.ts.map +1 -1
  23. package/lib/aws/ec2/internet-gateway.js +68 -40
  24. package/lib/aws/ec2/internet-gateway.js.map +1 -1
  25. package/lib/aws/ec2/nat-gateway.d.ts +99 -18
  26. package/lib/aws/ec2/nat-gateway.d.ts.map +1 -1
  27. package/lib/aws/ec2/nat-gateway.js +113 -37
  28. package/lib/aws/ec2/nat-gateway.js.map +1 -1
  29. package/lib/aws/ec2/route-table-association.d.ts +1 -1
  30. package/lib/aws/ec2/route-table-association.js +1 -1
  31. package/lib/aws/ec2/route-table.d.ts +64 -1
  32. package/lib/aws/ec2/route-table.d.ts.map +1 -1
  33. package/lib/aws/ec2/route-table.js +64 -1
  34. package/lib/aws/ec2/route-table.js.map +1 -1
  35. package/lib/aws/ec2/route.d.ts +60 -1
  36. package/lib/aws/ec2/route.d.ts.map +1 -1
  37. package/lib/aws/ec2/route.js +60 -1
  38. package/lib/aws/ec2/route.js.map +1 -1
  39. package/lib/aws/ec2/security-group.d.ts +96 -3
  40. package/lib/aws/ec2/security-group.d.ts.map +1 -1
  41. package/lib/aws/ec2/security-group.js +96 -3
  42. package/lib/aws/ec2/security-group.js.map +1 -1
  43. package/lib/aws/ec2/subnet.d.ts +61 -3
  44. package/lib/aws/ec2/subnet.d.ts.map +1 -1
  45. package/lib/aws/ec2/subnet.js +61 -3
  46. package/lib/aws/ec2/subnet.js.map +1 -1
  47. package/lib/aws/ec2/utils.d.ts +10 -2
  48. package/lib/aws/ec2/utils.d.ts.map +1 -1
  49. package/lib/aws/ec2/utils.js +68 -7
  50. package/lib/aws/ec2/utils.js.map +1 -1
  51. package/lib/aws/ec2/vpc.d.ts +50 -2
  52. package/lib/aws/ec2/vpc.d.ts.map +1 -1
  53. package/lib/aws/ec2/vpc.js +50 -2
  54. package/lib/aws/ec2/vpc.js.map +1 -1
  55. package/lib/aws/index.d.ts +2 -0
  56. package/lib/aws/index.d.ts.map +1 -1
  57. package/lib/aws/index.js +2 -0
  58. package/lib/aws/index.js.map +1 -1
  59. package/lib/cloudflare/api-response.js +2 -2
  60. package/lib/cloudflare/api-response.js.map +1 -1
  61. package/lib/cloudflare/api.d.ts +19 -0
  62. package/lib/cloudflare/api.d.ts.map +1 -1
  63. package/lib/cloudflare/api.js +4 -0
  64. package/lib/cloudflare/api.js.map +1 -1
  65. package/lib/cloudflare/assets.d.ts.map +1 -1
  66. package/lib/cloudflare/assets.js +3 -4
  67. package/lib/cloudflare/assets.js.map +1 -1
  68. package/lib/cloudflare/bucket.d.ts +5 -3
  69. package/lib/cloudflare/bucket.d.ts.map +1 -1
  70. package/lib/cloudflare/bucket.js +28 -20
  71. package/lib/cloudflare/bucket.js.map +1 -1
  72. package/lib/cloudflare/bundle/plugin-compat-warning.js +7 -7
  73. package/lib/cloudflare/bundle/plugin-compat-warning.js.map +1 -1
  74. package/lib/cloudflare/cloudflare-env-proxy.js.map +1 -1
  75. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  76. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  77. package/lib/cloudflare/d1-migrations.d.ts.map +1 -1
  78. package/lib/cloudflare/d1-migrations.js +3 -2
  79. package/lib/cloudflare/d1-migrations.js.map +1 -1
  80. package/lib/cloudflare/index.d.ts +2 -1
  81. package/lib/cloudflare/index.d.ts.map +1 -1
  82. package/lib/cloudflare/index.js +2 -1
  83. package/lib/cloudflare/index.js.map +1 -1
  84. package/lib/cloudflare/kv-namespace.d.ts +2 -2
  85. package/lib/cloudflare/kv-namespace.d.ts.map +1 -1
  86. package/lib/cloudflare/kv-namespace.js.map +1 -1
  87. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
  88. package/lib/cloudflare/miniflare/build-worker-options.js +16 -3
  89. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
  90. package/lib/cloudflare/miniflare/miniflare-controller.d.ts.map +1 -1
  91. package/lib/cloudflare/miniflare/miniflare-controller.js +3 -0
  92. package/lib/cloudflare/miniflare/miniflare-controller.js.map +1 -1
  93. package/lib/cloudflare/oauth.js +3 -3
  94. package/lib/cloudflare/oauth.js.map +1 -1
  95. package/lib/cloudflare/quick-tunnel.d.ts +17 -0
  96. package/lib/cloudflare/quick-tunnel.d.ts.map +1 -0
  97. package/lib/cloudflare/quick-tunnel.js +26 -0
  98. package/lib/cloudflare/quick-tunnel.js.map +1 -0
  99. package/lib/cloudflare/sveltekit/plugin.d.ts +2 -2
  100. package/lib/cloudflare/sveltekit/plugin.d.ts.map +1 -1
  101. package/lib/cloudflare/sveltekit/plugin.js +5 -4
  102. package/lib/cloudflare/sveltekit/plugin.js.map +1 -1
  103. package/lib/cloudflare/website.d.ts.map +1 -1
  104. package/lib/cloudflare/website.js +26 -97
  105. package/lib/cloudflare/website.js.map +1 -1
  106. package/lib/cloudflare/worker-bundle.d.ts.map +1 -1
  107. package/lib/cloudflare/worker-bundle.js +11 -9
  108. package/lib/cloudflare/worker-bundle.js.map +1 -1
  109. package/lib/cloudflare/worker-tail.d.ts.map +1 -1
  110. package/lib/cloudflare/worker-tail.js +6 -6
  111. package/lib/cloudflare/worker-tail.js.map +1 -1
  112. package/lib/resource.d.ts +1 -0
  113. package/lib/resource.d.ts.map +1 -1
  114. package/lib/resource.js +13 -2
  115. package/lib/resource.js.map +1 -1
  116. package/lib/scope.d.ts +38 -1
  117. package/lib/scope.d.ts.map +1 -1
  118. package/lib/scope.js +44 -19
  119. package/lib/scope.js.map +1 -1
  120. package/lib/state/cloudflare-state-store.d.ts.map +1 -1
  121. package/lib/state/cloudflare-state-store.js +2 -1
  122. package/lib/state/cloudflare-state-store.js.map +1 -1
  123. package/lib/util/idempotent-spawn.d.ts +24 -0
  124. package/lib/util/idempotent-spawn.d.ts.map +1 -0
  125. package/lib/util/idempotent-spawn.js +237 -0
  126. package/lib/util/idempotent-spawn.js.map +1 -0
  127. package/lib/util/index.d.ts +2 -0
  128. package/lib/util/index.d.ts.map +1 -0
  129. package/lib/util/index.js +2 -0
  130. package/lib/util/index.js.map +1 -0
  131. package/lib/util/safe-fetch.d.ts +24 -0
  132. package/lib/util/safe-fetch.d.ts.map +1 -1
  133. package/lib/util/safe-fetch.js +72 -0
  134. package/lib/util/safe-fetch.js.map +1 -1
  135. package/package.json +9 -5
  136. package/src/alchemy.ts +3 -2
  137. package/src/aws/client-props.ts +156 -0
  138. package/src/aws/credentials.ts +165 -4
  139. package/src/aws/ec2/internet-gateway-attachment.ts +59 -4
  140. package/src/aws/ec2/internet-gateway.ts +70 -41
  141. package/src/aws/ec2/nat-gateway.ts +123 -33
  142. package/src/aws/ec2/route-table-association.ts +1 -1
  143. package/src/aws/ec2/route-table.ts +66 -2
  144. package/src/aws/ec2/route.ts +62 -2
  145. package/src/aws/ec2/security-group.ts +98 -4
  146. package/src/aws/ec2/subnet.ts +63 -4
  147. package/src/aws/ec2/utils.ts +73 -7
  148. package/src/aws/ec2/vpc.ts +52 -3
  149. package/src/aws/index.ts +2 -0
  150. package/src/cloudflare/api-response.ts +2 -2
  151. package/src/cloudflare/api.ts +23 -0
  152. package/src/cloudflare/assets.ts +3 -4
  153. package/src/cloudflare/bucket.ts +39 -29
  154. package/src/cloudflare/bundle/plugin-compat-warning.ts +7 -7
  155. package/src/cloudflare/cloudflare-env-proxy.ts +2 -2
  156. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  157. package/src/cloudflare/d1-migrations.ts +4 -5
  158. package/src/cloudflare/index.ts +2 -1
  159. package/src/cloudflare/kv-namespace.ts +26 -27
  160. package/src/cloudflare/miniflare/build-worker-options.ts +16 -3
  161. package/src/cloudflare/miniflare/miniflare-controller.ts +4 -0
  162. package/src/cloudflare/oauth.ts +3 -3
  163. package/src/cloudflare/quick-tunnel.ts +30 -0
  164. package/src/cloudflare/sveltekit/plugin.ts +5 -5
  165. package/src/cloudflare/website.ts +30 -111
  166. package/src/cloudflare/worker-bundle.ts +11 -9
  167. package/src/cloudflare/worker-tail.ts +6 -8
  168. package/src/resource.ts +19 -2
  169. package/src/scope.ts +111 -20
  170. package/src/state/cloudflare-state-store.ts +2 -1
  171. package/src/util/idempotent-spawn.ts +303 -0
  172. package/src/util/index.ts +1 -0
  173. package/src/util/safe-fetch.ts +114 -0
  174. package/templates/rwsdk/package.json +1 -1
package/src/resource.ts CHANGED
@@ -5,6 +5,7 @@ import { Scope as _Scope, type Scope } from "./scope.ts";
5
5
 
6
6
  declare global {
7
7
  var ALCHEMY_PROVIDERS: Map<ResourceKind, Provider<string, any>>;
8
+ var ALCHEMY_HANDLERS: Map<ResourceKind, ResourceLifecycleHandler>;
8
9
  var ALCHEMY_DYNAMIC_RESOURCE_RESOLVERS: DynamicResourceResolver[];
9
10
  }
10
11
 
@@ -15,6 +16,12 @@ export const PROVIDERS: Map<
15
16
  ResourceKind,
16
17
  Provider<string, any>
17
18
  >());
19
+ const HANDLERS: Map<ResourceKind, ResourceLifecycleHandler> =
20
+ (globalThis.ALCHEMY_HANDLERS ??= new Map<
21
+ ResourceKind,
22
+ ResourceLifecycleHandler
23
+ >());
24
+
18
25
  const DYNAMIC_RESOURCE_RESOLVERS: DynamicResourceResolver[] =
19
26
  (globalThis.ALCHEMY_DYNAMIC_RESOURCE_RESOLVERS ??= []);
20
27
 
@@ -132,10 +139,20 @@ export function Resource<
132
139
  const Type extends ResourceKind,
133
140
  F extends ResourceLifecycleHandler,
134
141
  >(type: Type, ...args: [Partial<ProviderOptions>, F] | [F]): Handler<F> {
142
+ const [options, handler] = args.length === 2 ? args : [undefined, args[0]];
135
143
  if (PROVIDERS.has(type)) {
136
- throw new Error(`Resource ${type} already exists`);
144
+ // We want Alchemy to work in a PNPM monorepo environment unfortunately,
145
+ // global registries do not work because PNPM's symlinks result in multiple
146
+ // instances of alchemy being loaded even if the package version is the
147
+ // same (peers do not fix this).
148
+ // MITIGATION: to ensure that most cases of accidental double-registration
149
+ // are caught, we're going to check the handler's toString() to see if it's
150
+ // the same as the previous handler. if it is, we're going to overwrite it.
151
+ if (HANDLERS.get(type)!.toString() !== handler.toString()) {
152
+ throw new Error(`Resource ${type} already exists`);
153
+ }
137
154
  }
138
- const [options, handler] = args.length === 2 ? args : [undefined, args[0]];
155
+ HANDLERS.set(type, handler);
139
156
 
140
157
  type Out = Awaited<ReturnType<F>>;
141
158
 
package/src/scope.ts CHANGED
@@ -1,6 +1,7 @@
1
- import kleur from "kleur";
2
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import path from "node:path";
3
3
  import util from "node:util";
4
+ import pc from "picocolors";
4
5
  import type { Phase } from "./alchemy.ts";
5
6
  import { destroy, destroyAll, DestroyStrategy } from "./destroy.ts";
6
7
  import {
@@ -22,6 +23,10 @@ import {
22
23
  createLoggerInstance,
23
24
  type LoggerApi,
24
25
  } from "./util/cli.ts";
26
+ import {
27
+ idempotentSpawn,
28
+ type IdempotentSpawnOptions,
29
+ } from "./util/idempotent-spawn.ts";
25
30
  import { logger } from "./util/logger.ts";
26
31
  import { AsyncMutex } from "./util/mutex.ts";
27
32
  import type { ITelemetryClient } from "./util/telemetry/client.ts";
@@ -32,7 +37,7 @@ export class RootScopeStateAttemptError extends Error {
32
37
  }
33
38
  }
34
39
 
35
- export interface ScopeOptions {
40
+ export interface ScopeOptions extends ProviderCredentials {
36
41
  stage?: string;
37
42
  parent: Scope | undefined;
38
43
  scopeName: string;
@@ -64,8 +69,44 @@ export interface ScopeOptions {
64
69
  * @default "sequential"
65
70
  */
66
71
  destroyStrategy?: DestroyStrategy;
72
+ /**
73
+ * The telemetry client to use for the scope.
74
+ *
75
+ */
67
76
  telemetryClient?: ITelemetryClient;
77
+ /**
78
+ * The logger to use for the scope.
79
+ */
68
80
  logger?: LoggerApi;
81
+ /**
82
+ * The path to the .alchemy directory.
83
+ *
84
+ * @default "./.alchemy"
85
+ */
86
+ dotAlchemy?: string;
87
+ }
88
+
89
+ /**
90
+ * Base interface for provider credentials that can be extended by each provider.
91
+ * This allows providers to add their own credential properties without modifying the core scope interface.
92
+ *
93
+ * Provider credentials cannot conflict with core ScopeOptions properties.
94
+ *
95
+ * Providers can extend this interface using module augmentation:
96
+ *
97
+ * @example
98
+ * ```typescript
99
+ * // In aws/scope-extensions.ts
100
+ * declare module "../scope.ts" {
101
+ * interface ProviderCredentials {
102
+ * aws?: AwsClientProps;
103
+ * }
104
+ * }
105
+ * ```
106
+ */
107
+ export interface ProviderCredentials extends Record<string, unknown> {
108
+ // Provider credentials should not conflict with core scope properties
109
+ // TypeScript will enforce this at compile time when providers extend this interface
69
110
  }
70
111
 
71
112
  export type PendingDeletions = Array<{
@@ -140,6 +181,9 @@ export class Scope {
140
181
  public readonly telemetryClient: ITelemetryClient;
141
182
  public readonly dataMutex: AsyncMutex;
142
183
 
184
+ // Provider credentials for scope-level credential overrides
185
+ public readonly providerCredentials: ProviderCredentials;
186
+
143
187
  private isErrored = false;
144
188
  private isSkipped = false;
145
189
  private finalized = false;
@@ -154,10 +198,34 @@ export class Scope {
154
198
  return this.scopeName;
155
199
  }
156
200
 
201
+ public readonly dotAlchemy: string;
202
+
157
203
  constructor(options: ScopeOptions) {
158
- this.scopeName = options.scopeName;
204
+ // Extract core scope options first
205
+ const {
206
+ scopeName,
207
+ parent,
208
+ stage,
209
+ password,
210
+ stateStore,
211
+ quiet,
212
+ phase,
213
+ local,
214
+ watch,
215
+ force,
216
+ destroyStrategy,
217
+ telemetryClient,
218
+ logger,
219
+ dotAlchemy,
220
+ ...providerCredentials
221
+ } = options;
222
+
223
+ this.scopeName = scopeName;
159
224
  this.name = this.scopeName;
160
- this.parent = options.parent ?? Scope.getScope();
225
+ this.parent = parent ?? Scope.getScope();
226
+
227
+ // Store provider credentials (TypeScript ensures no conflicts with core options)
228
+ this.providerCredentials = providerCredentials as ProviderCredentials;
161
229
 
162
230
  const isChild = this.parent !== undefined;
163
231
  if (this.scopeName?.includes(":") && isChild) {
@@ -166,19 +234,23 @@ export class Scope {
166
234
  `Scope name "${this.scopeName}" cannot contain double colons`,
167
235
  );
168
236
  }
237
+ this.dotAlchemy =
238
+ options.dotAlchemy ??
239
+ this.parent?.dotAlchemy ??
240
+ path.join(process.cwd(), ".alchemy");
169
241
 
170
- this.stage = options?.stage ?? this.parent?.stage ?? DEFAULT_STAGE;
242
+ this.stage = stage ?? this.parent?.stage ?? DEFAULT_STAGE;
171
243
  this.parent?.children.set(this.scopeName!, this);
172
- this.quiet = options.quiet ?? this.parent?.quiet ?? false;
244
+ this.quiet = quiet ?? this.parent?.quiet ?? false;
173
245
  if (this.parent && !this.scopeName) {
174
246
  throw new Error("Scope name is required when creating a child scope");
175
247
  }
176
- this.password = options.password ?? this.parent?.password;
177
- const phase = options.phase ?? this.parent?.phase;
178
- if (phase === undefined) {
248
+ this.password = password ?? this.parent?.password;
249
+ const resolvedPhase = phase ?? this.parent?.phase;
250
+ if (resolvedPhase === undefined) {
179
251
  throw new Error("Phase is required");
180
252
  }
181
- this.phase = phase;
253
+ this.phase = resolvedPhase;
182
254
 
183
255
  this.logger = this.quiet
184
256
  ? createDummyLogger()
@@ -188,14 +260,14 @@ export class Scope {
188
260
  stage: this.stage,
189
261
  appName: this.appName ?? "",
190
262
  },
191
- options.logger,
263
+ logger,
192
264
  );
193
265
 
194
- this.local = options.local ?? this.parent?.local ?? false;
195
- this.watch = options.watch ?? this.parent?.watch ?? false;
196
- this.force = options.force ?? this.parent?.force ?? false;
266
+ this.local = local ?? this.parent?.local ?? false;
267
+ this.watch = watch ?? this.parent?.watch ?? false;
268
+ this.force = force ?? this.parent?.force ?? false;
197
269
  this.destroyStrategy =
198
- options.destroyStrategy ?? this.parent?.destroyStrategy ?? "sequential";
270
+ destroyStrategy ?? this.parent?.destroyStrategy ?? "sequential";
199
271
  if (this.local) {
200
272
  this.logger.warnOnce(
201
273
  "Development mode is in beta. Please report any issues to https://github.com/sam-goodwin/alchemy/issues.",
@@ -203,19 +275,37 @@ export class Scope {
203
275
  }
204
276
 
205
277
  this.stateStore =
206
- options.stateStore ?? this.parent?.stateStore ?? defaultStateStore;
207
- this.telemetryClient =
208
- options.telemetryClient ?? this.parent?.telemetryClient!;
278
+ stateStore ?? this.parent?.stateStore ?? defaultStateStore;
279
+ this.telemetryClient = telemetryClient ?? this.parent?.telemetryClient!;
209
280
  this.state = new InstrumentedStateStore(
210
281
  this.stateStore(this),
211
282
  this.telemetryClient,
212
283
  );
213
- if (!options.telemetryClient && !this.parent?.telemetryClient) {
284
+ if (!telemetryClient && !this.parent?.telemetryClient) {
214
285
  throw new Error("Telemetry client is required");
215
286
  }
216
287
  this.dataMutex = new AsyncMutex();
217
288
  }
218
289
 
290
+ public async spawn<
291
+ E extends ((line: string) => string | undefined) | undefined,
292
+ >(
293
+ // TODO(sam): validate uniqueness? Ensure a flat .logs/${id}.log dir? Or nest in scope dirs?
294
+ id: string,
295
+ options: Omit<IdempotentSpawnOptions, "log" | "stateFile">,
296
+ ) {
297
+ const dotAlchemy = path.join(process.cwd(), ".alchemy");
298
+ const logsDir = path.join(dotAlchemy, "logs");
299
+ const pidsDir = path.join(dotAlchemy, "pids");
300
+
301
+ const extracted = await idempotentSpawn({
302
+ log: path.join(logsDir, `${id}.log`),
303
+ stateFile: path.join(pidsDir, `${id}.pid.json`),
304
+ ...options,
305
+ });
306
+ return extracted as E extends undefined ? undefined : string;
307
+ }
308
+
219
309
  /**
220
310
  * @internal
221
311
  */
@@ -419,6 +509,7 @@ export class Scope {
419
509
  if (!this.isErrored && !this.isSkipped) {
420
510
  // TODO: need to detect if it is in error
421
511
  const resourceIds = await this.state.list();
512
+
422
513
  const aliveIds = new Set(this.resources.keys());
423
514
  const orphanIds = Array.from(
424
515
  resourceIds.filter((id) => !aliveIds.has(id)),
@@ -546,7 +637,7 @@ export class Scope {
546
637
  */
547
638
  public async cleanup() {
548
639
  if (this.parent || this.cleanups.length === 0) return;
549
- this.logger.log(kleur.gray("Exiting..."));
640
+ this.logger.log(pc.gray("Exiting..."));
550
641
  await Promise.allSettled(this.cleanups.map((cleanup) => cleanup()));
551
642
  }
552
643
 
@@ -15,6 +15,7 @@ import type { Scope } from "../scope.ts";
15
15
  import type { Secret } from "../secret.ts";
16
16
  import { logger } from "../util/logger.ts";
17
17
  import { memoize } from "../util/memoize.ts";
18
+ import { safeFetch } from "../util/safe-fetch.ts";
18
19
  import { StateStoreProxy } from "./proxy.ts";
19
20
 
20
21
  export interface CloudflareStateStoreOptions extends CloudflareApiOptions {
@@ -70,7 +71,7 @@ export class CloudflareStateStore extends StateStoreProxy {
70
71
  params,
71
72
  context: { chain: this.scope.chain },
72
73
  };
73
- const response = await fetch(url, {
74
+ const response = await safeFetch(url, {
74
75
  method: "POST",
75
76
  headers: {
76
77
  Authorization: `Bearer ${token}`,
@@ -0,0 +1,303 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import fsp from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { AsyncMutex } from "./mutex.ts";
6
+
7
+ export interface IdempotentSpawnOptions {
8
+ cmd: string;
9
+ cwd?: string;
10
+ env?: Record<string, string>;
11
+ log: string;
12
+ stateFile?: string;
13
+ overlapBytes?: number;
14
+ resume?: boolean;
15
+ isSameProcess?: (pid: number) => Promise<boolean>;
16
+ processName?: string;
17
+ extract?: (line: string) => string | undefined;
18
+ /**
19
+ * If true, the child's stdout and stderr will not be mirrored to this process's console.
20
+ */
21
+ quiet?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Idempotently ensure a long-lived child is running with stdout/stderr -> files,
26
+ * and mirror those logs to THIS process's console with persisted offsets.
27
+ *
28
+ * Use: await ensureLoggedChildAndMirror({ cmd: "vite dev" })
29
+ */
30
+ export async function idempotentSpawn({
31
+ cmd,
32
+ cwd,
33
+ env,
34
+ stateFile = "state.json",
35
+ overlapBytes = 0,
36
+ resume = false,
37
+ log = "log.txt",
38
+ processName,
39
+ extract,
40
+ isSameProcess,
41
+ quiet = false,
42
+ }: IdempotentSpawnOptions): Promise<string | undefined> {
43
+ if (!processName && !isSameProcess) {
44
+ processName = cmd.split(" ")[0];
45
+ }
46
+
47
+ const outPath = log;
48
+ await Promise.all([
49
+ fsp.mkdir(path.dirname(stateFile), { recursive: true }),
50
+ fsp.mkdir(path.dirname(log), { recursive: true }),
51
+ ]);
52
+
53
+ const { promise: extracted, resolve: resolveExtracted } =
54
+ Promise.withResolvers<string | undefined>();
55
+
56
+ if (!extract) {
57
+ resolveExtracted(undefined);
58
+ }
59
+
60
+ await ensureChildRunning();
61
+
62
+ const write = quiet
63
+ ? () => false
64
+ : (buf: Buffer) => process.stdout.write(buf);
65
+
66
+ // Start followers (stdout/stderr) and mirror to this process
67
+ await followFilePersisted(outPath, {
68
+ stateKey: `${path.resolve(outPath)}::stdout`,
69
+ write,
70
+ });
71
+
72
+ return extracted;
73
+
74
+ function isPidAlive(pid: number) {
75
+ if (!pid || Number.isNaN(pid)) return false;
76
+ try {
77
+ process.kill(pid, 0);
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ async function writeJsonAtomic(file: string, data: any) {
85
+ const dir = path.dirname(file);
86
+ const tmp = path.join(
87
+ dir,
88
+ `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`,
89
+ );
90
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
91
+ await fsp.rename(tmp, file); // atomic on POSIX
92
+ }
93
+
94
+ async function readJson(file: string) {
95
+ try {
96
+ return JSON.parse(await fsp.readFile(file, "utf8"));
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ async function spawnLoggedChild() {
103
+ const out = await fsp.open(outPath, "a");
104
+
105
+ // shell:true, NO args — pass a single command string
106
+ const child = spawn(cmd, {
107
+ shell: true,
108
+ cwd,
109
+ stdio: ["ignore", out.fd, out.fd], // stdout/stderr -> files (OS-level)
110
+ env,
111
+ detached: false,
112
+ });
113
+
114
+ // Child now owns dup'd fds; close our handles.
115
+ await out.close();
116
+
117
+ // Persist PID into the shared state file
118
+ const stateAll = (await readJson(stateFile)) ?? {};
119
+ stateAll.pid = child.pid;
120
+ await writeJsonAtomic(stateFile, stateAll);
121
+ return child;
122
+ }
123
+
124
+ async function ensureChildRunning() {
125
+ const stateAll = await readJson(stateFile);
126
+ if (stateAll) {
127
+ const pid = Number.parseInt(stateAll.pid, 10);
128
+ if (isPidAlive(pid)) return pid;
129
+ if (await isSameProcess?.(pid)) return pid;
130
+ if (processName) {
131
+ const { default: find } = await import("find-process");
132
+ const processes = await find("pid", pid);
133
+ if (processes.length > 1) {
134
+ console.warn(
135
+ `Found multiple processes with PID ${pid}, using the first one`,
136
+ );
137
+ }
138
+ if (processes.length > 0) {
139
+ if (processes[0].name.startsWith(processName)) {
140
+ return pid;
141
+ }
142
+ }
143
+ }
144
+ }
145
+ // not running, let's clear pid and state
146
+ await Promise.all([
147
+ fsp.rm(stateFile).catch(() => {}),
148
+ fsp.rm(log).catch(() => {}),
149
+ ]);
150
+ return (await spawnLoggedChild()).pid;
151
+ }
152
+
153
+ // Follow a file from persisted offset and mirror to a sink (stdout/stderr)
154
+ async function followFilePersisted(
155
+ logPath: string,
156
+ {
157
+ write,
158
+ chunkSize = 64 * 1024,
159
+ // tickMs = 100,
160
+ }: {
161
+ stateKey: string;
162
+ write: (buf: Buffer) => boolean;
163
+ chunkSize?: number;
164
+ tickMs?: number;
165
+ },
166
+ ) {
167
+ logPath = path.resolve(logPath);
168
+ let state = (await readJson(stateFile)) ?? {};
169
+
170
+ let fh = await fsp.open(logPath, "r");
171
+ let st = await fh.stat();
172
+ let ino = Number(st.ino ?? 0);
173
+ let dev = Number(st.dev ?? 0);
174
+
175
+ // Resume policy: if resume is enabled and same file, start at saved offset (with overlap); else start from end.
176
+ let offset =
177
+ resume &&
178
+ state.ino === ino &&
179
+ state.dev === dev &&
180
+ typeof state.offset === "number"
181
+ ? Math.max(0, Math.min(st.size, state.offset - overlapBytes))
182
+ : st.size;
183
+
184
+ let closed = false;
185
+ const drainMutex = new AsyncMutex();
186
+
187
+ // we read the log file and stream in chunks, not lines
188
+ // so we need to keep track of the remainder of the last line
189
+ let remainder: string | undefined;
190
+
191
+ // if we are resuming, then re-parse the log lines to extract the value
192
+ if (offset > 0) {
193
+ // read from start to offset
194
+ const fullBuffer = Buffer.allocUnsafe(offset);
195
+ await fh.read({
196
+ position: 0,
197
+ buffer: fullBuffer,
198
+ length: offset,
199
+ });
200
+ const content = fullBuffer.toString("utf8");
201
+ parse(content);
202
+ }
203
+
204
+ await drain();
205
+
206
+ const watcher = fs.watch(logPath, async () => {
207
+ if (closed) return;
208
+ try {
209
+ const cur = await fh.stat();
210
+ const curIno = Number(cur.ino ?? 0);
211
+ const curDev = Number(cur.dev ?? 0);
212
+ if (curIno !== ino || curDev !== dev) {
213
+ // Rotated/replaced: reopen and start at beginning of new file
214
+ await fh.close().catch(() => {});
215
+ fh = await fsp.open(logPath, "r");
216
+ const cur2 = await fh.stat();
217
+ ino = Number(cur2.ino ?? 0);
218
+ dev = Number(cur2.dev ?? 0);
219
+ offset = 0;
220
+ }
221
+ } catch {
222
+ // File might briefly disappear during rotation
223
+ }
224
+ await drain();
225
+ });
226
+
227
+ // TODO(sam): do we need this?
228
+ // const tick = setInterval(() => {
229
+ // drain().catch(() => {});
230
+ // }, tickMs);
231
+
232
+ return async function stop() {
233
+ closed = true;
234
+ watcher.close();
235
+ // clearInterval(tick);
236
+ await fh.close().catch(() => {});
237
+ await persist();
238
+ };
239
+
240
+ async function persist() {
241
+ try {
242
+ const cur = await fh.stat();
243
+ state = {
244
+ ...state,
245
+ offset,
246
+ ino: Number(cur.ino ?? ino),
247
+ dev: Number(cur.dev ?? dev),
248
+ size: cur.size,
249
+ mtimeMs: cur.mtimeMs,
250
+ };
251
+ await writeJsonAtomic(stateFile, state);
252
+ } catch {
253
+ // If the file vanished mid-rotation, we'll catch up on the next event.
254
+ }
255
+ }
256
+
257
+ function parse(content: string) {
258
+ const lines = content.split("\n");
259
+ if (extract) {
260
+ if (lines.length > 0) {
261
+ remainder = lines[lines.length - 1];
262
+ }
263
+ // parse all lines except the last one (which may be a partial line)
264
+ for (const line of lines.slice(0, -1)) {
265
+ const extracted = extract(line);
266
+ if (extracted) {
267
+ resolveExtracted(extracted);
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ function drain() {
275
+ return drainMutex.lock(async () => {
276
+ while (true) {
277
+ const cur = await fh.stat().catch(() => null);
278
+ if (!cur) break;
279
+
280
+ if (cur.size < offset) offset = 0; // truncated
281
+ const toRead = Math.min(chunkSize, cur.size - offset);
282
+ if (toRead <= 0) break;
283
+
284
+ const { bytesRead, buffer } = await fh.read({
285
+ position: offset,
286
+ buffer: Buffer.allocUnsafe(toRead),
287
+ length: toRead,
288
+ });
289
+ if (!bytesRead) break;
290
+
291
+ const chunk = buffer.subarray(0, bytesRead);
292
+
293
+ // write the newly read content to the stream
294
+ write(chunk);
295
+ // parse the previous remainder and the new chunk
296
+ parse(`${remainder || ""}${chunk.toString("utf8")}`);
297
+ offset += bytesRead;
298
+ }
299
+ await persist();
300
+ });
301
+ }
302
+ }
303
+ }
@@ -0,0 +1 @@
1
+ export * from "./safe-fetch.ts";