diff --git a/.github/workflows/test.unit.yml b/.github/workflows/test.unit.yml index 2e70b9860a..333ee6b5f2 100644 --- a/.github/workflows/test.unit.yml +++ b/.github/workflows/test.unit.yml @@ -36,3 +36,6 @@ jobs: - name: yarn @bud test unit run: yarn @bud test unit + env: + AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} + AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} diff --git a/examples/s3/bud.config.ts b/examples/s3/bud.config.ts new file mode 100644 index 0000000000..16576b9dfe --- /dev/null +++ b/examples/s3/bud.config.ts @@ -0,0 +1,16 @@ +import {bud} from '@roots/bud' + +bud + .html() + .when(bud.isProduction, bud => + bud.setPublicPath( + `https://bud-js-tests.s3.us-west-2.amazonaws.com/examples/s3/`, + ), + ) + .fs.setCredentials({ + accessKeyId: bud.env.get(`AWS_ACCESS_KEY_ID`), + secretAccessKey: bud.env.get(`AWS_SECRET_ACCESS_KEY`), + }) + .setRegion(`us-west-2`) + .setBucket(`bud-js-tests`) + .upload({destination: `examples/s3`}) diff --git a/examples/s3/package.json b/examples/s3/package.json new file mode 100644 index 0000000000..2af49ff210 --- /dev/null +++ b/examples/s3/package.json @@ -0,0 +1,14 @@ +{ + "name": "@examples/s3", + "$schema": "https://bud.js.org/bud.package.json", + "private": true, + "type": "module", + "browserslist": [ + "extends @roots/browserslist-config" + ], + "devDependencies": { + "@roots/bud": "workspace:*", + "@roots/bud-preset-recommend": "workspace:*", + "@roots/bud-react": "workspace:*" + } +} diff --git a/examples/s3/src/app.css b/examples/s3/src/app.css new file mode 100644 index 0000000000..1443248ece --- /dev/null +++ b/examples/s3/src/app.css @@ -0,0 +1,50 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', + 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', + 'Helvetica Neue', sans-serif; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} + +.App { + width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + text-align: center; + display: flex; + justify-content: center; + align-content: center; + background-color: rgba(4, 1, 17, 1); + + & .logo { + height: 30vmin; + pointer-events: none; + margin-bottom: 2rem; + animation: App-logo-spin infinite 20s linear; + } + + & .header { + min-height: 80vh; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; + } +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/examples/s3/src/components/App.js b/examples/s3/src/components/App.js new file mode 100644 index 0000000000..c68c336a5f --- /dev/null +++ b/examples/s3/src/components/App.js @@ -0,0 +1,12 @@ +import logo from './logo.svg' + +export const App = () => { + return ( +
+
+ logo + Edit src/components/App.js and save to reload +
+
+ ) +} diff --git a/examples/s3/src/components/logo.svg b/examples/s3/src/components/logo.svg new file mode 100644 index 0000000000..a7c8baa474 --- /dev/null +++ b/examples/s3/src/components/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + diff --git a/examples/s3/src/index.js b/examples/s3/src/index.js new file mode 100644 index 0000000000..d13d278544 --- /dev/null +++ b/examples/s3/src/index.js @@ -0,0 +1,7 @@ +import {createRoot} from 'react-dom/client' + +import {App} from './components/App.js' + +import './app.css' + +createRoot(document.getElementById('root')).render() diff --git a/examples/s3/tsconfig.json b/examples/s3/tsconfig.json new file mode 100644 index 0000000000..76c443d568 --- /dev/null +++ b/examples/s3/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@roots/bud/config/tsconfig.json", + "compilerOptions": { + "paths": { + "@src/*": ["./src/*"] + }, + "types": ["@roots/bud", "@roots/bud-preset-recommend", "@roots/bud-react"] + }, + "files": ["./bud.config.ts"], + "include": ["./src"], + "exclude": ["./dist"] +} diff --git a/sources/@roots/bud-framework/src/fs.ts b/sources/@roots/bud-framework/src/fs.ts index 3f9e68f475..c58182f4e6 100644 --- a/sources/@roots/bud-framework/src/fs.ts +++ b/sources/@roots/bud-framework/src/fs.ts @@ -90,10 +90,7 @@ export class FS extends Filesystem implements Contract { */ @bind public setBucket(bucket: string) { - this.app.after(async () => { - this.s3.config.set(`bucket`, bucket) - }) - + this.s3.config.set(`bucket`, bucket) return this } @@ -106,10 +103,7 @@ export class FS extends Filesystem implements Contract { */ @bind public setCredentials(credentials: S3[`config`][`credentials`]) { - this.app.after(async () => { - this.s3.config.set(`credentials`, credentials) - }) - + this.s3.config.set(`credentials`, credentials) return this } @@ -122,10 +116,7 @@ export class FS extends Filesystem implements Contract { */ @bind public setEndpoint(endpoint: S3[`config`][`endpoint`]) { - this.app.after(async () => { - this.s3.config.set(`endpoint`, endpoint) - }) - + this.s3.config.set(`endpoint`, endpoint) return this } @@ -138,10 +129,7 @@ export class FS extends Filesystem implements Contract { */ @bind public setRegion(region: S3[`config`][`region`]) { - this.app.after(async () => { - this.s3.config.set(`region`, region) - }) - + this.s3.config.set(`region`, region) return this } @@ -159,33 +147,38 @@ export class FS extends Filesystem implements Contract { keep?: false | number source?: string }): this { - if (!this.s3.config.credentials) { - throw BudError.normalize( - `S3 is not configured. See https://bud.js.org/reference/bud.fs/s3`, - ) - } + this.app.after(async () => { + if (!this.s3.config.credentials) { + throw BudError.normalize( + `S3 is not configured. See https://bud.js.org/reference/bud.fs/s3`, + ) + } - const {destination, files, keep, source} = { - destination: options?.destination, - files: options?.files ?? `**/*`, - keep: - isNumber(options?.keep) || isBoolean(options?.keep) - ? options?.keep - : 5, - source: options?.source ?? this.app.path(`@dist`), - } + const s3Path = (path: string) => + destination ? join(destination, path) : path - const s3Path = (path: string) => - destination ? join(destination, path) : path + const {destination, files, keep, source} = { + destination: options?.destination, + files: options?.files ?? `**/*`, + keep: + isNumber(options?.keep) || isBoolean(options?.keep) + ? options?.keep + : 5, + source: options?.source ?? this.app.path(`@dist`), + } - this.app.after(async () => { - // eslint-disable-next-line - console.log(`Uploading...`) + // eslint-disable-next-line no-console + console.log(`\nUploading files to ${this.s3.ident}`) await globby(files, {cwd: source}).then(async files => { const descriptions = await Promise.all( files.map(async file => { + this.logger.info( + `Attempting to read ${file}:`, + join(source, file), + ) const contents = await this.read(join(source, file), `buffer`) + this.logger.info(`Read ${file}:`, contents) return {contents, file} }), ) @@ -261,6 +254,9 @@ export class FS extends Filesystem implements Contract { this.logger.timeEnd( `Write upload-manifest.json to ${this.s3.ident}`, ) + + // eslint-disable-next-line no-console + console.log(`\nUpload complete`) }) }) diff --git a/sources/@roots/filesystem/src/s3/config.ts b/sources/@roots/filesystem/src/s3/config.ts index f784671329..4787e1a99b 100644 --- a/sources/@roots/filesystem/src/s3/config.ts +++ b/sources/@roots/filesystem/src/s3/config.ts @@ -36,9 +36,7 @@ export class Config { * @returns value - {@link S3ClientConfig} value */ public get< - K extends `${ - | (keyof Config & string) - | (keyof S3ClientConfig & keyof Config & string)}`, + K extends `bucket` | `credentials` | `endpoint` | `public` | `region`, >(key: K): this[K] { return this[key] } @@ -51,9 +49,7 @@ export class Config { * @returns void */ public set< - K extends `${ - | (keyof Config & string) - | (keyof S3ClientConfig & keyof Config & string)}`, + K extends `bucket` | `credentials` | `endpoint` | `public` | `region`, >(key: K, value: this[K]): void { this[key] = value } diff --git a/sources/@roots/filesystem/src/s3/index.ts b/sources/@roots/filesystem/src/s3/index.ts index 69758dba8b..a19f7b43c6 100644 --- a/sources/@roots/filesystem/src/s3/index.ts +++ b/sources/@roots/filesystem/src/s3/index.ts @@ -9,7 +9,6 @@ import type {Readable} from 'node:stream' import SDK from '@roots/filesystem/vendor/sdk' import {bind} from 'helpful-decorators' -import isString from 'lodash-es/isString.js' import * as mimetypes from 'mime-types' import {Client} from './client.js' @@ -22,28 +21,19 @@ export class S3 { /** * Client instance */ - public client: Client + public client: Client = new Client() /** * S3 configuration */ - public config: Config - - /** - * constructor - */ - public constructor() { - this.config = new Config() - this.client = new Client() - } + public config: Config = new Config() /** * Delete a file from s3 * * @param key - The file key * @returns S3 instance {@link S3} - * @throws Error - If the file does not exist - * @throws Error - If the file could not be deleted + * @throws Error - If the file does not exist or could not be deleted */ @bind public async delete(key: string) { @@ -53,9 +43,7 @@ export class S3 { Bucket: this.config.bucket, Key: key, }) - // @ts-ignore await client.send(DeleteObjectOutput) - return this } catch (error) { throw error @@ -69,10 +57,10 @@ export class S3 { * @returns boolean */ @bind - public async exists(key: string) { + public async exists(key: string): Promise { try { - const files = (await this.list()) as Array - return files.some(item => item === key) + const files = await this.list() + return files.some(item => item === key) ?? false } catch (error) { return false } @@ -100,20 +88,21 @@ export class S3 { * Identifier (for loggers, etc) */ public get ident() { - const maybeEndpoint = this.config.get(`endpoint`) + const [bucket, endpoint, region] = [ + this.config.get(`bucket`), + this.config.get(`endpoint`), + this.config.get(`region`), + ] + + if (!(typeof endpoint === `string`)) return `${bucket} (${region})` - if (!maybeEndpoint) - return `${this.config.get(`bucket`)} (${this.config.get(`region`)})` + if (endpoint.includes(`digitaloceanspaces`)) { + const hostname = new URL(endpoint).hostname - if ( - isString(maybeEndpoint) && - maybeEndpoint.includes(`digitaloceanspaces`) - ) - return `https://${this.config.get(`bucket`)}.${ - new URL(maybeEndpoint).hostname - }` + return `https://${bucket}.${hostname}` + } - return `https://${maybeEndpoint.toString()}` + return `https://${endpoint}` } /** @@ -138,8 +127,9 @@ export class S3 { ...(props ?? {}), }), ) + // @ts-ignore - return results?.Contents.map(({Key}) => Key) + return results?.Contents?.map(({Key}) => Key) ?? [] } catch (error) { throw error } @@ -159,7 +149,7 @@ export class S3 { */ @bind public async read(key: string, raw = false): Promise { - const streamToString = ({Body: stream}: {Body: Readable}) => + const streamToString = async ({Body: stream}: {Body: Readable}) => new Promise((resolve, reject) => { const chunks: Array = [] @@ -171,14 +161,17 @@ export class S3 { const client = this.getClient() - const GetObjectCommandOutput = new SDK.GetObjectCommand({ + const GetObject = new SDK.GetObjectCommand({ Bucket: this.config.bucket, Key: key, }) try { - const request: any = await client.send(GetObjectCommandOutput) - if (!request) return raw ? request : streamToString(request) + const request: any = await client.send(GetObject) + if (!request) return undefined + if (raw) return request + + return await streamToString(request) } catch (error) { throw error } @@ -197,7 +190,6 @@ export class S3 { | [string, Blob | Readable | ReadableStream | string] ) { const putProps = { - ACL: this.config.public ? `public-read` : `private`, Bucket: this.config.bucket, ContentType: null, Key: null, diff --git a/sources/@roots/filesystem/test/s3/config.test.ts b/sources/@roots/filesystem/test/s3/config.test.ts index 6ae6066291..7ee8efe4f9 100644 --- a/sources/@roots/filesystem/test/s3/config.test.ts +++ b/sources/@roots/filesystem/test/s3/config.test.ts @@ -3,7 +3,7 @@ import {beforeEach, describe, expect, it, vi} from 'vitest' import {Config} from '../../src/s3/config.js' describe(`s3 config`, () => { - let config + let config: Config beforeEach(async () => { vi.clearAllMocks() @@ -16,6 +16,7 @@ describe(`s3 config`, () => { accessKeyId: `foo`, secretAccessKey: `bar`, }) + expect(config.credentials).toEqual( expect.objectContaining({ accessKeyId: `foo`, diff --git a/sources/@roots/filesystem/test/s3/index.test.ts b/sources/@roots/filesystem/test/s3/index.test.ts index 4f7d3a392a..53afe14eba 100644 --- a/sources/@roots/filesystem/test/s3/index.test.ts +++ b/sources/@roots/filesystem/test/s3/index.test.ts @@ -11,9 +11,9 @@ const mockConfigImplementation = { accessKeyId: `foo`, secretAccessKey: `bar`, }, - region: `us-east-1`, endpoint: `https://s3.amazonaws.com`, get: vi.fn(), + region: `us-east-1`, set: vi.fn(), } mockConfigImplementation.get = vi.fn( diff --git a/tests/reproductions/issue-2574.test.ts b/tests/reproductions/issue-2574.test.ts new file mode 100644 index 0000000000..03d89a990c --- /dev/null +++ b/tests/reproductions/issue-2574.test.ts @@ -0,0 +1,48 @@ +/* eslint-disable n/no-process-env */ +import {sep} from 'node:path' + +import {path} from '@repo/constants' +import execa from '@roots/bud-support/execa' +import {Filesystem} from '@roots/bud-support/filesystem' +import {beforeAll, describe, expect, it} from 'vitest' + +describe(`issue-2574`, () => { + let fs: Filesystem + let head: string + + beforeAll(async () => { + fs = new Filesystem() + + head = (await fs.read(path(`.git`, `HEAD`))) + .toString() + .split(sep) + .pop() + .trim() + + await execa(`yarn`, [`bud`, `clean`], { + cwd: path(`tests`, `reproductions`, `issue-2574`), + }) + + await execa(`yarn`, [`bud`, `build`], { + cwd: path(`tests`, `reproductions`, `issue-2574`), + }) + }, 30000) + + it(`should upload to s3`, async () => { + const result = await fetch( + `https://bud-js-tests.s3.us-west-2.amazonaws.com/${head}/js/main.js`, + ) + expect(await result.text()).toBe( + await fs.read( + path( + `tests`, + `reproductions`, + `issue-2574`, + `dist`, + `js`, + `main.js`, + ), + ), + ) + }) +}, 120000) diff --git a/tests/reproductions/issue-2574/bud.config.ts b/tests/reproductions/issue-2574/bud.config.ts new file mode 100644 index 0000000000..c377ed19cd --- /dev/null +++ b/tests/reproductions/issue-2574/bud.config.ts @@ -0,0 +1,23 @@ +import { sep } from 'node:path' + +import {bud} from '@roots/bud' + +const path = bud.path(`..`, `..`, `..`, `.git`, `HEAD`) + +const head = (await bud.fs.read(path)) + ?.toString() + .split(sep) + .pop() + .trim() + +bud.fs + .setCredentials({ + accessKeyId: bud.env.get(`AWS_ACCESS_KEY_ID`), + secretAccessKey: bud.env.get(`AWS_SECRET_ACCESS_KEY`), + }) + .setRegion(`us-west-2`) + .setBucket(`bud-js-tests`) + .upload({ + destination: head, + keep: 2, + }) diff --git a/tests/reproductions/issue-2574/package.json b/tests/reproductions/issue-2574/package.json new file mode 100644 index 0000000000..b77f7ab0e7 --- /dev/null +++ b/tests/reproductions/issue-2574/package.json @@ -0,0 +1,9 @@ +{ + "name": "@tests/issue-2574", + "$schema": "https://bud.js.org/bud.package.json", + "private": true, + "type": "module", + "devDependencies": { + "@roots/bud": "workspace:*" + } +} diff --git a/tests/reproductions/issue-2574/src/index.css b/tests/reproductions/issue-2574/src/index.css new file mode 100644 index 0000000000..5878444be3 --- /dev/null +++ b/tests/reproductions/issue-2574/src/index.css @@ -0,0 +1,9 @@ +html, +body { + padding: 0; + margin: 0; +} + +body { + background-image: url(~@src/logo.svg?inline); +} diff --git a/tests/reproductions/issue-2574/src/index.js b/tests/reproductions/issue-2574/src/index.js new file mode 100644 index 0000000000..51f5b151a9 --- /dev/null +++ b/tests/reproductions/issue-2574/src/index.js @@ -0,0 +1,3 @@ +import './index.css' + +window.test = true diff --git a/tests/reproductions/issue-2574/src/logo.svg b/tests/reproductions/issue-2574/src/logo.svg new file mode 100644 index 0000000000..97ffc493db --- /dev/null +++ b/tests/reproductions/issue-2574/src/logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/reproductions/issue-2574/tsconfig.json b/tests/reproductions/issue-2574/tsconfig.json new file mode 100644 index 0000000000..e6deaedd52 --- /dev/null +++ b/tests/reproductions/issue-2574/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@roots/bud/config/tsconfig.json", + "compilerOptions": { + "types": ["node", "@roots/bud"] + }, + "files": ["./bud.config.ts"], + "include": ["./src"], + "exclude": ["./node_modules"] +} diff --git a/yarn.lock b/yarn.lock index f5096e11fa..1f41e1babe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6191,6 +6191,16 @@ __metadata: languageName: unknown linkType: soft +"@examples/s3@workspace:examples/s3": + version: 0.0.0-use.local + resolution: "@examples/s3@workspace:examples/s3" + dependencies: + "@roots/bud": "workspace:*" + "@roots/bud-preset-recommend": "workspace:*" + "@roots/bud-react": "workspace:*" + languageName: unknown + linkType: soft + "@examples/sage@workspace:examples/sage": version: 0.0.0-use.local resolution: "@examples/sage@workspace:examples/sage" @@ -12012,6 +12022,14 @@ __metadata: languageName: unknown linkType: soft +"@tests/issue-2574@workspace:tests/reproductions/issue-2574": + version: 0.0.0-use.local + resolution: "@tests/issue-2574@workspace:tests/reproductions/issue-2574" + dependencies: + "@roots/bud": "workspace:*" + languageName: unknown + linkType: soft + "@tests/minimize-flag@workspace:sources/@roots/bud/test/cli-flag-minimize/project": version: 0.0.0-use.local resolution: "@tests/minimize-flag@workspace:sources/@roots/bud/test/cli-flag-minimize/project"