diff --git a/playground/client-navigation/__tests__/e2e.test.mts b/playground/client-navigation/__tests__/e2e.test.mts index a1b7bcbbc..b3fbf7129 100644 --- a/playground/client-navigation/__tests__/e2e.test.mts +++ b/playground/client-navigation/__tests__/e2e.test.mts @@ -1,62 +1,71 @@ -import { - poll, - setupPlaygroundEnvironment, - testDevAndDeploy, - waitForHydration, -} from "rwsdk/e2e"; -import { expect } from "vitest"; - -setupPlaygroundEnvironment(import.meta.url); - -testDevAndDeploy("renders Hello World", async ({ page, url }) => { - await page.goto(url); - - const getPageContent = () => page.content(); - - await poll(async () => { - const content = await getPageContent(); - expect(content).toContain("Hello World"); - return true; - }); -}); - -testDevAndDeploy( - "programmatically navigates on button click", - async ({ page, url }) => { - await page.goto(url); - - await waitForHydration(page); - - await page.click("#navigate-to-about"); - - const getPageContent = () => page.content(); - - await poll(async () => { - const content = await getPageContent(); - expect(content).toContain("About Page"); - expect(content).not.toContain("Hello World"); - return true; +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { expect, test } from "vitest"; + +/** + * Derive the playground directory from import.meta.url by finding the nearest package.json + */ +function getPlaygroundDirFromImportMeta(importMetaUrl: string): string { + const testFilePath = fileURLToPath(importMetaUrl); + let currentDir = path.dirname(testFilePath); + // Walk up the tree from the test file's directory + while (path.dirname(currentDir) !== currentDir) { + // Check if a package.json exists in the current directory + if (existsSync(path.join(currentDir, "package.json"))) { + return currentDir; + } + currentDir = path.dirname(currentDir); + } + + throw new Error( + `Could not determine playground directory from import.meta.url: ${importMetaUrl}. ` + + `Failed to find a package.json in any parent directory.`, + ); +} + +test("tsc reports error for undefined route", () => { + const projectDir = getPlaygroundDirFromImportMeta(import.meta.url); + + // Generate types first to ensure worker-configuration.d.ts exists + try { + execSync("pnpm generate", { + cwd: projectDir, + encoding: "utf-8", + stdio: "pipe", }); + } catch (error: any) { + // Ignore errors from generate - it might fail if wrangler isn't configured, + // but we can still test tsc if the types file exists + } + + let tscOutput = ""; + let tscExitCode = 0; + + try { + execSync("tsc --noEmit", { + cwd: projectDir, + encoding: "utf-8", + stdio: "pipe", + }); + } catch (error: any) { + tscExitCode = error.status || error.code || 1; + tscOutput = error.stdout?.toString() || error.stderr?.toString() || ""; + } - expect(page.url()).toContain("/about"); - }, -); - -testDevAndDeploy("navigates on link click", async ({ page, url }) => { - await page.goto(url); - - await waitForHydration(page); - - await page.click("#about-link"); + // tsc should exit with a non-zero code when there are errors + expect(tscExitCode).not.toBe(0); - const getPageContent = () => page.content(); + // Count the number of errors in the output + // TypeScript error messages typically start with the file path followed by a colon and line number + const errorMatches = tscOutput.match(/\.tsx?:\d+:\d+ - error/g); + const errorCount = errorMatches ? errorMatches.length : 0; - await poll(async () => { - const content = await getPageContent(); - expect(content).toContain("About Page"); - expect(content).not.toContain("Hello World"); - return true; - }); + // Should have exactly one error + expect(errorCount).toBe(1); - expect(page.url()).toContain("/about"); + // The error should be about the undefined route + expect(tscOutput).toContain("/undefined-route"); + expect(tscOutput).toMatch(/error TS\d+:/); }); diff --git a/playground/client-navigation/src/app/shared/links.ts b/playground/client-navigation/src/app/shared/links.ts index 66152a5af..c825825ef 100644 --- a/playground/client-navigation/src/app/shared/links.ts +++ b/playground/client-navigation/src/app/shared/links.ts @@ -3,3 +3,6 @@ import { linkFor } from "rwsdk/router"; type App = typeof import("../../worker").default; export const link = linkFor(); + +// This should cause a TypeScript error because "/undefined-route" is not defined in the worker +export const undefinedRoute = link("/undefined-route"); diff --git a/playground/typed-routes/README.md b/playground/typed-routes/README.md new file mode 100644 index 000000000..e34342211 --- /dev/null +++ b/playground/typed-routes/README.md @@ -0,0 +1,29 @@ +# Typed Routes Playground + +This playground demonstrates and tests the typed routes functionality with `defineLinks` that automatically infers routes from the app definition. + +## Features Tested + +- Static routes (e.g., `/`) +- Routes with named parameters (e.g., `/users/:id`) +- Routes with wildcards (e.g., `/files/*`) +- Type-safe link generation with automatic route inference +- Parameter validation at compile-time and runtime + +## Running the dev server + +```shell +npm run dev +``` + +Point your browser to the URL displayed in the terminal (e.g. `http://localhost:5173/`). + +## Testing + +Run the end-to-end tests from the monorepo root: + +```shell +pnpm test:e2e -- playground/typed-routes/__tests__/e2e.test.mts +``` + + diff --git a/playground/typed-routes/__tests__/e2e.test.mts b/playground/typed-routes/__tests__/e2e.test.mts new file mode 100644 index 000000000..42fe29126 --- /dev/null +++ b/playground/typed-routes/__tests__/e2e.test.mts @@ -0,0 +1,95 @@ +import { poll, setupPlaygroundEnvironment, testDevAndDeploy } from "rwsdk/e2e"; +import { expect } from "vitest"; + +setupPlaygroundEnvironment(import.meta.url); + +testDevAndDeploy( + "renders home page with typed routes", + async ({ page, url }) => { + await page.goto(url); + + await page.waitForFunction('document.readyState === "complete"'); + + const getPageContent = async () => await page.content(); + + await poll(async () => { + const content = await getPageContent(); + expect(content).toContain("Typed Routes Playground"); + expect(content).toContain("/"); + expect(content).toContain("/users/"); + expect(content).toContain("/files/"); + expect(content).toContain("/blog/"); + return true; + }); + }, +); + +testDevAndDeploy("navigates to user profile page", async ({ page, url }) => { + await page.goto(url); + await page.waitForFunction('document.readyState === "complete"'); + + // Wait for navigation link and click it + await poll(async () => { + const userLink = await page.$('a[href*="/users/"]'); + if (!userLink) return false; + await userLink.click(); + return true; + }); + + // Wait for navigation + await page.waitForFunction('document.readyState === "complete"'); + + await poll(async () => { + const content = await page.content(); + expect(content).toContain("User Profile"); + expect(content).toContain("123"); + return true; + }); +}); + +testDevAndDeploy("navigates to file viewer page", async ({ page, url }) => { + await page.goto(url); + await page.waitForFunction('document.readyState === "complete"'); + + // Wait for file link and click it + await poll(async () => { + const fileLink = await page.$('a[href*="/files/"]'); + if (!fileLink) return false; + await fileLink.click(); + return true; + }); + + // Wait for navigation + await page.waitForFunction('document.readyState === "complete"'); + + await poll(async () => { + const content = await page.content(); + expect(content).toContain("File Viewer"); + expect(content).toContain("documents/readme.md"); + return true; + }); +}); + +testDevAndDeploy("navigates to blog post page", async ({ page, url }) => { + await page.goto(url); + await page.waitForFunction('document.readyState === "complete"'); + + // Wait for blog link and click it + await poll(async () => { + const blogLink = await page.$('a[href*="/blog/"]'); + if (!blogLink) return false; + await blogLink.click(); + return true; + }); + + // Wait for navigation + await page.waitForFunction('document.readyState === "complete"'); + + await poll(async () => { + const content = await page.content(); + expect(content).toContain("Blog Post"); + expect(content).toContain("2024"); + expect(content).toContain("hello-world"); + return true; + }); +}); diff --git a/playground/typed-routes/package.json b/playground/typed-routes/package.json new file mode 100644 index 000000000..0817fe3f2 --- /dev/null +++ b/playground/typed-routes/package.json @@ -0,0 +1,50 @@ +{ + "name": "typed-routes", + "version": "1.0.0", + "description": "Test playground for typed routes with defineLinks", + "main": "index.js", + "type": "module", + "keywords": [], + "author": "", + "license": "MIT", + "private": true, + "scripts": { + "build": "vite build", + "dev": "vite dev", + "dev:init": "rw-scripts dev-init", + "preview": "vite preview", + "worker:run": "rw-scripts worker-run", + "clean": "npm run clean:vite", + "clean:vite": "rm -rf ./node_modules/.vite", + "release": "rw-scripts ensure-deploy-env && npm run clean && npm run build && wrangler deploy", + "generate": "rw-scripts ensure-env && wrangler types", + "check": "npm run generate && npm run types", + "types": "tsc" + }, + "dependencies": { + "rwsdk": "workspace:*", + "react": "19.3.0-canary-fb2177c1-20251114", + "react-dom": "19.3.0-canary-fb2177c1-20251114", + "react-server-dom-webpack": "19.3.0-canary-fb2177c1-20251114" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@cloudflare/workers-types": "4.20251121.0", + "@types/node": "22.18.8", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "typescript": "5.9.3", + "vite": "7.2.4", + "vitest": "^3.1.1", + "wrangler": "4.50.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "sharp", + "workerd" + ] + } +} + + diff --git a/playground/typed-routes/public/favicon-dark.svg b/playground/typed-routes/public/favicon-dark.svg new file mode 100644 index 000000000..9a9845c73 --- /dev/null +++ b/playground/typed-routes/public/favicon-dark.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/playground/typed-routes/public/favicon-light.svg b/playground/typed-routes/public/favicon-light.svg new file mode 100644 index 000000000..945e25649 --- /dev/null +++ b/playground/typed-routes/public/favicon-light.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/playground/typed-routes/src/app/Document.tsx b/playground/typed-routes/src/app/Document.tsx new file mode 100644 index 000000000..09295f4c2 --- /dev/null +++ b/playground/typed-routes/src/app/Document.tsx @@ -0,0 +1,32 @@ +import stylesUrl from "./styles.css?url"; +export const Document: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( + + + + + Typed Routes Playground + + + + + + +
{children}
+ + + +); + + diff --git a/playground/typed-routes/src/app/headers.ts b/playground/typed-routes/src/app/headers.ts new file mode 100644 index 000000000..192676734 --- /dev/null +++ b/playground/typed-routes/src/app/headers.ts @@ -0,0 +1,33 @@ +import { RouteMiddleware } from "rwsdk/router"; + +export const setCommonHeaders = + (): RouteMiddleware => + ({ response, rw: { nonce } }) => { + if (!import.meta.env.VITE_IS_DEV_SERVER) { + // Forces browsers to always use HTTPS for a specified time period (2 years) + response.headers.set( + "Strict-Transport-Security", + "max-age=63072000; includeSubDomains; preload", + ); + } + + // Forces browser to use the declared content-type instead of trying to guess/sniff it + response.headers.set("X-Content-Type-Options", "nosniff"); + + // Stops browsers from sending the referring webpage URL in HTTP headers + response.headers.set("Referrer-Policy", "no-referrer"); + + // Explicitly disables access to specific browser features/APIs + response.headers.set( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=()", + ); + + // Defines trusted sources for content loading and script execution: + response.headers.set( + "Content-Security-Policy", + `default-src 'self'; script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; frame-src 'self' https://challenges.cloudflare.com https://rwsdk.com; object-src 'none';`, + ); + }; + + diff --git a/playground/typed-routes/src/app/pages/BlogPost.tsx b/playground/typed-routes/src/app/pages/BlogPost.tsx new file mode 100644 index 000000000..d1ee7366d --- /dev/null +++ b/playground/typed-routes/src/app/pages/BlogPost.tsx @@ -0,0 +1,41 @@ +import { link } from "@/app/shared/links"; +import { RequestInfo } from "rwsdk/worker"; + +export function BlogPost({ ctx, request }: RequestInfo) { + const url = new URL(request.url); + const pathParts = url.pathname.split("/").filter(Boolean); + const year = pathParts[1]; // Extract from /blog/:year/:slug + const slug = pathParts[2]; + + // Test linking back to home + const homeLink = link("/"); + // Test linking to another blog post + const otherPostLink = link("/blog/:year/:slug", { + year: "2025", + slug: "new-post", + }); + + return ( +
+

Blog Post

+

+ Viewing post: {slug} from year {year} +

+ + + +
+
+ Current post: {year}/{slug} +
+
Home link: {homeLink}
+
Other post link: {otherPostLink}
+
+
+ ); +} + + diff --git a/playground/typed-routes/src/app/pages/FileViewer.tsx b/playground/typed-routes/src/app/pages/FileViewer.tsx new file mode 100644 index 000000000..cf2e45b20 --- /dev/null +++ b/playground/typed-routes/src/app/pages/FileViewer.tsx @@ -0,0 +1,34 @@ +import { link } from "@/app/shared/links"; +import { RequestInfo } from "rwsdk/worker"; + +export function FileViewer({ ctx, request }: RequestInfo) { + const url = new URL(request.url); + const filePath = url.pathname.replace("/files/", ""); // Extract from /files/* + + // Test linking back to home + const homeLink = link("/"); + // Test linking to another file + const otherFileLink = link("/files/*", { $0: "images/photo.jpg" }); + + return ( +
+

File Viewer

+

+ Viewing file: {filePath} +

+ + + +
+
Current file: {filePath}
+
Home link: {homeLink}
+
Other file link: {otherFileLink}
+
+
+ ); +} + + diff --git a/playground/typed-routes/src/app/pages/Home.tsx b/playground/typed-routes/src/app/pages/Home.tsx new file mode 100644 index 000000000..06be9b807 --- /dev/null +++ b/playground/typed-routes/src/app/pages/Home.tsx @@ -0,0 +1,58 @@ +import { link } from "@/app/shared/links"; +import { RequestInfo } from "rwsdk/worker"; + +export function Home({ ctx }: RequestInfo) { + // Test static route + const homeLink = link("/"); + + // Test routes with parameters + const userLink = link("/users/:id", { id: "123" }); + const fileLink = link("/files/*", { $0: "documents/readme.md" }); + const blogLink = link("/blog/:year/:slug", { + year: "2024", + slug: "hello-world", + }); + + // TypeScript correctly catches invalid routes: + // link("/user/"); // Error: Argument of type '"/user/"' is not assignable to parameter of type '"/" | "/users/:id" | "/files/*" | "/blog/:year/:slug"' + + return ( +
+

Typed Routes Playground

+

+ This playground tests typed routes with automatic route inference using{" "} + defineLinks. +

+ + + +
+
Home: {homeLink}
+
User: {userLink}
+
File: {fileLink}
+
Blog: {blogLink}
+
+ +

Route Types Tested

+ +
+ ); +} diff --git a/playground/typed-routes/src/app/pages/UserProfile.tsx b/playground/typed-routes/src/app/pages/UserProfile.tsx new file mode 100644 index 000000000..bc9dacf63 --- /dev/null +++ b/playground/typed-routes/src/app/pages/UserProfile.tsx @@ -0,0 +1,34 @@ +import { link } from "@/app/shared/links"; +import { RequestInfo } from "rwsdk/worker"; + +export function UserProfile({ ctx, request }: RequestInfo) { + const url = new URL(request.url); + const userId = url.pathname.split("/")[2]; // Extract from /users/:id + + // Test linking back to home + const homeLink = link("/"); + // Test linking to another user + const otherUserLink = link("/users/:id", { id: "456" }); + + return ( +
+

User Profile

+

+ Viewing profile for user ID: {userId} +

+ + + +
+
Current user: {userId}
+
Home link: {homeLink}
+
Other user link: {otherUserLink}
+
+
+ ); +} + + diff --git a/playground/typed-routes/src/app/shared/links.ts b/playground/typed-routes/src/app/shared/links.ts new file mode 100644 index 000000000..31d21b980 --- /dev/null +++ b/playground/typed-routes/src/app/shared/links.ts @@ -0,0 +1,6 @@ +import { defineLinks } from "rwsdk/router"; + +type App = typeof import("../../worker").default; + +// Test defineLinks with automatic route inference from app type +export const link = defineLinks(); diff --git a/playground/typed-routes/src/app/styles.css b/playground/typed-routes/src/app/styles.css new file mode 100644 index 000000000..e5e821dc8 --- /dev/null +++ b/playground/typed-routes/src/app/styles.css @@ -0,0 +1,56 @@ +body { + min-height: 100vh; + padding: 0; + margin: 0; + font-family: + system-ui, + -apple-system, + sans-serif; +} + +#root, +#hydrate-root { + width: 100vw; + min-height: 100vh; + padding: 2rem; +} + +nav { + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #ccc; +} + +nav a { + margin-right: 1rem; + color: #0066cc; + text-decoration: none; +} + +nav a:hover { + text-decoration: underline; +} + +.page { + max-width: 800px; + margin: 0 auto; +} + +.page h1 { + margin-top: 0; +} + +.page p { + line-height: 1.6; +} + +.code { + background: #f5f5f5; + padding: 1rem; + border-radius: 4px; + font-family: monospace; + margin: 1rem 0; + overflow-x: auto; +} + + diff --git a/playground/typed-routes/src/client.tsx b/playground/typed-routes/src/client.tsx new file mode 100644 index 000000000..7ce7d63e7 --- /dev/null +++ b/playground/typed-routes/src/client.tsx @@ -0,0 +1,5 @@ +import { initClient } from "rwsdk/client"; + +initClient(); + + diff --git a/playground/typed-routes/src/worker.tsx b/playground/typed-routes/src/worker.tsx new file mode 100644 index 000000000..85411005e --- /dev/null +++ b/playground/typed-routes/src/worker.tsx @@ -0,0 +1,27 @@ +import { render, route } from "rwsdk/router"; +import { defineApp } from "rwsdk/worker"; + +import { Document } from "@/app/Document"; +import { setCommonHeaders } from "@/app/headers"; +import { BlogPost } from "@/app/pages/BlogPost"; +import { FileViewer } from "@/app/pages/FileViewer"; +import { Home } from "@/app/pages/Home"; +import { UserProfile } from "@/app/pages/UserProfile"; + +export type AppContext = {}; + +export default defineApp([ + setCommonHeaders(), + ({ ctx }) => { + // setup ctx here + ctx; + }, + render(Document, [ + route("/", Home), + route("/users/:id", UserProfile), + route("/files/*", FileViewer), + route("/blog/:year/:slug", BlogPost), + ]), +]); + + diff --git a/playground/typed-routes/tsconfig.json b/playground/typed-routes/tsconfig.json new file mode 100644 index 000000000..4027ae02c --- /dev/null +++ b/playground/typed-routes/tsconfig.json @@ -0,0 +1,49 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + + /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + "target": "es2021", + /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + "lib": ["DOM", "DOM.Iterable", "ESNext", "ES2022"], + /* Specify what JSX code is generated. */ + "jsx": "react-jsx", + + /* Specify what module code is generated. */ + "module": "es2022", + /* Specify how TypeScript looks up a file from a given module specifier. */ + "moduleResolution": "bundler", + "baseUrl": ".", + /* Specify type package names to be included without being referenced in a source file. */ + "types": [ + "./worker-configuration.d.ts", + "./types/rw.d.ts", + "./types/vite.d.ts" + ], + "paths": { + "@/*": ["./src/*"], + "rwsdk/*": ["../../sdk/src/*"] + }, + /* Enable importing .json files */ + "resolveJsonModule": true, + + /* Enable error reporting in type-checked JavaScript files. */ + "checkJs": false, + + /* Disable emitting files from a compilation. */ + "noEmit": true, + + /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true, + /* Allow 'import x from y' when a module doesn't have a default export. */ + "allowSyntheticDefaultImports": true, + /* Ensure that casing is correct in imports. */ + "forceConsistentCasingInFileNames": true, + + /* Enable all strict type-checking options. */ + "strict": true, + + /* Skip type checking all .d.ts files. */ + "skipLibCheck": true + } +} diff --git a/playground/typed-routes/types/rw.d.ts b/playground/typed-routes/types/rw.d.ts new file mode 100644 index 000000000..6dbbce641 --- /dev/null +++ b/playground/typed-routes/types/rw.d.ts @@ -0,0 +1,7 @@ +import { AppContext } from "../src/worker"; + +declare module "rwsdk/worker" { + interface DefaultAppContext extends AppContext {} +} + + diff --git a/playground/typed-routes/types/vite.d.ts b/playground/typed-routes/types/vite.d.ts new file mode 100644 index 000000000..e38cf722a --- /dev/null +++ b/playground/typed-routes/types/vite.d.ts @@ -0,0 +1,6 @@ +declare module "*?url" { + const result: string; + export default result; +} + + diff --git a/playground/typed-routes/vite.config.mts b/playground/typed-routes/vite.config.mts new file mode 100644 index 000000000..8d0294954 --- /dev/null +++ b/playground/typed-routes/vite.config.mts @@ -0,0 +1,14 @@ +import { cloudflare } from "@cloudflare/vite-plugin"; +import { redwood } from "rwsdk/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ + viteEnvironment: { name: "worker" }, + }), + redwood(), + ], +}); + + diff --git a/playground/typed-routes/wrangler.jsonc b/playground/typed-routes/wrangler.jsonc new file mode 100644 index 000000000..567baf1d2 --- /dev/null +++ b/playground/typed-routes/wrangler.jsonc @@ -0,0 +1,31 @@ +{ + // Schema reference for wrangler configuration + "$schema": "node_modules/wrangler/config-schema.json", + + // Name of your worker + "name": "__change_me__", + + // Entry point for your worker + "main": "src/worker.tsx", + + // Compatibility settings + "compatibility_date": "2025-08-21", + "compatibility_flags": ["nodejs_compat"], + + // Assets configuration + "assets": { + "binding": "ASSETS" + }, + + // Observability settings + "observability": { + "enabled": true + }, + + // Environment variables + "vars": { + // Add your environment variables here + } +} + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecfa2b96f..797480997 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1185,6 +1185,49 @@ importers: specifier: 4.50.0 version: 4.50.0(@cloudflare/workers-types@4.20251121.0) + playground/typed-routes: + dependencies: + react: + specifier: 19.3.0-canary-fb2177c1-20251114 + version: 19.3.0-canary-fb2177c1-20251114 + react-dom: + specifier: 19.3.0-canary-fb2177c1-20251114 + version: 19.3.0-canary-fb2177c1-20251114(react@19.3.0-canary-fb2177c1-20251114) + react-server-dom-webpack: + specifier: 19.3.0-canary-fb2177c1-20251114 + version: 19.3.0-canary-fb2177c1-20251114(react-dom@19.3.0-canary-fb2177c1-20251114(react@19.3.0-canary-fb2177c1-20251114))(react@19.3.0-canary-fb2177c1-20251114)(webpack@5.97.1) + rwsdk: + specifier: workspace:* + version: link:../../sdk + devDependencies: + '@cloudflare/vite-plugin': + specifier: 1.15.2 + version: 1.15.2(vite@7.2.4(@types/node@22.18.8)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) + '@cloudflare/workers-types': + specifier: 4.20251121.0 + version: 4.20251121.0 + '@types/node': + specifier: 22.18.8 + version: 22.18.8 + '@types/react': + specifier: 19.1.2 + version: 19.1.2 + '@types/react-dom': + specifier: 19.1.2 + version: 19.1.2(@types/react@19.1.2) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 7.2.4 + version: 7.2.4(@types/node@22.18.8)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.18.8)(jiti@2.6.1)(lightningcss@1.30.1)(msw@2.11.3(@types/node@22.18.8)(typescript@5.9.3))(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + wrangler: + specifier: 4.50.0 + version: 4.50.0(@cloudflare/workers-types@4.20251121.0) + playground/use-synced-state: dependencies: capnweb: @@ -14095,7 +14138,7 @@ snapshots: '@types/fontkit@2.0.8': dependencies: - '@types/node': 24.10.1 + '@types/node': 22.18.8 '@types/fs-extra@11.0.4': dependencies: @@ -14154,6 +14197,7 @@ snapshots: '@types/node@24.10.1': dependencies: undici-types: 7.16.0 + optional: true '@types/node@24.5.2': dependencies: @@ -14194,7 +14238,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 17.0.45 + '@types/node': 22.18.8 '@types/statuses@2.0.6': {} @@ -20176,7 +20220,8 @@ snapshots: undici-types@7.12.0: {} - undici-types@7.16.0: {} + undici-types@7.16.0: + optional: true undici@7.14.0: {} @@ -20444,7 +20489,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.9(@types/node@22.18.8)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.4(@types/node@22.18.8)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -20465,7 +20510,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.9(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -20486,7 +20531,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.9(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) + vite: 7.2.4(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -20602,6 +20647,24 @@ snapshots: tsx: 4.20.6 yaml: 2.8.1 + vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.1 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.1 + sugarss: 5.0.1(postcss@8.5.6) + terser: 5.44.1 + tsx: 4.20.6 + yaml: 2.8.1 + vite@7.2.4(@types/node@24.5.2)(jiti@2.6.1)(lightningcss@1.30.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.12 diff --git a/sdk/src/runtime/lib/links.ts b/sdk/src/runtime/lib/links.ts index 1aa05f8c8..6e28775f0 100644 --- a/sdk/src/runtime/lib/links.ts +++ b/sdk/src/runtime/lib/links.ts @@ -1,8 +1,4 @@ -import type { - Route, - RouteDefinition, - RouteMiddleware, -} from "./router"; +import type { RouteDefinition, RouteMiddleware } from "./router"; type PathParams = Path extends `${string}:${infer Param}/${infer Rest}` @@ -15,18 +11,11 @@ type PathParams = ? { $0: string } : {}; -type ParamsForPath = PathParams extends Record< - string, - never -> - ? undefined - : PathParams; +type ParamsForPath = + PathParams extends Record ? undefined : PathParams; export type LinkFunction = { - ( - path: Path, - params?: ParamsForPath, - ): string; + (path: Path, params?: ParamsForPath): string; }; type RoutePaths = @@ -38,15 +27,14 @@ type RoutePaths = ? never : never; -type RouteArrayPaths = number extends Routes["length"] - ? RoutePaths - : Routes extends readonly [infer Head, ...infer Tail] - ? RoutePaths | RouteArrayPaths - : never; +type RouteArrayPaths = + number extends Routes["length"] + ? RoutePaths + : Routes extends readonly [infer Head, ...infer Tail] + ? RoutePaths | RouteArrayPaths + : never; -type AppRoutes = App extends { __rwRoutes: infer Routes } - ? Routes - : never; +type AppRoutes = App extends { __rwRoutes: infer Routes } ? Routes : never; export type AppRoutePaths = RoutePaths>; @@ -60,22 +48,39 @@ export function createLinks(_app?: App): AppLink { return linkFor(); } +// Overload for automatic route inference from app type +export function defineLinks(): AppLink; +// Overload for manual route array export function defineLinks( routes: T, -): LinkFunction { +): LinkFunction; +// Implementation +export function defineLinks(routes?: readonly string[]): LinkFunction { + // If no routes provided, this is the app type overload + // At runtime, we can't distinguish, but the type system ensures + // this only happens when called as defineLinks() + // We delegate to linkFor which handles app types correctly + if (routes === undefined) { + // This branch is only reachable when called as defineLinks() + // The return type is AppLink due to the overload + // We use linkFor internally which doesn't need runtime route validation + return linkFor() as any; + } + + // Original implementation for route arrays routes.forEach((route) => { if (typeof route !== "string") { throw new Error(`Invalid route: ${route}. Routes must be strings.`); } }); - const link = createLinkFunction(); - return ((path: T[number], params?: Record) => { + const link = createLinkFunction<(typeof routes)[number]>(); + return ((path: (typeof routes)[number], params?: Record) => { if (!routes.includes(path)) { throw new Error(`Invalid route: ${path}`); } return link(path, params as any); - }) as LinkFunction; + }) as LinkFunction<(typeof routes)[number]>; } const TOKEN_REGEX = /:([a-zA-Z0-9_]+)|\*/g; @@ -102,10 +107,7 @@ function hasRouteParameters(path: string): boolean { return result; } -function interpolate( - template: string, - params: Record, -): string { +function interpolate(template: string, params: Record): string { let result = ""; let lastIndex = 0; let wildcardIndex = 0; @@ -121,9 +123,7 @@ function interpolate( const name = match[1]; const value = params[name]; if (value === undefined) { - throw new Error( - `Missing parameter "${name}" for route ${template}`, - ); + throw new Error(`Missing parameter "${name}" for route ${template}`); } result += encodeURIComponent(value); consumed.add(name); @@ -131,9 +131,7 @@ function interpolate( const key = `$${wildcardIndex}`; const value = params[key]; if (value === undefined) { - throw new Error( - `Missing parameter "${key}" for route ${template}`, - ); + throw new Error(`Missing parameter "${key}" for route ${template}`); } result += encodeWildcardValue(value); consumed.add(key);