Skip to content

Commit 383dc0a

Browse files
authored
wrangler support for SPA mode (#8501)
* Apply not_found_handling = single-page-application when single_page_application = true * Move over to using a compatibility flag and supporting 404-page * Move over to using a compatibility flag and supporting 404-page * Bump [email protected] * Implement SPA mode in wrangler dev * Move over to using a compatibility flag and supporting 404-page * Fix changeset * PR feedback
1 parent 8278db5 commit 383dc0a

File tree

18 files changed

+1031
-144
lines changed

18 files changed

+1031
-144
lines changed

.changeset/fine-jars-lick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
Add support for `assets_navigation_prefers_asset_serving` compatibility flag in `wrangler dev`
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# workers-assets-with-spa"
2+
3+
`workers-assets-with-spa` is a test fixture that showcases Workers with Assets in SPA mode. This particular fixture sets up a User Worker, assets in SPA mode, and a binding from the user Worker to the assets.
4+
5+
## dev
6+
7+
To start a dev session you can run
8+
9+
```
10+
wrangler dev
11+
```
12+
13+
## Run tests
14+
15+
```
16+
npm run test
17+
```
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "workers-assets-spa",
3+
"private": true,
4+
"scripts": {
5+
"check:type": "tsc",
6+
"dev": "wrangler dev",
7+
"pretest:ci": "pnpm playwright install chromium",
8+
"test:ci": "vitest run",
9+
"test:watch": "vitest",
10+
"type:tests": "tsc -p ./tests/tsconfig.json"
11+
},
12+
"devDependencies": {
13+
"@cloudflare/workers-tsconfig": "workspace:*",
14+
"@cloudflare/workers-types": "^4.20250310.0",
15+
"@types/jest-image-snapshot": "^6.4.0",
16+
"@types/node": "catalog:default",
17+
"jest-image-snapshot": "^6.4.0",
18+
"playwright-chromium": "catalog:default",
19+
"typescript": "catalog:default",
20+
"undici": "catalog:default",
21+
"vitest": "catalog:default",
22+
"wrangler": "workspace:*"
23+
},
24+
"volta": {
25+
"extends": "../../package.json"
26+
}
27+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<title>I'm a SPA</title>
5+
</head>
6+
<body>
7+
<div id="root"></div>
8+
9+
<script type="text/babel">
10+
const NavigationContext = React.createContext("navigation");
11+
12+
function Link({ href, children }) {
13+
const { navigate } = React.useContext(NavigationContext);
14+
15+
return (
16+
<a
17+
href={href}
18+
onClick={(e) => {
19+
e.preventDefault();
20+
navigate(href);
21+
}}
22+
children={children}
23+
/>
24+
);
25+
}
26+
27+
function Navigation() {
28+
return (
29+
<nav>
30+
<ul>
31+
<li>
32+
/ <a href="/">HARD</a> <Link href="/">SOFT</Link>
33+
</li>
34+
<li>
35+
/blog <a href="/blog">HARD</a> <Link href="/blog">SOFT</Link>
36+
</li>
37+
<li>
38+
/blog/random <a href="/blog/random">HARD</a>{" "}
39+
<Link href="/blog/random">SOFT</Link>
40+
</li>
41+
<li>
42+
/shadowed-by-asset.txt <a href="/shadowed-by-asset.txt">HARD</a>{" "}
43+
<Link href="/shadowed-by-asset.txt">SOFT</Link>
44+
</li>
45+
<li>
46+
/shadowed-by-spa <a href="/shadowed-by-spa">HARD</a>{" "}
47+
<Link href="/shadowed-by-spa">SOFT</Link>
48+
</li>
49+
<li>
50+
/api/math <a href="/api/math">HARD</a>{" "}
51+
<Link href="/api/math">SOFT</Link>
52+
</li>
53+
</ul>
54+
</nav>
55+
);
56+
}
57+
58+
function Blog() {
59+
const [slug, setSlug] = React.useState("/blog/hello-world");
60+
const { navigate } = React.useContext(NavigationContext);
61+
62+
return (
63+
<div>
64+
<h1>Blog</h1>
65+
<input value={slug} onChange={(e) => setSlug(e.target.value)} />
66+
<a href={slug}>HARD load</a>
67+
<button
68+
onClick={() => {
69+
navigate(slug);
70+
}}
71+
>
72+
SOFT load
73+
</button>
74+
<Navigation />
75+
</div>
76+
);
77+
}
78+
79+
function BlogEntry() {
80+
const { pathname } = new URL(document.location.href);
81+
const slug = pathname.split("/blog/")[1];
82+
83+
return (
84+
<div>
85+
<h1>Blog | {slug}</h1>
86+
<Navigation />
87+
</div>
88+
);
89+
}
90+
91+
function ShadowedBySpa() {
92+
return (
93+
<div>
94+
<h1>Shadowed by SPA!</h1>
95+
<Navigation />
96+
</div>
97+
);
98+
}
99+
100+
function FourOhFour() {
101+
return (
102+
<div>
103+
<h1>404 page!</h1>
104+
<Navigation />
105+
</div>
106+
);
107+
}
108+
109+
function Home() {
110+
const [mathResult, setMathResult] = React.useState("loading...");
111+
const [jsonResult, setJsonResult] = React.useState("loading...");
112+
const [htmlResult, setHtmlResult] = React.useState("loading...");
113+
114+
React.useEffect(() => {
115+
(async () => {
116+
const mathResponse = await fetch("/api/math");
117+
setMathResult(await mathResponse.text());
118+
})();
119+
}, [setMathResult]);
120+
121+
React.useEffect(() => {
122+
(async () => {
123+
const jsonResponse = await fetch("/api/json", {
124+
headers: { Accept: "application/json" },
125+
});
126+
setJsonResult(await jsonResponse.json());
127+
})();
128+
}, [setJsonResult]);
129+
130+
React.useEffect(() => {
131+
(async () => {
132+
const htmlResponse = await fetch("/api/html", {
133+
headers: { Accept: "text/html" },
134+
});
135+
setHtmlResult(await htmlResponse.text());
136+
})();
137+
}, [setHtmlResult]);
138+
139+
return (
140+
<div>
141+
<h1>Homepage</h1>
142+
<p>
143+
Math result: <span id="math-result">{mathResult}</span>
144+
</p>
145+
<p>JSON result:</p>
146+
<pre>
147+
<code id="json-result">
148+
{JSON.stringify(jsonResult, null, 2)}
149+
</code>
150+
</pre>
151+
<p>HTML result:</p>
152+
<div
153+
dangerouslySetInnerHTML={{ __html: htmlResult }}
154+
id="html-result"
155+
/>
156+
<Navigation />
157+
</div>
158+
);
159+
}
160+
161+
function Router() {
162+
const [pathname, setPathname] = React.useState(
163+
new URL(document.location.href).pathname
164+
);
165+
166+
const navigate = (location) => {
167+
window.history.pushState({}, "", location);
168+
setPathname(new URL(document.location.href).pathname);
169+
};
170+
171+
return (
172+
<NavigationContext.Provider value={{ navigate }}>
173+
{(() => {
174+
if (pathname.startsWith("/blog/")) {
175+
return <BlogEntry />;
176+
}
177+
switch (pathname) {
178+
case "/blog": {
179+
return <Blog />;
180+
}
181+
case "/shadowed-by-spa": {
182+
return (
183+
<NavigationContext.Provider value={{ navigate }}>
184+
<ShadowedBySpa />
185+
</NavigationContext.Provider>
186+
);
187+
}
188+
case "/": {
189+
return (
190+
<NavigationContext.Provider value={{ navigate }}>
191+
<Home />
192+
</NavigationContext.Provider>
193+
);
194+
}
195+
default: {
196+
return (
197+
<NavigationContext.Provider value={{ navigate }}>
198+
<FourOhFour />
199+
</NavigationContext.Provider>
200+
);
201+
}
202+
}
203+
})()}
204+
</NavigationContext.Provider>
205+
);
206+
}
207+
208+
const root = ReactDOM.createRoot(document.getElementById("root"));
209+
root.render(<Router />);
210+
</script>
211+
212+
<script
213+
crossorigin
214+
src="https://unpkg.com/[email protected]/umd/react.development.js"
215+
></script>
216+
<script
217+
crossorigin
218+
src="https://unpkg.com/[email protected]/umd/react-dom.development.js"
219+
></script>
220+
221+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
222+
</body>
223+
</html>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
i'm some text!
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export default {
2+
async fetch(request: Request): Promise<Response> {
3+
const url = new URL(request.url);
4+
if (url.pathname === "/api/math") {
5+
return new Response(`1 + 1 = ${1 + 1}`);
6+
}
7+
if (url.pathname === "/api/json") {
8+
return Response.json({ hello: "world" });
9+
}
10+
if (url.pathname === "/api/html") {
11+
return new Response("<h1>Hello, world!</h1>", {
12+
headers: { "Content-Type": "text/html" },
13+
});
14+
}
15+
if (url.pathname === "/shadowed-by-spa") {
16+
return new Response("nope");
17+
}
18+
if (url.pathname === "/shadowed-by-asset.txt") {
19+
return new Response("nope");
20+
}
21+
22+
return new Response("nope", { status: 404 });
23+
},
24+
};
37.1 KB
Loading

0 commit comments

Comments
 (0)