1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import type { Input } from "../../Input.ts";
5import * as Namespace from "../../Namespace.ts";
6import { initialCwd } from "../../Util/Node.ts";
7import { Object } from "./Object.ts";
8
9export interface FilesProps {
10 /** Bucket that receives the files. */
11 bucketName: Input<string>;
12 /**
13 * Local directory to upload, resolved against the working directory the
14 * deploy started in. Every regular file below it becomes one object.
15 */
16 path: string;
17 /** Key prefix prepended to every object name (no leading/trailing `/`). */
18 prefix?: string;
19 /**
20 * `Cache-Control` for every object, or a function of the object key.
21 * @default Cloud Storage's `public, max-age=3600` for public objects
22 */
23 cacheControl?: string | ((key: string) => string | undefined);
24}
25
26/**
27 * Upload a local directory into a Cloud Storage bucket: one
28 * {@link Object} per file, keyed by its POSIX path relative to `path`,
29 * with `Content-Type` inferred from the extension. Files removed from the
30 * directory are deleted from the bucket on the next deploy (their
31 * `Object` resources become orphans).
32 *
33 * ### Uploading a site
34 * **Example:** Upload `./dist` with no-cache HTML
35 * ```typescript
36 * const bucket = yield* GCP.Storage.Bucket("Site", { forceDestroy: true });
37 * const objects = yield* GCP.Storage.Files("SiteFiles", {
38 * bucketName: bucket.bucketName,
39 * path: "./dist",
40 * cacheControl: (key) =>
41 * key.endsWith(".html") ? "no-cache" : "public, max-age=31536000, immutable",
42 * });
43 * ```
44 *
45 * @resource
46 * @category Storage
47 */
48export const Files = (id: string, props: FilesProps) =>
49 Namespace.push(
50 id,
51 Effect.gen(function* () {
52 const fs = yield* FileSystem.FileSystem;
53 const path = yield* Path.Path;
54 const root = path.resolve(initialCwd, props.path);
55 const prefix = (props.prefix ?? "").replace(/^\/+|\/+$/g, "");
56 const entries = yield* fs.readDirectory(root, { recursive: true });
57 const files: string[] = [];
58 for (const entry of entries) {
59 const info = yield* fs.stat(path.join(root, entry));
60 if (info.type === "File") files.push(entry.replaceAll("\\", "/"));
61 }
62 files.sort();
63 // Logical ids are the URI-encoded relative path so `/` never clashes
64 // with the FQN separator and distinct paths never collide.
65 return yield* Effect.forEach(files, (relative) => {
66 const key = prefix ? `${prefix}/${relative}` : relative;
67 return Object(encodeURIComponent(relative), {
68 bucketName: props.bucketName,
69 key,
70 file: path.join(root, relative),
71 cacheControl:
72 typeof props.cacheControl === "function"
73 ? props.cacheControl(key)
74 : props.cacheControl,
75 });
76 });
77 }).pipe(
78 // An unreadable site directory is a stack authoring error.
79 Effect.catchTag("PlatformError", (error) =>
80 Effect.die(
81 new Error(
82 `GCP.Storage.Files(${id}): cannot read ${props.path}: ${error.message}`,
83 ),
84 ),
85 ),
86 ),
87 );