diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 6de8cba6c..adb9619bf 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -173,7 +173,7 @@ For dynamic routes, pass `params` in the options: ```ts // pages/users/[id].server.ts has an action -const form = useForm('/users/:id', { name: '' }, { params: { id: '42' } }); +const form = useForm('/users/[id]', { name: '' }, { params: { id: '42' } });
>( | `method` | `string` | HTTP method. Defaults to `"GET"`. | | `body` | `unknown` | Request body (auto-serialized as JSON). | | `query` | `Record` | Query string parameters. | -| `params` | `Record` | URL path parameters (`:id` segments). | +| `params` | `Record` | URL path parameters (`[id]` segments). | | `headers` | `HeadersInit` | Additional request headers. | | `signal` | `AbortSignal` | Abort signal. | | `baseURL` | `string` | Base URL prepended to the path. Useful for external calls. | @@ -55,7 +55,7 @@ Error class thrown by `fetch` on non-2xx responses. import { fetch, FetchError } from 'void/client'; try { - await fetch('/api/users/:id', { params: { id: '999' } }); + await fetch('/api/users/[id]', { params: { id: '999' } }); } catch (e) { if (e instanceof FetchError) { console.log(e.status); // 404 @@ -80,7 +80,7 @@ The typed client is built on [ofetch](https://github.com/unjs/ofetch) with a typ | ---------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | **Return type** | `Promise`, so you call `.json()` yourself | `Promise`, which auto-parses JSON and returns the typed result directly (`undefined` for `204`) | | **Error handling** | Resolves on any HTTP status; you check `response.ok` | Throws `FetchError` on non-2xx responses | -| **URL construction** | Raw URL string | Route path with `:param` interpolation from `options.params` + query string from `options.query` | +| **URL construction** | Raw URL string | Route path with `[param]` interpolation from `options.params` + query string from `options.query` | | **Body serialization** | Manual `JSON.stringify()` + `Content-Type` header | Auto-serializes `options.body` as JSON and sets `Content-Type: application/json` | | **Type safety** | Accepts any URL or method | Constrains paths to `RouteMap` keys and methods to those defined per route. Invalid combinations fail at compile time. | | **Retry** | None | Auto-retries on 408, 429, and 5xx (configurable via `retry` option) | diff --git a/docs/reference/api/handlers.md b/docs/reference/api/handlers.md index 70d886470..5c057f399 100644 --- a/docs/reference/api/handlers.md +++ b/docs/reference/api/handlers.md @@ -233,7 +233,7 @@ import type { PageParams } from 'void/routes'; export type Props = InferProps; export const loader = defineHandler(async (c) => { - const { slug } = c.req.param() as PageParams<'/posts/:slug'>; + const { slug } = c.req.param() as PageParams<'/posts/[slug]'>; const post = await getPost(slug); return { post }; }); diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index 29da7f21a..785b8b0ae 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -86,7 +86,7 @@ Imported from `"@void/vue"`. | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Link` | Vue component for SPA navigation. Renders `` for GET, `` for non-GET methods. Props: `href`, `method`, `data`, `prefetch`, `cacheFor`, `preserveScroll`, `preserveState`, `replace`, `reloadDocument`, `viewTransition`, `activeMatch`, `onNavigate`. Adds `aria-current="page"` and `data-void-active` to the link to the current page ([active links](../../guide/pages-routing/overview.md#active-links)), and `data-void-pending` to links to a page while it loads ([pending links](../../guide/pages-routing/overview.md#pending-links)). | | `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. | -| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/:id'>()`, to type them. | +| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/[id]'>()`, to type them. | | `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. | | `useForm(url, defaults, options?)` | Typed reactive form helper bound to a page action URL. Returns `{ data, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. | | `useIslandForm(defaults)` | Form helper for island components. Same form state as `useForm()`, plus `submit(url, method)`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. With [`routing.typedRoutes`](../config.md#routing-typedroutes), the URL is a [`RouteUrl`](./types.md#route-types). | @@ -141,7 +141,7 @@ Imported from `"@void/react"`. | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `Link` | React component for SPA navigation. Renders `` for GET, `` for non-GET methods. Props: `href`, `method`, `data`, `prefetch`, `cacheFor`, `preserveScroll`, `preserveState`, `replace`, `reloadDocument`, `viewTransition`, `activeMatch`, `onNavigate`. Adds `aria-current="page"` and `data-void-active` to the link to the current page ([active links](../../guide/pages-routing/overview.md#active-links)), and `data-void-pending` to links to a page while it loads ([pending links](../../guide/pages-routing/overview.md#pending-links)). | | `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. React navigations are scheduled as transitions. | -| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/:id'>()`, to type them. | +| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/[id]'>()`, to type them. | | `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. | | `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. | | `useIslandForm(defaults)` | Form helper hook for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. With [`routing.typedRoutes`](../config.md#routing-typedroutes), the URL is a [`RouteUrl`](./types.md#route-types). | @@ -229,7 +229,7 @@ Imported from `"@void/svelte"`. | `isCallSiteActionError(error)` | Returns `true` only for the expected `VoidActionError` statuses that `action()` returns, such as `409` or `422`. | | `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). | | `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. | -| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/:id'>()`, to type them. | +| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/[id]'>()`, to type them. | | `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. | Svelte `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links. @@ -282,7 +282,7 @@ Imported from `"@void/solid"`. | `isCallSiteActionError(error)` | Returns `true` only for the expected `VoidActionError` statuses that `action()` returns, such as `409` or `422`. | | `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). | | `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. | -| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/:id'>()`, to type them. | +| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/[id]'>()`, to type them. | | `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. | Solid `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links. diff --git a/docs/reference/api/rewrites.md b/docs/reference/api/rewrites.md index ed05fec80..d11bdd0d4 100644 --- a/docs/reference/api/rewrites.md +++ b/docs/reference/api/rewrites.md @@ -14,7 +14,7 @@ Pass a [`RouteDestination`](./types.md#route-types) object to check the route pa ```ts return c.rewrite({ - pathname: '/users/:id', + pathname: '/users/[id]', params: { id: 42 }, query: { tab: 'activity' }, }); @@ -29,8 +29,8 @@ import { routeUrl, type Href } from 'void/routes'; declare const checkoutUrl: string; -return c.redirect(routeUrl('/users/:id', { params: { id: 42 } })); -return c.redirect({ pathname: '/users/:id', params: { id: 42 } }); +return c.redirect(routeUrl('/users/[id]', { params: { id: 42 } })); +return c.redirect({ pathname: '/users/[id]', params: { id: 42 } }); return c.redirect('https://example.com/login'); return c.redirect(checkoutUrl as Href); ``` diff --git a/docs/reference/api/types.md b/docs/reference/api/types.md index a4241e86a..9b7a4d9fe 100644 --- a/docs/reference/api/types.md +++ b/docs/reference/api/types.md @@ -88,6 +88,8 @@ interface HeadDescriptor { Generated route types used by the typed `fetch` client. Contains each route’s methods, inputs, and outputs. +Keys use route patterns such as `/api/users/[id]` and `/docs/[...slug]`. + ```ts import type { RouteMap } from 'void/routes'; ``` @@ -101,7 +103,7 @@ Build a relative URL string from a known page, HTTP, or WebSocket route pattern: ```ts import { routeUrl } from 'void/routes'; -routeUrl('/users/:id', { +routeUrl('/users/[id]', { params: { id: 42 }, query: { tab: 'activity' }, hash: 'details', @@ -129,7 +131,7 @@ Import these types from `void/routes`: | `RoutePath` | Union of the known route patterns in `RoutePathMap`. | | `PagePathMap` | Generated map of page patterns to their parameter names. | | `PagePath` | Union of the page patterns in `PagePathMap`; accepted by `useParams()`. | -| `PageParams` | The params page `P` receives, such as `{ id: string }` for `/users/:id`. | +| `PageParams` | The params page `P` receives, such as `{ id: string }` for `/users/[id]`. | | `RouteOptions` | Options for a route pattern `P`, including its required `params`, optional `query`, and `hash`. | | `RouteDestination` | `{ pathname: P }` with `RouteOptions`. Defaults to all known routes when `P` is omitted. | | `RouteUrl` | The URL of one of your routes, returned by `routeUrl()`. `string` unless `typedRoutes` is on. | diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index aa46c3d05..7639115f8 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -164,6 +164,11 @@ Applied in order to a path relative to `routes/`: 5. Convert `[param]` → `:param`, `[...param]` → `:param{.+}`, `[...]` → `*` 6. Prefix with `/` +Keep these compiled patterns for internal matching. Generated public route maps use +bracket patterns (`/users/[id]`, `/files/[...path]`, `/files/[...]`) and retain the +original patterns as deprecated aliases with identical types. HTTP, WebSocket, +page-action and URL clients share `interpolateRouteParams()` for both syntaxes. + HTTP methods are determined by **named exports** in each file (detected by `detect-exports.ts` via `parseSync`), not by filename suffixes. Files/dirs starting with `_` are ignored by the scanner. `listRouteFiles()` uses `ROUTE_EXTENSIONS`, shared with HTTP/WebSocket filename parsing, resource-name inference, and route binding inference. Both route scanners use it, with `isWebSocketPatternFile()` sharing the WebSocket filename regex to distinguish `.ws.*` files. `listConventionFiles()` keeps fixed script extensions for crons and queues. Void sets no JSX options: route JSX follows framework plugins, tsconfig (TS files only) or `oxc.jsx`. Page `.server.tsx` and `.server.jsx` companions supply server metadata and handlers; exclude them from component inventories and component manifests. diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 359fe3e9f..1ff22c55b 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -22,6 +22,9 @@ Use `void` and `@void/*` in imports, examples, and package manifests. Follow the project's existing framework and deployment destination. Deploying to a Void platform requires an explicit `void connect ` first; never infer a platform from old examples, tokens, or project IDs. + +Use bracket route patterns such as `/users/[id]` and `/docs/[...slug]` with +`routeUrl()`, typed `fetch()`, `useParams()`, page actions, and WebSocket `connect()`. Platform hostnames passed to `void connect` default to HTTPS. Loopback hosts (`localhost`, `127.0.0.1`, and `[::1]`) default to HTTP: `void connect localhost:8787`. An explicit scheme overrides the default. diff --git a/packages/void/src/codegen/route-types.ts b/packages/void/src/codegen/route-types.ts index 654643a7d..6fc905919 100644 --- a/packages/void/src/codegen/route-types.ts +++ b/packages/void/src/codegen/route-types.ts @@ -7,12 +7,37 @@ export type RouteTypesResult = { dts: string; }; +/** Present bracket parameters without changing the router's compiled patterns. */ +function publicRoutePattern(pattern: string): string { + return pattern + .split('/') + .map((segment) => { + if (segment === '*') { + return '[...]'; + } + const param = segment.match(/^:([^/{}]+)(\{\.\+\})?$/); + return param ? `[${param[2] ? '...' : ''}${param[1]}]` : segment; + }) + .join('/'); +} + +/** Legacy keys stay indexable and carry the exact same contract as the public key. */ +function legacyRouteAlias(lines: Array, map: string, pattern: string): void { + const publicPattern = publicRoutePattern(pattern); + if (publicPattern !== pattern) { + // Escape comment delimiters in static segments before writing JSDoc. + const label = JSON.stringify(publicPattern).replace(/\*\//g, '*\\/'); + lines.push(` /** @deprecated Use ${label} instead. */`); + lines.push(` ${JSON.stringify(pattern)}: ${map}[${JSON.stringify(publicPattern)}];`); + } +} + /** Percent-encode a pattern's fixed segments the way `routeUrl()` serializes its pathname. */ function encodeRoutePattern(pattern: string): string { return pattern .split('/') .map((segment) => { - if (segment.startsWith(':') || segment === '*') { + if (segment.startsWith(':') || segment === '*' || /^\[.*\]$/.test(segment)) { return segment; } const url = new URL('https://void.invalid'); @@ -113,14 +138,16 @@ export function generateRouteTypes( lines.push(' ? { pathname: P } & RouteOptions'); lines.push(' : never;'); lines.push( - ' type RouteUrlSegment = S extends `:${string}` | "*" ? string : S;', + ' type RouteUrlSegment = S extends `:${string}` | `[${string}]` | "*" ? string : S;', ); lines.push( ' type RouteSegmentsUrl = P extends `${infer Segment}/${infer Rest}`', ); lines.push(' ? `${RouteUrlSegment}/${RouteSegmentsUrl}`'); lines.push(' : RouteUrlSegment;'); - lines.push(' type RoutePatternUrl = P extends `${infer Prefix}/*`'); + lines.push( + ' type RoutePatternUrl = P extends `${infer Prefix}/${"*" | "[...]"}`', + ); lines.push( ' ? (Prefix extends "" ? "/" : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}`', ); @@ -165,7 +192,8 @@ export function generateRouteTypes( } for (const [pattern, params] of paths) { const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never'; - lines.push(` ${JSON.stringify(pattern)}: ${names};`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: ${names};`); + legacyRouteAlias(lines, 'RoutePathMap', pattern); } lines.push(' }'); lines.push(''); @@ -184,7 +212,8 @@ export function generateRouteTypes( } for (const [pattern, params] of pagePaths) { const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never'; - lines.push(` ${JSON.stringify(pattern)}: ${names};`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: ${names};`); + legacyRouteAlias(lines, 'PagePathMap', pattern); } lines.push(' }'); lines.push(' export type PagePath = keyof PagePathMap & string;'); @@ -195,9 +224,16 @@ export function generateRouteTypes( // `routeUrl()` returns `/caf%C3%A9%20menu` for `/café menu`, so accept that spelling too. lines.push(' export interface EncodedRoutePathMap {'); for (const pattern of paths.keys()) { - const encoded = encodeRoutePattern(pattern); - if (encoded !== pattern) { - lines.push(` ${JSON.stringify(pattern)}: ${JSON.stringify(encoded)};`); + const publicPattern = publicRoutePattern(pattern); + const encoded = encodeRoutePattern(publicPattern); + if (encoded !== publicPattern) { + lines.push(` ${JSON.stringify(publicPattern)}: ${JSON.stringify(encoded)};`); + // Preserve the original encoded map's keys and values too. + if (publicPattern !== pattern) { + lines.push( + ` ${JSON.stringify(pattern)}: ${JSON.stringify(encodeRoutePattern(pattern))};`, + ); + } } } lines.push(' }'); @@ -300,7 +336,7 @@ export function generateRouteTypes( } for (const [pattern, methods] of routesByPattern) { - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); for (const [method, sources] of methods) { const imports = sources.map((route) => { @@ -318,6 +354,7 @@ export function generateRouteTypes( } lines.push(' };'); + legacyRouteAlias(lines, 'RouteMap', pattern); } lines.push(' }'); @@ -336,7 +373,7 @@ export function generateRouteTypes( const importPath = relative(outDir, `${routesDir}/${route.filePath}`); return `(typeof import(${JSON.stringify(importPath)}))["default"]["messages"]`; }); - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); lines.push(` params: RouteParams<${JSON.stringify(sources[0]!.params)}>;`); lines.push( ` client: ${imports.map((source) => `ExtractInput<${source}["client"]>`).join(' & ')};`, @@ -345,6 +382,7 @@ export function generateRouteTypes( ` server: ${imports.map((source) => `ExtractOutput<${source}["server"]>`).join(' | ')};`, ); lines.push(' };'); + legacyRouteAlias(lines, 'WebSocketRouteMap', pattern); } lines.push(' }'); @@ -402,7 +440,7 @@ export function generateRouteTypes( } for (const [pattern, bodies] of bodiesByPattern) { - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); bodies.forEach((body, index) => { if (index > 0) { lines.push(' } | {'); @@ -410,6 +448,7 @@ export function generateRouteTypes( lines.push(...body); }); lines.push(' };'); + legacyRouteAlias(lines, 'PageActionMap', pattern); } lines.push(' }'); diff --git a/packages/void/src/routes-stub.ts b/packages/void/src/routes-stub.ts index 7668ec1e3..933b90897 100644 --- a/packages/void/src/routes-stub.ts +++ b/packages/void/src/routes-stub.ts @@ -16,11 +16,11 @@ export interface RoutePathMap {} /** Page URL patterns and their parameter names, augmented by .void/routes.d.ts. */ export interface PagePathMap {} -/** Union of the app's page URL patterns, such as `/users/:id`. */ +/** Union of the app's page URL patterns, such as `/users/[id]`. */ export type PagePath = keyof PagePathMap & string; /** - * Params a page receives: `{ id: string }` for `/users/:id`. An unnamed `[...]` + * Params a page receives: `{ id: string }` for `/users/[id]`. An unnamed `[...]` * catch-all has no param. A union of patterns gives a union of params. */ export type PageParams = P extends PagePath diff --git a/packages/void/src/runtime/fetch.ts b/packages/void/src/runtime/fetch.ts index a80a1d7c3..f69872256 100644 --- a/packages/void/src/runtime/fetch.ts +++ b/packages/void/src/runtime/fetch.ts @@ -73,7 +73,7 @@ const browserFetch = createRouteFetch((...args) => globalThis.fetch(...args)); * import { fetch } from "void/client"; * * const users = await fetch("/api/users"); // typed! - * const user = await fetch("/api/users/:id", { params: { id: "1" } }); + * const user = await fetch("/api/users/[id]", { params: { id: "1" } }); * ``` */ export function fetch( diff --git a/packages/void/src/runtime/route-url.ts b/packages/void/src/runtime/route-url.ts index fe6c9572f..a9e74901c 100644 --- a/packages/void/src/runtime/route-url.ts +++ b/packages/void/src/runtime/route-url.ts @@ -28,18 +28,18 @@ export function interpolateRouteParams( const suffix = suffixIndex === -1 ? '' : path.slice(suffixIndex); let leadingName: string | undefined; const replaced = pathname.replace( - /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\*)(?=\/|$)/g, + /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\[(\.\.\.)?([^/[\]]*)\]|\*)(?=\/|$)/g, ( token: string, prefix: string, name: string | undefined, catchAll: string | undefined, + bracketCatchAll: string | undefined, + bracketName: string | undefined, offset: number, ) => { + name ??= bracketName || undefined; const anonymous = name === undefined; - if (anonymous && !requireParams) { - return token; - } name ??= '*'; if (!params || !Object.hasOwn(params, name)) { if (requireParams) { @@ -56,7 +56,7 @@ export function interpolateRouteParams( } return ( prefix + - (catchAll || anonymous + (catchAll || bracketCatchAll || anonymous ? value .split('/') .map((segment) => encodeSegment(name, segment)) @@ -77,15 +77,15 @@ export function interpolateRouteParams( /** Known page, HTTP and WebSocket route patterns. */ export type RoutePath = keyof RoutePathMap & string; -// With `routing.typedRoutes`, each `:name`, `:name{.+}` or `*` segment of a -// pattern matches any text, so `/users/:id` accepts `/users/${string}`. Template +// Dynamic segments in either public or legacy patterns match any text, +// so `/users/[id]` accepts `/users/${string}`. Template // literal types cannot exclude empty or `/`-leading text without per-character unions. -type RouteUrlSegment = S extends `:${string}` | '*' ? string : S; +type RouteUrlSegment = S extends `:${string}` | `[${string}]` | '*' ? string : S; type RouteSegmentsUrl = P extends `${infer Segment}/${infer Rest}` ? `${RouteUrlSegment}/${RouteSegmentsUrl}` : RouteUrlSegment; // A trailing `/*` also matches the bare prefix, as in Hono: `/docs/*` matches `/docs`. -type RoutePatternUrl = P extends `${infer Prefix}/*` +type RoutePatternUrl = P extends `${infer Prefix}/${'*' | '[...]'}` ? (Prefix extends '' ? '/' : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}` : RouteSegmentsUrl; // Fixed segments are also accepted percent-encoded, as `routeUrl()` returns them. diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index f55a40595..84fb6854c 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -238,7 +238,7 @@ import { useParams as vue } from ${adapterSource('vue/src/runtime/use-params.ts' import { useParams as svelte } from ${adapterSource('svelte/src/runtime/use-params.ts')}; import { useParams as solid } from ${adapterSource('solid/src/runtime/use-params.ts')}; type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const pages: Array = ['/', '/about', '/users/:id', '/files/:path{.+}', '/wild/*']; +const pages: Array = ['/', '/about', '/users/[id]', '/files/[...path]', '/wild/[...]', '/users/:id', '/files/:path{.+}', '/wild/*']; // @ts-expect-error HTTP routes are not pages const api: PagePath = '/api/users/:id'; void [pages, api]; @@ -247,6 +247,15 @@ ${['react', 'vue', 'svelte', 'solid'] .map( (useParams) => `{ const user = ${useParams}<'/users/:id'>(); + const bracketUser = ${useParams}<'/users/[id]'>(); + const bracketFiles = ${useParams}<'/files/[...path]'>(); + const bracketWild = ${useParams}<'/wild/[...]'>(); + const aliases: [ + Same, + Same, + Same, + ] = [true, true, true]; + void aliases; const files = ${useParams}<'/files/:path{.+}'>(); const wild = ${useParams}<'/wild/*'>(); const about = ${useParams}<'/about'>(); @@ -325,6 +334,17 @@ routeUrl('/café menu'); routeUrl('/api/users/:id', { params: { id: '123' } }); routeUrl('/files/:path{.+}', { params: { path: 'docs/start' } }); routeUrl('/wild/*', { params: { '*': 'a/b' } }); +routeUrl('/users/[id]', { params: { id: 123 } }); +routeUrl('/files/[...path]', { params: { path: 'docs/start' } }); +routeUrl('/wild/[...]', { params: { '*': 'a/b' } }); +// @ts-expect-error bracket patterns require params +routeUrl('/users/[id]'); +// @ts-expect-error bracket catch-all patterns require params +routeUrl('/files/[...path]'); +// @ts-expect-error bracket anonymous catch-alls require a wildcard param +routeUrl('/wild/[...]'); +// @ts-expect-error bracket params are checked +routeUrl('/users/[id]', { params: { wrong: 123 } }); // @ts-expect-error a known route's params are required routeUrl('/users/:id'); // @ts-expect-error params cannot be omitted while providing other options @@ -341,6 +361,8 @@ routeUrl('/files/:path{.+}'); routeUrl('/wild/*'); void url; const destination: RouteDestination = { pathname: '/users/:id', params: { id: 123 }, query: { tab: 'activity' } }; +const bracketDestination: RouteDestination = { pathname: '/users/[id]', params: { id: 123 } }; +void bracketDestination; declare const router: VoidRouter; void router.visit(destination); void router.prefetch(destination); @@ -440,6 +462,77 @@ function typecheck( } describe('standalone routeUrl utility', () => { + it.each(['generated', 'package'] as const)( + 'preserves HTTP, WebSocket and page action contracts for both syntaxes through %s declarations', + async (declarations) => { + const root = await createFixture({ + extraFiles: { + 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const message: Schema<{ text: string }>; +export default defineRoom({ messages: { client: message, server: message } }); +`, + 'pages/users/[id].server.ts': `import { defineHandler } from 'void/handler'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const body: Schema<{ name: string }>; +export const actions = { save: defineHandler.withValidator({ body })(() => ({ ok: true })) }; +`, + 'check-aliases.ts': `import { fetch } from 'void/client'; +import { connect } from 'void/ws'; +import { createActionClient } from 'void/pages-client'; +import type { RouteMap, RoutePathMap, PagePathMap, PageActionMap, WebSocketRouteMap } from 'void/routes'; +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const aliases: [ + Same, + Same, + Same, + Same, + Same, +] = [true, true, true, true, true]; +void aliases; +const bracket = fetch('/api/users/[id]', { params: { id: '42' } }); +const legacy = fetch('/api/users/:id', { params: { id: '42' } }); +const responses: Same = true; +void responses; +// @ts-expect-error methods stay constrained through bracket keys +fetch('/api/users/[id]', { method: 'POST' }); +// @ts-expect-error legacy methods stay constrained +fetch('/api/users/:id', { method: 'POST' }); +connect('/chat/[room]', { params: { room: 'lobby' } }).send({ text: 'hi' }); +connect('/chat/:room', { params: { room: 'lobby' } }).send({ text: 'hi' }); +// @ts-expect-error bracket WebSocket params are required +connect('/chat/[room]', {}); +// @ts-expect-error legacy WebSocket params are required +connect('/chat/:room', {}); +connect('/chat/[room]', { params: { room: 'lobby' } }).on('message', (message) => { + const text: string = message.text; + // @ts-expect-error server message types stay constrained + void message.missing; + void text; +}); +const { action } = createActionClient(); +action('/users/[id]?save', { params: { id: '42' }, data: { name: 'Alice' } }); +action('/users/:id?save', { params: { id: '42' }, data: { name: 'Alice' } }); +// @ts-expect-error bracket actions require params +action('/users/[id]?save'); +// @ts-expect-error legacy actions require params +action('/users/:id?save'); +// @ts-expect-error bracket action bodies stay constrained +action('/users/[id]?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error legacy action bodies stay constrained +action('/users/:id?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error bracket action names stay constrained +action('/users/[id]?missing', { params: { id: '42' } }); +`, + }, + }); + expect(typecheck(root, declarations, ['check-aliases.ts'])).toEqual({ + output: '', + status: 0, + }); + }, + ); + it.each(['generated', 'package'] as const)( 'typechecks through the %s declarations', async (declarations) => { @@ -525,7 +618,7 @@ void loose; join(root, 'routes.d.ts'), `export {}; declare module 'void/routes' { - interface RoutePathMap { '/': never; '/docs/*': '*'; '/users/:id': 'id'; '/café menu': never } + interface RoutePathMap { '/': never; '/docs/[...]': '*'; '/users/[id]': 'id'; '/café menu': never } interface EncodedRoutePathMap { '/café menu': '/caf%C3%A9%20menu' } interface RouteTypesConfig { typedRoutes: true } } @@ -611,7 +704,12 @@ void [hrefs, route, unknown, external]; `import { routeUrl } from 'void/routes'; export function buildUrls() { return [ + routeUrl('/users/[id]', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), routeUrl('/users/:id', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), + routeUrl('/files/[...path]', { params: { path: 'docs/start here' } }), + routeUrl('/files/:path{.+}', { params: { path: 'docs/start here' } }), + routeUrl('/wild/[...]', { params: { '*': 'a/b' } }), + routeUrl('/wild/*', { params: { '*': 'a/b' } }), routeUrl('/café menu', { query: { q: 'café menu' }, hash: 'café menu' }), ]; } @@ -628,6 +726,11 @@ export function buildUrls() { }); const expected = [ '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/files/docs/start%20here', + '/files/docs/start%20here', + '/wild/a/b', + '/wild/a/b', '/caf%C3%A9%20menu?q=caf%C3%A9+menu#caf%C3%A9%20menu', ]; const scriptPath = join(root, 'dist/route.mjs'); diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index 5db822319..894801294 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -14,6 +14,7 @@ import { declare module 'void/routes' { interface PageActionMap { + '/__action-test/users/[id]': PageActionMap['/__action-test/users/:id']; '/__action-test/static': { body: { name: string } }; '/__action-test/users/:id': { params: { id: string }; @@ -248,23 +249,26 @@ describe('action clients', () => { }); }); - it('encodes path parameters while preserving named actions and submission options', async () => { - const client = createActionClient(); - const visit = vi.fn(async () => ({ props: {} })); - client.setActionRouter(routerWithVisit(visit)); - - await client.action('/__action-test/users/:id?save', { - params: { id: 'a/b?c' }, - method: 'PATCH', - data: { name: 'Alice' }, - }); - - expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { - method: 'PATCH', - data: { name: 'Alice' }, - preserveScroll: undefined, - }); - }); + it.each(['/__action-test/users/[id]?save', '/__action-test/users/:id?save'] as const)( + 'encodes params and preserves named actions in %s', + async (url) => { + const client = createActionClient(); + const visit = vi.fn(async () => ({ props: {} })); + client.setActionRouter(routerWithVisit(visit)); + + await client.action(url, { + params: { id: 'a/b?c' }, + method: 'PATCH', + data: { name: 'Alice' }, + }); + + expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { + method: 'PATCH', + data: { name: 'Alice' }, + preserveScroll: undefined, + }); + }, + ); it('preserves validation errors as action results', async () => { const client = createActionClient(); diff --git a/packages/void/test/unit/fetch-stream.test.ts b/packages/void/test/unit/fetch-stream.test.ts index 79009c929..bb894efcd 100644 --- a/packages/void/test/unit/fetch-stream.test.ts +++ b/packages/void/test/unit/fetch-stream.test.ts @@ -17,6 +17,7 @@ vi.mock('ofetch', () => { declare module 'void/routes' { interface RouteMap { + '/api/stream/[...path]': RouteMap['/api/stream/:path{.+}']; '/api/chat': { POST: { input: { body: { messages: Array } }; output: unknown }; }; @@ -50,11 +51,14 @@ beforeEach(() => { }); describe('fetchStream', () => { - it('expands complete catch-all tokens', async () => { - vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); - await fetchStream('/api/stream/:path{.+}', { params: { path: 'docs/start here' } }).next(); - expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); - }); + it.each(['/api/stream/[...path]', '/api/stream/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); + await fetchStream(path, { params: { path: 'docs/start here' } }).next(); + expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); + }, + ); it('parses Workers AI JSON SSE format', async () => { const stream = makeSSEStream([ 'data: {"response":"Hello"}\n\n', diff --git a/packages/void/test/unit/fetch.test.ts b/packages/void/test/unit/fetch.test.ts index 63063053d..6e240e49f 100644 --- a/packages/void/test/unit/fetch.test.ts +++ b/packages/void/test/unit/fetch.test.ts @@ -3,6 +3,8 @@ import { fetch as typedFetch, FetchError } from '../../src/runtime/fetch.ts'; declare module 'void/routes' { interface RouteMap { + '/api/users/[id]': RouteMap['/api/users/:id']; + '/api/files/[...path]': RouteMap['/api/files/:path{.+}']; '/api/hello': { GET: { input: {}; output: { ok: boolean } }; }; @@ -53,13 +55,23 @@ describe('typed fetch client', () => { expect(result).toEqual({ ok: true }); }); - it('expands complete catch-all tokens', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); - await typedFetch('/api/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/files/docs/start%20here', - expect.anything(), - ); + it.each(['/api/files/[...path]', '/api/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); + await typedFetch(path, { params: { path: 'docs/start here' } }); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/files/docs/start%20here', + expect.anything(), + ); + }, + ); + + it('interpolates bracket parameters for typed requests', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({ id: 'a/b', name: 'Alice' })); + const result = await typedFetch('/api/users/[id]', { params: { id: 'a/b' } }); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/users/a%2Fb', expect.anything()); + expect(result).toEqual({ id: 'a/b', name: 'Alice' }); }); it('builds query string', async () => { diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index fe888ed61..42d4002a4 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -198,9 +198,12 @@ describe('generateRouteTypes', () => { const pageMap = dts.split(' export interface PagePathMap {')[1]!.split(' }')[0]!; expect(pageMap).toContain('"/about": never;'); - expect(pageMap).toContain('"/users/:id": "id";'); - expect(pageMap).toContain('"/files/:path{.+}": "path";'); - expect(pageMap).toContain('"/wild/*": "*";'); + expect(pageMap).toContain('"/users/[id]": "id";'); + expect(pageMap).toContain('"/users/:id": PagePathMap["/users/[id]"];'); + expect(pageMap).toContain('"/files/[...path]": "path";'); + expect(pageMap).toContain('"/files/:path{.+}": PagePathMap["/files/[...path]"];'); + expect(pageMap).toContain('"/wild/[...]": "*";'); + expect(pageMap).toContain('"/wild/*": PagePathMap["/wild/[...]"];'); expect(pageMap).not.toContain('/api/users/:id'); }); @@ -220,7 +223,9 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); - expect(dts).toContain('"/api/users/:id"'); + expect(dts).toContain('"/api/users/[id]": {'); + expect(dts).toContain('/** @deprecated Use "/api/users/[id]" instead. */'); + expect(dts).toContain('"/api/users/:id": RouteMap["/api/users/[id]"];'); expect(dts).toContain('typeof import("../routes/api/users/[id].ts")'); }); @@ -290,6 +295,7 @@ describe('generateRouteTypes', () => { expect(encoded.trim().split('\n')).toEqual([ '"/café menu": "/caf%C3%A9%20menu";', + ' "/café/[id]": "/caf%C3%A9/[id]";', ' "/café/:id": "/caf%C3%A9/:id";', ]); expect(dts).toContain('type RoutePathUrl = RoutePatternUrl;'); @@ -405,14 +411,14 @@ describe('generateRouteTypes', () => { expect(actionMap.split('"/users/:id"')).toHaveLength(2); expect(actionMap).toContain( [ - ' "/users/:id": {', + ' "/users/[id]": {', ` body: ExtractInput<${dev}["__validators"]> extends { body: infer B } ? B : Record;`, ' params: { id: string };', ' } | {', ` actions: { [K in keyof ${prod}]: {`, ].join('\n'), ); - expect(actionMap).toContain([' params: { id: string };', ' };', ' }'].join('\n')); + expect(actionMap).toContain(' "/users/:id": PageActionMap["/users/[id]"];'); }); it('generates WebSocketRouteMap entries', () => { @@ -472,7 +478,8 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const websocketMap = dts.split(' interface WebSocketRouteMap {')[1]!.split('\n }\n}')[0]!; - expect(websocketMap.match(/"\/chat\/:room": \{/g)).toHaveLength(1); + expect(websocketMap.match(/"\/chat\/\[room\]": \{/g)).toHaveLength(1); + expect(websocketMap).toContain('"/chat/:room": WebSocketRouteMap["/chat/[room]"];'); expect(websocketMap).toContain('chat/[room].dev.ws.ts'); expect(websocketMap).toContain('chat/[room].prod.ws.ts'); expect(websocketMap).toMatch(/client: .*dev\.ws\.ts.* & .*prod\.ws\.ts/); @@ -636,11 +643,15 @@ describe('generateRouteTypes', () => { }); const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; - expect(pathMap).toContain('"/api/users/:id": "id";'); + expect(pathMap).toContain('"/api/users/[id]": "id";'); + expect(pathMap).toContain('"/api/users/:id": RoutePathMap["/api/users/[id]"];'); expect(pathMap).toContain('"/about": never;'); - expect(pathMap).toContain('"/files/:path{.+}": "path";'); - expect(pathMap).toContain('"/files/*": "*";'); - expect(pathMap).toContain('"/chat/:room": "room";'); + expect(pathMap).toContain('"/files/[...path]": "path";'); + expect(pathMap).toContain('"/files/:path{.+}": RoutePathMap["/files/[...path]"];'); + expect(pathMap).toContain('"/files/[...]": "*";'); + expect(pathMap).toContain('"/files/*": RoutePathMap["/files/[...]"];'); + expect(pathMap).toContain('"/chat/[room]": "room";'); + expect(pathMap).toContain('"/chat/:room": RoutePathMap["/chat/[room]"];'); expect(dts).not.toContain('import("../pages/'); }); @@ -672,6 +683,7 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; expect(pathMap.match(/"\/users\/:id":/g)).toHaveLength(1); - expect(pathMap).toContain('"/users/:id": "id";'); + expect(pathMap).toContain('"/users/[id]": "id";'); + expect(pathMap).toContain('"/users/:id": RoutePathMap["/users/[id]"];'); }); }); diff --git a/packages/void/test/unit/route-url-builder.test.ts b/packages/void/test/unit/route-url-builder.test.ts index db21c0199..31e1d7dc7 100644 --- a/packages/void/test/unit/route-url-builder.test.ts +++ b/packages/void/test/unit/route-url-builder.test.ts @@ -13,10 +13,25 @@ declare module 'void/routes' { '/route-util/files/*': '*'; '/route-util/:toString': 'toString'; '/:leading{.+}': 'leading'; + '/route-util/[id]': 'id'; + '/route-util/files/[...path]': 'path'; + '/route-util/files/[...]': '*'; } } describe('routeUrl', () => { + it('builds URLs and destinations from bracket patterns', () => { + expect(routeUrl('/route-util/[id]', { params: { id: 42 } })).toBe('/route-util/42'); + expect(routeUrl('/route-util/files/[...path]', { params: { path: 'docs/start here' } })).toBe( + '/route-util/files/docs/start%20here', + ); + expect(routeUrl('/route-util/files/[...]', { params: { '*': 'a/b' } })).toBe( + '/route-util/files/a/b', + ); + expect( + toUrlString({ pathname: '/route-util/[id]', params: { id: 'a/b' }, hash: 'details' }), + ).toBe('/route-util/a%2Fb#details'); + }); it('converts typed destinations and preserves ordinary URLs', () => { expect( toUrlString({ diff --git a/packages/void/test/unit/route-url.test.ts b/packages/void/test/unit/route-url.test.ts index 7a828ecbd..bba54a7a5 100644 --- a/packages/void/test/unit/route-url.test.ts +++ b/packages/void/test/unit/route-url.test.ts @@ -2,6 +2,38 @@ import { describe, expect, it } from 'vite-plus/test'; import { interpolateRouteParams } from '../../src/runtime/route-url.ts'; describe('route URL interpolation', () => { + it.each([ + ['/users/[id]', '/users/:id', { id: 'a/b ?#%' }, '/users/a%2Fb%20%3F%23%25'], + [ + '/docs/[...slug]', + '/docs/:slug{.+}', + { slug: 'guides/get started' }, + '/docs/guides/get%20started', + ], + ['/docs/[...]', '/docs/*', { '*': 'a/b ?#%' }, '/docs/a/b%20%3F%23%25'], + ['/[id2]/[id]/[id]', '/:id2/:id/:id', { id: 'one', id2: 'two' }, '/two/one/one'], + ['/docs/[...]', '/docs/*', { '*': '' }, '/docs/'], + ] as const)( + 'resolves public %s and legacy %s patterns identically', + (path, legacy, params, url) => { + expect(interpolateRouteParams(path, params)).toBe(url); + expect(interpolateRouteParams(legacy, params)).toBe(url); + }, + ); + + it('preserves literal brackets, missing params, queries and named action suffixes', () => { + expect(interpolateRouteParams('/x[id]/[id]/[missing]?save#/[id]', { id: 'one' })).toBe( + '/x[id]/one/[missing]?save#/[id]', + ); + expect(interpolateRouteParams('/[id]', undefined)).toBe('/[id]'); + expect(interpolateRouteParams('/[toString]', {})).toBe('/[toString]'); + expect(interpolateRouteParams('/docs/[...]', {})).toBe('/docs/[...]'); + expect(() => interpolateRouteParams('/[id]', {}, true)).toThrow('Missing route param "id".'); + expect(() => interpolateRouteParams('/docs/[...slug]', { slug: '' }, true)).toThrow( + 'must not be empty', + ); + }); + it('expands catch-all segments and encodes each path component', () => { expect(interpolateRouteParams('/files/:path{.+}', { path: 'docs/start ?#%.md' })).toBe( '/files/docs/start%20%3F%23%25.md', @@ -27,12 +59,18 @@ describe('route URL interpolation', () => { ['/api/posts/:id', { id: '.' }], ['/files/:path{.+}', { path: 'a/../../admin' }], ['/files/:path{.+}', { path: './x' }], + ['/api/posts/[id]', { id: '..' }], + ['/files/[...path]', { path: 'a/../../admin' }], + ['/files/[...]', { '*': './x' }], ])('rejects dot segments that would change the path of %s (%j)', (path, params) => { expect(() => interpolateRouteParams(path, params)).toThrow(TypeError); expect(() => interpolateRouteParams(path, params)).toThrow(/it would change the request path/); }); it('rejects a leading param that would make the path start with //', () => { + expect(() => interpolateRouteParams('/[...path]', { path: '/evil.com/x' })).toThrow( + 'changes the request host', + ); expect(() => interpolateRouteParams('/:path{.+}', { path: '/evil.com/x' })).toThrow( new TypeError( 'Route param "path" would make the request path start with "//", which changes the request host.', diff --git a/packages/void/test/unit/ws.test.ts b/packages/void/test/unit/ws.test.ts index 440cdcfd3..929063e63 100644 --- a/packages/void/test/unit/ws.test.ts +++ b/packages/void/test/unit/ws.test.ts @@ -3,6 +3,8 @@ import { connect } from '../../src/runtime/ws.ts'; declare module 'void/routes' { interface WebSocketRouteMap { + '/chat/[room]': WebSocketRouteMap['/chat/:room']; + '/files/[...path]': WebSocketRouteMap['/files/:path{.+}']; '/chat/:room': { params: { room: string }; client: { type: 'chat.message'; text: string }; @@ -80,9 +82,17 @@ describe('typed websocket client', () => { (globalThis as { location?: unknown }).location = originalLocation; }); - it('expands complete catch-all tokens', () => { - connect('/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + it.each(['/files/[...path]', '/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + (path) => { + connect(path, { params: { path: 'docs/start here' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + }, + ); + + it('interpolates bracket parameters in WebSocket URLs', () => { + connect('/chat/[room]', { params: { room: 'a/b' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/chat/a%2Fb'); }); it('keeps a secure wss baseURL secure', () => {
` | The params page `P` receives, such as `{ id: string }` for `/users/:id`. | +| `PageParams
` | The params page `P` receives, such as `{ id: string }` for `/users/[id]`. | | `RouteOptions
` | Options for a route pattern `P`, including its required `params`, optional `query`, and `hash`. | | `RouteDestination
` | `{ pathname: P }` with `RouteOptions
`. Defaults to all known routes when `P` is omitted. | | `RouteUrl` | The URL of one of your routes, returned by `routeUrl()`. `string` unless `typedRoutes` is on. | diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index aa46c3d05..7639115f8 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -164,6 +164,11 @@ Applied in order to a path relative to `routes/`: 5. Convert `[param]` → `:param`, `[...param]` → `:param{.+}`, `[...]` → `*` 6. Prefix with `/` +Keep these compiled patterns for internal matching. Generated public route maps use +bracket patterns (`/users/[id]`, `/files/[...path]`, `/files/[...]`) and retain the +original patterns as deprecated aliases with identical types. HTTP, WebSocket, +page-action and URL clients share `interpolateRouteParams()` for both syntaxes. + HTTP methods are determined by **named exports** in each file (detected by `detect-exports.ts` via `parseSync`), not by filename suffixes. Files/dirs starting with `_` are ignored by the scanner. `listRouteFiles()` uses `ROUTE_EXTENSIONS`, shared with HTTP/WebSocket filename parsing, resource-name inference, and route binding inference. Both route scanners use it, with `isWebSocketPatternFile()` sharing the WebSocket filename regex to distinguish `.ws.*` files. `listConventionFiles()` keeps fixed script extensions for crons and queues. Void sets no JSX options: route JSX follows framework plugins, tsconfig (TS files only) or `oxc.jsx`. Page `.server.tsx` and `.server.jsx` companions supply server metadata and handlers; exclude them from component inventories and component manifests. diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index 359fe3e9f..1ff22c55b 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -22,6 +22,9 @@ Use `void` and `@void/*` in imports, examples, and package manifests. Follow the project's existing framework and deployment destination. Deploying to a Void platform requires an explicit `void connect ` first; never infer a platform from old examples, tokens, or project IDs. + +Use bracket route patterns such as `/users/[id]` and `/docs/[...slug]` with +`routeUrl()`, typed `fetch()`, `useParams()`, page actions, and WebSocket `connect()`. Platform hostnames passed to `void connect` default to HTTPS. Loopback hosts (`localhost`, `127.0.0.1`, and `[::1]`) default to HTTP: `void connect localhost:8787`. An explicit scheme overrides the default. diff --git a/packages/void/src/codegen/route-types.ts b/packages/void/src/codegen/route-types.ts index 654643a7d..6fc905919 100644 --- a/packages/void/src/codegen/route-types.ts +++ b/packages/void/src/codegen/route-types.ts @@ -7,12 +7,37 @@ export type RouteTypesResult = { dts: string; }; +/** Present bracket parameters without changing the router's compiled patterns. */ +function publicRoutePattern(pattern: string): string { + return pattern + .split('/') + .map((segment) => { + if (segment === '*') { + return '[...]'; + } + const param = segment.match(/^:([^/{}]+)(\{\.\+\})?$/); + return param ? `[${param[2] ? '...' : ''}${param[1]}]` : segment; + }) + .join('/'); +} + +/** Legacy keys stay indexable and carry the exact same contract as the public key. */ +function legacyRouteAlias(lines: Array, map: string, pattern: string): void { + const publicPattern = publicRoutePattern(pattern); + if (publicPattern !== pattern) { + // Escape comment delimiters in static segments before writing JSDoc. + const label = JSON.stringify(publicPattern).replace(/\*\//g, '*\\/'); + lines.push(` /** @deprecated Use ${label} instead. */`); + lines.push(` ${JSON.stringify(pattern)}: ${map}[${JSON.stringify(publicPattern)}];`); + } +} + /** Percent-encode a pattern's fixed segments the way `routeUrl()` serializes its pathname. */ function encodeRoutePattern(pattern: string): string { return pattern .split('/') .map((segment) => { - if (segment.startsWith(':') || segment === '*') { + if (segment.startsWith(':') || segment === '*' || /^\[.*\]$/.test(segment)) { return segment; } const url = new URL('https://void.invalid'); @@ -113,14 +138,16 @@ export function generateRouteTypes( lines.push(' ? { pathname: P } & RouteOptions'); lines.push(' : never;'); lines.push( - ' type RouteUrlSegment = S extends `:${string}` | "*" ? string : S;', + ' type RouteUrlSegment = S extends `:${string}` | `[${string}]` | "*" ? string : S;', ); lines.push( ' type RouteSegmentsUrl = P extends `${infer Segment}/${infer Rest}`', ); lines.push(' ? `${RouteUrlSegment}/${RouteSegmentsUrl}`'); lines.push(' : RouteUrlSegment;'); - lines.push(' type RoutePatternUrl = P extends `${infer Prefix}/*`'); + lines.push( + ' type RoutePatternUrl = P extends `${infer Prefix}/${"*" | "[...]"}`', + ); lines.push( ' ? (Prefix extends "" ? "/" : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}`', ); @@ -165,7 +192,8 @@ export function generateRouteTypes( } for (const [pattern, params] of paths) { const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never'; - lines.push(` ${JSON.stringify(pattern)}: ${names};`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: ${names};`); + legacyRouteAlias(lines, 'RoutePathMap', pattern); } lines.push(' }'); lines.push(''); @@ -184,7 +212,8 @@ export function generateRouteTypes( } for (const [pattern, params] of pagePaths) { const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never'; - lines.push(` ${JSON.stringify(pattern)}: ${names};`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: ${names};`); + legacyRouteAlias(lines, 'PagePathMap', pattern); } lines.push(' }'); lines.push(' export type PagePath = keyof PagePathMap & string;'); @@ -195,9 +224,16 @@ export function generateRouteTypes( // `routeUrl()` returns `/caf%C3%A9%20menu` for `/café menu`, so accept that spelling too. lines.push(' export interface EncodedRoutePathMap {'); for (const pattern of paths.keys()) { - const encoded = encodeRoutePattern(pattern); - if (encoded !== pattern) { - lines.push(` ${JSON.stringify(pattern)}: ${JSON.stringify(encoded)};`); + const publicPattern = publicRoutePattern(pattern); + const encoded = encodeRoutePattern(publicPattern); + if (encoded !== publicPattern) { + lines.push(` ${JSON.stringify(publicPattern)}: ${JSON.stringify(encoded)};`); + // Preserve the original encoded map's keys and values too. + if (publicPattern !== pattern) { + lines.push( + ` ${JSON.stringify(pattern)}: ${JSON.stringify(encodeRoutePattern(pattern))};`, + ); + } } } lines.push(' }'); @@ -300,7 +336,7 @@ export function generateRouteTypes( } for (const [pattern, methods] of routesByPattern) { - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); for (const [method, sources] of methods) { const imports = sources.map((route) => { @@ -318,6 +354,7 @@ export function generateRouteTypes( } lines.push(' };'); + legacyRouteAlias(lines, 'RouteMap', pattern); } lines.push(' }'); @@ -336,7 +373,7 @@ export function generateRouteTypes( const importPath = relative(outDir, `${routesDir}/${route.filePath}`); return `(typeof import(${JSON.stringify(importPath)}))["default"]["messages"]`; }); - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); lines.push(` params: RouteParams<${JSON.stringify(sources[0]!.params)}>;`); lines.push( ` client: ${imports.map((source) => `ExtractInput<${source}["client"]>`).join(' & ')};`, @@ -345,6 +382,7 @@ export function generateRouteTypes( ` server: ${imports.map((source) => `ExtractOutput<${source}["server"]>`).join(' | ')};`, ); lines.push(' };'); + legacyRouteAlias(lines, 'WebSocketRouteMap', pattern); } lines.push(' }'); @@ -402,7 +440,7 @@ export function generateRouteTypes( } for (const [pattern, bodies] of bodiesByPattern) { - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); bodies.forEach((body, index) => { if (index > 0) { lines.push(' } | {'); @@ -410,6 +448,7 @@ export function generateRouteTypes( lines.push(...body); }); lines.push(' };'); + legacyRouteAlias(lines, 'PageActionMap', pattern); } lines.push(' }'); diff --git a/packages/void/src/routes-stub.ts b/packages/void/src/routes-stub.ts index 7668ec1e3..933b90897 100644 --- a/packages/void/src/routes-stub.ts +++ b/packages/void/src/routes-stub.ts @@ -16,11 +16,11 @@ export interface RoutePathMap {} /** Page URL patterns and their parameter names, augmented by .void/routes.d.ts. */ export interface PagePathMap {} -/** Union of the app's page URL patterns, such as `/users/:id`. */ +/** Union of the app's page URL patterns, such as `/users/[id]`. */ export type PagePath = keyof PagePathMap & string; /** - * Params a page receives: `{ id: string }` for `/users/:id`. An unnamed `[...]` + * Params a page receives: `{ id: string }` for `/users/[id]`. An unnamed `[...]` * catch-all has no param. A union of patterns gives a union of params. */ export type PageParams = P extends PagePath diff --git a/packages/void/src/runtime/fetch.ts b/packages/void/src/runtime/fetch.ts index a80a1d7c3..f69872256 100644 --- a/packages/void/src/runtime/fetch.ts +++ b/packages/void/src/runtime/fetch.ts @@ -73,7 +73,7 @@ const browserFetch = createRouteFetch((...args) => globalThis.fetch(...args)); * import { fetch } from "void/client"; * * const users = await fetch("/api/users"); // typed! - * const user = await fetch("/api/users/:id", { params: { id: "1" } }); + * const user = await fetch("/api/users/[id]", { params: { id: "1" } }); * ``` */ export function fetch( diff --git a/packages/void/src/runtime/route-url.ts b/packages/void/src/runtime/route-url.ts index fe6c9572f..a9e74901c 100644 --- a/packages/void/src/runtime/route-url.ts +++ b/packages/void/src/runtime/route-url.ts @@ -28,18 +28,18 @@ export function interpolateRouteParams( const suffix = suffixIndex === -1 ? '' : path.slice(suffixIndex); let leadingName: string | undefined; const replaced = pathname.replace( - /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\*)(?=\/|$)/g, + /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\[(\.\.\.)?([^/[\]]*)\]|\*)(?=\/|$)/g, ( token: string, prefix: string, name: string | undefined, catchAll: string | undefined, + bracketCatchAll: string | undefined, + bracketName: string | undefined, offset: number, ) => { + name ??= bracketName || undefined; const anonymous = name === undefined; - if (anonymous && !requireParams) { - return token; - } name ??= '*'; if (!params || !Object.hasOwn(params, name)) { if (requireParams) { @@ -56,7 +56,7 @@ export function interpolateRouteParams( } return ( prefix + - (catchAll || anonymous + (catchAll || bracketCatchAll || anonymous ? value .split('/') .map((segment) => encodeSegment(name, segment)) @@ -77,15 +77,15 @@ export function interpolateRouteParams( /** Known page, HTTP and WebSocket route patterns. */ export type RoutePath = keyof RoutePathMap & string; -// With `routing.typedRoutes`, each `:name`, `:name{.+}` or `*` segment of a -// pattern matches any text, so `/users/:id` accepts `/users/${string}`. Template +// Dynamic segments in either public or legacy patterns match any text, +// so `/users/[id]` accepts `/users/${string}`. Template // literal types cannot exclude empty or `/`-leading text without per-character unions. -type RouteUrlSegment = S extends `:${string}` | '*' ? string : S; +type RouteUrlSegment = S extends `:${string}` | `[${string}]` | '*' ? string : S; type RouteSegmentsUrl = P extends `${infer Segment}/${infer Rest}` ? `${RouteUrlSegment}/${RouteSegmentsUrl}` : RouteUrlSegment; // A trailing `/*` also matches the bare prefix, as in Hono: `/docs/*` matches `/docs`. -type RoutePatternUrl = P extends `${infer Prefix}/*` +type RoutePatternUrl = P extends `${infer Prefix}/${'*' | '[...]'}` ? (Prefix extends '' ? '/' : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}` : RouteSegmentsUrl; // Fixed segments are also accepted percent-encoded, as `routeUrl()` returns them. diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index f55a40595..84fb6854c 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -238,7 +238,7 @@ import { useParams as vue } from ${adapterSource('vue/src/runtime/use-params.ts' import { useParams as svelte } from ${adapterSource('svelte/src/runtime/use-params.ts')}; import { useParams as solid } from ${adapterSource('solid/src/runtime/use-params.ts')}; type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const pages: Array = ['/', '/about', '/users/:id', '/files/:path{.+}', '/wild/*']; +const pages: Array = ['/', '/about', '/users/[id]', '/files/[...path]', '/wild/[...]', '/users/:id', '/files/:path{.+}', '/wild/*']; // @ts-expect-error HTTP routes are not pages const api: PagePath = '/api/users/:id'; void [pages, api]; @@ -247,6 +247,15 @@ ${['react', 'vue', 'svelte', 'solid'] .map( (useParams) => `{ const user = ${useParams}<'/users/:id'>(); + const bracketUser = ${useParams}<'/users/[id]'>(); + const bracketFiles = ${useParams}<'/files/[...path]'>(); + const bracketWild = ${useParams}<'/wild/[...]'>(); + const aliases: [ + Same, + Same, + Same, + ] = [true, true, true]; + void aliases; const files = ${useParams}<'/files/:path{.+}'>(); const wild = ${useParams}<'/wild/*'>(); const about = ${useParams}<'/about'>(); @@ -325,6 +334,17 @@ routeUrl('/café menu'); routeUrl('/api/users/:id', { params: { id: '123' } }); routeUrl('/files/:path{.+}', { params: { path: 'docs/start' } }); routeUrl('/wild/*', { params: { '*': 'a/b' } }); +routeUrl('/users/[id]', { params: { id: 123 } }); +routeUrl('/files/[...path]', { params: { path: 'docs/start' } }); +routeUrl('/wild/[...]', { params: { '*': 'a/b' } }); +// @ts-expect-error bracket patterns require params +routeUrl('/users/[id]'); +// @ts-expect-error bracket catch-all patterns require params +routeUrl('/files/[...path]'); +// @ts-expect-error bracket anonymous catch-alls require a wildcard param +routeUrl('/wild/[...]'); +// @ts-expect-error bracket params are checked +routeUrl('/users/[id]', { params: { wrong: 123 } }); // @ts-expect-error a known route's params are required routeUrl('/users/:id'); // @ts-expect-error params cannot be omitted while providing other options @@ -341,6 +361,8 @@ routeUrl('/files/:path{.+}'); routeUrl('/wild/*'); void url; const destination: RouteDestination = { pathname: '/users/:id', params: { id: 123 }, query: { tab: 'activity' } }; +const bracketDestination: RouteDestination = { pathname: '/users/[id]', params: { id: 123 } }; +void bracketDestination; declare const router: VoidRouter; void router.visit(destination); void router.prefetch(destination); @@ -440,6 +462,77 @@ function typecheck( } describe('standalone routeUrl utility', () => { + it.each(['generated', 'package'] as const)( + 'preserves HTTP, WebSocket and page action contracts for both syntaxes through %s declarations', + async (declarations) => { + const root = await createFixture({ + extraFiles: { + 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const message: Schema<{ text: string }>; +export default defineRoom({ messages: { client: message, server: message } }); +`, + 'pages/users/[id].server.ts': `import { defineHandler } from 'void/handler'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const body: Schema<{ name: string }>; +export const actions = { save: defineHandler.withValidator({ body })(() => ({ ok: true })) }; +`, + 'check-aliases.ts': `import { fetch } from 'void/client'; +import { connect } from 'void/ws'; +import { createActionClient } from 'void/pages-client'; +import type { RouteMap, RoutePathMap, PagePathMap, PageActionMap, WebSocketRouteMap } from 'void/routes'; +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const aliases: [ + Same, + Same, + Same, + Same, + Same, +] = [true, true, true, true, true]; +void aliases; +const bracket = fetch('/api/users/[id]', { params: { id: '42' } }); +const legacy = fetch('/api/users/:id', { params: { id: '42' } }); +const responses: Same = true; +void responses; +// @ts-expect-error methods stay constrained through bracket keys +fetch('/api/users/[id]', { method: 'POST' }); +// @ts-expect-error legacy methods stay constrained +fetch('/api/users/:id', { method: 'POST' }); +connect('/chat/[room]', { params: { room: 'lobby' } }).send({ text: 'hi' }); +connect('/chat/:room', { params: { room: 'lobby' } }).send({ text: 'hi' }); +// @ts-expect-error bracket WebSocket params are required +connect('/chat/[room]', {}); +// @ts-expect-error legacy WebSocket params are required +connect('/chat/:room', {}); +connect('/chat/[room]', { params: { room: 'lobby' } }).on('message', (message) => { + const text: string = message.text; + // @ts-expect-error server message types stay constrained + void message.missing; + void text; +}); +const { action } = createActionClient(); +action('/users/[id]?save', { params: { id: '42' }, data: { name: 'Alice' } }); +action('/users/:id?save', { params: { id: '42' }, data: { name: 'Alice' } }); +// @ts-expect-error bracket actions require params +action('/users/[id]?save'); +// @ts-expect-error legacy actions require params +action('/users/:id?save'); +// @ts-expect-error bracket action bodies stay constrained +action('/users/[id]?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error legacy action bodies stay constrained +action('/users/:id?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error bracket action names stay constrained +action('/users/[id]?missing', { params: { id: '42' } }); +`, + }, + }); + expect(typecheck(root, declarations, ['check-aliases.ts'])).toEqual({ + output: '', + status: 0, + }); + }, + ); + it.each(['generated', 'package'] as const)( 'typechecks through the %s declarations', async (declarations) => { @@ -525,7 +618,7 @@ void loose; join(root, 'routes.d.ts'), `export {}; declare module 'void/routes' { - interface RoutePathMap { '/': never; '/docs/*': '*'; '/users/:id': 'id'; '/café menu': never } + interface RoutePathMap { '/': never; '/docs/[...]': '*'; '/users/[id]': 'id'; '/café menu': never } interface EncodedRoutePathMap { '/café menu': '/caf%C3%A9%20menu' } interface RouteTypesConfig { typedRoutes: true } } @@ -611,7 +704,12 @@ void [hrefs, route, unknown, external]; `import { routeUrl } from 'void/routes'; export function buildUrls() { return [ + routeUrl('/users/[id]', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), routeUrl('/users/:id', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), + routeUrl('/files/[...path]', { params: { path: 'docs/start here' } }), + routeUrl('/files/:path{.+}', { params: { path: 'docs/start here' } }), + routeUrl('/wild/[...]', { params: { '*': 'a/b' } }), + routeUrl('/wild/*', { params: { '*': 'a/b' } }), routeUrl('/café menu', { query: { q: 'café menu' }, hash: 'café menu' }), ]; } @@ -628,6 +726,11 @@ export function buildUrls() { }); const expected = [ '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/files/docs/start%20here', + '/files/docs/start%20here', + '/wild/a/b', + '/wild/a/b', '/caf%C3%A9%20menu?q=caf%C3%A9+menu#caf%C3%A9%20menu', ]; const scriptPath = join(root, 'dist/route.mjs'); diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index 5db822319..894801294 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -14,6 +14,7 @@ import { declare module 'void/routes' { interface PageActionMap { + '/__action-test/users/[id]': PageActionMap['/__action-test/users/:id']; '/__action-test/static': { body: { name: string } }; '/__action-test/users/:id': { params: { id: string }; @@ -248,23 +249,26 @@ describe('action clients', () => { }); }); - it('encodes path parameters while preserving named actions and submission options', async () => { - const client = createActionClient(); - const visit = vi.fn(async () => ({ props: {} })); - client.setActionRouter(routerWithVisit(visit)); - - await client.action('/__action-test/users/:id?save', { - params: { id: 'a/b?c' }, - method: 'PATCH', - data: { name: 'Alice' }, - }); - - expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { - method: 'PATCH', - data: { name: 'Alice' }, - preserveScroll: undefined, - }); - }); + it.each(['/__action-test/users/[id]?save', '/__action-test/users/:id?save'] as const)( + 'encodes params and preserves named actions in %s', + async (url) => { + const client = createActionClient(); + const visit = vi.fn(async () => ({ props: {} })); + client.setActionRouter(routerWithVisit(visit)); + + await client.action(url, { + params: { id: 'a/b?c' }, + method: 'PATCH', + data: { name: 'Alice' }, + }); + + expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { + method: 'PATCH', + data: { name: 'Alice' }, + preserveScroll: undefined, + }); + }, + ); it('preserves validation errors as action results', async () => { const client = createActionClient(); diff --git a/packages/void/test/unit/fetch-stream.test.ts b/packages/void/test/unit/fetch-stream.test.ts index 79009c929..bb894efcd 100644 --- a/packages/void/test/unit/fetch-stream.test.ts +++ b/packages/void/test/unit/fetch-stream.test.ts @@ -17,6 +17,7 @@ vi.mock('ofetch', () => { declare module 'void/routes' { interface RouteMap { + '/api/stream/[...path]': RouteMap['/api/stream/:path{.+}']; '/api/chat': { POST: { input: { body: { messages: Array } }; output: unknown }; }; @@ -50,11 +51,14 @@ beforeEach(() => { }); describe('fetchStream', () => { - it('expands complete catch-all tokens', async () => { - vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); - await fetchStream('/api/stream/:path{.+}', { params: { path: 'docs/start here' } }).next(); - expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); - }); + it.each(['/api/stream/[...path]', '/api/stream/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); + await fetchStream(path, { params: { path: 'docs/start here' } }).next(); + expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); + }, + ); it('parses Workers AI JSON SSE format', async () => { const stream = makeSSEStream([ 'data: {"response":"Hello"}\n\n', diff --git a/packages/void/test/unit/fetch.test.ts b/packages/void/test/unit/fetch.test.ts index 63063053d..6e240e49f 100644 --- a/packages/void/test/unit/fetch.test.ts +++ b/packages/void/test/unit/fetch.test.ts @@ -3,6 +3,8 @@ import { fetch as typedFetch, FetchError } from '../../src/runtime/fetch.ts'; declare module 'void/routes' { interface RouteMap { + '/api/users/[id]': RouteMap['/api/users/:id']; + '/api/files/[...path]': RouteMap['/api/files/:path{.+}']; '/api/hello': { GET: { input: {}; output: { ok: boolean } }; }; @@ -53,13 +55,23 @@ describe('typed fetch client', () => { expect(result).toEqual({ ok: true }); }); - it('expands complete catch-all tokens', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); - await typedFetch('/api/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/files/docs/start%20here', - expect.anything(), - ); + it.each(['/api/files/[...path]', '/api/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); + await typedFetch(path, { params: { path: 'docs/start here' } }); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/files/docs/start%20here', + expect.anything(), + ); + }, + ); + + it('interpolates bracket parameters for typed requests', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({ id: 'a/b', name: 'Alice' })); + const result = await typedFetch('/api/users/[id]', { params: { id: 'a/b' } }); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/users/a%2Fb', expect.anything()); + expect(result).toEqual({ id: 'a/b', name: 'Alice' }); }); it('builds query string', async () => { diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index fe888ed61..42d4002a4 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -198,9 +198,12 @@ describe('generateRouteTypes', () => { const pageMap = dts.split(' export interface PagePathMap {')[1]!.split(' }')[0]!; expect(pageMap).toContain('"/about": never;'); - expect(pageMap).toContain('"/users/:id": "id";'); - expect(pageMap).toContain('"/files/:path{.+}": "path";'); - expect(pageMap).toContain('"/wild/*": "*";'); + expect(pageMap).toContain('"/users/[id]": "id";'); + expect(pageMap).toContain('"/users/:id": PagePathMap["/users/[id]"];'); + expect(pageMap).toContain('"/files/[...path]": "path";'); + expect(pageMap).toContain('"/files/:path{.+}": PagePathMap["/files/[...path]"];'); + expect(pageMap).toContain('"/wild/[...]": "*";'); + expect(pageMap).toContain('"/wild/*": PagePathMap["/wild/[...]"];'); expect(pageMap).not.toContain('/api/users/:id'); }); @@ -220,7 +223,9 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); - expect(dts).toContain('"/api/users/:id"'); + expect(dts).toContain('"/api/users/[id]": {'); + expect(dts).toContain('/** @deprecated Use "/api/users/[id]" instead. */'); + expect(dts).toContain('"/api/users/:id": RouteMap["/api/users/[id]"];'); expect(dts).toContain('typeof import("../routes/api/users/[id].ts")'); }); @@ -290,6 +295,7 @@ describe('generateRouteTypes', () => { expect(encoded.trim().split('\n')).toEqual([ '"/café menu": "/caf%C3%A9%20menu";', + ' "/café/[id]": "/caf%C3%A9/[id]";', ' "/café/:id": "/caf%C3%A9/:id";', ]); expect(dts).toContain('type RoutePathUrl = RoutePatternUrl;'); @@ -405,14 +411,14 @@ describe('generateRouteTypes', () => { expect(actionMap.split('"/users/:id"')).toHaveLength(2); expect(actionMap).toContain( [ - ' "/users/:id": {', + ' "/users/[id]": {', ` body: ExtractInput<${dev}["__validators"]> extends { body: infer B } ? B : Record;`, ' params: { id: string };', ' } | {', ` actions: { [K in keyof ${prod}]: {`, ].join('\n'), ); - expect(actionMap).toContain([' params: { id: string };', ' };', ' }'].join('\n')); + expect(actionMap).toContain(' "/users/:id": PageActionMap["/users/[id]"];'); }); it('generates WebSocketRouteMap entries', () => { @@ -472,7 +478,8 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const websocketMap = dts.split(' interface WebSocketRouteMap {')[1]!.split('\n }\n}')[0]!; - expect(websocketMap.match(/"\/chat\/:room": \{/g)).toHaveLength(1); + expect(websocketMap.match(/"\/chat\/\[room\]": \{/g)).toHaveLength(1); + expect(websocketMap).toContain('"/chat/:room": WebSocketRouteMap["/chat/[room]"];'); expect(websocketMap).toContain('chat/[room].dev.ws.ts'); expect(websocketMap).toContain('chat/[room].prod.ws.ts'); expect(websocketMap).toMatch(/client: .*dev\.ws\.ts.* & .*prod\.ws\.ts/); @@ -636,11 +643,15 @@ describe('generateRouteTypes', () => { }); const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; - expect(pathMap).toContain('"/api/users/:id": "id";'); + expect(pathMap).toContain('"/api/users/[id]": "id";'); + expect(pathMap).toContain('"/api/users/:id": RoutePathMap["/api/users/[id]"];'); expect(pathMap).toContain('"/about": never;'); - expect(pathMap).toContain('"/files/:path{.+}": "path";'); - expect(pathMap).toContain('"/files/*": "*";'); - expect(pathMap).toContain('"/chat/:room": "room";'); + expect(pathMap).toContain('"/files/[...path]": "path";'); + expect(pathMap).toContain('"/files/:path{.+}": RoutePathMap["/files/[...path]"];'); + expect(pathMap).toContain('"/files/[...]": "*";'); + expect(pathMap).toContain('"/files/*": RoutePathMap["/files/[...]"];'); + expect(pathMap).toContain('"/chat/[room]": "room";'); + expect(pathMap).toContain('"/chat/:room": RoutePathMap["/chat/[room]"];'); expect(dts).not.toContain('import("../pages/'); }); @@ -672,6 +683,7 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; expect(pathMap.match(/"\/users\/:id":/g)).toHaveLength(1); - expect(pathMap).toContain('"/users/:id": "id";'); + expect(pathMap).toContain('"/users/[id]": "id";'); + expect(pathMap).toContain('"/users/:id": RoutePathMap["/users/[id]"];'); }); }); diff --git a/packages/void/test/unit/route-url-builder.test.ts b/packages/void/test/unit/route-url-builder.test.ts index db21c0199..31e1d7dc7 100644 --- a/packages/void/test/unit/route-url-builder.test.ts +++ b/packages/void/test/unit/route-url-builder.test.ts @@ -13,10 +13,25 @@ declare module 'void/routes' { '/route-util/files/*': '*'; '/route-util/:toString': 'toString'; '/:leading{.+}': 'leading'; + '/route-util/[id]': 'id'; + '/route-util/files/[...path]': 'path'; + '/route-util/files/[...]': '*'; } } describe('routeUrl', () => { + it('builds URLs and destinations from bracket patterns', () => { + expect(routeUrl('/route-util/[id]', { params: { id: 42 } })).toBe('/route-util/42'); + expect(routeUrl('/route-util/files/[...path]', { params: { path: 'docs/start here' } })).toBe( + '/route-util/files/docs/start%20here', + ); + expect(routeUrl('/route-util/files/[...]', { params: { '*': 'a/b' } })).toBe( + '/route-util/files/a/b', + ); + expect( + toUrlString({ pathname: '/route-util/[id]', params: { id: 'a/b' }, hash: 'details' }), + ).toBe('/route-util/a%2Fb#details'); + }); it('converts typed destinations and preserves ordinary URLs', () => { expect( toUrlString({ diff --git a/packages/void/test/unit/route-url.test.ts b/packages/void/test/unit/route-url.test.ts index 7a828ecbd..bba54a7a5 100644 --- a/packages/void/test/unit/route-url.test.ts +++ b/packages/void/test/unit/route-url.test.ts @@ -2,6 +2,38 @@ import { describe, expect, it } from 'vite-plus/test'; import { interpolateRouteParams } from '../../src/runtime/route-url.ts'; describe('route URL interpolation', () => { + it.each([ + ['/users/[id]', '/users/:id', { id: 'a/b ?#%' }, '/users/a%2Fb%20%3F%23%25'], + [ + '/docs/[...slug]', + '/docs/:slug{.+}', + { slug: 'guides/get started' }, + '/docs/guides/get%20started', + ], + ['/docs/[...]', '/docs/*', { '*': 'a/b ?#%' }, '/docs/a/b%20%3F%23%25'], + ['/[id2]/[id]/[id]', '/:id2/:id/:id', { id: 'one', id2: 'two' }, '/two/one/one'], + ['/docs/[...]', '/docs/*', { '*': '' }, '/docs/'], + ] as const)( + 'resolves public %s and legacy %s patterns identically', + (path, legacy, params, url) => { + expect(interpolateRouteParams(path, params)).toBe(url); + expect(interpolateRouteParams(legacy, params)).toBe(url); + }, + ); + + it('preserves literal brackets, missing params, queries and named action suffixes', () => { + expect(interpolateRouteParams('/x[id]/[id]/[missing]?save#/[id]', { id: 'one' })).toBe( + '/x[id]/one/[missing]?save#/[id]', + ); + expect(interpolateRouteParams('/[id]', undefined)).toBe('/[id]'); + expect(interpolateRouteParams('/[toString]', {})).toBe('/[toString]'); + expect(interpolateRouteParams('/docs/[...]', {})).toBe('/docs/[...]'); + expect(() => interpolateRouteParams('/[id]', {}, true)).toThrow('Missing route param "id".'); + expect(() => interpolateRouteParams('/docs/[...slug]', { slug: '' }, true)).toThrow( + 'must not be empty', + ); + }); + it('expands catch-all segments and encodes each path component', () => { expect(interpolateRouteParams('/files/:path{.+}', { path: 'docs/start ?#%.md' })).toBe( '/files/docs/start%20%3F%23%25.md', @@ -27,12 +59,18 @@ describe('route URL interpolation', () => { ['/api/posts/:id', { id: '.' }], ['/files/:path{.+}', { path: 'a/../../admin' }], ['/files/:path{.+}', { path: './x' }], + ['/api/posts/[id]', { id: '..' }], + ['/files/[...path]', { path: 'a/../../admin' }], + ['/files/[...]', { '*': './x' }], ])('rejects dot segments that would change the path of %s (%j)', (path, params) => { expect(() => interpolateRouteParams(path, params)).toThrow(TypeError); expect(() => interpolateRouteParams(path, params)).toThrow(/it would change the request path/); }); it('rejects a leading param that would make the path start with //', () => { + expect(() => interpolateRouteParams('/[...path]', { path: '/evil.com/x' })).toThrow( + 'changes the request host', + ); expect(() => interpolateRouteParams('/:path{.+}', { path: '/evil.com/x' })).toThrow( new TypeError( 'Route param "path" would make the request path start with "//", which changes the request host.', diff --git a/packages/void/test/unit/ws.test.ts b/packages/void/test/unit/ws.test.ts index 440cdcfd3..929063e63 100644 --- a/packages/void/test/unit/ws.test.ts +++ b/packages/void/test/unit/ws.test.ts @@ -3,6 +3,8 @@ import { connect } from '../../src/runtime/ws.ts'; declare module 'void/routes' { interface WebSocketRouteMap { + '/chat/[room]': WebSocketRouteMap['/chat/:room']; + '/files/[...path]': WebSocketRouteMap['/files/:path{.+}']; '/chat/:room': { params: { room: string }; client: { type: 'chat.message'; text: string }; @@ -80,9 +82,17 @@ describe('typed websocket client', () => { (globalThis as { location?: unknown }).location = originalLocation; }); - it('expands complete catch-all tokens', () => { - connect('/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + it.each(['/files/[...path]', '/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + (path) => { + connect(path, { params: { path: 'docs/start here' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + }, + ); + + it('interpolates bracket parameters in WebSocket URLs', () => { + connect('/chat/[room]', { params: { room: 'a/b' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/chat/a%2Fb'); }); it('keeps a secure wss baseURL secure', () => {
'); lines.push(' : never;'); lines.push( - ' type RouteUrlSegment = S extends `:${string}` | "*" ? string : S;', + ' type RouteUrlSegment = S extends `:${string}` | `[${string}]` | "*" ? string : S;', ); lines.push( ' type RouteSegmentsUrl
= P extends `${infer Segment}/${infer Rest}`', ); lines.push(' ? `${RouteUrlSegment}/${RouteSegmentsUrl}`'); lines.push(' : RouteUrlSegment;'); - lines.push(' type RoutePatternUrl = P extends `${infer Prefix}/*`'); + lines.push( + ' type RoutePatternUrl = P extends `${infer Prefix}/${"*" | "[...]"}`', + ); lines.push( ' ? (Prefix extends "" ? "/" : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}`', ); @@ -165,7 +192,8 @@ export function generateRouteTypes( } for (const [pattern, params] of paths) { const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never'; - lines.push(` ${JSON.stringify(pattern)}: ${names};`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: ${names};`); + legacyRouteAlias(lines, 'RoutePathMap', pattern); } lines.push(' }'); lines.push(''); @@ -184,7 +212,8 @@ export function generateRouteTypes( } for (const [pattern, params] of pagePaths) { const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never'; - lines.push(` ${JSON.stringify(pattern)}: ${names};`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: ${names};`); + legacyRouteAlias(lines, 'PagePathMap', pattern); } lines.push(' }'); lines.push(' export type PagePath = keyof PagePathMap & string;'); @@ -195,9 +224,16 @@ export function generateRouteTypes( // `routeUrl()` returns `/caf%C3%A9%20menu` for `/café menu`, so accept that spelling too. lines.push(' export interface EncodedRoutePathMap {'); for (const pattern of paths.keys()) { - const encoded = encodeRoutePattern(pattern); - if (encoded !== pattern) { - lines.push(` ${JSON.stringify(pattern)}: ${JSON.stringify(encoded)};`); + const publicPattern = publicRoutePattern(pattern); + const encoded = encodeRoutePattern(publicPattern); + if (encoded !== publicPattern) { + lines.push(` ${JSON.stringify(publicPattern)}: ${JSON.stringify(encoded)};`); + // Preserve the original encoded map's keys and values too. + if (publicPattern !== pattern) { + lines.push( + ` ${JSON.stringify(pattern)}: ${JSON.stringify(encodeRoutePattern(pattern))};`, + ); + } } } lines.push(' }'); @@ -300,7 +336,7 @@ export function generateRouteTypes( } for (const [pattern, methods] of routesByPattern) { - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); for (const [method, sources] of methods) { const imports = sources.map((route) => { @@ -318,6 +354,7 @@ export function generateRouteTypes( } lines.push(' };'); + legacyRouteAlias(lines, 'RouteMap', pattern); } lines.push(' }'); @@ -336,7 +373,7 @@ export function generateRouteTypes( const importPath = relative(outDir, `${routesDir}/${route.filePath}`); return `(typeof import(${JSON.stringify(importPath)}))["default"]["messages"]`; }); - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); lines.push(` params: RouteParams<${JSON.stringify(sources[0]!.params)}>;`); lines.push( ` client: ${imports.map((source) => `ExtractInput<${source}["client"]>`).join(' & ')};`, @@ -345,6 +382,7 @@ export function generateRouteTypes( ` server: ${imports.map((source) => `ExtractOutput<${source}["server"]>`).join(' | ')};`, ); lines.push(' };'); + legacyRouteAlias(lines, 'WebSocketRouteMap', pattern); } lines.push(' }'); @@ -402,7 +440,7 @@ export function generateRouteTypes( } for (const [pattern, bodies] of bodiesByPattern) { - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); bodies.forEach((body, index) => { if (index > 0) { lines.push(' } | {'); @@ -410,6 +448,7 @@ export function generateRouteTypes( lines.push(...body); }); lines.push(' };'); + legacyRouteAlias(lines, 'PageActionMap', pattern); } lines.push(' }'); diff --git a/packages/void/src/routes-stub.ts b/packages/void/src/routes-stub.ts index 7668ec1e3..933b90897 100644 --- a/packages/void/src/routes-stub.ts +++ b/packages/void/src/routes-stub.ts @@ -16,11 +16,11 @@ export interface RoutePathMap {} /** Page URL patterns and their parameter names, augmented by .void/routes.d.ts. */ export interface PagePathMap {} -/** Union of the app's page URL patterns, such as `/users/:id`. */ +/** Union of the app's page URL patterns, such as `/users/[id]`. */ export type PagePath = keyof PagePathMap & string; /** - * Params a page receives: `{ id: string }` for `/users/:id`. An unnamed `[...]` + * Params a page receives: `{ id: string }` for `/users/[id]`. An unnamed `[...]` * catch-all has no param. A union of patterns gives a union of params. */ export type PageParams = P extends PagePath diff --git a/packages/void/src/runtime/fetch.ts b/packages/void/src/runtime/fetch.ts index a80a1d7c3..f69872256 100644 --- a/packages/void/src/runtime/fetch.ts +++ b/packages/void/src/runtime/fetch.ts @@ -73,7 +73,7 @@ const browserFetch = createRouteFetch((...args) => globalThis.fetch(...args)); * import { fetch } from "void/client"; * * const users = await fetch("/api/users"); // typed! - * const user = await fetch("/api/users/:id", { params: { id: "1" } }); + * const user = await fetch("/api/users/[id]", { params: { id: "1" } }); * ``` */ export function fetch( diff --git a/packages/void/src/runtime/route-url.ts b/packages/void/src/runtime/route-url.ts index fe6c9572f..a9e74901c 100644 --- a/packages/void/src/runtime/route-url.ts +++ b/packages/void/src/runtime/route-url.ts @@ -28,18 +28,18 @@ export function interpolateRouteParams( const suffix = suffixIndex === -1 ? '' : path.slice(suffixIndex); let leadingName: string | undefined; const replaced = pathname.replace( - /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\*)(?=\/|$)/g, + /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\[(\.\.\.)?([^/[\]]*)\]|\*)(?=\/|$)/g, ( token: string, prefix: string, name: string | undefined, catchAll: string | undefined, + bracketCatchAll: string | undefined, + bracketName: string | undefined, offset: number, ) => { + name ??= bracketName || undefined; const anonymous = name === undefined; - if (anonymous && !requireParams) { - return token; - } name ??= '*'; if (!params || !Object.hasOwn(params, name)) { if (requireParams) { @@ -56,7 +56,7 @@ export function interpolateRouteParams( } return ( prefix + - (catchAll || anonymous + (catchAll || bracketCatchAll || anonymous ? value .split('/') .map((segment) => encodeSegment(name, segment)) @@ -77,15 +77,15 @@ export function interpolateRouteParams( /** Known page, HTTP and WebSocket route patterns. */ export type RoutePath = keyof RoutePathMap & string; -// With `routing.typedRoutes`, each `:name`, `:name{.+}` or `*` segment of a -// pattern matches any text, so `/users/:id` accepts `/users/${string}`. Template +// Dynamic segments in either public or legacy patterns match any text, +// so `/users/[id]` accepts `/users/${string}`. Template // literal types cannot exclude empty or `/`-leading text without per-character unions. -type RouteUrlSegment = S extends `:${string}` | '*' ? string : S; +type RouteUrlSegment = S extends `:${string}` | `[${string}]` | '*' ? string : S; type RouteSegmentsUrl = P extends `${infer Segment}/${infer Rest}` ? `${RouteUrlSegment}/${RouteSegmentsUrl}` : RouteUrlSegment; // A trailing `/*` also matches the bare prefix, as in Hono: `/docs/*` matches `/docs`. -type RoutePatternUrl = P extends `${infer Prefix}/*` +type RoutePatternUrl = P extends `${infer Prefix}/${'*' | '[...]'}` ? (Prefix extends '' ? '/' : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}` : RouteSegmentsUrl; // Fixed segments are also accepted percent-encoded, as `routeUrl()` returns them. diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index f55a40595..84fb6854c 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -238,7 +238,7 @@ import { useParams as vue } from ${adapterSource('vue/src/runtime/use-params.ts' import { useParams as svelte } from ${adapterSource('svelte/src/runtime/use-params.ts')}; import { useParams as solid } from ${adapterSource('solid/src/runtime/use-params.ts')}; type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const pages: Array = ['/', '/about', '/users/:id', '/files/:path{.+}', '/wild/*']; +const pages: Array = ['/', '/about', '/users/[id]', '/files/[...path]', '/wild/[...]', '/users/:id', '/files/:path{.+}', '/wild/*']; // @ts-expect-error HTTP routes are not pages const api: PagePath = '/api/users/:id'; void [pages, api]; @@ -247,6 +247,15 @@ ${['react', 'vue', 'svelte', 'solid'] .map( (useParams) => `{ const user = ${useParams}<'/users/:id'>(); + const bracketUser = ${useParams}<'/users/[id]'>(); + const bracketFiles = ${useParams}<'/files/[...path]'>(); + const bracketWild = ${useParams}<'/wild/[...]'>(); + const aliases: [ + Same, + Same, + Same, + ] = [true, true, true]; + void aliases; const files = ${useParams}<'/files/:path{.+}'>(); const wild = ${useParams}<'/wild/*'>(); const about = ${useParams}<'/about'>(); @@ -325,6 +334,17 @@ routeUrl('/café menu'); routeUrl('/api/users/:id', { params: { id: '123' } }); routeUrl('/files/:path{.+}', { params: { path: 'docs/start' } }); routeUrl('/wild/*', { params: { '*': 'a/b' } }); +routeUrl('/users/[id]', { params: { id: 123 } }); +routeUrl('/files/[...path]', { params: { path: 'docs/start' } }); +routeUrl('/wild/[...]', { params: { '*': 'a/b' } }); +// @ts-expect-error bracket patterns require params +routeUrl('/users/[id]'); +// @ts-expect-error bracket catch-all patterns require params +routeUrl('/files/[...path]'); +// @ts-expect-error bracket anonymous catch-alls require a wildcard param +routeUrl('/wild/[...]'); +// @ts-expect-error bracket params are checked +routeUrl('/users/[id]', { params: { wrong: 123 } }); // @ts-expect-error a known route's params are required routeUrl('/users/:id'); // @ts-expect-error params cannot be omitted while providing other options @@ -341,6 +361,8 @@ routeUrl('/files/:path{.+}'); routeUrl('/wild/*'); void url; const destination: RouteDestination = { pathname: '/users/:id', params: { id: 123 }, query: { tab: 'activity' } }; +const bracketDestination: RouteDestination = { pathname: '/users/[id]', params: { id: 123 } }; +void bracketDestination; declare const router: VoidRouter; void router.visit(destination); void router.prefetch(destination); @@ -440,6 +462,77 @@ function typecheck( } describe('standalone routeUrl utility', () => { + it.each(['generated', 'package'] as const)( + 'preserves HTTP, WebSocket and page action contracts for both syntaxes through %s declarations', + async (declarations) => { + const root = await createFixture({ + extraFiles: { + 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const message: Schema<{ text: string }>; +export default defineRoom({ messages: { client: message, server: message } }); +`, + 'pages/users/[id].server.ts': `import { defineHandler } from 'void/handler'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const body: Schema<{ name: string }>; +export const actions = { save: defineHandler.withValidator({ body })(() => ({ ok: true })) }; +`, + 'check-aliases.ts': `import { fetch } from 'void/client'; +import { connect } from 'void/ws'; +import { createActionClient } from 'void/pages-client'; +import type { RouteMap, RoutePathMap, PagePathMap, PageActionMap, WebSocketRouteMap } from 'void/routes'; +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const aliases: [ + Same, + Same, + Same, + Same, + Same, +] = [true, true, true, true, true]; +void aliases; +const bracket = fetch('/api/users/[id]', { params: { id: '42' } }); +const legacy = fetch('/api/users/:id', { params: { id: '42' } }); +const responses: Same = true; +void responses; +// @ts-expect-error methods stay constrained through bracket keys +fetch('/api/users/[id]', { method: 'POST' }); +// @ts-expect-error legacy methods stay constrained +fetch('/api/users/:id', { method: 'POST' }); +connect('/chat/[room]', { params: { room: 'lobby' } }).send({ text: 'hi' }); +connect('/chat/:room', { params: { room: 'lobby' } }).send({ text: 'hi' }); +// @ts-expect-error bracket WebSocket params are required +connect('/chat/[room]', {}); +// @ts-expect-error legacy WebSocket params are required +connect('/chat/:room', {}); +connect('/chat/[room]', { params: { room: 'lobby' } }).on('message', (message) => { + const text: string = message.text; + // @ts-expect-error server message types stay constrained + void message.missing; + void text; +}); +const { action } = createActionClient(); +action('/users/[id]?save', { params: { id: '42' }, data: { name: 'Alice' } }); +action('/users/:id?save', { params: { id: '42' }, data: { name: 'Alice' } }); +// @ts-expect-error bracket actions require params +action('/users/[id]?save'); +// @ts-expect-error legacy actions require params +action('/users/:id?save'); +// @ts-expect-error bracket action bodies stay constrained +action('/users/[id]?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error legacy action bodies stay constrained +action('/users/:id?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error bracket action names stay constrained +action('/users/[id]?missing', { params: { id: '42' } }); +`, + }, + }); + expect(typecheck(root, declarations, ['check-aliases.ts'])).toEqual({ + output: '', + status: 0, + }); + }, + ); + it.each(['generated', 'package'] as const)( 'typechecks through the %s declarations', async (declarations) => { @@ -525,7 +618,7 @@ void loose; join(root, 'routes.d.ts'), `export {}; declare module 'void/routes' { - interface RoutePathMap { '/': never; '/docs/*': '*'; '/users/:id': 'id'; '/café menu': never } + interface RoutePathMap { '/': never; '/docs/[...]': '*'; '/users/[id]': 'id'; '/café menu': never } interface EncodedRoutePathMap { '/café menu': '/caf%C3%A9%20menu' } interface RouteTypesConfig { typedRoutes: true } } @@ -611,7 +704,12 @@ void [hrefs, route, unknown, external]; `import { routeUrl } from 'void/routes'; export function buildUrls() { return [ + routeUrl('/users/[id]', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), routeUrl('/users/:id', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), + routeUrl('/files/[...path]', { params: { path: 'docs/start here' } }), + routeUrl('/files/:path{.+}', { params: { path: 'docs/start here' } }), + routeUrl('/wild/[...]', { params: { '*': 'a/b' } }), + routeUrl('/wild/*', { params: { '*': 'a/b' } }), routeUrl('/café menu', { query: { q: 'café menu' }, hash: 'café menu' }), ]; } @@ -628,6 +726,11 @@ export function buildUrls() { }); const expected = [ '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/files/docs/start%20here', + '/files/docs/start%20here', + '/wild/a/b', + '/wild/a/b', '/caf%C3%A9%20menu?q=caf%C3%A9+menu#caf%C3%A9%20menu', ]; const scriptPath = join(root, 'dist/route.mjs'); diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index 5db822319..894801294 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -14,6 +14,7 @@ import { declare module 'void/routes' { interface PageActionMap { + '/__action-test/users/[id]': PageActionMap['/__action-test/users/:id']; '/__action-test/static': { body: { name: string } }; '/__action-test/users/:id': { params: { id: string }; @@ -248,23 +249,26 @@ describe('action clients', () => { }); }); - it('encodes path parameters while preserving named actions and submission options', async () => { - const client = createActionClient(); - const visit = vi.fn(async () => ({ props: {} })); - client.setActionRouter(routerWithVisit(visit)); - - await client.action('/__action-test/users/:id?save', { - params: { id: 'a/b?c' }, - method: 'PATCH', - data: { name: 'Alice' }, - }); - - expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { - method: 'PATCH', - data: { name: 'Alice' }, - preserveScroll: undefined, - }); - }); + it.each(['/__action-test/users/[id]?save', '/__action-test/users/:id?save'] as const)( + 'encodes params and preserves named actions in %s', + async (url) => { + const client = createActionClient(); + const visit = vi.fn(async () => ({ props: {} })); + client.setActionRouter(routerWithVisit(visit)); + + await client.action(url, { + params: { id: 'a/b?c' }, + method: 'PATCH', + data: { name: 'Alice' }, + }); + + expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { + method: 'PATCH', + data: { name: 'Alice' }, + preserveScroll: undefined, + }); + }, + ); it('preserves validation errors as action results', async () => { const client = createActionClient(); diff --git a/packages/void/test/unit/fetch-stream.test.ts b/packages/void/test/unit/fetch-stream.test.ts index 79009c929..bb894efcd 100644 --- a/packages/void/test/unit/fetch-stream.test.ts +++ b/packages/void/test/unit/fetch-stream.test.ts @@ -17,6 +17,7 @@ vi.mock('ofetch', () => { declare module 'void/routes' { interface RouteMap { + '/api/stream/[...path]': RouteMap['/api/stream/:path{.+}']; '/api/chat': { POST: { input: { body: { messages: Array } }; output: unknown }; }; @@ -50,11 +51,14 @@ beforeEach(() => { }); describe('fetchStream', () => { - it('expands complete catch-all tokens', async () => { - vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); - await fetchStream('/api/stream/:path{.+}', { params: { path: 'docs/start here' } }).next(); - expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); - }); + it.each(['/api/stream/[...path]', '/api/stream/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); + await fetchStream(path, { params: { path: 'docs/start here' } }).next(); + expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); + }, + ); it('parses Workers AI JSON SSE format', async () => { const stream = makeSSEStream([ 'data: {"response":"Hello"}\n\n', diff --git a/packages/void/test/unit/fetch.test.ts b/packages/void/test/unit/fetch.test.ts index 63063053d..6e240e49f 100644 --- a/packages/void/test/unit/fetch.test.ts +++ b/packages/void/test/unit/fetch.test.ts @@ -3,6 +3,8 @@ import { fetch as typedFetch, FetchError } from '../../src/runtime/fetch.ts'; declare module 'void/routes' { interface RouteMap { + '/api/users/[id]': RouteMap['/api/users/:id']; + '/api/files/[...path]': RouteMap['/api/files/:path{.+}']; '/api/hello': { GET: { input: {}; output: { ok: boolean } }; }; @@ -53,13 +55,23 @@ describe('typed fetch client', () => { expect(result).toEqual({ ok: true }); }); - it('expands complete catch-all tokens', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); - await typedFetch('/api/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/files/docs/start%20here', - expect.anything(), - ); + it.each(['/api/files/[...path]', '/api/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); + await typedFetch(path, { params: { path: 'docs/start here' } }); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/files/docs/start%20here', + expect.anything(), + ); + }, + ); + + it('interpolates bracket parameters for typed requests', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({ id: 'a/b', name: 'Alice' })); + const result = await typedFetch('/api/users/[id]', { params: { id: 'a/b' } }); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/users/a%2Fb', expect.anything()); + expect(result).toEqual({ id: 'a/b', name: 'Alice' }); }); it('builds query string', async () => { diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index fe888ed61..42d4002a4 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -198,9 +198,12 @@ describe('generateRouteTypes', () => { const pageMap = dts.split(' export interface PagePathMap {')[1]!.split(' }')[0]!; expect(pageMap).toContain('"/about": never;'); - expect(pageMap).toContain('"/users/:id": "id";'); - expect(pageMap).toContain('"/files/:path{.+}": "path";'); - expect(pageMap).toContain('"/wild/*": "*";'); + expect(pageMap).toContain('"/users/[id]": "id";'); + expect(pageMap).toContain('"/users/:id": PagePathMap["/users/[id]"];'); + expect(pageMap).toContain('"/files/[...path]": "path";'); + expect(pageMap).toContain('"/files/:path{.+}": PagePathMap["/files/[...path]"];'); + expect(pageMap).toContain('"/wild/[...]": "*";'); + expect(pageMap).toContain('"/wild/*": PagePathMap["/wild/[...]"];'); expect(pageMap).not.toContain('/api/users/:id'); }); @@ -220,7 +223,9 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); - expect(dts).toContain('"/api/users/:id"'); + expect(dts).toContain('"/api/users/[id]": {'); + expect(dts).toContain('/** @deprecated Use "/api/users/[id]" instead. */'); + expect(dts).toContain('"/api/users/:id": RouteMap["/api/users/[id]"];'); expect(dts).toContain('typeof import("../routes/api/users/[id].ts")'); }); @@ -290,6 +295,7 @@ describe('generateRouteTypes', () => { expect(encoded.trim().split('\n')).toEqual([ '"/café menu": "/caf%C3%A9%20menu";', + ' "/café/[id]": "/caf%C3%A9/[id]";', ' "/café/:id": "/caf%C3%A9/:id";', ]); expect(dts).toContain('type RoutePathUrl = RoutePatternUrl;'); @@ -405,14 +411,14 @@ describe('generateRouteTypes', () => { expect(actionMap.split('"/users/:id"')).toHaveLength(2); expect(actionMap).toContain( [ - ' "/users/:id": {', + ' "/users/[id]": {', ` body: ExtractInput<${dev}["__validators"]> extends { body: infer B } ? B : Record;`, ' params: { id: string };', ' } | {', ` actions: { [K in keyof ${prod}]: {`, ].join('\n'), ); - expect(actionMap).toContain([' params: { id: string };', ' };', ' }'].join('\n')); + expect(actionMap).toContain(' "/users/:id": PageActionMap["/users/[id]"];'); }); it('generates WebSocketRouteMap entries', () => { @@ -472,7 +478,8 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const websocketMap = dts.split(' interface WebSocketRouteMap {')[1]!.split('\n }\n}')[0]!; - expect(websocketMap.match(/"\/chat\/:room": \{/g)).toHaveLength(1); + expect(websocketMap.match(/"\/chat\/\[room\]": \{/g)).toHaveLength(1); + expect(websocketMap).toContain('"/chat/:room": WebSocketRouteMap["/chat/[room]"];'); expect(websocketMap).toContain('chat/[room].dev.ws.ts'); expect(websocketMap).toContain('chat/[room].prod.ws.ts'); expect(websocketMap).toMatch(/client: .*dev\.ws\.ts.* & .*prod\.ws\.ts/); @@ -636,11 +643,15 @@ describe('generateRouteTypes', () => { }); const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; - expect(pathMap).toContain('"/api/users/:id": "id";'); + expect(pathMap).toContain('"/api/users/[id]": "id";'); + expect(pathMap).toContain('"/api/users/:id": RoutePathMap["/api/users/[id]"];'); expect(pathMap).toContain('"/about": never;'); - expect(pathMap).toContain('"/files/:path{.+}": "path";'); - expect(pathMap).toContain('"/files/*": "*";'); - expect(pathMap).toContain('"/chat/:room": "room";'); + expect(pathMap).toContain('"/files/[...path]": "path";'); + expect(pathMap).toContain('"/files/:path{.+}": RoutePathMap["/files/[...path]"];'); + expect(pathMap).toContain('"/files/[...]": "*";'); + expect(pathMap).toContain('"/files/*": RoutePathMap["/files/[...]"];'); + expect(pathMap).toContain('"/chat/[room]": "room";'); + expect(pathMap).toContain('"/chat/:room": RoutePathMap["/chat/[room]"];'); expect(dts).not.toContain('import("../pages/'); }); @@ -672,6 +683,7 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; expect(pathMap.match(/"\/users\/:id":/g)).toHaveLength(1); - expect(pathMap).toContain('"/users/:id": "id";'); + expect(pathMap).toContain('"/users/[id]": "id";'); + expect(pathMap).toContain('"/users/:id": RoutePathMap["/users/[id]"];'); }); }); diff --git a/packages/void/test/unit/route-url-builder.test.ts b/packages/void/test/unit/route-url-builder.test.ts index db21c0199..31e1d7dc7 100644 --- a/packages/void/test/unit/route-url-builder.test.ts +++ b/packages/void/test/unit/route-url-builder.test.ts @@ -13,10 +13,25 @@ declare module 'void/routes' { '/route-util/files/*': '*'; '/route-util/:toString': 'toString'; '/:leading{.+}': 'leading'; + '/route-util/[id]': 'id'; + '/route-util/files/[...path]': 'path'; + '/route-util/files/[...]': '*'; } } describe('routeUrl', () => { + it('builds URLs and destinations from bracket patterns', () => { + expect(routeUrl('/route-util/[id]', { params: { id: 42 } })).toBe('/route-util/42'); + expect(routeUrl('/route-util/files/[...path]', { params: { path: 'docs/start here' } })).toBe( + '/route-util/files/docs/start%20here', + ); + expect(routeUrl('/route-util/files/[...]', { params: { '*': 'a/b' } })).toBe( + '/route-util/files/a/b', + ); + expect( + toUrlString({ pathname: '/route-util/[id]', params: { id: 'a/b' }, hash: 'details' }), + ).toBe('/route-util/a%2Fb#details'); + }); it('converts typed destinations and preserves ordinary URLs', () => { expect( toUrlString({ diff --git a/packages/void/test/unit/route-url.test.ts b/packages/void/test/unit/route-url.test.ts index 7a828ecbd..bba54a7a5 100644 --- a/packages/void/test/unit/route-url.test.ts +++ b/packages/void/test/unit/route-url.test.ts @@ -2,6 +2,38 @@ import { describe, expect, it } from 'vite-plus/test'; import { interpolateRouteParams } from '../../src/runtime/route-url.ts'; describe('route URL interpolation', () => { + it.each([ + ['/users/[id]', '/users/:id', { id: 'a/b ?#%' }, '/users/a%2Fb%20%3F%23%25'], + [ + '/docs/[...slug]', + '/docs/:slug{.+}', + { slug: 'guides/get started' }, + '/docs/guides/get%20started', + ], + ['/docs/[...]', '/docs/*', { '*': 'a/b ?#%' }, '/docs/a/b%20%3F%23%25'], + ['/[id2]/[id]/[id]', '/:id2/:id/:id', { id: 'one', id2: 'two' }, '/two/one/one'], + ['/docs/[...]', '/docs/*', { '*': '' }, '/docs/'], + ] as const)( + 'resolves public %s and legacy %s patterns identically', + (path, legacy, params, url) => { + expect(interpolateRouteParams(path, params)).toBe(url); + expect(interpolateRouteParams(legacy, params)).toBe(url); + }, + ); + + it('preserves literal brackets, missing params, queries and named action suffixes', () => { + expect(interpolateRouteParams('/x[id]/[id]/[missing]?save#/[id]', { id: 'one' })).toBe( + '/x[id]/one/[missing]?save#/[id]', + ); + expect(interpolateRouteParams('/[id]', undefined)).toBe('/[id]'); + expect(interpolateRouteParams('/[toString]', {})).toBe('/[toString]'); + expect(interpolateRouteParams('/docs/[...]', {})).toBe('/docs/[...]'); + expect(() => interpolateRouteParams('/[id]', {}, true)).toThrow('Missing route param "id".'); + expect(() => interpolateRouteParams('/docs/[...slug]', { slug: '' }, true)).toThrow( + 'must not be empty', + ); + }); + it('expands catch-all segments and encodes each path component', () => { expect(interpolateRouteParams('/files/:path{.+}', { path: 'docs/start ?#%.md' })).toBe( '/files/docs/start%20%3F%23%25.md', @@ -27,12 +59,18 @@ describe('route URL interpolation', () => { ['/api/posts/:id', { id: '.' }], ['/files/:path{.+}', { path: 'a/../../admin' }], ['/files/:path{.+}', { path: './x' }], + ['/api/posts/[id]', { id: '..' }], + ['/files/[...path]', { path: 'a/../../admin' }], + ['/files/[...]', { '*': './x' }], ])('rejects dot segments that would change the path of %s (%j)', (path, params) => { expect(() => interpolateRouteParams(path, params)).toThrow(TypeError); expect(() => interpolateRouteParams(path, params)).toThrow(/it would change the request path/); }); it('rejects a leading param that would make the path start with //', () => { + expect(() => interpolateRouteParams('/[...path]', { path: '/evil.com/x' })).toThrow( + 'changes the request host', + ); expect(() => interpolateRouteParams('/:path{.+}', { path: '/evil.com/x' })).toThrow( new TypeError( 'Route param "path" would make the request path start with "//", which changes the request host.', diff --git a/packages/void/test/unit/ws.test.ts b/packages/void/test/unit/ws.test.ts index 440cdcfd3..929063e63 100644 --- a/packages/void/test/unit/ws.test.ts +++ b/packages/void/test/unit/ws.test.ts @@ -3,6 +3,8 @@ import { connect } from '../../src/runtime/ws.ts'; declare module 'void/routes' { interface WebSocketRouteMap { + '/chat/[room]': WebSocketRouteMap['/chat/:room']; + '/files/[...path]': WebSocketRouteMap['/files/:path{.+}']; '/chat/:room': { params: { room: string }; client: { type: 'chat.message'; text: string }; @@ -80,9 +82,17 @@ describe('typed websocket client', () => { (globalThis as { location?: unknown }).location = originalLocation; }); - it('expands complete catch-all tokens', () => { - connect('/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + it.each(['/files/[...path]', '/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + (path) => { + connect(path, { params: { path: 'docs/start here' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + }, + ); + + it('interpolates bracket parameters in WebSocket URLs', () => { + connect('/chat/[room]', { params: { room: 'a/b' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/chat/a%2Fb'); }); it('keeps a secure wss baseURL secure', () => {
;'); - lines.push(' type RoutePatternUrl
= P extends `${infer Prefix}/*`'); + lines.push( + ' type RoutePatternUrl
= P extends `${infer Prefix}/${"*" | "[...]"}`', + ); lines.push( ' ? (Prefix extends "" ? "/" : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}`', ); @@ -165,7 +192,8 @@ export function generateRouteTypes( } for (const [pattern, params] of paths) { const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never'; - lines.push(` ${JSON.stringify(pattern)}: ${names};`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: ${names};`); + legacyRouteAlias(lines, 'RoutePathMap', pattern); } lines.push(' }'); lines.push(''); @@ -184,7 +212,8 @@ export function generateRouteTypes( } for (const [pattern, params] of pagePaths) { const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never'; - lines.push(` ${JSON.stringify(pattern)}: ${names};`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: ${names};`); + legacyRouteAlias(lines, 'PagePathMap', pattern); } lines.push(' }'); lines.push(' export type PagePath = keyof PagePathMap & string;'); @@ -195,9 +224,16 @@ export function generateRouteTypes( // `routeUrl()` returns `/caf%C3%A9%20menu` for `/café menu`, so accept that spelling too. lines.push(' export interface EncodedRoutePathMap {'); for (const pattern of paths.keys()) { - const encoded = encodeRoutePattern(pattern); - if (encoded !== pattern) { - lines.push(` ${JSON.stringify(pattern)}: ${JSON.stringify(encoded)};`); + const publicPattern = publicRoutePattern(pattern); + const encoded = encodeRoutePattern(publicPattern); + if (encoded !== publicPattern) { + lines.push(` ${JSON.stringify(publicPattern)}: ${JSON.stringify(encoded)};`); + // Preserve the original encoded map's keys and values too. + if (publicPattern !== pattern) { + lines.push( + ` ${JSON.stringify(pattern)}: ${JSON.stringify(encodeRoutePattern(pattern))};`, + ); + } } } lines.push(' }'); @@ -300,7 +336,7 @@ export function generateRouteTypes( } for (const [pattern, methods] of routesByPattern) { - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); for (const [method, sources] of methods) { const imports = sources.map((route) => { @@ -318,6 +354,7 @@ export function generateRouteTypes( } lines.push(' };'); + legacyRouteAlias(lines, 'RouteMap', pattern); } lines.push(' }'); @@ -336,7 +373,7 @@ export function generateRouteTypes( const importPath = relative(outDir, `${routesDir}/${route.filePath}`); return `(typeof import(${JSON.stringify(importPath)}))["default"]["messages"]`; }); - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); lines.push(` params: RouteParams<${JSON.stringify(sources[0]!.params)}>;`); lines.push( ` client: ${imports.map((source) => `ExtractInput<${source}["client"]>`).join(' & ')};`, @@ -345,6 +382,7 @@ export function generateRouteTypes( ` server: ${imports.map((source) => `ExtractOutput<${source}["server"]>`).join(' | ')};`, ); lines.push(' };'); + legacyRouteAlias(lines, 'WebSocketRouteMap', pattern); } lines.push(' }'); @@ -402,7 +440,7 @@ export function generateRouteTypes( } for (const [pattern, bodies] of bodiesByPattern) { - lines.push(` ${JSON.stringify(pattern)}: {`); + lines.push(` ${JSON.stringify(publicRoutePattern(pattern))}: {`); bodies.forEach((body, index) => { if (index > 0) { lines.push(' } | {'); @@ -410,6 +448,7 @@ export function generateRouteTypes( lines.push(...body); }); lines.push(' };'); + legacyRouteAlias(lines, 'PageActionMap', pattern); } lines.push(' }'); diff --git a/packages/void/src/routes-stub.ts b/packages/void/src/routes-stub.ts index 7668ec1e3..933b90897 100644 --- a/packages/void/src/routes-stub.ts +++ b/packages/void/src/routes-stub.ts @@ -16,11 +16,11 @@ export interface RoutePathMap {} /** Page URL patterns and their parameter names, augmented by .void/routes.d.ts. */ export interface PagePathMap {} -/** Union of the app's page URL patterns, such as `/users/:id`. */ +/** Union of the app's page URL patterns, such as `/users/[id]`. */ export type PagePath = keyof PagePathMap & string; /** - * Params a page receives: `{ id: string }` for `/users/:id`. An unnamed `[...]` + * Params a page receives: `{ id: string }` for `/users/[id]`. An unnamed `[...]` * catch-all has no param. A union of patterns gives a union of params. */ export type PageParams = P extends PagePath diff --git a/packages/void/src/runtime/fetch.ts b/packages/void/src/runtime/fetch.ts index a80a1d7c3..f69872256 100644 --- a/packages/void/src/runtime/fetch.ts +++ b/packages/void/src/runtime/fetch.ts @@ -73,7 +73,7 @@ const browserFetch = createRouteFetch((...args) => globalThis.fetch(...args)); * import { fetch } from "void/client"; * * const users = await fetch("/api/users"); // typed! - * const user = await fetch("/api/users/:id", { params: { id: "1" } }); + * const user = await fetch("/api/users/[id]", { params: { id: "1" } }); * ``` */ export function fetch( diff --git a/packages/void/src/runtime/route-url.ts b/packages/void/src/runtime/route-url.ts index fe6c9572f..a9e74901c 100644 --- a/packages/void/src/runtime/route-url.ts +++ b/packages/void/src/runtime/route-url.ts @@ -28,18 +28,18 @@ export function interpolateRouteParams( const suffix = suffixIndex === -1 ? '' : path.slice(suffixIndex); let leadingName: string | undefined; const replaced = pathname.replace( - /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\*)(?=\/|$)/g, + /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\[(\.\.\.)?([^/[\]]*)\]|\*)(?=\/|$)/g, ( token: string, prefix: string, name: string | undefined, catchAll: string | undefined, + bracketCatchAll: string | undefined, + bracketName: string | undefined, offset: number, ) => { + name ??= bracketName || undefined; const anonymous = name === undefined; - if (anonymous && !requireParams) { - return token; - } name ??= '*'; if (!params || !Object.hasOwn(params, name)) { if (requireParams) { @@ -56,7 +56,7 @@ export function interpolateRouteParams( } return ( prefix + - (catchAll || anonymous + (catchAll || bracketCatchAll || anonymous ? value .split('/') .map((segment) => encodeSegment(name, segment)) @@ -77,15 +77,15 @@ export function interpolateRouteParams( /** Known page, HTTP and WebSocket route patterns. */ export type RoutePath = keyof RoutePathMap & string; -// With `routing.typedRoutes`, each `:name`, `:name{.+}` or `*` segment of a -// pattern matches any text, so `/users/:id` accepts `/users/${string}`. Template +// Dynamic segments in either public or legacy patterns match any text, +// so `/users/[id]` accepts `/users/${string}`. Template // literal types cannot exclude empty or `/`-leading text without per-character unions. -type RouteUrlSegment = S extends `:${string}` | '*' ? string : S; +type RouteUrlSegment = S extends `:${string}` | `[${string}]` | '*' ? string : S; type RouteSegmentsUrl = P extends `${infer Segment}/${infer Rest}` ? `${RouteUrlSegment}/${RouteSegmentsUrl}` : RouteUrlSegment; // A trailing `/*` also matches the bare prefix, as in Hono: `/docs/*` matches `/docs`. -type RoutePatternUrl = P extends `${infer Prefix}/*` +type RoutePatternUrl = P extends `${infer Prefix}/${'*' | '[...]'}` ? (Prefix extends '' ? '/' : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}` : RouteSegmentsUrl; // Fixed segments are also accepted percent-encoded, as `routeUrl()` returns them. diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index f55a40595..84fb6854c 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -238,7 +238,7 @@ import { useParams as vue } from ${adapterSource('vue/src/runtime/use-params.ts' import { useParams as svelte } from ${adapterSource('svelte/src/runtime/use-params.ts')}; import { useParams as solid } from ${adapterSource('solid/src/runtime/use-params.ts')}; type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const pages: Array = ['/', '/about', '/users/:id', '/files/:path{.+}', '/wild/*']; +const pages: Array = ['/', '/about', '/users/[id]', '/files/[...path]', '/wild/[...]', '/users/:id', '/files/:path{.+}', '/wild/*']; // @ts-expect-error HTTP routes are not pages const api: PagePath = '/api/users/:id'; void [pages, api]; @@ -247,6 +247,15 @@ ${['react', 'vue', 'svelte', 'solid'] .map( (useParams) => `{ const user = ${useParams}<'/users/:id'>(); + const bracketUser = ${useParams}<'/users/[id]'>(); + const bracketFiles = ${useParams}<'/files/[...path]'>(); + const bracketWild = ${useParams}<'/wild/[...]'>(); + const aliases: [ + Same, + Same, + Same, + ] = [true, true, true]; + void aliases; const files = ${useParams}<'/files/:path{.+}'>(); const wild = ${useParams}<'/wild/*'>(); const about = ${useParams}<'/about'>(); @@ -325,6 +334,17 @@ routeUrl('/café menu'); routeUrl('/api/users/:id', { params: { id: '123' } }); routeUrl('/files/:path{.+}', { params: { path: 'docs/start' } }); routeUrl('/wild/*', { params: { '*': 'a/b' } }); +routeUrl('/users/[id]', { params: { id: 123 } }); +routeUrl('/files/[...path]', { params: { path: 'docs/start' } }); +routeUrl('/wild/[...]', { params: { '*': 'a/b' } }); +// @ts-expect-error bracket patterns require params +routeUrl('/users/[id]'); +// @ts-expect-error bracket catch-all patterns require params +routeUrl('/files/[...path]'); +// @ts-expect-error bracket anonymous catch-alls require a wildcard param +routeUrl('/wild/[...]'); +// @ts-expect-error bracket params are checked +routeUrl('/users/[id]', { params: { wrong: 123 } }); // @ts-expect-error a known route's params are required routeUrl('/users/:id'); // @ts-expect-error params cannot be omitted while providing other options @@ -341,6 +361,8 @@ routeUrl('/files/:path{.+}'); routeUrl('/wild/*'); void url; const destination: RouteDestination = { pathname: '/users/:id', params: { id: 123 }, query: { tab: 'activity' } }; +const bracketDestination: RouteDestination = { pathname: '/users/[id]', params: { id: 123 } }; +void bracketDestination; declare const router: VoidRouter; void router.visit(destination); void router.prefetch(destination); @@ -440,6 +462,77 @@ function typecheck( } describe('standalone routeUrl utility', () => { + it.each(['generated', 'package'] as const)( + 'preserves HTTP, WebSocket and page action contracts for both syntaxes through %s declarations', + async (declarations) => { + const root = await createFixture({ + extraFiles: { + 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const message: Schema<{ text: string }>; +export default defineRoom({ messages: { client: message, server: message } }); +`, + 'pages/users/[id].server.ts': `import { defineHandler } from 'void/handler'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const body: Schema<{ name: string }>; +export const actions = { save: defineHandler.withValidator({ body })(() => ({ ok: true })) }; +`, + 'check-aliases.ts': `import { fetch } from 'void/client'; +import { connect } from 'void/ws'; +import { createActionClient } from 'void/pages-client'; +import type { RouteMap, RoutePathMap, PagePathMap, PageActionMap, WebSocketRouteMap } from 'void/routes'; +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const aliases: [ + Same, + Same, + Same, + Same, + Same, +] = [true, true, true, true, true]; +void aliases; +const bracket = fetch('/api/users/[id]', { params: { id: '42' } }); +const legacy = fetch('/api/users/:id', { params: { id: '42' } }); +const responses: Same = true; +void responses; +// @ts-expect-error methods stay constrained through bracket keys +fetch('/api/users/[id]', { method: 'POST' }); +// @ts-expect-error legacy methods stay constrained +fetch('/api/users/:id', { method: 'POST' }); +connect('/chat/[room]', { params: { room: 'lobby' } }).send({ text: 'hi' }); +connect('/chat/:room', { params: { room: 'lobby' } }).send({ text: 'hi' }); +// @ts-expect-error bracket WebSocket params are required +connect('/chat/[room]', {}); +// @ts-expect-error legacy WebSocket params are required +connect('/chat/:room', {}); +connect('/chat/[room]', { params: { room: 'lobby' } }).on('message', (message) => { + const text: string = message.text; + // @ts-expect-error server message types stay constrained + void message.missing; + void text; +}); +const { action } = createActionClient(); +action('/users/[id]?save', { params: { id: '42' }, data: { name: 'Alice' } }); +action('/users/:id?save', { params: { id: '42' }, data: { name: 'Alice' } }); +// @ts-expect-error bracket actions require params +action('/users/[id]?save'); +// @ts-expect-error legacy actions require params +action('/users/:id?save'); +// @ts-expect-error bracket action bodies stay constrained +action('/users/[id]?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error legacy action bodies stay constrained +action('/users/:id?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error bracket action names stay constrained +action('/users/[id]?missing', { params: { id: '42' } }); +`, + }, + }); + expect(typecheck(root, declarations, ['check-aliases.ts'])).toEqual({ + output: '', + status: 0, + }); + }, + ); + it.each(['generated', 'package'] as const)( 'typechecks through the %s declarations', async (declarations) => { @@ -525,7 +618,7 @@ void loose; join(root, 'routes.d.ts'), `export {}; declare module 'void/routes' { - interface RoutePathMap { '/': never; '/docs/*': '*'; '/users/:id': 'id'; '/café menu': never } + interface RoutePathMap { '/': never; '/docs/[...]': '*'; '/users/[id]': 'id'; '/café menu': never } interface EncodedRoutePathMap { '/café menu': '/caf%C3%A9%20menu' } interface RouteTypesConfig { typedRoutes: true } } @@ -611,7 +704,12 @@ void [hrefs, route, unknown, external]; `import { routeUrl } from 'void/routes'; export function buildUrls() { return [ + routeUrl('/users/[id]', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), routeUrl('/users/:id', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), + routeUrl('/files/[...path]', { params: { path: 'docs/start here' } }), + routeUrl('/files/:path{.+}', { params: { path: 'docs/start here' } }), + routeUrl('/wild/[...]', { params: { '*': 'a/b' } }), + routeUrl('/wild/*', { params: { '*': 'a/b' } }), routeUrl('/café menu', { query: { q: 'café menu' }, hash: 'café menu' }), ]; } @@ -628,6 +726,11 @@ export function buildUrls() { }); const expected = [ '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/files/docs/start%20here', + '/files/docs/start%20here', + '/wild/a/b', + '/wild/a/b', '/caf%C3%A9%20menu?q=caf%C3%A9+menu#caf%C3%A9%20menu', ]; const scriptPath = join(root, 'dist/route.mjs'); diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index 5db822319..894801294 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -14,6 +14,7 @@ import { declare module 'void/routes' { interface PageActionMap { + '/__action-test/users/[id]': PageActionMap['/__action-test/users/:id']; '/__action-test/static': { body: { name: string } }; '/__action-test/users/:id': { params: { id: string }; @@ -248,23 +249,26 @@ describe('action clients', () => { }); }); - it('encodes path parameters while preserving named actions and submission options', async () => { - const client = createActionClient(); - const visit = vi.fn(async () => ({ props: {} })); - client.setActionRouter(routerWithVisit(visit)); - - await client.action('/__action-test/users/:id?save', { - params: { id: 'a/b?c' }, - method: 'PATCH', - data: { name: 'Alice' }, - }); - - expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { - method: 'PATCH', - data: { name: 'Alice' }, - preserveScroll: undefined, - }); - }); + it.each(['/__action-test/users/[id]?save', '/__action-test/users/:id?save'] as const)( + 'encodes params and preserves named actions in %s', + async (url) => { + const client = createActionClient(); + const visit = vi.fn(async () => ({ props: {} })); + client.setActionRouter(routerWithVisit(visit)); + + await client.action(url, { + params: { id: 'a/b?c' }, + method: 'PATCH', + data: { name: 'Alice' }, + }); + + expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { + method: 'PATCH', + data: { name: 'Alice' }, + preserveScroll: undefined, + }); + }, + ); it('preserves validation errors as action results', async () => { const client = createActionClient(); diff --git a/packages/void/test/unit/fetch-stream.test.ts b/packages/void/test/unit/fetch-stream.test.ts index 79009c929..bb894efcd 100644 --- a/packages/void/test/unit/fetch-stream.test.ts +++ b/packages/void/test/unit/fetch-stream.test.ts @@ -17,6 +17,7 @@ vi.mock('ofetch', () => { declare module 'void/routes' { interface RouteMap { + '/api/stream/[...path]': RouteMap['/api/stream/:path{.+}']; '/api/chat': { POST: { input: { body: { messages: Array } }; output: unknown }; }; @@ -50,11 +51,14 @@ beforeEach(() => { }); describe('fetchStream', () => { - it('expands complete catch-all tokens', async () => { - vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); - await fetchStream('/api/stream/:path{.+}', { params: { path: 'docs/start here' } }).next(); - expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); - }); + it.each(['/api/stream/[...path]', '/api/stream/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); + await fetchStream(path, { params: { path: 'docs/start here' } }).next(); + expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); + }, + ); it('parses Workers AI JSON SSE format', async () => { const stream = makeSSEStream([ 'data: {"response":"Hello"}\n\n', diff --git a/packages/void/test/unit/fetch.test.ts b/packages/void/test/unit/fetch.test.ts index 63063053d..6e240e49f 100644 --- a/packages/void/test/unit/fetch.test.ts +++ b/packages/void/test/unit/fetch.test.ts @@ -3,6 +3,8 @@ import { fetch as typedFetch, FetchError } from '../../src/runtime/fetch.ts'; declare module 'void/routes' { interface RouteMap { + '/api/users/[id]': RouteMap['/api/users/:id']; + '/api/files/[...path]': RouteMap['/api/files/:path{.+}']; '/api/hello': { GET: { input: {}; output: { ok: boolean } }; }; @@ -53,13 +55,23 @@ describe('typed fetch client', () => { expect(result).toEqual({ ok: true }); }); - it('expands complete catch-all tokens', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); - await typedFetch('/api/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/files/docs/start%20here', - expect.anything(), - ); + it.each(['/api/files/[...path]', '/api/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); + await typedFetch(path, { params: { path: 'docs/start here' } }); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/files/docs/start%20here', + expect.anything(), + ); + }, + ); + + it('interpolates bracket parameters for typed requests', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({ id: 'a/b', name: 'Alice' })); + const result = await typedFetch('/api/users/[id]', { params: { id: 'a/b' } }); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/users/a%2Fb', expect.anything()); + expect(result).toEqual({ id: 'a/b', name: 'Alice' }); }); it('builds query string', async () => { diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index fe888ed61..42d4002a4 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -198,9 +198,12 @@ describe('generateRouteTypes', () => { const pageMap = dts.split(' export interface PagePathMap {')[1]!.split(' }')[0]!; expect(pageMap).toContain('"/about": never;'); - expect(pageMap).toContain('"/users/:id": "id";'); - expect(pageMap).toContain('"/files/:path{.+}": "path";'); - expect(pageMap).toContain('"/wild/*": "*";'); + expect(pageMap).toContain('"/users/[id]": "id";'); + expect(pageMap).toContain('"/users/:id": PagePathMap["/users/[id]"];'); + expect(pageMap).toContain('"/files/[...path]": "path";'); + expect(pageMap).toContain('"/files/:path{.+}": PagePathMap["/files/[...path]"];'); + expect(pageMap).toContain('"/wild/[...]": "*";'); + expect(pageMap).toContain('"/wild/*": PagePathMap["/wild/[...]"];'); expect(pageMap).not.toContain('/api/users/:id'); }); @@ -220,7 +223,9 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); - expect(dts).toContain('"/api/users/:id"'); + expect(dts).toContain('"/api/users/[id]": {'); + expect(dts).toContain('/** @deprecated Use "/api/users/[id]" instead. */'); + expect(dts).toContain('"/api/users/:id": RouteMap["/api/users/[id]"];'); expect(dts).toContain('typeof import("../routes/api/users/[id].ts")'); }); @@ -290,6 +295,7 @@ describe('generateRouteTypes', () => { expect(encoded.trim().split('\n')).toEqual([ '"/café menu": "/caf%C3%A9%20menu";', + ' "/café/[id]": "/caf%C3%A9/[id]";', ' "/café/:id": "/caf%C3%A9/:id";', ]); expect(dts).toContain('type RoutePathUrl = RoutePatternUrl;'); @@ -405,14 +411,14 @@ describe('generateRouteTypes', () => { expect(actionMap.split('"/users/:id"')).toHaveLength(2); expect(actionMap).toContain( [ - ' "/users/:id": {', + ' "/users/[id]": {', ` body: ExtractInput<${dev}["__validators"]> extends { body: infer B } ? B : Record;`, ' params: { id: string };', ' } | {', ` actions: { [K in keyof ${prod}]: {`, ].join('\n'), ); - expect(actionMap).toContain([' params: { id: string };', ' };', ' }'].join('\n')); + expect(actionMap).toContain(' "/users/:id": PageActionMap["/users/[id]"];'); }); it('generates WebSocketRouteMap entries', () => { @@ -472,7 +478,8 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const websocketMap = dts.split(' interface WebSocketRouteMap {')[1]!.split('\n }\n}')[0]!; - expect(websocketMap.match(/"\/chat\/:room": \{/g)).toHaveLength(1); + expect(websocketMap.match(/"\/chat\/\[room\]": \{/g)).toHaveLength(1); + expect(websocketMap).toContain('"/chat/:room": WebSocketRouteMap["/chat/[room]"];'); expect(websocketMap).toContain('chat/[room].dev.ws.ts'); expect(websocketMap).toContain('chat/[room].prod.ws.ts'); expect(websocketMap).toMatch(/client: .*dev\.ws\.ts.* & .*prod\.ws\.ts/); @@ -636,11 +643,15 @@ describe('generateRouteTypes', () => { }); const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; - expect(pathMap).toContain('"/api/users/:id": "id";'); + expect(pathMap).toContain('"/api/users/[id]": "id";'); + expect(pathMap).toContain('"/api/users/:id": RoutePathMap["/api/users/[id]"];'); expect(pathMap).toContain('"/about": never;'); - expect(pathMap).toContain('"/files/:path{.+}": "path";'); - expect(pathMap).toContain('"/files/*": "*";'); - expect(pathMap).toContain('"/chat/:room": "room";'); + expect(pathMap).toContain('"/files/[...path]": "path";'); + expect(pathMap).toContain('"/files/:path{.+}": RoutePathMap["/files/[...path]"];'); + expect(pathMap).toContain('"/files/[...]": "*";'); + expect(pathMap).toContain('"/files/*": RoutePathMap["/files/[...]"];'); + expect(pathMap).toContain('"/chat/[room]": "room";'); + expect(pathMap).toContain('"/chat/:room": RoutePathMap["/chat/[room]"];'); expect(dts).not.toContain('import("../pages/'); }); @@ -672,6 +683,7 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; expect(pathMap.match(/"\/users\/:id":/g)).toHaveLength(1); - expect(pathMap).toContain('"/users/:id": "id";'); + expect(pathMap).toContain('"/users/[id]": "id";'); + expect(pathMap).toContain('"/users/:id": RoutePathMap["/users/[id]"];'); }); }); diff --git a/packages/void/test/unit/route-url-builder.test.ts b/packages/void/test/unit/route-url-builder.test.ts index db21c0199..31e1d7dc7 100644 --- a/packages/void/test/unit/route-url-builder.test.ts +++ b/packages/void/test/unit/route-url-builder.test.ts @@ -13,10 +13,25 @@ declare module 'void/routes' { '/route-util/files/*': '*'; '/route-util/:toString': 'toString'; '/:leading{.+}': 'leading'; + '/route-util/[id]': 'id'; + '/route-util/files/[...path]': 'path'; + '/route-util/files/[...]': '*'; } } describe('routeUrl', () => { + it('builds URLs and destinations from bracket patterns', () => { + expect(routeUrl('/route-util/[id]', { params: { id: 42 } })).toBe('/route-util/42'); + expect(routeUrl('/route-util/files/[...path]', { params: { path: 'docs/start here' } })).toBe( + '/route-util/files/docs/start%20here', + ); + expect(routeUrl('/route-util/files/[...]', { params: { '*': 'a/b' } })).toBe( + '/route-util/files/a/b', + ); + expect( + toUrlString({ pathname: '/route-util/[id]', params: { id: 'a/b' }, hash: 'details' }), + ).toBe('/route-util/a%2Fb#details'); + }); it('converts typed destinations and preserves ordinary URLs', () => { expect( toUrlString({ diff --git a/packages/void/test/unit/route-url.test.ts b/packages/void/test/unit/route-url.test.ts index 7a828ecbd..bba54a7a5 100644 --- a/packages/void/test/unit/route-url.test.ts +++ b/packages/void/test/unit/route-url.test.ts @@ -2,6 +2,38 @@ import { describe, expect, it } from 'vite-plus/test'; import { interpolateRouteParams } from '../../src/runtime/route-url.ts'; describe('route URL interpolation', () => { + it.each([ + ['/users/[id]', '/users/:id', { id: 'a/b ?#%' }, '/users/a%2Fb%20%3F%23%25'], + [ + '/docs/[...slug]', + '/docs/:slug{.+}', + { slug: 'guides/get started' }, + '/docs/guides/get%20started', + ], + ['/docs/[...]', '/docs/*', { '*': 'a/b ?#%' }, '/docs/a/b%20%3F%23%25'], + ['/[id2]/[id]/[id]', '/:id2/:id/:id', { id: 'one', id2: 'two' }, '/two/one/one'], + ['/docs/[...]', '/docs/*', { '*': '' }, '/docs/'], + ] as const)( + 'resolves public %s and legacy %s patterns identically', + (path, legacy, params, url) => { + expect(interpolateRouteParams(path, params)).toBe(url); + expect(interpolateRouteParams(legacy, params)).toBe(url); + }, + ); + + it('preserves literal brackets, missing params, queries and named action suffixes', () => { + expect(interpolateRouteParams('/x[id]/[id]/[missing]?save#/[id]', { id: 'one' })).toBe( + '/x[id]/one/[missing]?save#/[id]', + ); + expect(interpolateRouteParams('/[id]', undefined)).toBe('/[id]'); + expect(interpolateRouteParams('/[toString]', {})).toBe('/[toString]'); + expect(interpolateRouteParams('/docs/[...]', {})).toBe('/docs/[...]'); + expect(() => interpolateRouteParams('/[id]', {}, true)).toThrow('Missing route param "id".'); + expect(() => interpolateRouteParams('/docs/[...slug]', { slug: '' }, true)).toThrow( + 'must not be empty', + ); + }); + it('expands catch-all segments and encodes each path component', () => { expect(interpolateRouteParams('/files/:path{.+}', { path: 'docs/start ?#%.md' })).toBe( '/files/docs/start%20%3F%23%25.md', @@ -27,12 +59,18 @@ describe('route URL interpolation', () => { ['/api/posts/:id', { id: '.' }], ['/files/:path{.+}', { path: 'a/../../admin' }], ['/files/:path{.+}', { path: './x' }], + ['/api/posts/[id]', { id: '..' }], + ['/files/[...path]', { path: 'a/../../admin' }], + ['/files/[...]', { '*': './x' }], ])('rejects dot segments that would change the path of %s (%j)', (path, params) => { expect(() => interpolateRouteParams(path, params)).toThrow(TypeError); expect(() => interpolateRouteParams(path, params)).toThrow(/it would change the request path/); }); it('rejects a leading param that would make the path start with //', () => { + expect(() => interpolateRouteParams('/[...path]', { path: '/evil.com/x' })).toThrow( + 'changes the request host', + ); expect(() => interpolateRouteParams('/:path{.+}', { path: '/evil.com/x' })).toThrow( new TypeError( 'Route param "path" would make the request path start with "//", which changes the request host.', diff --git a/packages/void/test/unit/ws.test.ts b/packages/void/test/unit/ws.test.ts index 440cdcfd3..929063e63 100644 --- a/packages/void/test/unit/ws.test.ts +++ b/packages/void/test/unit/ws.test.ts @@ -3,6 +3,8 @@ import { connect } from '../../src/runtime/ws.ts'; declare module 'void/routes' { interface WebSocketRouteMap { + '/chat/[room]': WebSocketRouteMap['/chat/:room']; + '/files/[...path]': WebSocketRouteMap['/files/:path{.+}']; '/chat/:room': { params: { room: string }; client: { type: 'chat.message'; text: string }; @@ -80,9 +82,17 @@ describe('typed websocket client', () => { (globalThis as { location?: unknown }).location = originalLocation; }); - it('expands complete catch-all tokens', () => { - connect('/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + it.each(['/files/[...path]', '/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + (path) => { + connect(path, { params: { path: 'docs/start here' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + }, + ); + + it('interpolates bracket parameters in WebSocket URLs', () => { + connect('/chat/[room]', { params: { room: 'a/b' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/chat/a%2Fb'); }); it('keeps a secure wss baseURL secure', () => {
= P extends PagePath diff --git a/packages/void/src/runtime/fetch.ts b/packages/void/src/runtime/fetch.ts index a80a1d7c3..f69872256 100644 --- a/packages/void/src/runtime/fetch.ts +++ b/packages/void/src/runtime/fetch.ts @@ -73,7 +73,7 @@ const browserFetch = createRouteFetch((...args) => globalThis.fetch(...args)); * import { fetch } from "void/client"; * * const users = await fetch("/api/users"); // typed! - * const user = await fetch("/api/users/:id", { params: { id: "1" } }); + * const user = await fetch("/api/users/[id]", { params: { id: "1" } }); * ``` */ export function fetch
( diff --git a/packages/void/src/runtime/route-url.ts b/packages/void/src/runtime/route-url.ts index fe6c9572f..a9e74901c 100644 --- a/packages/void/src/runtime/route-url.ts +++ b/packages/void/src/runtime/route-url.ts @@ -28,18 +28,18 @@ export function interpolateRouteParams( const suffix = suffixIndex === -1 ? '' : path.slice(suffixIndex); let leadingName: string | undefined; const replaced = pathname.replace( - /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\*)(?=\/|$)/g, + /(^|\/)(?::([^/{}]+)(\{\.\+\})?|\[(\.\.\.)?([^/[\]]*)\]|\*)(?=\/|$)/g, ( token: string, prefix: string, name: string | undefined, catchAll: string | undefined, + bracketCatchAll: string | undefined, + bracketName: string | undefined, offset: number, ) => { + name ??= bracketName || undefined; const anonymous = name === undefined; - if (anonymous && !requireParams) { - return token; - } name ??= '*'; if (!params || !Object.hasOwn(params, name)) { if (requireParams) { @@ -56,7 +56,7 @@ export function interpolateRouteParams( } return ( prefix + - (catchAll || anonymous + (catchAll || bracketCatchAll || anonymous ? value .split('/') .map((segment) => encodeSegment(name, segment)) @@ -77,15 +77,15 @@ export function interpolateRouteParams( /** Known page, HTTP and WebSocket route patterns. */ export type RoutePath = keyof RoutePathMap & string; -// With `routing.typedRoutes`, each `:name`, `:name{.+}` or `*` segment of a -// pattern matches any text, so `/users/:id` accepts `/users/${string}`. Template +// Dynamic segments in either public or legacy patterns match any text, +// so `/users/[id]` accepts `/users/${string}`. Template // literal types cannot exclude empty or `/`-leading text without per-character unions. -type RouteUrlSegment = S extends `:${string}` | '*' ? string : S; +type RouteUrlSegment = S extends `:${string}` | `[${string}]` | '*' ? string : S; type RouteSegmentsUrl
= P extends `${infer Segment}/${infer Rest}` ? `${RouteUrlSegment}/${RouteSegmentsUrl}` : RouteUrlSegment; // A trailing `/*` also matches the bare prefix, as in Hono: `/docs/*` matches `/docs`. -type RoutePatternUrl = P extends `${infer Prefix}/*` +type RoutePatternUrl = P extends `${infer Prefix}/${'*' | '[...]'}` ? (Prefix extends '' ? '/' : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}` : RouteSegmentsUrl; // Fixed segments are also accepted percent-encoded, as `routeUrl()` returns them. diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index f55a40595..84fb6854c 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -238,7 +238,7 @@ import { useParams as vue } from ${adapterSource('vue/src/runtime/use-params.ts' import { useParams as svelte } from ${adapterSource('svelte/src/runtime/use-params.ts')}; import { useParams as solid } from ${adapterSource('solid/src/runtime/use-params.ts')}; type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const pages: Array = ['/', '/about', '/users/:id', '/files/:path{.+}', '/wild/*']; +const pages: Array = ['/', '/about', '/users/[id]', '/files/[...path]', '/wild/[...]', '/users/:id', '/files/:path{.+}', '/wild/*']; // @ts-expect-error HTTP routes are not pages const api: PagePath = '/api/users/:id'; void [pages, api]; @@ -247,6 +247,15 @@ ${['react', 'vue', 'svelte', 'solid'] .map( (useParams) => `{ const user = ${useParams}<'/users/:id'>(); + const bracketUser = ${useParams}<'/users/[id]'>(); + const bracketFiles = ${useParams}<'/files/[...path]'>(); + const bracketWild = ${useParams}<'/wild/[...]'>(); + const aliases: [ + Same, + Same, + Same, + ] = [true, true, true]; + void aliases; const files = ${useParams}<'/files/:path{.+}'>(); const wild = ${useParams}<'/wild/*'>(); const about = ${useParams}<'/about'>(); @@ -325,6 +334,17 @@ routeUrl('/café menu'); routeUrl('/api/users/:id', { params: { id: '123' } }); routeUrl('/files/:path{.+}', { params: { path: 'docs/start' } }); routeUrl('/wild/*', { params: { '*': 'a/b' } }); +routeUrl('/users/[id]', { params: { id: 123 } }); +routeUrl('/files/[...path]', { params: { path: 'docs/start' } }); +routeUrl('/wild/[...]', { params: { '*': 'a/b' } }); +// @ts-expect-error bracket patterns require params +routeUrl('/users/[id]'); +// @ts-expect-error bracket catch-all patterns require params +routeUrl('/files/[...path]'); +// @ts-expect-error bracket anonymous catch-alls require a wildcard param +routeUrl('/wild/[...]'); +// @ts-expect-error bracket params are checked +routeUrl('/users/[id]', { params: { wrong: 123 } }); // @ts-expect-error a known route's params are required routeUrl('/users/:id'); // @ts-expect-error params cannot be omitted while providing other options @@ -341,6 +361,8 @@ routeUrl('/files/:path{.+}'); routeUrl('/wild/*'); void url; const destination: RouteDestination = { pathname: '/users/:id', params: { id: 123 }, query: { tab: 'activity' } }; +const bracketDestination: RouteDestination = { pathname: '/users/[id]', params: { id: 123 } }; +void bracketDestination; declare const router: VoidRouter; void router.visit(destination); void router.prefetch(destination); @@ -440,6 +462,77 @@ function typecheck( } describe('standalone routeUrl utility', () => { + it.each(['generated', 'package'] as const)( + 'preserves HTTP, WebSocket and page action contracts for both syntaxes through %s declarations', + async (declarations) => { + const root = await createFixture({ + extraFiles: { + 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const message: Schema<{ text: string }>; +export default defineRoom({ messages: { client: message, server: message } }); +`, + 'pages/users/[id].server.ts': `import { defineHandler } from 'void/handler'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const body: Schema<{ name: string }>; +export const actions = { save: defineHandler.withValidator({ body })(() => ({ ok: true })) }; +`, + 'check-aliases.ts': `import { fetch } from 'void/client'; +import { connect } from 'void/ws'; +import { createActionClient } from 'void/pages-client'; +import type { RouteMap, RoutePathMap, PagePathMap, PageActionMap, WebSocketRouteMap } from 'void/routes'; +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const aliases: [ + Same, + Same, + Same, + Same, + Same, +] = [true, true, true, true, true]; +void aliases; +const bracket = fetch('/api/users/[id]', { params: { id: '42' } }); +const legacy = fetch('/api/users/:id', { params: { id: '42' } }); +const responses: Same = true; +void responses; +// @ts-expect-error methods stay constrained through bracket keys +fetch('/api/users/[id]', { method: 'POST' }); +// @ts-expect-error legacy methods stay constrained +fetch('/api/users/:id', { method: 'POST' }); +connect('/chat/[room]', { params: { room: 'lobby' } }).send({ text: 'hi' }); +connect('/chat/:room', { params: { room: 'lobby' } }).send({ text: 'hi' }); +// @ts-expect-error bracket WebSocket params are required +connect('/chat/[room]', {}); +// @ts-expect-error legacy WebSocket params are required +connect('/chat/:room', {}); +connect('/chat/[room]', { params: { room: 'lobby' } }).on('message', (message) => { + const text: string = message.text; + // @ts-expect-error server message types stay constrained + void message.missing; + void text; +}); +const { action } = createActionClient(); +action('/users/[id]?save', { params: { id: '42' }, data: { name: 'Alice' } }); +action('/users/:id?save', { params: { id: '42' }, data: { name: 'Alice' } }); +// @ts-expect-error bracket actions require params +action('/users/[id]?save'); +// @ts-expect-error legacy actions require params +action('/users/:id?save'); +// @ts-expect-error bracket action bodies stay constrained +action('/users/[id]?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error legacy action bodies stay constrained +action('/users/:id?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error bracket action names stay constrained +action('/users/[id]?missing', { params: { id: '42' } }); +`, + }, + }); + expect(typecheck(root, declarations, ['check-aliases.ts'])).toEqual({ + output: '', + status: 0, + }); + }, + ); + it.each(['generated', 'package'] as const)( 'typechecks through the %s declarations', async (declarations) => { @@ -525,7 +618,7 @@ void loose; join(root, 'routes.d.ts'), `export {}; declare module 'void/routes' { - interface RoutePathMap { '/': never; '/docs/*': '*'; '/users/:id': 'id'; '/café menu': never } + interface RoutePathMap { '/': never; '/docs/[...]': '*'; '/users/[id]': 'id'; '/café menu': never } interface EncodedRoutePathMap { '/café menu': '/caf%C3%A9%20menu' } interface RouteTypesConfig { typedRoutes: true } } @@ -611,7 +704,12 @@ void [hrefs, route, unknown, external]; `import { routeUrl } from 'void/routes'; export function buildUrls() { return [ + routeUrl('/users/[id]', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), routeUrl('/users/:id', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), + routeUrl('/files/[...path]', { params: { path: 'docs/start here' } }), + routeUrl('/files/:path{.+}', { params: { path: 'docs/start here' } }), + routeUrl('/wild/[...]', { params: { '*': 'a/b' } }), + routeUrl('/wild/*', { params: { '*': 'a/b' } }), routeUrl('/café menu', { query: { q: 'café menu' }, hash: 'café menu' }), ]; } @@ -628,6 +726,11 @@ export function buildUrls() { }); const expected = [ '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/files/docs/start%20here', + '/files/docs/start%20here', + '/wild/a/b', + '/wild/a/b', '/caf%C3%A9%20menu?q=caf%C3%A9+menu#caf%C3%A9%20menu', ]; const scriptPath = join(root, 'dist/route.mjs'); diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index 5db822319..894801294 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -14,6 +14,7 @@ import { declare module 'void/routes' { interface PageActionMap { + '/__action-test/users/[id]': PageActionMap['/__action-test/users/:id']; '/__action-test/static': { body: { name: string } }; '/__action-test/users/:id': { params: { id: string }; @@ -248,23 +249,26 @@ describe('action clients', () => { }); }); - it('encodes path parameters while preserving named actions and submission options', async () => { - const client = createActionClient(); - const visit = vi.fn(async () => ({ props: {} })); - client.setActionRouter(routerWithVisit(visit)); - - await client.action('/__action-test/users/:id?save', { - params: { id: 'a/b?c' }, - method: 'PATCH', - data: { name: 'Alice' }, - }); - - expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { - method: 'PATCH', - data: { name: 'Alice' }, - preserveScroll: undefined, - }); - }); + it.each(['/__action-test/users/[id]?save', '/__action-test/users/:id?save'] as const)( + 'encodes params and preserves named actions in %s', + async (url) => { + const client = createActionClient(); + const visit = vi.fn(async () => ({ props: {} })); + client.setActionRouter(routerWithVisit(visit)); + + await client.action(url, { + params: { id: 'a/b?c' }, + method: 'PATCH', + data: { name: 'Alice' }, + }); + + expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { + method: 'PATCH', + data: { name: 'Alice' }, + preserveScroll: undefined, + }); + }, + ); it('preserves validation errors as action results', async () => { const client = createActionClient(); diff --git a/packages/void/test/unit/fetch-stream.test.ts b/packages/void/test/unit/fetch-stream.test.ts index 79009c929..bb894efcd 100644 --- a/packages/void/test/unit/fetch-stream.test.ts +++ b/packages/void/test/unit/fetch-stream.test.ts @@ -17,6 +17,7 @@ vi.mock('ofetch', () => { declare module 'void/routes' { interface RouteMap { + '/api/stream/[...path]': RouteMap['/api/stream/:path{.+}']; '/api/chat': { POST: { input: { body: { messages: Array } }; output: unknown }; }; @@ -50,11 +51,14 @@ beforeEach(() => { }); describe('fetchStream', () => { - it('expands complete catch-all tokens', async () => { - vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); - await fetchStream('/api/stream/:path{.+}', { params: { path: 'docs/start here' } }).next(); - expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); - }); + it.each(['/api/stream/[...path]', '/api/stream/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); + await fetchStream(path, { params: { path: 'docs/start here' } }).next(); + expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); + }, + ); it('parses Workers AI JSON SSE format', async () => { const stream = makeSSEStream([ 'data: {"response":"Hello"}\n\n', diff --git a/packages/void/test/unit/fetch.test.ts b/packages/void/test/unit/fetch.test.ts index 63063053d..6e240e49f 100644 --- a/packages/void/test/unit/fetch.test.ts +++ b/packages/void/test/unit/fetch.test.ts @@ -3,6 +3,8 @@ import { fetch as typedFetch, FetchError } from '../../src/runtime/fetch.ts'; declare module 'void/routes' { interface RouteMap { + '/api/users/[id]': RouteMap['/api/users/:id']; + '/api/files/[...path]': RouteMap['/api/files/:path{.+}']; '/api/hello': { GET: { input: {}; output: { ok: boolean } }; }; @@ -53,13 +55,23 @@ describe('typed fetch client', () => { expect(result).toEqual({ ok: true }); }); - it('expands complete catch-all tokens', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); - await typedFetch('/api/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/files/docs/start%20here', - expect.anything(), - ); + it.each(['/api/files/[...path]', '/api/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); + await typedFetch(path, { params: { path: 'docs/start here' } }); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/files/docs/start%20here', + expect.anything(), + ); + }, + ); + + it('interpolates bracket parameters for typed requests', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({ id: 'a/b', name: 'Alice' })); + const result = await typedFetch('/api/users/[id]', { params: { id: 'a/b' } }); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/users/a%2Fb', expect.anything()); + expect(result).toEqual({ id: 'a/b', name: 'Alice' }); }); it('builds query string', async () => { diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index fe888ed61..42d4002a4 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -198,9 +198,12 @@ describe('generateRouteTypes', () => { const pageMap = dts.split(' export interface PagePathMap {')[1]!.split(' }')[0]!; expect(pageMap).toContain('"/about": never;'); - expect(pageMap).toContain('"/users/:id": "id";'); - expect(pageMap).toContain('"/files/:path{.+}": "path";'); - expect(pageMap).toContain('"/wild/*": "*";'); + expect(pageMap).toContain('"/users/[id]": "id";'); + expect(pageMap).toContain('"/users/:id": PagePathMap["/users/[id]"];'); + expect(pageMap).toContain('"/files/[...path]": "path";'); + expect(pageMap).toContain('"/files/:path{.+}": PagePathMap["/files/[...path]"];'); + expect(pageMap).toContain('"/wild/[...]": "*";'); + expect(pageMap).toContain('"/wild/*": PagePathMap["/wild/[...]"];'); expect(pageMap).not.toContain('/api/users/:id'); }); @@ -220,7 +223,9 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); - expect(dts).toContain('"/api/users/:id"'); + expect(dts).toContain('"/api/users/[id]": {'); + expect(dts).toContain('/** @deprecated Use "/api/users/[id]" instead. */'); + expect(dts).toContain('"/api/users/:id": RouteMap["/api/users/[id]"];'); expect(dts).toContain('typeof import("../routes/api/users/[id].ts")'); }); @@ -290,6 +295,7 @@ describe('generateRouteTypes', () => { expect(encoded.trim().split('\n')).toEqual([ '"/café menu": "/caf%C3%A9%20menu";', + ' "/café/[id]": "/caf%C3%A9/[id]";', ' "/café/:id": "/caf%C3%A9/:id";', ]); expect(dts).toContain('type RoutePathUrl = RoutePatternUrl;'); @@ -405,14 +411,14 @@ describe('generateRouteTypes', () => { expect(actionMap.split('"/users/:id"')).toHaveLength(2); expect(actionMap).toContain( [ - ' "/users/:id": {', + ' "/users/[id]": {', ` body: ExtractInput<${dev}["__validators"]> extends { body: infer B } ? B : Record;`, ' params: { id: string };', ' } | {', ` actions: { [K in keyof ${prod}]: {`, ].join('\n'), ); - expect(actionMap).toContain([' params: { id: string };', ' };', ' }'].join('\n')); + expect(actionMap).toContain(' "/users/:id": PageActionMap["/users/[id]"];'); }); it('generates WebSocketRouteMap entries', () => { @@ -472,7 +478,8 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const websocketMap = dts.split(' interface WebSocketRouteMap {')[1]!.split('\n }\n}')[0]!; - expect(websocketMap.match(/"\/chat\/:room": \{/g)).toHaveLength(1); + expect(websocketMap.match(/"\/chat\/\[room\]": \{/g)).toHaveLength(1); + expect(websocketMap).toContain('"/chat/:room": WebSocketRouteMap["/chat/[room]"];'); expect(websocketMap).toContain('chat/[room].dev.ws.ts'); expect(websocketMap).toContain('chat/[room].prod.ws.ts'); expect(websocketMap).toMatch(/client: .*dev\.ws\.ts.* & .*prod\.ws\.ts/); @@ -636,11 +643,15 @@ describe('generateRouteTypes', () => { }); const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; - expect(pathMap).toContain('"/api/users/:id": "id";'); + expect(pathMap).toContain('"/api/users/[id]": "id";'); + expect(pathMap).toContain('"/api/users/:id": RoutePathMap["/api/users/[id]"];'); expect(pathMap).toContain('"/about": never;'); - expect(pathMap).toContain('"/files/:path{.+}": "path";'); - expect(pathMap).toContain('"/files/*": "*";'); - expect(pathMap).toContain('"/chat/:room": "room";'); + expect(pathMap).toContain('"/files/[...path]": "path";'); + expect(pathMap).toContain('"/files/:path{.+}": RoutePathMap["/files/[...path]"];'); + expect(pathMap).toContain('"/files/[...]": "*";'); + expect(pathMap).toContain('"/files/*": RoutePathMap["/files/[...]"];'); + expect(pathMap).toContain('"/chat/[room]": "room";'); + expect(pathMap).toContain('"/chat/:room": RoutePathMap["/chat/[room]"];'); expect(dts).not.toContain('import("../pages/'); }); @@ -672,6 +683,7 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; expect(pathMap.match(/"\/users\/:id":/g)).toHaveLength(1); - expect(pathMap).toContain('"/users/:id": "id";'); + expect(pathMap).toContain('"/users/[id]": "id";'); + expect(pathMap).toContain('"/users/:id": RoutePathMap["/users/[id]"];'); }); }); diff --git a/packages/void/test/unit/route-url-builder.test.ts b/packages/void/test/unit/route-url-builder.test.ts index db21c0199..31e1d7dc7 100644 --- a/packages/void/test/unit/route-url-builder.test.ts +++ b/packages/void/test/unit/route-url-builder.test.ts @@ -13,10 +13,25 @@ declare module 'void/routes' { '/route-util/files/*': '*'; '/route-util/:toString': 'toString'; '/:leading{.+}': 'leading'; + '/route-util/[id]': 'id'; + '/route-util/files/[...path]': 'path'; + '/route-util/files/[...]': '*'; } } describe('routeUrl', () => { + it('builds URLs and destinations from bracket patterns', () => { + expect(routeUrl('/route-util/[id]', { params: { id: 42 } })).toBe('/route-util/42'); + expect(routeUrl('/route-util/files/[...path]', { params: { path: 'docs/start here' } })).toBe( + '/route-util/files/docs/start%20here', + ); + expect(routeUrl('/route-util/files/[...]', { params: { '*': 'a/b' } })).toBe( + '/route-util/files/a/b', + ); + expect( + toUrlString({ pathname: '/route-util/[id]', params: { id: 'a/b' }, hash: 'details' }), + ).toBe('/route-util/a%2Fb#details'); + }); it('converts typed destinations and preserves ordinary URLs', () => { expect( toUrlString({ diff --git a/packages/void/test/unit/route-url.test.ts b/packages/void/test/unit/route-url.test.ts index 7a828ecbd..bba54a7a5 100644 --- a/packages/void/test/unit/route-url.test.ts +++ b/packages/void/test/unit/route-url.test.ts @@ -2,6 +2,38 @@ import { describe, expect, it } from 'vite-plus/test'; import { interpolateRouteParams } from '../../src/runtime/route-url.ts'; describe('route URL interpolation', () => { + it.each([ + ['/users/[id]', '/users/:id', { id: 'a/b ?#%' }, '/users/a%2Fb%20%3F%23%25'], + [ + '/docs/[...slug]', + '/docs/:slug{.+}', + { slug: 'guides/get started' }, + '/docs/guides/get%20started', + ], + ['/docs/[...]', '/docs/*', { '*': 'a/b ?#%' }, '/docs/a/b%20%3F%23%25'], + ['/[id2]/[id]/[id]', '/:id2/:id/:id', { id: 'one', id2: 'two' }, '/two/one/one'], + ['/docs/[...]', '/docs/*', { '*': '' }, '/docs/'], + ] as const)( + 'resolves public %s and legacy %s patterns identically', + (path, legacy, params, url) => { + expect(interpolateRouteParams(path, params)).toBe(url); + expect(interpolateRouteParams(legacy, params)).toBe(url); + }, + ); + + it('preserves literal brackets, missing params, queries and named action suffixes', () => { + expect(interpolateRouteParams('/x[id]/[id]/[missing]?save#/[id]', { id: 'one' })).toBe( + '/x[id]/one/[missing]?save#/[id]', + ); + expect(interpolateRouteParams('/[id]', undefined)).toBe('/[id]'); + expect(interpolateRouteParams('/[toString]', {})).toBe('/[toString]'); + expect(interpolateRouteParams('/docs/[...]', {})).toBe('/docs/[...]'); + expect(() => interpolateRouteParams('/[id]', {}, true)).toThrow('Missing route param "id".'); + expect(() => interpolateRouteParams('/docs/[...slug]', { slug: '' }, true)).toThrow( + 'must not be empty', + ); + }); + it('expands catch-all segments and encodes each path component', () => { expect(interpolateRouteParams('/files/:path{.+}', { path: 'docs/start ?#%.md' })).toBe( '/files/docs/start%20%3F%23%25.md', @@ -27,12 +59,18 @@ describe('route URL interpolation', () => { ['/api/posts/:id', { id: '.' }], ['/files/:path{.+}', { path: 'a/../../admin' }], ['/files/:path{.+}', { path: './x' }], + ['/api/posts/[id]', { id: '..' }], + ['/files/[...path]', { path: 'a/../../admin' }], + ['/files/[...]', { '*': './x' }], ])('rejects dot segments that would change the path of %s (%j)', (path, params) => { expect(() => interpolateRouteParams(path, params)).toThrow(TypeError); expect(() => interpolateRouteParams(path, params)).toThrow(/it would change the request path/); }); it('rejects a leading param that would make the path start with //', () => { + expect(() => interpolateRouteParams('/[...path]', { path: '/evil.com/x' })).toThrow( + 'changes the request host', + ); expect(() => interpolateRouteParams('/:path{.+}', { path: '/evil.com/x' })).toThrow( new TypeError( 'Route param "path" would make the request path start with "//", which changes the request host.', diff --git a/packages/void/test/unit/ws.test.ts b/packages/void/test/unit/ws.test.ts index 440cdcfd3..929063e63 100644 --- a/packages/void/test/unit/ws.test.ts +++ b/packages/void/test/unit/ws.test.ts @@ -3,6 +3,8 @@ import { connect } from '../../src/runtime/ws.ts'; declare module 'void/routes' { interface WebSocketRouteMap { + '/chat/[room]': WebSocketRouteMap['/chat/:room']; + '/files/[...path]': WebSocketRouteMap['/files/:path{.+}']; '/chat/:room': { params: { room: string }; client: { type: 'chat.message'; text: string }; @@ -80,9 +82,17 @@ describe('typed websocket client', () => { (globalThis as { location?: unknown }).location = originalLocation; }); - it('expands complete catch-all tokens', () => { - connect('/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + it.each(['/files/[...path]', '/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + (path) => { + connect(path, { params: { path: 'docs/start here' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + }, + ); + + it('interpolates bracket parameters in WebSocket URLs', () => { + connect('/chat/[room]', { params: { room: 'a/b' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/chat/a%2Fb'); }); it('keeps a secure wss baseURL secure', () => {
; // A trailing `/*` also matches the bare prefix, as in Hono: `/docs/*` matches `/docs`. -type RoutePatternUrl
= P extends `${infer Prefix}/*` +type RoutePatternUrl
= P extends `${infer Prefix}/${'*' | '[...]'}` ? (Prefix extends '' ? '/' : RouteSegmentsUrl) | `${RouteSegmentsUrl}/${string}` : RouteSegmentsUrl; // Fixed segments are also accepted percent-encoded, as `routeUrl()` returns them. diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index f55a40595..84fb6854c 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -238,7 +238,7 @@ import { useParams as vue } from ${adapterSource('vue/src/runtime/use-params.ts' import { useParams as svelte } from ${adapterSource('svelte/src/runtime/use-params.ts')}; import { useParams as solid } from ${adapterSource('solid/src/runtime/use-params.ts')}; type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const pages: Array = ['/', '/about', '/users/:id', '/files/:path{.+}', '/wild/*']; +const pages: Array = ['/', '/about', '/users/[id]', '/files/[...path]', '/wild/[...]', '/users/:id', '/files/:path{.+}', '/wild/*']; // @ts-expect-error HTTP routes are not pages const api: PagePath = '/api/users/:id'; void [pages, api]; @@ -247,6 +247,15 @@ ${['react', 'vue', 'svelte', 'solid'] .map( (useParams) => `{ const user = ${useParams}<'/users/:id'>(); + const bracketUser = ${useParams}<'/users/[id]'>(); + const bracketFiles = ${useParams}<'/files/[...path]'>(); + const bracketWild = ${useParams}<'/wild/[...]'>(); + const aliases: [ + Same, + Same, + Same, + ] = [true, true, true]; + void aliases; const files = ${useParams}<'/files/:path{.+}'>(); const wild = ${useParams}<'/wild/*'>(); const about = ${useParams}<'/about'>(); @@ -325,6 +334,17 @@ routeUrl('/café menu'); routeUrl('/api/users/:id', { params: { id: '123' } }); routeUrl('/files/:path{.+}', { params: { path: 'docs/start' } }); routeUrl('/wild/*', { params: { '*': 'a/b' } }); +routeUrl('/users/[id]', { params: { id: 123 } }); +routeUrl('/files/[...path]', { params: { path: 'docs/start' } }); +routeUrl('/wild/[...]', { params: { '*': 'a/b' } }); +// @ts-expect-error bracket patterns require params +routeUrl('/users/[id]'); +// @ts-expect-error bracket catch-all patterns require params +routeUrl('/files/[...path]'); +// @ts-expect-error bracket anonymous catch-alls require a wildcard param +routeUrl('/wild/[...]'); +// @ts-expect-error bracket params are checked +routeUrl('/users/[id]', { params: { wrong: 123 } }); // @ts-expect-error a known route's params are required routeUrl('/users/:id'); // @ts-expect-error params cannot be omitted while providing other options @@ -341,6 +361,8 @@ routeUrl('/files/:path{.+}'); routeUrl('/wild/*'); void url; const destination: RouteDestination = { pathname: '/users/:id', params: { id: 123 }, query: { tab: 'activity' } }; +const bracketDestination: RouteDestination = { pathname: '/users/[id]', params: { id: 123 } }; +void bracketDestination; declare const router: VoidRouter; void router.visit(destination); void router.prefetch(destination); @@ -440,6 +462,77 @@ function typecheck( } describe('standalone routeUrl utility', () => { + it.each(['generated', 'package'] as const)( + 'preserves HTTP, WebSocket and page action contracts for both syntaxes through %s declarations', + async (declarations) => { + const root = await createFixture({ + extraFiles: { + 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const message: Schema<{ text: string }>; +export default defineRoom({ messages: { client: message, server: message } }); +`, + 'pages/users/[id].server.ts': `import { defineHandler } from 'void/handler'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const body: Schema<{ name: string }>; +export const actions = { save: defineHandler.withValidator({ body })(() => ({ ok: true })) }; +`, + 'check-aliases.ts': `import { fetch } from 'void/client'; +import { connect } from 'void/ws'; +import { createActionClient } from 'void/pages-client'; +import type { RouteMap, RoutePathMap, PagePathMap, PageActionMap, WebSocketRouteMap } from 'void/routes'; +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const aliases: [ + Same, + Same, + Same, + Same, + Same, +] = [true, true, true, true, true]; +void aliases; +const bracket = fetch('/api/users/[id]', { params: { id: '42' } }); +const legacy = fetch('/api/users/:id', { params: { id: '42' } }); +const responses: Same = true; +void responses; +// @ts-expect-error methods stay constrained through bracket keys +fetch('/api/users/[id]', { method: 'POST' }); +// @ts-expect-error legacy methods stay constrained +fetch('/api/users/:id', { method: 'POST' }); +connect('/chat/[room]', { params: { room: 'lobby' } }).send({ text: 'hi' }); +connect('/chat/:room', { params: { room: 'lobby' } }).send({ text: 'hi' }); +// @ts-expect-error bracket WebSocket params are required +connect('/chat/[room]', {}); +// @ts-expect-error legacy WebSocket params are required +connect('/chat/:room', {}); +connect('/chat/[room]', { params: { room: 'lobby' } }).on('message', (message) => { + const text: string = message.text; + // @ts-expect-error server message types stay constrained + void message.missing; + void text; +}); +const { action } = createActionClient(); +action('/users/[id]?save', { params: { id: '42' }, data: { name: 'Alice' } }); +action('/users/:id?save', { params: { id: '42' }, data: { name: 'Alice' } }); +// @ts-expect-error bracket actions require params +action('/users/[id]?save'); +// @ts-expect-error legacy actions require params +action('/users/:id?save'); +// @ts-expect-error bracket action bodies stay constrained +action('/users/[id]?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error legacy action bodies stay constrained +action('/users/:id?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error bracket action names stay constrained +action('/users/[id]?missing', { params: { id: '42' } }); +`, + }, + }); + expect(typecheck(root, declarations, ['check-aliases.ts'])).toEqual({ + output: '', + status: 0, + }); + }, + ); + it.each(['generated', 'package'] as const)( 'typechecks through the %s declarations', async (declarations) => { @@ -525,7 +618,7 @@ void loose; join(root, 'routes.d.ts'), `export {}; declare module 'void/routes' { - interface RoutePathMap { '/': never; '/docs/*': '*'; '/users/:id': 'id'; '/café menu': never } + interface RoutePathMap { '/': never; '/docs/[...]': '*'; '/users/[id]': 'id'; '/café menu': never } interface EncodedRoutePathMap { '/café menu': '/caf%C3%A9%20menu' } interface RouteTypesConfig { typedRoutes: true } } @@ -611,7 +704,12 @@ void [hrefs, route, unknown, external]; `import { routeUrl } from 'void/routes'; export function buildUrls() { return [ + routeUrl('/users/[id]', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), routeUrl('/users/:id', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), + routeUrl('/files/[...path]', { params: { path: 'docs/start here' } }), + routeUrl('/files/:path{.+}', { params: { path: 'docs/start here' } }), + routeUrl('/wild/[...]', { params: { '*': 'a/b' } }), + routeUrl('/wild/*', { params: { '*': 'a/b' } }), routeUrl('/café menu', { query: { q: 'café menu' }, hash: 'café menu' }), ]; } @@ -628,6 +726,11 @@ export function buildUrls() { }); const expected = [ '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/files/docs/start%20here', + '/files/docs/start%20here', + '/wild/a/b', + '/wild/a/b', '/caf%C3%A9%20menu?q=caf%C3%A9+menu#caf%C3%A9%20menu', ]; const scriptPath = join(root, 'dist/route.mjs'); diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index 5db822319..894801294 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -14,6 +14,7 @@ import { declare module 'void/routes' { interface PageActionMap { + '/__action-test/users/[id]': PageActionMap['/__action-test/users/:id']; '/__action-test/static': { body: { name: string } }; '/__action-test/users/:id': { params: { id: string }; @@ -248,23 +249,26 @@ describe('action clients', () => { }); }); - it('encodes path parameters while preserving named actions and submission options', async () => { - const client = createActionClient(); - const visit = vi.fn(async () => ({ props: {} })); - client.setActionRouter(routerWithVisit(visit)); - - await client.action('/__action-test/users/:id?save', { - params: { id: 'a/b?c' }, - method: 'PATCH', - data: { name: 'Alice' }, - }); - - expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { - method: 'PATCH', - data: { name: 'Alice' }, - preserveScroll: undefined, - }); - }); + it.each(['/__action-test/users/[id]?save', '/__action-test/users/:id?save'] as const)( + 'encodes params and preserves named actions in %s', + async (url) => { + const client = createActionClient(); + const visit = vi.fn(async () => ({ props: {} })); + client.setActionRouter(routerWithVisit(visit)); + + await client.action(url, { + params: { id: 'a/b?c' }, + method: 'PATCH', + data: { name: 'Alice' }, + }); + + expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { + method: 'PATCH', + data: { name: 'Alice' }, + preserveScroll: undefined, + }); + }, + ); it('preserves validation errors as action results', async () => { const client = createActionClient(); diff --git a/packages/void/test/unit/fetch-stream.test.ts b/packages/void/test/unit/fetch-stream.test.ts index 79009c929..bb894efcd 100644 --- a/packages/void/test/unit/fetch-stream.test.ts +++ b/packages/void/test/unit/fetch-stream.test.ts @@ -17,6 +17,7 @@ vi.mock('ofetch', () => { declare module 'void/routes' { interface RouteMap { + '/api/stream/[...path]': RouteMap['/api/stream/:path{.+}']; '/api/chat': { POST: { input: { body: { messages: Array } }; output: unknown }; }; @@ -50,11 +51,14 @@ beforeEach(() => { }); describe('fetchStream', () => { - it('expands complete catch-all tokens', async () => { - vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); - await fetchStream('/api/stream/:path{.+}', { params: { path: 'docs/start here' } }).next(); - expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); - }); + it.each(['/api/stream/[...path]', '/api/stream/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); + await fetchStream(path, { params: { path: 'docs/start here' } }).next(); + expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); + }, + ); it('parses Workers AI JSON SSE format', async () => { const stream = makeSSEStream([ 'data: {"response":"Hello"}\n\n', diff --git a/packages/void/test/unit/fetch.test.ts b/packages/void/test/unit/fetch.test.ts index 63063053d..6e240e49f 100644 --- a/packages/void/test/unit/fetch.test.ts +++ b/packages/void/test/unit/fetch.test.ts @@ -3,6 +3,8 @@ import { fetch as typedFetch, FetchError } from '../../src/runtime/fetch.ts'; declare module 'void/routes' { interface RouteMap { + '/api/users/[id]': RouteMap['/api/users/:id']; + '/api/files/[...path]': RouteMap['/api/files/:path{.+}']; '/api/hello': { GET: { input: {}; output: { ok: boolean } }; }; @@ -53,13 +55,23 @@ describe('typed fetch client', () => { expect(result).toEqual({ ok: true }); }); - it('expands complete catch-all tokens', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); - await typedFetch('/api/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/files/docs/start%20here', - expect.anything(), - ); + it.each(['/api/files/[...path]', '/api/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); + await typedFetch(path, { params: { path: 'docs/start here' } }); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/files/docs/start%20here', + expect.anything(), + ); + }, + ); + + it('interpolates bracket parameters for typed requests', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({ id: 'a/b', name: 'Alice' })); + const result = await typedFetch('/api/users/[id]', { params: { id: 'a/b' } }); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/users/a%2Fb', expect.anything()); + expect(result).toEqual({ id: 'a/b', name: 'Alice' }); }); it('builds query string', async () => { diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index fe888ed61..42d4002a4 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -198,9 +198,12 @@ describe('generateRouteTypes', () => { const pageMap = dts.split(' export interface PagePathMap {')[1]!.split(' }')[0]!; expect(pageMap).toContain('"/about": never;'); - expect(pageMap).toContain('"/users/:id": "id";'); - expect(pageMap).toContain('"/files/:path{.+}": "path";'); - expect(pageMap).toContain('"/wild/*": "*";'); + expect(pageMap).toContain('"/users/[id]": "id";'); + expect(pageMap).toContain('"/users/:id": PagePathMap["/users/[id]"];'); + expect(pageMap).toContain('"/files/[...path]": "path";'); + expect(pageMap).toContain('"/files/:path{.+}": PagePathMap["/files/[...path]"];'); + expect(pageMap).toContain('"/wild/[...]": "*";'); + expect(pageMap).toContain('"/wild/*": PagePathMap["/wild/[...]"];'); expect(pageMap).not.toContain('/api/users/:id'); }); @@ -220,7 +223,9 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); - expect(dts).toContain('"/api/users/:id"'); + expect(dts).toContain('"/api/users/[id]": {'); + expect(dts).toContain('/** @deprecated Use "/api/users/[id]" instead. */'); + expect(dts).toContain('"/api/users/:id": RouteMap["/api/users/[id]"];'); expect(dts).toContain('typeof import("../routes/api/users/[id].ts")'); }); @@ -290,6 +295,7 @@ describe('generateRouteTypes', () => { expect(encoded.trim().split('\n')).toEqual([ '"/café menu": "/caf%C3%A9%20menu";', + ' "/café/[id]": "/caf%C3%A9/[id]";', ' "/café/:id": "/caf%C3%A9/:id";', ]); expect(dts).toContain('type RoutePathUrl = RoutePatternUrl;'); @@ -405,14 +411,14 @@ describe('generateRouteTypes', () => { expect(actionMap.split('"/users/:id"')).toHaveLength(2); expect(actionMap).toContain( [ - ' "/users/:id": {', + ' "/users/[id]": {', ` body: ExtractInput<${dev}["__validators"]> extends { body: infer B } ? B : Record;`, ' params: { id: string };', ' } | {', ` actions: { [K in keyof ${prod}]: {`, ].join('\n'), ); - expect(actionMap).toContain([' params: { id: string };', ' };', ' }'].join('\n')); + expect(actionMap).toContain(' "/users/:id": PageActionMap["/users/[id]"];'); }); it('generates WebSocketRouteMap entries', () => { @@ -472,7 +478,8 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const websocketMap = dts.split(' interface WebSocketRouteMap {')[1]!.split('\n }\n}')[0]!; - expect(websocketMap.match(/"\/chat\/:room": \{/g)).toHaveLength(1); + expect(websocketMap.match(/"\/chat\/\[room\]": \{/g)).toHaveLength(1); + expect(websocketMap).toContain('"/chat/:room": WebSocketRouteMap["/chat/[room]"];'); expect(websocketMap).toContain('chat/[room].dev.ws.ts'); expect(websocketMap).toContain('chat/[room].prod.ws.ts'); expect(websocketMap).toMatch(/client: .*dev\.ws\.ts.* & .*prod\.ws\.ts/); @@ -636,11 +643,15 @@ describe('generateRouteTypes', () => { }); const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; - expect(pathMap).toContain('"/api/users/:id": "id";'); + expect(pathMap).toContain('"/api/users/[id]": "id";'); + expect(pathMap).toContain('"/api/users/:id": RoutePathMap["/api/users/[id]"];'); expect(pathMap).toContain('"/about": never;'); - expect(pathMap).toContain('"/files/:path{.+}": "path";'); - expect(pathMap).toContain('"/files/*": "*";'); - expect(pathMap).toContain('"/chat/:room": "room";'); + expect(pathMap).toContain('"/files/[...path]": "path";'); + expect(pathMap).toContain('"/files/:path{.+}": RoutePathMap["/files/[...path]"];'); + expect(pathMap).toContain('"/files/[...]": "*";'); + expect(pathMap).toContain('"/files/*": RoutePathMap["/files/[...]"];'); + expect(pathMap).toContain('"/chat/[room]": "room";'); + expect(pathMap).toContain('"/chat/:room": RoutePathMap["/chat/[room]"];'); expect(dts).not.toContain('import("../pages/'); }); @@ -672,6 +683,7 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; expect(pathMap.match(/"\/users\/:id":/g)).toHaveLength(1); - expect(pathMap).toContain('"/users/:id": "id";'); + expect(pathMap).toContain('"/users/[id]": "id";'); + expect(pathMap).toContain('"/users/:id": RoutePathMap["/users/[id]"];'); }); }); diff --git a/packages/void/test/unit/route-url-builder.test.ts b/packages/void/test/unit/route-url-builder.test.ts index db21c0199..31e1d7dc7 100644 --- a/packages/void/test/unit/route-url-builder.test.ts +++ b/packages/void/test/unit/route-url-builder.test.ts @@ -13,10 +13,25 @@ declare module 'void/routes' { '/route-util/files/*': '*'; '/route-util/:toString': 'toString'; '/:leading{.+}': 'leading'; + '/route-util/[id]': 'id'; + '/route-util/files/[...path]': 'path'; + '/route-util/files/[...]': '*'; } } describe('routeUrl', () => { + it('builds URLs and destinations from bracket patterns', () => { + expect(routeUrl('/route-util/[id]', { params: { id: 42 } })).toBe('/route-util/42'); + expect(routeUrl('/route-util/files/[...path]', { params: { path: 'docs/start here' } })).toBe( + '/route-util/files/docs/start%20here', + ); + expect(routeUrl('/route-util/files/[...]', { params: { '*': 'a/b' } })).toBe( + '/route-util/files/a/b', + ); + expect( + toUrlString({ pathname: '/route-util/[id]', params: { id: 'a/b' }, hash: 'details' }), + ).toBe('/route-util/a%2Fb#details'); + }); it('converts typed destinations and preserves ordinary URLs', () => { expect( toUrlString({ diff --git a/packages/void/test/unit/route-url.test.ts b/packages/void/test/unit/route-url.test.ts index 7a828ecbd..bba54a7a5 100644 --- a/packages/void/test/unit/route-url.test.ts +++ b/packages/void/test/unit/route-url.test.ts @@ -2,6 +2,38 @@ import { describe, expect, it } from 'vite-plus/test'; import { interpolateRouteParams } from '../../src/runtime/route-url.ts'; describe('route URL interpolation', () => { + it.each([ + ['/users/[id]', '/users/:id', { id: 'a/b ?#%' }, '/users/a%2Fb%20%3F%23%25'], + [ + '/docs/[...slug]', + '/docs/:slug{.+}', + { slug: 'guides/get started' }, + '/docs/guides/get%20started', + ], + ['/docs/[...]', '/docs/*', { '*': 'a/b ?#%' }, '/docs/a/b%20%3F%23%25'], + ['/[id2]/[id]/[id]', '/:id2/:id/:id', { id: 'one', id2: 'two' }, '/two/one/one'], + ['/docs/[...]', '/docs/*', { '*': '' }, '/docs/'], + ] as const)( + 'resolves public %s and legacy %s patterns identically', + (path, legacy, params, url) => { + expect(interpolateRouteParams(path, params)).toBe(url); + expect(interpolateRouteParams(legacy, params)).toBe(url); + }, + ); + + it('preserves literal brackets, missing params, queries and named action suffixes', () => { + expect(interpolateRouteParams('/x[id]/[id]/[missing]?save#/[id]', { id: 'one' })).toBe( + '/x[id]/one/[missing]?save#/[id]', + ); + expect(interpolateRouteParams('/[id]', undefined)).toBe('/[id]'); + expect(interpolateRouteParams('/[toString]', {})).toBe('/[toString]'); + expect(interpolateRouteParams('/docs/[...]', {})).toBe('/docs/[...]'); + expect(() => interpolateRouteParams('/[id]', {}, true)).toThrow('Missing route param "id".'); + expect(() => interpolateRouteParams('/docs/[...slug]', { slug: '' }, true)).toThrow( + 'must not be empty', + ); + }); + it('expands catch-all segments and encodes each path component', () => { expect(interpolateRouteParams('/files/:path{.+}', { path: 'docs/start ?#%.md' })).toBe( '/files/docs/start%20%3F%23%25.md', @@ -27,12 +59,18 @@ describe('route URL interpolation', () => { ['/api/posts/:id', { id: '.' }], ['/files/:path{.+}', { path: 'a/../../admin' }], ['/files/:path{.+}', { path: './x' }], + ['/api/posts/[id]', { id: '..' }], + ['/files/[...path]', { path: 'a/../../admin' }], + ['/files/[...]', { '*': './x' }], ])('rejects dot segments that would change the path of %s (%j)', (path, params) => { expect(() => interpolateRouteParams(path, params)).toThrow(TypeError); expect(() => interpolateRouteParams(path, params)).toThrow(/it would change the request path/); }); it('rejects a leading param that would make the path start with //', () => { + expect(() => interpolateRouteParams('/[...path]', { path: '/evil.com/x' })).toThrow( + 'changes the request host', + ); expect(() => interpolateRouteParams('/:path{.+}', { path: '/evil.com/x' })).toThrow( new TypeError( 'Route param "path" would make the request path start with "//", which changes the request host.', diff --git a/packages/void/test/unit/ws.test.ts b/packages/void/test/unit/ws.test.ts index 440cdcfd3..929063e63 100644 --- a/packages/void/test/unit/ws.test.ts +++ b/packages/void/test/unit/ws.test.ts @@ -3,6 +3,8 @@ import { connect } from '../../src/runtime/ws.ts'; declare module 'void/routes' { interface WebSocketRouteMap { + '/chat/[room]': WebSocketRouteMap['/chat/:room']; + '/files/[...path]': WebSocketRouteMap['/files/:path{.+}']; '/chat/:room': { params: { room: string }; client: { type: 'chat.message'; text: string }; @@ -80,9 +82,17 @@ describe('typed websocket client', () => { (globalThis as { location?: unknown }).location = originalLocation; }); - it('expands complete catch-all tokens', () => { - connect('/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + it.each(['/files/[...path]', '/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + (path) => { + connect(path, { params: { path: 'docs/start here' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + }, + ); + + it('interpolates bracket parameters in WebSocket URLs', () => { + connect('/chat/[room]', { params: { room: 'a/b' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/chat/a%2Fb'); }); it('keeps a secure wss baseURL secure', () => {
; // Fixed segments are also accepted percent-encoded, as `routeUrl()` returns them. diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index f55a40595..84fb6854c 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -238,7 +238,7 @@ import { useParams as vue } from ${adapterSource('vue/src/runtime/use-params.ts' import { useParams as svelte } from ${adapterSource('svelte/src/runtime/use-params.ts')}; import { useParams as solid } from ${adapterSource('solid/src/runtime/use-params.ts')}; type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -const pages: Array = ['/', '/about', '/users/:id', '/files/:path{.+}', '/wild/*']; +const pages: Array = ['/', '/about', '/users/[id]', '/files/[...path]', '/wild/[...]', '/users/:id', '/files/:path{.+}', '/wild/*']; // @ts-expect-error HTTP routes are not pages const api: PagePath = '/api/users/:id'; void [pages, api]; @@ -247,6 +247,15 @@ ${['react', 'vue', 'svelte', 'solid'] .map( (useParams) => `{ const user = ${useParams}<'/users/:id'>(); + const bracketUser = ${useParams}<'/users/[id]'>(); + const bracketFiles = ${useParams}<'/files/[...path]'>(); + const bracketWild = ${useParams}<'/wild/[...]'>(); + const aliases: [ + Same, + Same, + Same, + ] = [true, true, true]; + void aliases; const files = ${useParams}<'/files/:path{.+}'>(); const wild = ${useParams}<'/wild/*'>(); const about = ${useParams}<'/about'>(); @@ -325,6 +334,17 @@ routeUrl('/café menu'); routeUrl('/api/users/:id', { params: { id: '123' } }); routeUrl('/files/:path{.+}', { params: { path: 'docs/start' } }); routeUrl('/wild/*', { params: { '*': 'a/b' } }); +routeUrl('/users/[id]', { params: { id: 123 } }); +routeUrl('/files/[...path]', { params: { path: 'docs/start' } }); +routeUrl('/wild/[...]', { params: { '*': 'a/b' } }); +// @ts-expect-error bracket patterns require params +routeUrl('/users/[id]'); +// @ts-expect-error bracket catch-all patterns require params +routeUrl('/files/[...path]'); +// @ts-expect-error bracket anonymous catch-alls require a wildcard param +routeUrl('/wild/[...]'); +// @ts-expect-error bracket params are checked +routeUrl('/users/[id]', { params: { wrong: 123 } }); // @ts-expect-error a known route's params are required routeUrl('/users/:id'); // @ts-expect-error params cannot be omitted while providing other options @@ -341,6 +361,8 @@ routeUrl('/files/:path{.+}'); routeUrl('/wild/*'); void url; const destination: RouteDestination = { pathname: '/users/:id', params: { id: 123 }, query: { tab: 'activity' } }; +const bracketDestination: RouteDestination = { pathname: '/users/[id]', params: { id: 123 } }; +void bracketDestination; declare const router: VoidRouter; void router.visit(destination); void router.prefetch(destination); @@ -440,6 +462,77 @@ function typecheck( } describe('standalone routeUrl utility', () => { + it.each(['generated', 'package'] as const)( + 'preserves HTTP, WebSocket and page action contracts for both syntaxes through %s declarations', + async (declarations) => { + const root = await createFixture({ + extraFiles: { + 'routes/chat/[room].ws.ts': `import { defineRoom } from 'void/ws'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const message: Schema<{ text: string }>; +export default defineRoom({ messages: { client: message, server: message } }); +`, + 'pages/users/[id].server.ts': `import { defineHandler } from 'void/handler'; +type Schema = { '~standard': { version: 1; vendor: string; validate: (value: unknown) => { value: T }; types?: { input: T; output: T } } }; +declare const body: Schema<{ name: string }>; +export const actions = { save: defineHandler.withValidator({ body })(() => ({ ok: true })) }; +`, + 'check-aliases.ts': `import { fetch } from 'void/client'; +import { connect } from 'void/ws'; +import { createActionClient } from 'void/pages-client'; +import type { RouteMap, RoutePathMap, PagePathMap, PageActionMap, WebSocketRouteMap } from 'void/routes'; +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const aliases: [ + Same, + Same, + Same, + Same, + Same, +] = [true, true, true, true, true]; +void aliases; +const bracket = fetch('/api/users/[id]', { params: { id: '42' } }); +const legacy = fetch('/api/users/:id', { params: { id: '42' } }); +const responses: Same = true; +void responses; +// @ts-expect-error methods stay constrained through bracket keys +fetch('/api/users/[id]', { method: 'POST' }); +// @ts-expect-error legacy methods stay constrained +fetch('/api/users/:id', { method: 'POST' }); +connect('/chat/[room]', { params: { room: 'lobby' } }).send({ text: 'hi' }); +connect('/chat/:room', { params: { room: 'lobby' } }).send({ text: 'hi' }); +// @ts-expect-error bracket WebSocket params are required +connect('/chat/[room]', {}); +// @ts-expect-error legacy WebSocket params are required +connect('/chat/:room', {}); +connect('/chat/[room]', { params: { room: 'lobby' } }).on('message', (message) => { + const text: string = message.text; + // @ts-expect-error server message types stay constrained + void message.missing; + void text; +}); +const { action } = createActionClient(); +action('/users/[id]?save', { params: { id: '42' }, data: { name: 'Alice' } }); +action('/users/:id?save', { params: { id: '42' }, data: { name: 'Alice' } }); +// @ts-expect-error bracket actions require params +action('/users/[id]?save'); +// @ts-expect-error legacy actions require params +action('/users/:id?save'); +// @ts-expect-error bracket action bodies stay constrained +action('/users/[id]?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error legacy action bodies stay constrained +action('/users/:id?save', { params: { id: '42' }, data: { name: 42 } }); +// @ts-expect-error bracket action names stay constrained +action('/users/[id]?missing', { params: { id: '42' } }); +`, + }, + }); + expect(typecheck(root, declarations, ['check-aliases.ts'])).toEqual({ + output: '', + status: 0, + }); + }, + ); + it.each(['generated', 'package'] as const)( 'typechecks through the %s declarations', async (declarations) => { @@ -525,7 +618,7 @@ void loose; join(root, 'routes.d.ts'), `export {}; declare module 'void/routes' { - interface RoutePathMap { '/': never; '/docs/*': '*'; '/users/:id': 'id'; '/café menu': never } + interface RoutePathMap { '/': never; '/docs/[...]': '*'; '/users/[id]': 'id'; '/café menu': never } interface EncodedRoutePathMap { '/café menu': '/caf%C3%A9%20menu' } interface RouteTypesConfig { typedRoutes: true } } @@ -611,7 +704,12 @@ void [hrefs, route, unknown, external]; `import { routeUrl } from 'void/routes'; export function buildUrls() { return [ + routeUrl('/users/[id]', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), routeUrl('/users/:id', { params: { id: 'a/b' }, query: { page: 2, tag: ['one', 'two'] }, hash: '/settings?tab=1&next=%2Fhome' }), + routeUrl('/files/[...path]', { params: { path: 'docs/start here' } }), + routeUrl('/files/:path{.+}', { params: { path: 'docs/start here' } }), + routeUrl('/wild/[...]', { params: { '*': 'a/b' } }), + routeUrl('/wild/*', { params: { '*': 'a/b' } }), routeUrl('/café menu', { query: { q: 'café menu' }, hash: 'café menu' }), ]; } @@ -628,6 +726,11 @@ export function buildUrls() { }); const expected = [ '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/users/a%2Fb?page=2&tag=one&tag=two#/settings?tab=1&next=%2Fhome', + '/files/docs/start%20here', + '/files/docs/start%20here', + '/wild/a/b', + '/wild/a/b', '/caf%C3%A9%20menu?q=caf%C3%A9+menu#caf%C3%A9%20menu', ]; const scriptPath = join(root, 'dist/route.mjs'); diff --git a/packages/void/test/unit/action-result.test.ts b/packages/void/test/unit/action-result.test.ts index 5db822319..894801294 100644 --- a/packages/void/test/unit/action-result.test.ts +++ b/packages/void/test/unit/action-result.test.ts @@ -14,6 +14,7 @@ import { declare module 'void/routes' { interface PageActionMap { + '/__action-test/users/[id]': PageActionMap['/__action-test/users/:id']; '/__action-test/static': { body: { name: string } }; '/__action-test/users/:id': { params: { id: string }; @@ -248,23 +249,26 @@ describe('action clients', () => { }); }); - it('encodes path parameters while preserving named actions and submission options', async () => { - const client = createActionClient(); - const visit = vi.fn(async () => ({ props: {} })); - client.setActionRouter(routerWithVisit(visit)); - - await client.action('/__action-test/users/:id?save', { - params: { id: 'a/b?c' }, - method: 'PATCH', - data: { name: 'Alice' }, - }); - - expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { - method: 'PATCH', - data: { name: 'Alice' }, - preserveScroll: undefined, - }); - }); + it.each(['/__action-test/users/[id]?save', '/__action-test/users/:id?save'] as const)( + 'encodes params and preserves named actions in %s', + async (url) => { + const client = createActionClient(); + const visit = vi.fn(async () => ({ props: {} })); + client.setActionRouter(routerWithVisit(visit)); + + await client.action(url, { + params: { id: 'a/b?c' }, + method: 'PATCH', + data: { name: 'Alice' }, + }); + + expect(visit).toHaveBeenCalledWith('/__action-test/users/a%2Fb%3Fc?save', { + method: 'PATCH', + data: { name: 'Alice' }, + preserveScroll: undefined, + }); + }, + ); it('preserves validation errors as action results', async () => { const client = createActionClient(); diff --git a/packages/void/test/unit/fetch-stream.test.ts b/packages/void/test/unit/fetch-stream.test.ts index 79009c929..bb894efcd 100644 --- a/packages/void/test/unit/fetch-stream.test.ts +++ b/packages/void/test/unit/fetch-stream.test.ts @@ -17,6 +17,7 @@ vi.mock('ofetch', () => { declare module 'void/routes' { interface RouteMap { + '/api/stream/[...path]': RouteMap['/api/stream/:path{.+}']; '/api/chat': { POST: { input: { body: { messages: Array } }; output: unknown }; }; @@ -50,11 +51,14 @@ beforeEach(() => { }); describe('fetchStream', () => { - it('expands complete catch-all tokens', async () => { - vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); - await fetchStream('/api/stream/:path{.+}', { params: { path: 'docs/start here' } }).next(); - expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); - }); + it.each(['/api/stream/[...path]', '/api/stream/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(ofetch.raw).mockResolvedValue({ _data: null } as never); + await fetchStream(path, { params: { path: 'docs/start here' } }).next(); + expect(ofetch.raw).toHaveBeenCalledWith('/api/stream/docs/start%20here', expect.anything()); + }, + ); it('parses Workers AI JSON SSE format', async () => { const stream = makeSSEStream([ 'data: {"response":"Hello"}\n\n', diff --git a/packages/void/test/unit/fetch.test.ts b/packages/void/test/unit/fetch.test.ts index 63063053d..6e240e49f 100644 --- a/packages/void/test/unit/fetch.test.ts +++ b/packages/void/test/unit/fetch.test.ts @@ -3,6 +3,8 @@ import { fetch as typedFetch, FetchError } from '../../src/runtime/fetch.ts'; declare module 'void/routes' { interface RouteMap { + '/api/users/[id]': RouteMap['/api/users/:id']; + '/api/files/[...path]': RouteMap['/api/files/:path{.+}']; '/api/hello': { GET: { input: {}; output: { ok: boolean } }; }; @@ -53,13 +55,23 @@ describe('typed fetch client', () => { expect(result).toEqual({ ok: true }); }); - it('expands complete catch-all tokens', async () => { - vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); - await typedFetch('/api/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(globalThis.fetch).toHaveBeenCalledWith( - '/api/files/docs/start%20here', - expect.anything(), - ); + it.each(['/api/files/[...path]', '/api/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + async (path) => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({})); + await typedFetch(path, { params: { path: 'docs/start here' } }); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/api/files/docs/start%20here', + expect.anything(), + ); + }, + ); + + it('interpolates bracket parameters for typed requests', async () => { + vi.mocked(globalThis.fetch).mockResolvedValue(jsonResponse({ id: 'a/b', name: 'Alice' })); + const result = await typedFetch('/api/users/[id]', { params: { id: 'a/b' } }); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/users/a%2Fb', expect.anything()); + expect(result).toEqual({ id: 'a/b', name: 'Alice' }); }); it('builds query string', async () => { diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts index fe888ed61..42d4002a4 100644 --- a/packages/void/test/unit/route-types.test.ts +++ b/packages/void/test/unit/route-types.test.ts @@ -198,9 +198,12 @@ describe('generateRouteTypes', () => { const pageMap = dts.split(' export interface PagePathMap {')[1]!.split(' }')[0]!; expect(pageMap).toContain('"/about": never;'); - expect(pageMap).toContain('"/users/:id": "id";'); - expect(pageMap).toContain('"/files/:path{.+}": "path";'); - expect(pageMap).toContain('"/wild/*": "*";'); + expect(pageMap).toContain('"/users/[id]": "id";'); + expect(pageMap).toContain('"/users/:id": PagePathMap["/users/[id]"];'); + expect(pageMap).toContain('"/files/[...path]": "path";'); + expect(pageMap).toContain('"/files/:path{.+}": PagePathMap["/files/[...path]"];'); + expect(pageMap).toContain('"/wild/[...]": "*";'); + expect(pageMap).toContain('"/wild/*": PagePathMap["/wild/[...]"];'); expect(pageMap).not.toContain('/api/users/:id'); }); @@ -220,7 +223,9 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); - expect(dts).toContain('"/api/users/:id"'); + expect(dts).toContain('"/api/users/[id]": {'); + expect(dts).toContain('/** @deprecated Use "/api/users/[id]" instead. */'); + expect(dts).toContain('"/api/users/:id": RouteMap["/api/users/[id]"];'); expect(dts).toContain('typeof import("../routes/api/users/[id].ts")'); }); @@ -290,6 +295,7 @@ describe('generateRouteTypes', () => { expect(encoded.trim().split('\n')).toEqual([ '"/café menu": "/caf%C3%A9%20menu";', + ' "/café/[id]": "/caf%C3%A9/[id]";', ' "/café/:id": "/caf%C3%A9/:id";', ]); expect(dts).toContain('type RoutePathUrl = RoutePatternUrl;'); @@ -405,14 +411,14 @@ describe('generateRouteTypes', () => { expect(actionMap.split('"/users/:id"')).toHaveLength(2); expect(actionMap).toContain( [ - ' "/users/:id": {', + ' "/users/[id]": {', ` body: ExtractInput<${dev}["__validators"]> extends { body: infer B } ? B : Record;`, ' params: { id: string };', ' } | {', ` actions: { [K in keyof ${prod}]: {`, ].join('\n'), ); - expect(actionMap).toContain([' params: { id: string };', ' };', ' }'].join('\n')); + expect(actionMap).toContain(' "/users/:id": PageActionMap["/users/[id]"];'); }); it('generates WebSocketRouteMap entries', () => { @@ -472,7 +478,8 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const websocketMap = dts.split(' interface WebSocketRouteMap {')[1]!.split('\n }\n}')[0]!; - expect(websocketMap.match(/"\/chat\/:room": \{/g)).toHaveLength(1); + expect(websocketMap.match(/"\/chat\/\[room\]": \{/g)).toHaveLength(1); + expect(websocketMap).toContain('"/chat/:room": WebSocketRouteMap["/chat/[room]"];'); expect(websocketMap).toContain('chat/[room].dev.ws.ts'); expect(websocketMap).toContain('chat/[room].prod.ws.ts'); expect(websocketMap).toMatch(/client: .*dev\.ws\.ts.* & .*prod\.ws\.ts/); @@ -636,11 +643,15 @@ describe('generateRouteTypes', () => { }); const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; - expect(pathMap).toContain('"/api/users/:id": "id";'); + expect(pathMap).toContain('"/api/users/[id]": "id";'); + expect(pathMap).toContain('"/api/users/:id": RoutePathMap["/api/users/[id]"];'); expect(pathMap).toContain('"/about": never;'); - expect(pathMap).toContain('"/files/:path{.+}": "path";'); - expect(pathMap).toContain('"/files/*": "*";'); - expect(pathMap).toContain('"/chat/:room": "room";'); + expect(pathMap).toContain('"/files/[...path]": "path";'); + expect(pathMap).toContain('"/files/:path{.+}": RoutePathMap["/files/[...path]"];'); + expect(pathMap).toContain('"/files/[...]": "*";'); + expect(pathMap).toContain('"/files/*": RoutePathMap["/files/[...]"];'); + expect(pathMap).toContain('"/chat/[room]": "room";'); + expect(pathMap).toContain('"/chat/:room": RoutePathMap["/chat/[room]"];'); expect(dts).not.toContain('import("../pages/'); }); @@ -672,6 +683,7 @@ describe('generateRouteTypes', () => { const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void'); const pathMap = dts.split(' export interface RoutePathMap {')[1]!.split(' }')[0]!; expect(pathMap.match(/"\/users\/:id":/g)).toHaveLength(1); - expect(pathMap).toContain('"/users/:id": "id";'); + expect(pathMap).toContain('"/users/[id]": "id";'); + expect(pathMap).toContain('"/users/:id": RoutePathMap["/users/[id]"];'); }); }); diff --git a/packages/void/test/unit/route-url-builder.test.ts b/packages/void/test/unit/route-url-builder.test.ts index db21c0199..31e1d7dc7 100644 --- a/packages/void/test/unit/route-url-builder.test.ts +++ b/packages/void/test/unit/route-url-builder.test.ts @@ -13,10 +13,25 @@ declare module 'void/routes' { '/route-util/files/*': '*'; '/route-util/:toString': 'toString'; '/:leading{.+}': 'leading'; + '/route-util/[id]': 'id'; + '/route-util/files/[...path]': 'path'; + '/route-util/files/[...]': '*'; } } describe('routeUrl', () => { + it('builds URLs and destinations from bracket patterns', () => { + expect(routeUrl('/route-util/[id]', { params: { id: 42 } })).toBe('/route-util/42'); + expect(routeUrl('/route-util/files/[...path]', { params: { path: 'docs/start here' } })).toBe( + '/route-util/files/docs/start%20here', + ); + expect(routeUrl('/route-util/files/[...]', { params: { '*': 'a/b' } })).toBe( + '/route-util/files/a/b', + ); + expect( + toUrlString({ pathname: '/route-util/[id]', params: { id: 'a/b' }, hash: 'details' }), + ).toBe('/route-util/a%2Fb#details'); + }); it('converts typed destinations and preserves ordinary URLs', () => { expect( toUrlString({ diff --git a/packages/void/test/unit/route-url.test.ts b/packages/void/test/unit/route-url.test.ts index 7a828ecbd..bba54a7a5 100644 --- a/packages/void/test/unit/route-url.test.ts +++ b/packages/void/test/unit/route-url.test.ts @@ -2,6 +2,38 @@ import { describe, expect, it } from 'vite-plus/test'; import { interpolateRouteParams } from '../../src/runtime/route-url.ts'; describe('route URL interpolation', () => { + it.each([ + ['/users/[id]', '/users/:id', { id: 'a/b ?#%' }, '/users/a%2Fb%20%3F%23%25'], + [ + '/docs/[...slug]', + '/docs/:slug{.+}', + { slug: 'guides/get started' }, + '/docs/guides/get%20started', + ], + ['/docs/[...]', '/docs/*', { '*': 'a/b ?#%' }, '/docs/a/b%20%3F%23%25'], + ['/[id2]/[id]/[id]', '/:id2/:id/:id', { id: 'one', id2: 'two' }, '/two/one/one'], + ['/docs/[...]', '/docs/*', { '*': '' }, '/docs/'], + ] as const)( + 'resolves public %s and legacy %s patterns identically', + (path, legacy, params, url) => { + expect(interpolateRouteParams(path, params)).toBe(url); + expect(interpolateRouteParams(legacy, params)).toBe(url); + }, + ); + + it('preserves literal brackets, missing params, queries and named action suffixes', () => { + expect(interpolateRouteParams('/x[id]/[id]/[missing]?save#/[id]', { id: 'one' })).toBe( + '/x[id]/one/[missing]?save#/[id]', + ); + expect(interpolateRouteParams('/[id]', undefined)).toBe('/[id]'); + expect(interpolateRouteParams('/[toString]', {})).toBe('/[toString]'); + expect(interpolateRouteParams('/docs/[...]', {})).toBe('/docs/[...]'); + expect(() => interpolateRouteParams('/[id]', {}, true)).toThrow('Missing route param "id".'); + expect(() => interpolateRouteParams('/docs/[...slug]', { slug: '' }, true)).toThrow( + 'must not be empty', + ); + }); + it('expands catch-all segments and encodes each path component', () => { expect(interpolateRouteParams('/files/:path{.+}', { path: 'docs/start ?#%.md' })).toBe( '/files/docs/start%20%3F%23%25.md', @@ -27,12 +59,18 @@ describe('route URL interpolation', () => { ['/api/posts/:id', { id: '.' }], ['/files/:path{.+}', { path: 'a/../../admin' }], ['/files/:path{.+}', { path: './x' }], + ['/api/posts/[id]', { id: '..' }], + ['/files/[...path]', { path: 'a/../../admin' }], + ['/files/[...]', { '*': './x' }], ])('rejects dot segments that would change the path of %s (%j)', (path, params) => { expect(() => interpolateRouteParams(path, params)).toThrow(TypeError); expect(() => interpolateRouteParams(path, params)).toThrow(/it would change the request path/); }); it('rejects a leading param that would make the path start with //', () => { + expect(() => interpolateRouteParams('/[...path]', { path: '/evil.com/x' })).toThrow( + 'changes the request host', + ); expect(() => interpolateRouteParams('/:path{.+}', { path: '/evil.com/x' })).toThrow( new TypeError( 'Route param "path" would make the request path start with "//", which changes the request host.', diff --git a/packages/void/test/unit/ws.test.ts b/packages/void/test/unit/ws.test.ts index 440cdcfd3..929063e63 100644 --- a/packages/void/test/unit/ws.test.ts +++ b/packages/void/test/unit/ws.test.ts @@ -3,6 +3,8 @@ import { connect } from '../../src/runtime/ws.ts'; declare module 'void/routes' { interface WebSocketRouteMap { + '/chat/[room]': WebSocketRouteMap['/chat/:room']; + '/files/[...path]': WebSocketRouteMap['/files/:path{.+}']; '/chat/:room': { params: { room: string }; client: { type: 'chat.message'; text: string }; @@ -80,9 +82,17 @@ describe('typed websocket client', () => { (globalThis as { location?: unknown }).location = originalLocation; }); - it('expands complete catch-all tokens', () => { - connect('/files/:path{.+}', { params: { path: 'docs/start here' } }); - expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + it.each(['/files/[...path]', '/files/:path{.+}'] as const)( + 'expands catch-all tokens in %s', + (path) => { + connect(path, { params: { path: 'docs/start here' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/files/docs/start%20here'); + }, + ); + + it('interpolates bracket parameters in WebSocket URLs', () => { + connect('/chat/[room]', { params: { room: 'a/b' } }); + expect(MockWebSocket.instances[0]!.url).toBe('wss://void.test/chat/a%2Fb'); }); it('keeps a secure wss baseURL secure', () => {