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,