From f1262d9e0be9e809dba71733bc3dd36567e27325 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Sat, 7 Nov 2026 11:00:14 +0800 Subject: [PATCH] feat(routes): check SSE and live stream client URLs with typedRoutes With `routing.typedRoutes` on, `connectEventStream()` and `connectLiveStream()` accept one of the app's route URLs, an absolute `http://`/`https://` URL, or a `URL` object. Unknown paths, WebSocket, `mailto:`, `tel:`, same-page URLs and unchecked strings are type errors. With the flag off, the parameter stays `string | URL`. Co-Authored-By: Claude Opus 5.5 --- docs/guide/live.md | 2 ++ docs/guide/sse.md | 2 ++ docs/guide/type-safety.md | 6 +++-- docs/reference/config.md | 2 +- packages/void/src/runtime/live-client.ts | 11 +++++++- packages/void/src/runtime/route-url.ts | 9 +++++++ packages/void/src/runtime/sse-client.ts | 11 +++++++- .../integration/route-url-builder.test.ts | 26 +++++++++++++++++++ .../test/unit/typed-navigation.typecheck.ts | 11 ++++++++ 9 files changed, 75 insertions(+), 5 deletions(-) diff --git a/docs/guide/live.md b/docs/guide/live.md index f862face6..db51efcd7 100644 --- a/docs/guide/live.md +++ b/docs/guide/live.md @@ -77,6 +77,8 @@ stream.close(); Subscription IDs are scoped to a connection. Reusing an ID replaces its subscription. After reconnecting, the helper restores active subscriptions and sends their latest `eventId` as `lastEventId`. +With [`routing.typedRoutes`](../reference/config.md#routing-typedroutes), the URL must match one of your routes or be an `http://` or `https://` URL. Cast a `string` that TypeScript can't check with `as RouteUrl` from `void/routes`. The cast turns off the check for that URL. + ## Publish Publish from any server-side code that has a Void runtime env: diff --git a/docs/guide/sse.md b/docs/guide/sse.md index 99b53ff44..1b83eac9f 100644 --- a/docs/guide/sse.md +++ b/docs/guide/sse.md @@ -141,6 +141,8 @@ stream.close(); The client wraps native `EventSource`. It supports cookie credentials and typed event handlers, but it does not support request bodies or arbitrary headers. Use cookie auth, signed URLs, or a fetch-based streaming route when you need custom headers. +With [`routing.typedRoutes`](../reference/config.md#routing-typedroutes), the URL must match one of your routes, such as `` `/api/jobs/${id}` ``, or be an `http://` or `https://` URL. Cast a `string` that TypeScript can't check with `as RouteUrl` from `void/routes`. The cast turns off the check for that URL. + JSON parsing is the default. Set `parse: 'text'` or pass a custom parser for non-JSON payloads: ```ts diff --git a/docs/guide/type-safety.md b/docs/guide/type-safety.md index 561daab29..074a86218 100644 --- a/docs/guide/type-safety.md +++ b/docs/guide/type-safety.md @@ -257,7 +257,7 @@ await revalidate({ paths: ['/', '/blog/hello-world'] satisfies RouteUrl[] }); ### Checked URL strings -[`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. Apps created with `void init` have it on. To turn it on in an existing app: +[`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, `c.rewrite()`, `connectEventStream()`, and `connectLiveStream()`. Apps created with `void init` have it on. To turn it on in an existing app: ```ts // void.config.ts @@ -324,6 +324,8 @@ import { routeUrl } from 'void/routes'; Links can also point to the current page (`?page=2`, `#comments`) and to `http://`, `https://`, `mailto:`, and `tel:` URLs. Relative paths such as `../settings` are rejected; use the absolute path. A fixed part with spaces or non-ASCII characters can be written as is (`/café menu`) or percent-encoded in uppercase, as `routeUrl()` returns it (`/caf%C3%A9%20menu`). A dynamic segment matches any text, including none, so `/users/:id` also accepts `/users/` and `/users/42/edit`. With a root dynamic page such as `pages/[slug].tsx`, `.vue`, or `.svelte`, a protocol-relative URL like `//example.com` passes too. +The [SSE](./sse.md#client) and [live stream](./live.md#subscribe-from-the-browser) clients, `connectEventStream()` and `connectLiveStream()`, take one of your routes or an `http://` or `https://` URL for a stream on another origin. They reject same-page, `mailto:`, `tel:`, and `ws://` URLs. Cast a stream URL that TypeScript can't check with `as RouteUrl`; the cast turns off the check for that URL. + `c.redirect()` is not part of that check. It still accepts any string. Build an internal path with `routeUrl()`, or annotate a full path with `satisfies RouteUrl`. With `routing.typedRoutes` on, a path that matches no route is a type error. An external URL stays a plain string: ```ts @@ -401,7 +403,7 @@ Route types update with the dev server. For CI or standalone typechecking, run ` | Handlers | `c.env` bindings, validator input, return type | | Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type | | Navigation | Route patterns and required parameter names and values in `routeUrl()` and destination objects | -| Links | With `routing.typedRoutes`, URL strings in `Link`, router methods, and `c.rewrite()` | +| Links | With `routing.typedRoutes`, URL strings in `Link`, router methods, `c.rewrite()`, and SSE and live stream clients | | `c.redirect` | An internal path built with `routeUrl()`, or a full path annotated with `satisfies RouteUrl` | | `useParams` | Page patterns and the names of the params they return | | `c.req.param` | Page param names, when the result is annotated with `PageParams` | diff --git a/docs/reference/config.md b/docs/reference/config.md index d16bee826..c62773f79 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -378,7 +378,7 @@ routing: { #### `routing.typedRoutes` -Type-check URL strings against your app's routes in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `c.redirect()`, `revalidate({ paths })`, and `routing.prerender` stay plain strings until you annotate them. See [Checked URL strings](../guide/type-safety#checked-url-strings). +Type-check URL strings against your app's routes in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, `c.rewrite()`, `connectEventStream()`, and `connectLiveStream()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `c.redirect()`, `revalidate({ paths })`, and `routing.prerender` stay plain strings until you annotate them. See [Checked URL strings](../guide/type-safety#checked-url-strings). ```ts routing: { diff --git a/packages/void/src/runtime/live-client.ts b/packages/void/src/runtime/live-client.ts index 03fabb0c2..6b08d637c 100644 --- a/packages/void/src/runtime/live-client.ts +++ b/packages/void/src/runtime/live-client.ts @@ -1,4 +1,5 @@ import type { LiveClientEvent, LiveControlOperation, LiveControlResponse } from './live.ts'; +import type { RequestUrl } from './route-url.ts'; export type LiveSubscribeOptions = { id: string; @@ -47,8 +48,16 @@ type PendingOperation = { reject: (error: unknown) => void; }; +/** + * Open one `EventSource` to a `defineLiveStream()` route and send subscribe and + * unsubscribe requests to the same URL. + * + * With `routing.typedRoutes`, a string `url` must match one of the app's routes + * or be an absolute `http://`/`https://` URL. Cast a path built at runtime with + * `as RouteUrl`. + */ export function connectLiveStream( - url: string | URL, + url: RequestUrl | URL, options: ConnectLiveStreamOptions = {}, ): LiveClient { const EventSourceCtor = diff --git a/packages/void/src/runtime/route-url.ts b/packages/void/src/runtime/route-url.ts index 3497f69cf..fe6c9572f 100644 --- a/packages/void/src/runtime/route-url.ts +++ b/packages/void/src/runtime/route-url.ts @@ -105,6 +105,15 @@ export type Href = TypedRoutes extends true ? RouteUrl | `${'?' | '#'}${string}` | `${'http://' | 'https://' | 'mailto:' | 'tel:'}${string}` : string; +/** + * A URL an HTTP request can target: one of the app's routes or an absolute + * `http://`/`https://` URL. A plain string unless `routing.typedRoutes` is on. + */ +// Not re-exported from `void/routes`: apps cast runtime strings with `as RouteUrl`. +export type RequestUrl = TypedRoutes extends true + ? RouteUrl | `${'http://' | 'https://'}${string}` + : string; + export type RouteQueryValue = string | number | boolean | null | undefined; export type RouteQuery = Record>; diff --git a/packages/void/src/runtime/sse-client.ts b/packages/void/src/runtime/sse-client.ts index 602f33b5d..cc6d5b461 100644 --- a/packages/void/src/runtime/sse-client.ts +++ b/packages/void/src/runtime/sse-client.ts @@ -1,3 +1,5 @@ +import type { RequestUrl } from './route-url.ts'; + export type SseClientEvent = { data: T; event: string; @@ -38,8 +40,15 @@ type EventSourceConstructor = new ( options?: { withCredentials?: boolean }, ) => EventSourceLike; +/** + * Open an `EventSource` to a route that returns `eventStream()`. + * + * With `routing.typedRoutes`, a string `url` must match one of the app's routes + * or be an absolute `http://`/`https://` URL. Cast a path built at runtime with + * `as RouteUrl`. + */ export function connectEventStream( - url: string | URL, + url: RequestUrl | URL, options: ConnectEventStreamOptions = {}, ): SseClient { const EventSourceCtor = (globalThis as { EventSource?: EventSourceConstructor }).EventSource; diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts index 7edc46d4a..44888aba9 100644 --- a/packages/void/test/integration/route-url-builder.test.ts +++ b/packages/void/test/integration/route-url-builder.test.ts @@ -27,6 +27,8 @@ const typedCheck = `import { routeUrl, type Href, type RouteUrl } from 'void/rou import { defineConfig } from 'void/config'; import type { VoidRouter } from 'void/pages-client'; import { defineMiddleware, type CloudContext } from 'void/handler'; +import { connectLiveStream } from 'void/live/client'; +import { connectEventStream } from 'void/sse/client'; declare const id: number; declare const dynamic: string; const user: RouteUrl = routeUrl('/users/:id', { params: { id } }); @@ -92,6 +94,30 @@ void context.rewrite('https://example.com'); // @ts-expect-error rewrites do not take same-page hashes void context.rewrite('#details'); context.redirect('https://example.com/elsewhere'); +connectEventStream('/api/legacy'); +connectEventStream(\`/api/users/\${id}\`); +connectEventStream(user); +connectEventStream('https://example.com/events'); +connectEventStream(new URL('https://example.com/events')); +connectEventStream(dynamic as RouteUrl); +// @ts-expect-error event streams reject unknown routes +connectEventStream('/unknown'); +// @ts-expect-error event streams do not take WebSocket URLs +connectEventStream('ws://example.com/events'); +// @ts-expect-error event streams do not take same-page hashes +connectEventStream('#details'); +// @ts-expect-error event streams reject unchecked strings +connectEventStream(dynamic); +connectLiveStream('/api/legacy'); +connectLiveStream(\`/api/users/\${id}?room=1\`); +connectLiveStream('http://localhost:5173/live'); +connectLiveStream(new URL('https://example.com/live')); +// @ts-expect-error live streams reject unknown routes +connectLiveStream('/unknown'); +// @ts-expect-error live streams do not take WebSocket URLs +connectLiveStream('wss://example.com/live'); +// @ts-expect-error live streams reject unchecked strings +connectLiveStream(dynamic); // Config rewrites keep placeholders and static files. defineConfig({ routing: { diff --git a/packages/void/test/unit/typed-navigation.typecheck.ts b/packages/void/test/unit/typed-navigation.typecheck.ts index 4bc97e65b..1b54df8ae 100644 --- a/packages/void/test/unit/typed-navigation.typecheck.ts +++ b/packages/void/test/unit/typed-navigation.typecheck.ts @@ -14,6 +14,8 @@ import { type CloudContext, type CloudEnv, } from '../../src/runtime/handler.ts'; +import { connectLiveStream } from '../../src/runtime/live-client.ts'; +import { connectEventStream } from '../../src/runtime/sse-client.ts'; declare module 'void/routes' { interface RoutePathMap { @@ -80,3 +82,12 @@ export function checkUntypedRouteStrings(): void { expectTypeOf().toEqualTypeOf(); expectTypeOf(routeUrl('/typed-navigation')).toEqualTypeOf(); } + +export function checkUntypedStreamUrls(url: string): void { + expectTypeOf[0]>().toEqualTypeOf(); + expectTypeOf[0]>().toEqualTypeOf(); + connectEventStream(url); + connectLiveStream(url); + connectEventStream('/unknown'); + connectLiveStream('ws://example.com/live'); +}