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.
- package/README.md +1 -2
- package/bin/alchemy.js +1480 -1498
- package/bin/commands/init.ts +94 -25
- package/bin/services/execute-alchemy.ts +32 -23
- package/lib/alchemy.d.ts +2 -2
- package/lib/alchemy.d.ts.map +1 -1
- package/lib/alchemy.js +1 -0
- package/lib/alchemy.js.map +1 -1
- package/lib/aws/client-props.d.ts +149 -0
- package/lib/aws/client-props.d.ts.map +1 -0
- package/lib/aws/client-props.js +1 -0
- package/lib/aws/client-props.js.map +1 -0
- package/lib/aws/credentials.d.ts +68 -5
- package/lib/aws/credentials.d.ts.map +1 -1
- package/lib/aws/credentials.js +148 -0
- package/lib/aws/credentials.js.map +1 -1
- package/lib/aws/ec2/internet-gateway-attachment.d.ts +57 -3
- package/lib/aws/ec2/internet-gateway-attachment.d.ts.map +1 -1
- package/lib/aws/ec2/internet-gateway-attachment.js +57 -3
- package/lib/aws/ec2/internet-gateway-attachment.js.map +1 -1
- package/lib/aws/ec2/internet-gateway.d.ts +68 -40
- package/lib/aws/ec2/internet-gateway.d.ts.map +1 -1
- package/lib/aws/ec2/internet-gateway.js +68 -40
- package/lib/aws/ec2/internet-gateway.js.map +1 -1
- package/lib/aws/ec2/nat-gateway.d.ts +99 -18
- package/lib/aws/ec2/nat-gateway.d.ts.map +1 -1
- package/lib/aws/ec2/nat-gateway.js +113 -37
- package/lib/aws/ec2/nat-gateway.js.map +1 -1
- package/lib/aws/ec2/route-table-association.d.ts +1 -1
- package/lib/aws/ec2/route-table-association.js +1 -1
- package/lib/aws/ec2/route-table.d.ts +64 -1
- package/lib/aws/ec2/route-table.d.ts.map +1 -1
- package/lib/aws/ec2/route-table.js +64 -1
- package/lib/aws/ec2/route-table.js.map +1 -1
- package/lib/aws/ec2/route.d.ts +60 -1
- package/lib/aws/ec2/route.d.ts.map +1 -1
- package/lib/aws/ec2/route.js +60 -1
- package/lib/aws/ec2/route.js.map +1 -1
- package/lib/aws/ec2/security-group.d.ts +96 -3
- package/lib/aws/ec2/security-group.d.ts.map +1 -1
- package/lib/aws/ec2/security-group.js +96 -3
- package/lib/aws/ec2/security-group.js.map +1 -1
- package/lib/aws/ec2/subnet.d.ts +61 -3
- package/lib/aws/ec2/subnet.d.ts.map +1 -1
- package/lib/aws/ec2/subnet.js +61 -3
- package/lib/aws/ec2/subnet.js.map +1 -1
- package/lib/aws/ec2/utils.d.ts +10 -2
- package/lib/aws/ec2/utils.d.ts.map +1 -1
- package/lib/aws/ec2/utils.js +68 -7
- package/lib/aws/ec2/utils.js.map +1 -1
- package/lib/aws/ec2/vpc.d.ts +50 -2
- package/lib/aws/ec2/vpc.d.ts.map +1 -1
- package/lib/aws/ec2/vpc.js +50 -2
- package/lib/aws/ec2/vpc.js.map +1 -1
- package/lib/aws/index.d.ts +2 -0
- package/lib/aws/index.d.ts.map +1 -1
- package/lib/aws/index.js +2 -0
- package/lib/aws/index.js.map +1 -1
- package/lib/cloudflare/api-response.js +2 -2
- package/lib/cloudflare/api-response.js.map +1 -1
- package/lib/cloudflare/api.d.ts +19 -0
- package/lib/cloudflare/api.d.ts.map +1 -1
- package/lib/cloudflare/api.js +4 -0
- package/lib/cloudflare/api.js.map +1 -1
- package/lib/cloudflare/assets.d.ts.map +1 -1
- package/lib/cloudflare/assets.js +3 -4
- package/lib/cloudflare/assets.js.map +1 -1
- package/lib/cloudflare/bucket.d.ts +5 -3
- package/lib/cloudflare/bucket.d.ts.map +1 -1
- package/lib/cloudflare/bucket.js +28 -20
- package/lib/cloudflare/bucket.js.map +1 -1
- package/lib/cloudflare/bundle/plugin-compat-warning.js +7 -7
- package/lib/cloudflare/bundle/plugin-compat-warning.js.map +1 -1
- package/lib/cloudflare/cloudflare-env-proxy.js.map +1 -1
- package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
- package/lib/cloudflare/compatibility-date.gen.js +1 -1
- package/lib/cloudflare/d1-migrations.d.ts.map +1 -1
- package/lib/cloudflare/d1-migrations.js +3 -2
- package/lib/cloudflare/d1-migrations.js.map +1 -1
- package/lib/cloudflare/index.d.ts +2 -1
- package/lib/cloudflare/index.d.ts.map +1 -1
- package/lib/cloudflare/index.js +2 -1
- package/lib/cloudflare/index.js.map +1 -1
- package/lib/cloudflare/kv-namespace.d.ts +2 -2
- package/lib/cloudflare/kv-namespace.d.ts.map +1 -1
- package/lib/cloudflare/kv-namespace.js.map +1 -1
- package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -1
- package/lib/cloudflare/miniflare/build-worker-options.js +16 -3
- package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -1
- package/lib/cloudflare/miniflare/miniflare-controller.d.ts.map +1 -1
- package/lib/cloudflare/miniflare/miniflare-controller.js +3 -0
- package/lib/cloudflare/miniflare/miniflare-controller.js.map +1 -1
- package/lib/cloudflare/oauth.js +3 -3
- package/lib/cloudflare/oauth.js.map +1 -1
- package/lib/cloudflare/quick-tunnel.d.ts +17 -0
- package/lib/cloudflare/quick-tunnel.d.ts.map +1 -0
- package/lib/cloudflare/quick-tunnel.js +26 -0
- package/lib/cloudflare/quick-tunnel.js.map +1 -0
- package/lib/cloudflare/sveltekit/plugin.d.ts +2 -2
- package/lib/cloudflare/sveltekit/plugin.d.ts.map +1 -1
- package/lib/cloudflare/sveltekit/plugin.js +5 -4
- package/lib/cloudflare/sveltekit/plugin.js.map +1 -1
- package/lib/cloudflare/website.d.ts.map +1 -1
- package/lib/cloudflare/website.js +26 -97
- package/lib/cloudflare/website.js.map +1 -1
- package/lib/cloudflare/worker-bundle.d.ts.map +1 -1
- package/lib/cloudflare/worker-bundle.js +11 -9
- package/lib/cloudflare/worker-bundle.js.map +1 -1
- package/lib/cloudflare/worker-tail.d.ts.map +1 -1
- package/lib/cloudflare/worker-tail.js +6 -6
- package/lib/cloudflare/worker-tail.js.map +1 -1
- package/lib/resource.d.ts +1 -0
- package/lib/resource.d.ts.map +1 -1
- package/lib/resource.js +13 -2
- package/lib/resource.js.map +1 -1
- package/lib/scope.d.ts +38 -1
- package/lib/scope.d.ts.map +1 -1
- package/lib/scope.js +44 -19
- package/lib/scope.js.map +1 -1
- package/lib/state/cloudflare-state-store.d.ts.map +1 -1
- package/lib/state/cloudflare-state-store.js +2 -1
- package/lib/state/cloudflare-state-store.js.map +1 -1
- package/lib/util/idempotent-spawn.d.ts +24 -0
- package/lib/util/idempotent-spawn.d.ts.map +1 -0
- package/lib/util/idempotent-spawn.js +237 -0
- package/lib/util/idempotent-spawn.js.map +1 -0
- package/lib/util/index.d.ts +2 -0
- package/lib/util/index.d.ts.map +1 -0
- package/lib/util/index.js +2 -0
- package/lib/util/index.js.map +1 -0
- package/lib/util/safe-fetch.d.ts +24 -0
- package/lib/util/safe-fetch.d.ts.map +1 -1
- package/lib/util/safe-fetch.js +72 -0
- package/lib/util/safe-fetch.js.map +1 -1
- package/package.json +9 -5
- package/src/alchemy.ts +3 -2
- package/src/aws/client-props.ts +156 -0
- package/src/aws/credentials.ts +165 -4
- package/src/aws/ec2/internet-gateway-attachment.ts +59 -4
- package/src/aws/ec2/internet-gateway.ts +70 -41
- package/src/aws/ec2/nat-gateway.ts +123 -33
- package/src/aws/ec2/route-table-association.ts +1 -1
- package/src/aws/ec2/route-table.ts +66 -2
- package/src/aws/ec2/route.ts +62 -2
- package/src/aws/ec2/security-group.ts +98 -4
- package/src/aws/ec2/subnet.ts +63 -4
- package/src/aws/ec2/utils.ts +73 -7
- package/src/aws/ec2/vpc.ts +52 -3
- package/src/aws/index.ts +2 -0
- package/src/cloudflare/api-response.ts +2 -2
- package/src/cloudflare/api.ts +23 -0
- package/src/cloudflare/assets.ts +3 -4
- package/src/cloudflare/bucket.ts +39 -29
- package/src/cloudflare/bundle/plugin-compat-warning.ts +7 -7
- package/src/cloudflare/cloudflare-env-proxy.ts +2 -2
- package/src/cloudflare/compatibility-date.gen.ts +1 -1
- package/src/cloudflare/d1-migrations.ts +4 -5
- package/src/cloudflare/index.ts +2 -1
- package/src/cloudflare/kv-namespace.ts +26 -27
- package/src/cloudflare/miniflare/build-worker-options.ts +16 -3
- package/src/cloudflare/miniflare/miniflare-controller.ts +4 -0
- package/src/cloudflare/oauth.ts +3 -3
- package/src/cloudflare/quick-tunnel.ts +30 -0
- package/src/cloudflare/sveltekit/plugin.ts +5 -5
- package/src/cloudflare/website.ts +30 -111
- package/src/cloudflare/worker-bundle.ts +11 -9
- package/src/cloudflare/worker-tail.ts +6 -8
- package/src/resource.ts +19 -2
- package/src/scope.ts +111 -20
- package/src/state/cloudflare-state-store.ts +2 -1
- package/src/util/idempotent-spawn.ts +303 -0
- package/src/util/index.ts +1 -0
- package/src/util/safe-fetch.ts +114 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
242
|
+
this.stage = stage ?? this.parent?.stage ?? DEFAULT_STAGE;
|
|
171
243
|
this.parent?.children.set(this.scopeName!, this);
|
|
172
|
-
this.quiet =
|
|
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 =
|
|
177
|
-
const
|
|
178
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
263
|
+
logger,
|
|
192
264
|
);
|
|
193
265
|
|
|
194
|
-
this.local =
|
|
195
|
-
this.watch =
|
|
196
|
-
this.force =
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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(
|
|
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
|
|
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";
|