alchemy-effect 0.6.4 → 0.7.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/bin/alchemy-effect.js +90 -20
- package/bin/alchemy-effect.js.map +1 -1
- package/bin/alchemy-effect.ts +27 -24
- package/lib/cli/index.d.ts +1 -0
- package/lib/cli/index.d.ts.map +1 -1
- package/package.json +13 -3
- package/src/AWS/Website/StaticSite.ts +5 -7
- package/src/AWS/Website/shared.ts +15 -3
- package/src/Apply.ts +143 -107
- package/src/Artifacts.ts +147 -0
- package/src/Build/Command.ts +21 -99
- package/src/Build/Memo.ts +187 -0
- package/src/Bundle/Bundle.ts +23 -17
- package/src/Cloudflare/Website/Vite.ts +65 -0
- package/src/Cloudflare/Website/index.ts +1 -1
- package/src/Cloudflare/Workers/Assets.ts +7 -2
- package/src/Cloudflare/Workers/Worker.ts +189 -59
- package/src/Destroy.ts +3 -2
- package/src/Output.ts +4 -0
- package/src/Plan.ts +606 -585
- package/src/Stack.ts +18 -6
- package/src/Test/Vitest.ts +20 -11
- package/src/Util/gitignore-rules-to-globs.ts +80 -0
- package/src/Cloudflare/Website/TanstackStart.ts +0 -14
package/src/Artifacts.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
import * as Option from "effect/Option";
|
|
4
|
+
import * as ServiceMap from "effect/ServiceMap";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-resource in-memory artifacts shared across a single `Plan.make -> apply`
|
|
8
|
+
* execution.
|
|
9
|
+
*
|
|
10
|
+
* The engine scopes this service by resource `FQN` before invoking lifecycle
|
|
11
|
+
* handlers, so providers should treat it as a resource-local bag for expensive,
|
|
12
|
+
* deterministic intermediate results that can be reused across phases.
|
|
13
|
+
*
|
|
14
|
+
* Expected usage:
|
|
15
|
+
*
|
|
16
|
+
* - `diff` computes an expensive artifact once and stores it
|
|
17
|
+
* - `create` / `update` reads the same artifact and skips recomputing it
|
|
18
|
+
* - artifacts are ephemeral and must never be required for correctness on a
|
|
19
|
+
* later deploy because the bag is reset between runs
|
|
20
|
+
*
|
|
21
|
+
* Example:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* const artifacts = yield* Artifacts;
|
|
25
|
+
* const cached = yield* artifacts.get<PreparedBundle>("bundle");
|
|
26
|
+
* if (cached) return cached;
|
|
27
|
+
*
|
|
28
|
+
* const bundle = yield* prepareBundle();
|
|
29
|
+
* yield* artifacts.set("bundle", bundle);
|
|
30
|
+
* return bundle;
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export class Artifacts extends ServiceMap.Service<
|
|
34
|
+
Artifacts,
|
|
35
|
+
{
|
|
36
|
+
/**
|
|
37
|
+
* Get an artifact by key from the current resource's bag.
|
|
38
|
+
*/
|
|
39
|
+
get<T>(key: string): Effect.Effect<T | undefined>;
|
|
40
|
+
/**
|
|
41
|
+
* Store an artifact by key in the current resource's bag.
|
|
42
|
+
*/
|
|
43
|
+
set<T>(key: string, value: T): Effect.Effect<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Delete an artifact by key from the current resource's bag.
|
|
46
|
+
*/
|
|
47
|
+
delete(key: string): Effect.Effect<void>;
|
|
48
|
+
}
|
|
49
|
+
>()("Artifacts") {}
|
|
50
|
+
|
|
51
|
+
type ArtifactBag = Map<string, unknown>;
|
|
52
|
+
|
|
53
|
+
export class ArtifactStore extends ServiceMap.Service<
|
|
54
|
+
ArtifactStore,
|
|
55
|
+
Map<string, ArtifactBag>
|
|
56
|
+
>()("Artifacts/Store") {}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a fresh root store for one deploy/test run.
|
|
60
|
+
*/
|
|
61
|
+
export const createArtifactStore = (): ArtifactStore["Service"] =>
|
|
62
|
+
new Map<string, ArtifactBag>();
|
|
63
|
+
|
|
64
|
+
const getOrCreateBag = (
|
|
65
|
+
store: Map<string, ArtifactBag>,
|
|
66
|
+
fqn: string,
|
|
67
|
+
): ArtifactBag => {
|
|
68
|
+
const existing = store.get(fqn);
|
|
69
|
+
if (existing) {
|
|
70
|
+
return existing;
|
|
71
|
+
}
|
|
72
|
+
const bag = new Map<string, unknown>();
|
|
73
|
+
store.set(fqn, bag);
|
|
74
|
+
return bag;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const makeScopedArtifacts = (
|
|
78
|
+
store: Map<string, ArtifactBag>,
|
|
79
|
+
fqn: string,
|
|
80
|
+
): typeof Artifacts.Service => {
|
|
81
|
+
const bag = getOrCreateBag(store, fqn);
|
|
82
|
+
return {
|
|
83
|
+
get: <T>(key: string) => Effect.sync(() => bag.get(key) as T | undefined),
|
|
84
|
+
set: <T>(key: string, value: T) =>
|
|
85
|
+
Effect.sync(() => {
|
|
86
|
+
bag.set(key, value);
|
|
87
|
+
}),
|
|
88
|
+
delete: (key: string) =>
|
|
89
|
+
Effect.sync(() => {
|
|
90
|
+
bag.delete(key);
|
|
91
|
+
}),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const scopedArtifacts = (
|
|
96
|
+
fqn: string,
|
|
97
|
+
): Layer.Layer<Artifacts, never, ArtifactStore> =>
|
|
98
|
+
Layer.effect(
|
|
99
|
+
Artifacts,
|
|
100
|
+
ArtifactStore.asEffect().pipe(
|
|
101
|
+
Effect.map((store) => makeScopedArtifacts(store, fqn)),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run an effect with a fresh artifact root, replacing any existing store.
|
|
107
|
+
* Use this at top-level entrypoints that intentionally define a new deploy run.
|
|
108
|
+
*/
|
|
109
|
+
export const provideFreshArtifactStore = <A, E, R>(
|
|
110
|
+
effect: Effect.Effect<A, E, R | ArtifactStore>,
|
|
111
|
+
): Effect.Effect<A, E, R> =>
|
|
112
|
+
effect.pipe(
|
|
113
|
+
Effect.provideServiceEffect(
|
|
114
|
+
ArtifactStore,
|
|
115
|
+
Effect.sync(createArtifactStore),
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ensure an artifact root exists, reusing the ambient store when one is already
|
|
121
|
+
* present. This lets nested helpers participate in the same run-scoped cache.
|
|
122
|
+
*/
|
|
123
|
+
export const ensureArtifactStore = <A, E, R>(
|
|
124
|
+
effect: Effect.Effect<A, E, R | ArtifactStore>,
|
|
125
|
+
): Effect.Effect<A, E, R> =>
|
|
126
|
+
Effect.serviceOption(ArtifactStore).pipe(
|
|
127
|
+
Effect.map(Option.getOrUndefined),
|
|
128
|
+
Effect.flatMap((existing) =>
|
|
129
|
+
effect.pipe(
|
|
130
|
+
Effect.provideService(ArtifactStore, existing ?? createArtifactStore()),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
export const cached =
|
|
136
|
+
(id: string) =>
|
|
137
|
+
<A, Err = never, Req = never>(eff: Effect.Effect<A, Err, Req>) =>
|
|
138
|
+
Effect.gen(function* () {
|
|
139
|
+
const artifacts = yield* Artifacts;
|
|
140
|
+
const cached = yield* artifacts.get<A>(id);
|
|
141
|
+
if (cached) {
|
|
142
|
+
return cached;
|
|
143
|
+
}
|
|
144
|
+
const result = yield* eff;
|
|
145
|
+
yield* artifacts.set(id, result);
|
|
146
|
+
return result;
|
|
147
|
+
});
|
package/src/Build/Command.ts
CHANGED
|
@@ -5,7 +5,7 @@ import * as Stream from "effect/Stream";
|
|
|
5
5
|
import { ChildProcess } from "effect/unstable/process";
|
|
6
6
|
import { isResolved } from "../Diff.ts";
|
|
7
7
|
import { Resource } from "../Resource.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { hashDirectory, type MemoOptions } from "./Memo.ts";
|
|
9
9
|
|
|
10
10
|
export interface CommandProps {
|
|
11
11
|
/**
|
|
@@ -20,16 +20,13 @@ export interface CommandProps {
|
|
|
20
20
|
*/
|
|
21
21
|
cwd?: string;
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Controls which files are hashed to decide whether the build should re-run.
|
|
24
|
+
* By default every non-gitignored file in `cwd` is hashed, plus the nearest
|
|
25
|
+
* lockfile. Provide explicit globs to narrow the scope.
|
|
26
|
+
*
|
|
27
|
+
* @see {@link MemoOptions}
|
|
26
28
|
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Glob patterns to exclude from input hashing.
|
|
30
|
-
* Defaults to node_modules and .git directories.
|
|
31
|
-
*/
|
|
32
|
-
exclude?: string[];
|
|
29
|
+
memo?: MemoOptions;
|
|
33
30
|
/**
|
|
34
31
|
* The output path (file or directory) produced by the build.
|
|
35
32
|
* This path is relative to the working directory.
|
|
@@ -67,8 +64,7 @@ export interface Command extends Resource<
|
|
|
67
64
|
* const build = yield* Build("vite-build", {
|
|
68
65
|
* command: "npm run build",
|
|
69
66
|
* cwd: "./frontend",
|
|
70
|
-
*
|
|
71
|
-
* output: "dist",
|
|
67
|
+
* outdir: "dist",
|
|
72
68
|
* });
|
|
73
69
|
* yield* Console.log(build.path); // absolute path to dist directory
|
|
74
70
|
* yield* Console.log(build.hash); // hash of input files
|
|
@@ -80,7 +76,6 @@ export interface Command extends Resource<
|
|
|
80
76
|
* const build = yield* Build("production-build", {
|
|
81
77
|
* command: "npm run build",
|
|
82
78
|
* cwd: "./app",
|
|
83
|
-
* include: ["src/*", "package.json"],
|
|
84
79
|
* output: "dist",
|
|
85
80
|
* env: {
|
|
86
81
|
* NODE_ENV: "production",
|
|
@@ -88,6 +83,16 @@ export interface Command extends Resource<
|
|
|
88
83
|
* },
|
|
89
84
|
* });
|
|
90
85
|
* ```
|
|
86
|
+
*
|
|
87
|
+
* @section Customizing Memoization
|
|
88
|
+
* @example Customize Memoization
|
|
89
|
+
* ```typescript
|
|
90
|
+
* const build = yield* Build("custom-build", {
|
|
91
|
+
* command: "npm run build",
|
|
92
|
+
* cwd: "./app",
|
|
93
|
+
* output: "dist",
|
|
94
|
+
* memo: { include: ["src/*", "package.json"], exclude: ["node_modules", "dist"] },
|
|
95
|
+
* });
|
|
91
96
|
*/
|
|
92
97
|
export const Command = Resource<Command>("Build.Command");
|
|
93
98
|
|
|
@@ -97,26 +102,6 @@ export const CommandProvider = () =>
|
|
|
97
102
|
const fs = yield* FileSystem.FileSystem;
|
|
98
103
|
const pathModule = yield* Path.Path;
|
|
99
104
|
|
|
100
|
-
const computeInputHash = (props: CommandProps) =>
|
|
101
|
-
Effect.gen(function* () {
|
|
102
|
-
const cwd = props.cwd ? pathModule.resolve(props.cwd) : process.cwd();
|
|
103
|
-
const files = yield* listBuildFiles({
|
|
104
|
-
cwd,
|
|
105
|
-
include: props.hash,
|
|
106
|
-
exclude: props.exclude ?? defaultBuildExclude,
|
|
107
|
-
});
|
|
108
|
-
const fileHashes = yield* hashBuildFiles({
|
|
109
|
-
cwd,
|
|
110
|
-
files,
|
|
111
|
-
});
|
|
112
|
-
const hash = yield* sha256Object({
|
|
113
|
-
command: props.command,
|
|
114
|
-
env: props.env,
|
|
115
|
-
files: fileHashes,
|
|
116
|
-
});
|
|
117
|
-
return hash;
|
|
118
|
-
});
|
|
119
|
-
|
|
120
105
|
const runBuild = (props: CommandProps) =>
|
|
121
106
|
Effect.gen(function* () {
|
|
122
107
|
const cwd = props.cwd ? pathModule.resolve(props.cwd) : process.cwd();
|
|
@@ -139,7 +124,7 @@ export const CommandProvider = () =>
|
|
|
139
124
|
if (!output) {
|
|
140
125
|
return undefined;
|
|
141
126
|
}
|
|
142
|
-
const newHash = yield*
|
|
127
|
+
const newHash = yield* hashDirectory(news);
|
|
143
128
|
if (newHash !== output.hash) {
|
|
144
129
|
return { action: "update" as const };
|
|
145
130
|
}
|
|
@@ -156,7 +141,7 @@ export const CommandProvider = () =>
|
|
|
156
141
|
return output;
|
|
157
142
|
}),
|
|
158
143
|
create: Effect.fnUntraced(function* ({ news, session }) {
|
|
159
|
-
const hash = yield*
|
|
144
|
+
const hash = yield* hashDirectory(news);
|
|
160
145
|
const outputPath = getOutputPath(news);
|
|
161
146
|
|
|
162
147
|
yield* session.note(`Running build: ${news.command}`);
|
|
@@ -177,7 +162,7 @@ export const CommandProvider = () =>
|
|
|
177
162
|
};
|
|
178
163
|
}),
|
|
179
164
|
update: Effect.fnUntraced(function* ({ news, session }) {
|
|
180
|
-
const hash = yield*
|
|
165
|
+
const hash = yield* hashDirectory(news);
|
|
181
166
|
const outputPath = getOutputPath(news);
|
|
182
167
|
|
|
183
168
|
yield* session.note(`Rebuilding: ${news.command}`);
|
|
@@ -208,69 +193,6 @@ export const CommandProvider = () =>
|
|
|
208
193
|
}),
|
|
209
194
|
);
|
|
210
195
|
|
|
211
|
-
export const defaultBuildExclude = ["**/node_modules/**", "**/.git/**"];
|
|
212
|
-
|
|
213
|
-
export interface BuildFileGlobOptions {
|
|
214
|
-
cwd: string;
|
|
215
|
-
include: ReadonlyArray<string>;
|
|
216
|
-
exclude?: ReadonlyArray<string>;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export const listBuildFiles = Effect.fnUntraced(function* ({
|
|
220
|
-
cwd,
|
|
221
|
-
include,
|
|
222
|
-
exclude = defaultBuildExclude,
|
|
223
|
-
}: BuildFileGlobOptions) {
|
|
224
|
-
const mod = yield* Effect.promise(() => import("fast-glob"));
|
|
225
|
-
const fg = mod.default ?? mod;
|
|
226
|
-
const files = yield* Effect.promise(() =>
|
|
227
|
-
fg.glob(Array.from(include), {
|
|
228
|
-
cwd,
|
|
229
|
-
ignore: Array.from(exclude),
|
|
230
|
-
onlyFiles: true,
|
|
231
|
-
dot: true,
|
|
232
|
-
}),
|
|
233
|
-
);
|
|
234
|
-
files.sort();
|
|
235
|
-
return files.map((file) => file.replaceAll("\\", "/"));
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
export interface HashBuildFilesOptions {
|
|
239
|
-
cwd: string;
|
|
240
|
-
files: ReadonlyArray<string>;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export const hashBuildFiles = Effect.fnUntraced(function* ({
|
|
244
|
-
cwd,
|
|
245
|
-
files,
|
|
246
|
-
}: HashBuildFilesOptions) {
|
|
247
|
-
const fs = yield* FileSystem.FileSystem;
|
|
248
|
-
const pathModule = yield* Path.Path;
|
|
249
|
-
const parts = yield* Effect.all(
|
|
250
|
-
files.map((file) =>
|
|
251
|
-
fs.readFile(pathModule.join(cwd, file)).pipe(
|
|
252
|
-
Effect.flatMap((content) =>
|
|
253
|
-
sha256(content).pipe(Effect.map((hash) => `${file}:${hash}`)),
|
|
254
|
-
),
|
|
255
|
-
Effect.catch(() => Effect.succeed(undefined)),
|
|
256
|
-
),
|
|
257
|
-
),
|
|
258
|
-
{ concurrency: 10 },
|
|
259
|
-
);
|
|
260
|
-
return parts.filter((part): part is string => part !== undefined);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
export const hashBuildDirectory = Effect.fnUntraced(function* (
|
|
264
|
-
directory: string,
|
|
265
|
-
) {
|
|
266
|
-
const files = yield* listBuildFiles({
|
|
267
|
-
cwd: directory,
|
|
268
|
-
include: ["**/*"],
|
|
269
|
-
exclude: [],
|
|
270
|
-
});
|
|
271
|
-
return yield* hashBuildFiles({ cwd: directory, files });
|
|
272
|
-
});
|
|
273
|
-
|
|
274
196
|
export interface RunBuildCommandOptions {
|
|
275
197
|
command: string;
|
|
276
198
|
cwd?: string;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as FileSystem from "effect/FileSystem";
|
|
3
|
+
import * as Path from "effect/Path";
|
|
4
|
+
import type { PlatformError } from "effect/PlatformError";
|
|
5
|
+
import fg from "fast-glob";
|
|
6
|
+
import { gitignoreRulesToGlobs } from "../Util/gitignore-rules-to-globs.ts";
|
|
7
|
+
import { sha256, sha256Object } from "../Util/sha256.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Controls which files are included in the content hash that determines
|
|
11
|
+
* whether a build needs to re-run.
|
|
12
|
+
*
|
|
13
|
+
* By default (no options), every non-gitignored file in the working directory
|
|
14
|
+
* is hashed, plus the nearest package-manager lockfile. Provide explicit
|
|
15
|
+
* `include`/`exclude` globs to narrow the scope when the default is too broad.
|
|
16
|
+
*/
|
|
17
|
+
export interface MemoOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Glob patterns of files to hash. Paths are relative to the working directory.
|
|
20
|
+
*
|
|
21
|
+
* @default ["**\/*"] (all files, filtered by `exclude`)
|
|
22
|
+
* @example ["src/**", "package.json", "tsconfig.json"]
|
|
23
|
+
*/
|
|
24
|
+
include?: string[];
|
|
25
|
+
/**
|
|
26
|
+
* Glob patterns to exclude from hashing. Paths are relative to the working directory.
|
|
27
|
+
*
|
|
28
|
+
* @default gitignore rules collected from the working directory up to the repo root
|
|
29
|
+
*/
|
|
30
|
+
exclude?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Whether to include the nearest package-manager lockfile (`bun.lock`,
|
|
33
|
+
* `package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) in the hash,
|
|
34
|
+
* even when it lives above the working directory (e.g. monorepo root).
|
|
35
|
+
*
|
|
36
|
+
* @default true when both `include` and `exclude` are unset; false otherwise
|
|
37
|
+
*/
|
|
38
|
+
lockfile?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ResolvedMemoOptions {
|
|
42
|
+
cwd: string;
|
|
43
|
+
include: string[];
|
|
44
|
+
exclude: string[];
|
|
45
|
+
lockfile: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Internal service that resolves memo options, lists matching files, and
|
|
50
|
+
* produces a single SHA-256 content hash. Constructed as an Effect so it
|
|
51
|
+
* can access the platform `FileSystem` and `Path` services.
|
|
52
|
+
*/
|
|
53
|
+
const Memo = Effect.gen(function* () {
|
|
54
|
+
const fs = yield* FileSystem.FileSystem;
|
|
55
|
+
const path = yield* Path.Path;
|
|
56
|
+
|
|
57
|
+
const findUp = Effect.fnUntraced(function* (
|
|
58
|
+
cwd: string,
|
|
59
|
+
filenames: string[],
|
|
60
|
+
): Effect.fn.Return<string | undefined, PlatformError> {
|
|
61
|
+
const [file] = yield* Effect.filter(
|
|
62
|
+
filenames.map((filename) => path.join(cwd, filename)),
|
|
63
|
+
fs.exists,
|
|
64
|
+
{ concurrency: "unbounded" },
|
|
65
|
+
);
|
|
66
|
+
if (file) {
|
|
67
|
+
return file;
|
|
68
|
+
}
|
|
69
|
+
const parent = path.dirname(cwd);
|
|
70
|
+
if (parent === cwd) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return yield* findUp(parent, filenames);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const readGitIgnoreRules = Effect.fnUntraced(function* (
|
|
77
|
+
cwd: string,
|
|
78
|
+
): Effect.fn.Return<string[], PlatformError> {
|
|
79
|
+
const rules = yield* fs.readFileString(path.join(cwd, ".gitignore")).pipe(
|
|
80
|
+
Effect.map((file) => file.split("\n")),
|
|
81
|
+
Effect.catchIf(
|
|
82
|
+
(error) =>
|
|
83
|
+
error._tag === "PlatformError" && error.reason._tag === "NotFound",
|
|
84
|
+
() => Effect.succeed([]),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
const parent = path.dirname(cwd);
|
|
88
|
+
if (parent === cwd || (yield* fs.exists(path.join(cwd, ".git")))) {
|
|
89
|
+
return rules;
|
|
90
|
+
}
|
|
91
|
+
return [...(yield* readGitIgnoreRules(parent)), ...rules];
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const resolveMemoOptions = Effect.fnUntraced(function* (
|
|
95
|
+
cwd: string | undefined,
|
|
96
|
+
options: MemoOptions,
|
|
97
|
+
): Effect.fn.Return<ResolvedMemoOptions, PlatformError> {
|
|
98
|
+
const resolvedCwd = cwd ? path.resolve(cwd) : process.cwd();
|
|
99
|
+
return {
|
|
100
|
+
cwd: resolvedCwd,
|
|
101
|
+
include: options.include ?? ["**/*"],
|
|
102
|
+
exclude:
|
|
103
|
+
options.exclude ??
|
|
104
|
+
(yield* readGitIgnoreRules(resolvedCwd).pipe(
|
|
105
|
+
Effect.map(gitignoreRulesToGlobs),
|
|
106
|
+
Effect.map((globs) => ["**/.git/**", ...globs]),
|
|
107
|
+
)),
|
|
108
|
+
lockfile: options.lockfile ?? !(options.exclude || options.include),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const listFiles = Effect.fnUntraced(function* (
|
|
113
|
+
options: ResolvedMemoOptions,
|
|
114
|
+
): Effect.fn.Return<string[], PlatformError> {
|
|
115
|
+
const [files, lockfile] = yield* Effect.all(
|
|
116
|
+
[
|
|
117
|
+
Effect.promise(() =>
|
|
118
|
+
fg.glob(options.include, {
|
|
119
|
+
cwd: options.cwd,
|
|
120
|
+
ignore: options.exclude,
|
|
121
|
+
onlyFiles: true,
|
|
122
|
+
dot: true,
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
options.lockfile
|
|
126
|
+
? findUp(options.cwd, [
|
|
127
|
+
"bun.lock",
|
|
128
|
+
"bun.lockb",
|
|
129
|
+
"package-lock.json",
|
|
130
|
+
"pnpm-lock.yaml",
|
|
131
|
+
"yarn.lock",
|
|
132
|
+
]).pipe(
|
|
133
|
+
Effect.map((lockfile) =>
|
|
134
|
+
lockfile ? path.relative(options.cwd, lockfile) : undefined,
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
: Effect.succeed(undefined),
|
|
138
|
+
],
|
|
139
|
+
{ concurrency: "unbounded" },
|
|
140
|
+
);
|
|
141
|
+
if (lockfile && !files.includes(lockfile)) {
|
|
142
|
+
files.push(lockfile);
|
|
143
|
+
}
|
|
144
|
+
return files.sort();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const hashFiles = Effect.fnUntraced(function* (
|
|
148
|
+
cwd: string,
|
|
149
|
+
files: string[],
|
|
150
|
+
): Effect.fn.Return<string, PlatformError> {
|
|
151
|
+
const hashes = yield* Effect.forEach(
|
|
152
|
+
files,
|
|
153
|
+
(file) =>
|
|
154
|
+
fs.readFile(path.join(cwd, file)).pipe(
|
|
155
|
+
Effect.flatMap(sha256),
|
|
156
|
+
Effect.map((hash) => `${file}:${hash}`),
|
|
157
|
+
),
|
|
158
|
+
{ concurrency: "unbounded" },
|
|
159
|
+
);
|
|
160
|
+
return yield* sha256Object(hashes);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
resolveMemoOptions,
|
|
165
|
+
listFiles,
|
|
166
|
+
hashFiles,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Produces a deterministic SHA-256 hash of all files matched by the given
|
|
172
|
+
* memo options. The hash changes if and only if the content of the matched
|
|
173
|
+
* files changes, making it suitable for cache-busting build outputs.
|
|
174
|
+
*/
|
|
175
|
+
export const hashDirectory = Effect.fn(function* (props: {
|
|
176
|
+
cwd?: string;
|
|
177
|
+
memo?: MemoOptions;
|
|
178
|
+
}): Effect.fn.Return<string, PlatformError, FileSystem.FileSystem | Path.Path> {
|
|
179
|
+
const service = yield* Memo;
|
|
180
|
+
const resolvedOptions = yield* service.resolveMemoOptions(
|
|
181
|
+
props.cwd,
|
|
182
|
+
props.memo ?? {},
|
|
183
|
+
);
|
|
184
|
+
const files = yield* service.listFiles(resolvedOptions);
|
|
185
|
+
const hash = yield* service.hashFiles(resolvedOptions.cwd, files);
|
|
186
|
+
return hash;
|
|
187
|
+
});
|
package/src/Bundle/Bundle.ts
CHANGED
|
@@ -110,24 +110,9 @@ export const watch = (
|
|
|
110
110
|
if (result._tag === "Failure") {
|
|
111
111
|
return Result.fail(result.failure);
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
// These are sanity checks - with rolldown, the first file is always an entry chunk.
|
|
115
|
-
if (!files[0] || files[0].type !== "chunk" || !files[0].isEntry) {
|
|
116
|
-
return Result.fail(
|
|
117
|
-
new BundleError({
|
|
118
|
-
message: "Invalid bundle output",
|
|
119
|
-
}),
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
return yield* Effect.forEach(
|
|
123
|
-
files as [
|
|
124
|
-
rolldown.OutputChunk,
|
|
125
|
-
...(rolldown.OutputChunk | rolldown.OutputAsset)[],
|
|
126
|
-
],
|
|
127
|
-
bundleFileFromOutputChunk,
|
|
128
|
-
).pipe(
|
|
129
|
-
Effect.flatMap(bundleOutputFromFiles),
|
|
113
|
+
return yield* bundleOutputFromRolldownOutputBundle(result.success).pipe(
|
|
130
114
|
Effect.map(Result.succeed),
|
|
115
|
+
Effect.catch((error) => Effect.succeed(Result.fail(error))),
|
|
131
116
|
);
|
|
132
117
|
}),
|
|
133
118
|
),
|
|
@@ -173,6 +158,27 @@ export const virtualEntryPlugin = Effect.gen(function* () {
|
|
|
173
158
|
};
|
|
174
159
|
});
|
|
175
160
|
|
|
161
|
+
export function bundleOutputFromRolldownOutputBundle(
|
|
162
|
+
bundle: rolldown.OutputBundle,
|
|
163
|
+
): Effect.Effect<BundleOutput, BundleError> {
|
|
164
|
+
const files = Object.values(bundle);
|
|
165
|
+
// These are sanity checks - with rolldown, the first file is always an entry chunk.
|
|
166
|
+
if (!files[0] || files[0].type !== "chunk" || !files[0].isEntry) {
|
|
167
|
+
return Effect.fail(
|
|
168
|
+
new BundleError({
|
|
169
|
+
message: "Invalid bundle output",
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return Effect.forEach(
|
|
174
|
+
files as [
|
|
175
|
+
rolldown.OutputChunk,
|
|
176
|
+
...(rolldown.OutputChunk | rolldown.OutputAsset)[],
|
|
177
|
+
],
|
|
178
|
+
bundleFileFromOutputChunk,
|
|
179
|
+
).pipe(Effect.flatMap(bundleOutputFromFiles));
|
|
180
|
+
}
|
|
181
|
+
|
|
176
182
|
function bundleErrorFromUnknown(error: unknown): BundleError {
|
|
177
183
|
const message = error instanceof Error ? error.message : String(error);
|
|
178
184
|
return new BundleError({
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { MemoOptions } from "../../Build/Memo.ts";
|
|
2
|
+
import { Worker, type WorkerProps } from "../Workers/Worker.ts";
|
|
3
|
+
|
|
4
|
+
export interface ViteProps extends Omit<WorkerProps, "vite" | "main"> {
|
|
5
|
+
/**
|
|
6
|
+
* Root directory passed to Vite's `root` option.
|
|
7
|
+
* Defaults to the current working directory (`process.cwd()`).
|
|
8
|
+
*/
|
|
9
|
+
rootDir?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Controls which files are hashed to decide whether a rebuild is needed.
|
|
12
|
+
* By default every non-gitignored file in `cwd` is hashed, plus the nearest
|
|
13
|
+
* lockfile. Provide explicit globs to narrow the scope.
|
|
14
|
+
*
|
|
15
|
+
* @see {@link MemoOptions}
|
|
16
|
+
*/
|
|
17
|
+
memo?: MemoOptions;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A Cloudflare Worker deployed from a Vite project.
|
|
22
|
+
*
|
|
23
|
+
* `Vite` uses the Cloudflare Vite plugin to build both the server bundle and
|
|
24
|
+
* client assets in a single `vite build` invocation — no manual `main`
|
|
25
|
+
* entrypoint, build command, output directory, or Wrangler configuration
|
|
26
|
+
* required.
|
|
27
|
+
*
|
|
28
|
+
* Input files are content-hashed (respecting `.gitignore` by default) so
|
|
29
|
+
* unchanged projects skip the build and deploy entirely.
|
|
30
|
+
*
|
|
31
|
+
* @section Deploying a Static Site
|
|
32
|
+
* @example Basic Static Site
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const site = yield* Cloudflare.Vite("Website");
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @section Deploying a TanStack Start App
|
|
38
|
+
* @example TanStack Start with SSR
|
|
39
|
+
* ```typescript
|
|
40
|
+
* const app = yield* Cloudflare.Vite("TanStackStart", {
|
|
41
|
+
* compatibility: {
|
|
42
|
+
* flags: ["nodejs_compat"],
|
|
43
|
+
* },
|
|
44
|
+
* });
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @section Custom Rebuild Scope
|
|
48
|
+
* @example Narrow the Memo Scope
|
|
49
|
+
* ```typescript
|
|
50
|
+
* const site = yield* Cloudflare.Vite("Docs", {
|
|
51
|
+
* memo: {
|
|
52
|
+
* include: ["src/**", "content/**", "package.json"],
|
|
53
|
+
* },
|
|
54
|
+
* });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export const Vite = (id: string, props: ViteProps = {}) =>
|
|
58
|
+
Worker(id, {
|
|
59
|
+
...props,
|
|
60
|
+
main: undefined!,
|
|
61
|
+
vite: {
|
|
62
|
+
rootDir: props.rootDir,
|
|
63
|
+
memo: props.memo,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export * from "./StaticSite.ts";
|
|
2
|
-
export * from "./
|
|
2
|
+
export * from "./Vite.ts";
|
|
@@ -7,7 +7,7 @@ import * as Path from "effect/Path";
|
|
|
7
7
|
import type { PlatformError } from "effect/PlatformError";
|
|
8
8
|
import * as ServiceMap from "effect/ServiceMap";
|
|
9
9
|
import type { ScopedPlanStatusSession } from "../../Cli/Cli.ts";
|
|
10
|
-
import { sha256 } from "../../Util/index.ts";
|
|
10
|
+
import { sha256, sha256Object } from "../../Util/index.ts";
|
|
11
11
|
|
|
12
12
|
const MAX_ASSET_SIZE = 1024 * 1024 * 25; // 25MB
|
|
13
13
|
const MAX_ASSET_COUNT = 20_000;
|
|
@@ -23,6 +23,7 @@ export interface AssetReadResult {
|
|
|
23
23
|
manifest: Record<string, { hash: string; size: number }>;
|
|
24
24
|
_headers: string | undefined;
|
|
25
25
|
_redirects: string | undefined;
|
|
26
|
+
hash: string;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface AssetsProps {
|
|
@@ -190,7 +191,7 @@ export const AssetsProvider = () =>
|
|
|
190
191
|
});
|
|
191
192
|
}),
|
|
192
193
|
);
|
|
193
|
-
|
|
194
|
+
const result = {
|
|
194
195
|
directory: props.directory,
|
|
195
196
|
config: props.config,
|
|
196
197
|
manifest: Object.fromEntries(
|
|
@@ -201,6 +202,10 @@ export const AssetsProvider = () =>
|
|
|
201
202
|
_headers,
|
|
202
203
|
_redirects,
|
|
203
204
|
};
|
|
205
|
+
return {
|
|
206
|
+
...result,
|
|
207
|
+
hash: yield* sha256Object(result),
|
|
208
|
+
};
|
|
204
209
|
}),
|
|
205
210
|
upload: Effect.fnUntraced(function* (
|
|
206
211
|
accountId: string,
|