Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/live.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/sse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 17 additions & 15 deletions docs/guide/type-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`, `c.rewrite()`, and `useIslandForm()`'s `post()`, `put()`, `patch()`, `delete()`, and `submit()`. 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()`, `connectLiveStream()`, and `useIslandForm()`'s `post()`, `put()`, `patch()`, `delete()`, and `submit()`. Apps created with `void init` have it on. To turn it on in an existing app:

```ts
// void.config.ts
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -395,20 +397,20 @@ Route types update with the dev server. For CI or standalone typechecking, run `

## What Gets Checked

| Layer | What's type-checked |
| ------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Database | Table columns, value types, insert/update shapes from Drizzle schema |
| 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, `c.rewrite()`, and `useIslandForm()` |
| `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` |
| `getPrerenderPaths` | Page param names, when the return type is annotated with `PageParams` |
| `revalidate` | `paths`, and `routing.prerender`, when the array is annotated with `satisfies RouteUrl[]` |
| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` |
| `action()` | Action URLs, `data` payload, `params` |
| Layer | What's type-checked |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Database | Table columns, value types, insert/update shapes from Drizzle schema |
| 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, `c.rewrite()`, `useIslandForm()`, 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` |
| `getPrerenderPaths` | Page param names, when the return type is annotated with `PageParams` |
| `revalidate` | `paths`, and `routing.prerender`, when the array is annotated with `satisfies RouteUrl[]` |
| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` |
| `action()` | Action URLs, `data` payload, `params` |

## Serialization

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`, `c.rewrite()`, and `useIslandForm()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Link and router strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `useIslandForm()` takes a route URL. `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()`, `useIslandForm()`, `connectEventStream()`, and `connectLiveStream()`. Omitted or `false` means off; `void init` turns it on for new apps. It changes types only, not navigation at runtime. Link and router strings may also be same-page queries or hashes, or `http://`, `https://`, `mailto:`, and `tel:` URLs. `useIslandForm()` takes a route URL; `connectEventStream()` and `connectLiveStream()` also take `http://` and `https://` 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: {
Expand Down
11 changes: 10 additions & 1 deletion packages/void/src/runtime/live-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { LiveClientEvent, LiveControlOperation, LiveControlResponse } from './live.ts';
import type { RequestUrl } from './route-url.ts';

export type LiveSubscribeOptions<Data = unknown> = {
id: string;
Expand Down Expand Up @@ -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 =
Expand Down
9 changes: 9 additions & 0 deletions packages/void/src/runtime/route-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RouteQueryValue | ReadonlyArray<RouteQueryValue>>;

Expand Down
11 changes: 10 additions & 1 deletion packages/void/src/runtime/sse-client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { RequestUrl } from './route-url.ts';

export type SseClientEvent<T = unknown> = {
data: T;
event: string;
Expand Down Expand Up @@ -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<Events extends SseEventMap = SseEventMap>(
url: string | URL,
url: RequestUrl | URL,
options: ConnectEventStreamOptions = {},
): SseClient<Events> {
const EventSourceCtor = (globalThis as { EventSource?: EventSourceConstructor }).EventSource;
Expand Down
26 changes: 26 additions & 0 deletions packages/void/test/integration/route-url-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import type { IslandFormState } from 'void/pages-client';
declare const id: number;
declare const dynamic: string;
Expand Down Expand Up @@ -93,6 +95,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);
declare const island: IslandFormState<{ title: string }, 'setters'>;
void island.post('/about');
void island.post('/users/42?update');
Expand Down
11 changes: 11 additions & 0 deletions packages/void/test/unit/typed-navigation.typecheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -80,3 +82,12 @@ export function checkUntypedRouteStrings(): void {
expectTypeOf<Href>().toEqualTypeOf<string>();
expectTypeOf(routeUrl('/typed-navigation')).toEqualTypeOf<string>();
}

export function checkUntypedStreamUrls(url: string): void {
expectTypeOf<Parameters<typeof connectEventStream>[0]>().toEqualTypeOf<string | URL>();
expectTypeOf<Parameters<typeof connectLiveStream>[0]>().toEqualTypeOf<string | URL>();
connectEventStream(url);
connectLiveStream(url);
connectEventStream('/unknown');
connectLiveStream('ws://example.com/live');
}
Loading