diff --git a/examples/cloudflare-vite/index.html b/examples/cloudflare-vite/index.html new file mode 100644 index 0000000000..3c8bccdd23 --- /dev/null +++ b/examples/cloudflare-vite/index.html @@ -0,0 +1,13 @@ + + + + + + + Cloudflare + Vite + React + + +
+ + + diff --git a/examples/cloudflare-vite/package.json b/examples/cloudflare-vite/package.json new file mode 100644 index 0000000000..c9b4d94e05 --- /dev/null +++ b/examples/cloudflare-vite/package.json @@ -0,0 +1,23 @@ +{ + "name": "cloudflare-vite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.4", + "typescript": "^5.2.2", + "vite": "^7.3.1" + } +} diff --git a/examples/cloudflare-vite/public/vite.svg b/examples/cloudflare-vite/public/vite.svg new file mode 100644 index 0000000000..0906f9ca5e --- /dev/null +++ b/examples/cloudflare-vite/public/vite.svg @@ -0,0 +1 @@ + diff --git a/examples/cloudflare-vite/src/App.css b/examples/cloudflare-vite/src/App.css new file mode 100644 index 0000000000..861d6f7911 --- /dev/null +++ b/examples/cloudflare-vite/src/App.css @@ -0,0 +1,21 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} + +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} + +.card { + padding: 2em; +} diff --git a/examples/cloudflare-vite/src/App.tsx b/examples/cloudflare-vite/src/App.tsx new file mode 100644 index 0000000000..6f4a5236ec --- /dev/null +++ b/examples/cloudflare-vite/src/App.tsx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import "./App.css"; + +function App() { + const [count, setCount] = useState(0); + + return ( +
+ {" "} +

Cloudflare + Vite + React

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+
+ ); +} + +export default App; diff --git a/examples/cloudflare-vite/src/index.css b/examples/cloudflare-vite/src/index.css new file mode 100644 index 0000000000..18aa9a2239 --- /dev/null +++ b/examples/cloudflare-vite/src/index.css @@ -0,0 +1,73 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + + a:hover { + color: #747bff; + } + + button { + background-color: #f9f9f9; + } +} diff --git a/examples/cloudflare-vite/src/main.tsx b/examples/cloudflare-vite/src/main.tsx new file mode 100644 index 0000000000..3d7150da80 --- /dev/null +++ b/examples/cloudflare-vite/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/examples/cloudflare-vite/src/sst-env.d.ts b/examples/cloudflare-vite/src/sst-env.d.ts new file mode 100644 index 0000000000..035e323c04 --- /dev/null +++ b/examples/cloudflare-vite/src/sst-env.d.ts @@ -0,0 +1,12 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* biome-ignore-all lint: auto-generated */ + +/// +interface ImportMetaEnv { + +} +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/examples/cloudflare-vite/sst-env.d.ts b/examples/cloudflare-vite/sst-env.d.ts new file mode 100644 index 0000000000..8d2fe09d2f --- /dev/null +++ b/examples/cloudflare-vite/sst-env.d.ts @@ -0,0 +1,18 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +declare module "sst" { + export interface Resource { + "Vite": { + "type": "sst.cloudflare.StaticSite" + "url": string + } + } +} +/// + +import "sst" +export {} \ No newline at end of file diff --git a/examples/cloudflare-vite/sst.config.ts b/examples/cloudflare-vite/sst.config.ts new file mode 100644 index 0000000000..2fb95c356d --- /dev/null +++ b/examples/cloudflare-vite/sst.config.ts @@ -0,0 +1,25 @@ +/// + +/** + * ## React SPA with Vite + * + * Deploy a React single-page app (SPA) with Vite to Cloudflare. + */ +export default $config({ + app(input) { + return { + name: "cf-vite", + home: "cloudflare", + removal: input?.stage === "production" ? "retain" : "remove", + }; + }, + async run() { + new sst.cloudflare.x.StaticSite("Vite", { + build: { + command: "pnpm run build", + output: "dist", + }, + notFoundHandling: "single-page-application", + }); + }, +}); diff --git a/examples/cloudflare-vite/tsconfig.json b/examples/cloudflare-vite/tsconfig.json new file mode 100644 index 0000000000..a7fc6fbf23 --- /dev/null +++ b/examples/cloudflare-vite/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/cloudflare-vite/tsconfig.node.json b/examples/cloudflare-vite/tsconfig.node.json new file mode 100644 index 0000000000..97ede7ee6f --- /dev/null +++ b/examples/cloudflare-vite/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/cloudflare-vite/vite.config.ts b/examples/cloudflare-vite/vite.config.ts new file mode 100644 index 0000000000..5a33944a9b --- /dev/null +++ b/examples/cloudflare-vite/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/platform/src/components/cloudflare/experimental/static-site.ts b/platform/src/components/cloudflare/experimental/static-site.ts index 3b9b60ddf1..0f3556593d 100644 --- a/platform/src/components/cloudflare/experimental/static-site.ts +++ b/platform/src/components/cloudflare/experimental/static-site.ts @@ -10,7 +10,8 @@ import { prepare, } from "../../base/base-static-site.js"; -export interface StaticSiteArgs extends BaseStaticSiteArgs { +export interface StaticSiteArgs + extends Omit { /** * Path to the directory where your static site is located. By default this assumes your static site is in the root of your SST app. * @@ -70,6 +71,13 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * ``` */ domain?: Input; + htmlHandling?: Input< + | "auto-trailing-slash" + | "force-trailing-slash" + | "drop-trailing-slash" + | "none" + >; + notFoundHandling?: Input<"404-page" | "single-page-application" | "none">; } /** @@ -84,7 +92,7 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * Simply uploads the current directory as a static site. * * ```js - * new sst.aws.StaticSite("MyWeb"); + * new sst.cloudflare.x.StaticSite("MyWeb"); * ``` * * #### Change the path @@ -92,7 +100,7 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * Change the `path` that should be uploaded. * * ```js - * new sst.aws.StaticSite("MyWeb", { + * new sst.cloudflare.x.StaticSite("MyWeb", { * path: "path/to/site" * }); * ``` @@ -102,11 +110,12 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * Use [Vite](https://vitejs.dev) to deploy a React/Vue/Svelte/etc. SPA by specifying the `build` config. * * ```js - * new sst.aws.StaticSite("MyWeb", { + * new sst.cloudflare.x.StaticSite("MyWeb", { * build: { * command: "npm run build", * output: "dist" - * } + * }, + * notFoundHandling: "single-page-application" * }); * ``` * @@ -115,7 +124,7 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * Use [Jekyll](https://jekyllrb.com) to deploy a static site. * * ```js - * new sst.aws.StaticSite("MyWeb", { + * new sst.cloudflare.x.StaticSite("MyWeb", { * errorPage: "404.html", * build: { * command: "bundle exec jekyll build", @@ -129,7 +138,7 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * Use [Gatsby](https://www.gatsbyjs.com) to deploy a static site. * * ```js - * new sst.aws.StaticSite("MyWeb", { + * new sst.cloudflare.x.StaticSite("MyWeb", { * errorPage: "404.html", * build: { * command: "npm run build", @@ -143,7 +152,7 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * Use [Angular](https://angular.dev) to deploy a SPA. * * ```js - * new sst.aws.StaticSite("MyWeb", { + * new sst.cloudflare.x.StaticSite("MyWeb", { * build: { * command: "ng build --output-path dist", * output: "dist" @@ -156,24 +165,11 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * Set a custom domain for your site. * * ```js {2} - * new sst.aws.StaticSite("MyWeb", { + * new sst.cloudflare.x.StaticSite("MyWeb", { * domain: "my-app.com" * }); * ``` * - * #### Redirect www to apex domain - * - * Redirect `www.my-app.com` to `my-app.com`. - * - * ```js {4} - * new sst.aws.StaticSite("MyWeb", { - * domain: { - * name: "my-app.com", - * redirects: ["www.my-app.com"] - * } - * }); - * ``` - * * #### Set environment variables * * Set `environment` variables for the build process of your static site. These will be used locally and on deploy. @@ -185,9 +181,9 @@ export interface StaticSiteArgs extends BaseStaticSiteArgs { * For some static site generators like Vite, [environment variables](https://vitejs.dev/guide/env-and-mode) prefixed with `VITE_` can be accessed in the browser. * * ```ts {5-7} - * const bucket = new sst.aws.Bucket("MyBucket"); + * const bucket = new sst.cloudflare.Bucket("MyBucket"); * - * new sst.aws.StaticSite("MyWeb", { + * new sst.cloudflare.x.StaticSite("MyWeb", { * environment: { * BUCKET_NAME: bucket.name, * // Accessible in the browser @@ -211,7 +207,7 @@ export class StaticSite extends Component implements Link.Linkable { super(__pulumiType, name, args, opts); const self = this; - const { sitePath, environment, indexPage } = prepare(args); + const { sitePath, environment } = prepare(args); const outputPath = $dev ? path.join($cli.paths.platform, "functions", "empty-site") : buildApp(self, name, args.build, sitePath, environment); @@ -246,14 +242,16 @@ export class StaticSite extends Component implements Link.Linkable { ), environment: environment.apply((e) => ({ ...e, - INDEX_PAGE: indexPage, - ...(args.errorPage ? { ERROR_PAGE: args.errorPage } : {}), })), url: true, dev: false, domain: args.domain, assets: { directory: outputPath, + htmlHandling: args.htmlHandling + ? args.htmlHandling + : "auto-trailing-slash", + notFoundHandling: args.notFoundHandling, }, }, { parent: self }, diff --git a/platform/src/components/cloudflare/worker.ts b/platform/src/components/cloudflare/worker.ts index 9c19bc07f5..6ce3a350e8 100644 --- a/platform/src/components/cloudflare/worker.ts +++ b/platform/src/components/cloudflare/worker.ts @@ -22,8 +22,6 @@ import { Permission } from "../aws/permission.js"; import { binding } from "./binding.js"; import { DEFAULT_ACCOUNT_ID } from "./account-id.js"; import { rpc } from "../rpc/rpc.js"; -import { WorkerAssets } from "./providers/worker-assets"; -import { globSync } from "glob"; import { VisibleError } from "../error"; import { getContentType } from "../base/base-site"; import { physicalName } from "../naming"; @@ -181,6 +179,13 @@ export interface WorkerArgs { * The directory containing the assets. */ directory: Input; + htmlHandling?: Input< + | "auto-trailing-slash" + | "force-trailing-slash" + | "drop-trailing-slash" + | "none" + >; + notFoundHandling?: Input<"404-page" | "single-page-application" | "none">; }>; /** * Configure [placement](https://developers.cloudflare.com/workers/configuration/placement/) @@ -507,23 +512,37 @@ export class Worker extends Component implements Link.Linkable { compatibilityFlags: ["nodejs_compat"], assets: args.assets ? output(args.assets).apply(async (assets) => { - const directory = path.join( - $cli.paths.root, - assets.directory, - ); + const directory = path.isAbsolute(assets.directory) + ? assets.directory + : path.join($cli.paths.root, assets.directory); + let headers; + let redirects; try { headers = await fs.readFile( path.join(directory, "_headers"), "utf-8", ); } catch (e) {} + + try { + redirects = await fs.readFile( + path.join(directory, "_redirects"), + "utf-8", + ); + } catch (e) {} return { directory, - config: { headers }, + config: { + headers, + redirects, + htmlHandling: assets.htmlHandling, + notFoundHandling: assets.notFoundHandling, + }, }; }) : undefined, + bindings: all([args.environment, iamCredentials, bindings]).apply( ([environment, iamCredentials, bindings]) => [ ...bindings,