@rsc-kit/core 0.24.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  React Server Components as a Vite plugin: it discovers the route tree, renders it, and serves it. Nitro builds the server around it, so there is no server file to write.
4
4
 
5
- This is the library. Most people want [`rsc-kit`](https://www.npmjs.com/package/rsc-kit) (the command line) or `bun create rsc-kit@latest` (a new app).
5
+ This is the library. Most people start with `bun create rsc-kit@latest my-app` (a new app) or `bunx rsc-kit@latest init` (an existing project).
6
6
 
7
7
  ```sh
8
8
  bun create rsc-kit@latest my-app
@@ -10,90 +10,97 @@ bun create rsc-kit@latest my-app
10
10
 
11
11
  ## Why not just use Next?
12
12
 
13
- That is the right first question, and the honest answer is that the routing
14
- conventions here *are* Next's — `app/`, `page`/`layout`/`loading`, `@slot`,
15
- `(.)` interception, `"use client"`, server actions, `generateMetadata`.
16
- Inventing different names for the same ideas would cost every reader a
17
- translation table and buy nothing.
18
-
19
- The difference is what sits underneath.
20
-
21
- **It is a Vite plugin, not a bundler.** You keep your `vite.config.ts`, your
22
- plugins, your ecosystem. Adding RSC to an existing app is one entry in the
23
- plugins array — not a migration.
24
-
25
- **It runs on your server.** Hono, Elysia, `Bun.serve`, `node:http`,
26
- Cloudflare Workers. The adapter is a `Request` in and a `Response` out, so
27
- binding it is one line and anything it does not claim falls through to your
28
- own routes.
13
+ The routing conventions here *are* Next's — `app/`, `page`/`layout`/`loading`,
14
+ `@slot`, `(.)` interception, `"use client"`, server actions,
15
+ `generateMetadata` — so there is no translation table to learn. The difference
16
+ is underneath:
17
+
18
+ - **It is a Vite plugin, not a bundler.** You keep your `vite.config.ts` and
19
+ your plugins. Adding RSC is one entry in the plugins array.
20
+ - **It deploys wherever Nitro does.** [Nitro](https://nitro.build) builds the
21
+ server around the route tree, so the target is a preset — Bun, Node,
22
+ Cloudflare Workers, Vercel, Netlify, Deno.
23
+ - **A page ships no JavaScript until something on it needs some.** No client
24
+ component and no server action in its tree means stored HTML, with no React
25
+ in the browser ([guide](https://docs.rsc-kit.dev/guides/no-javascript)).
26
+ - **It can sit in front of a backend you already have.** With Laravel or Go
27
+ behind it, a server component calls the backend with
28
+ `await rpc('Orders.recent')`, typed from the backend's own signatures. See
29
+ [Backend-Answered Pages](https://docs.rsc-kit.dev/hosts/backend-answered-pages).
30
+ - **It compiles to a single binary** with `bun build --compile`, assets and
31
+ frozen pages inside it.
32
+
33
+ ## Setup
29
34
 
30
35
  ```ts
31
- app.all('*', async (c) => (await rsc(c.req.raw)) ?? c.notFound())
36
+ // vite.config.ts
37
+ import { defineConfig } from 'vite'
38
+ import { nitro } from 'nitro/vite'
39
+ import react from '@vitejs/plugin-react'
40
+ import { rscKit } from '@rsc-kit/core/vite'
41
+
42
+ export default defineConfig({
43
+ plugins: [nitro({ preset: 'bun', serveStatic: 'inline' }), rscKit(), react()],
44
+ })
32
45
  ```
33
46
 
34
- **A route can ship no JavaScript at all.** `clientJs = false` renders to HTML
35
- and stops — no bootstrap, no React in the browser, no router. That is about
36
- 70 kB gzipped back on a page with nothing to hydrate, of which react-dom alone
37
- is ~54 kB. The build refuses the combination rather than shipping a button that
38
- does nothing.
39
-
40
- **And the framework itself is small, because most of what ships is React.**
41
- Measured on the example app: a typical route is 85 kB gzipped, of which
42
- **66 kB is React and react-dom** and this framework's own runtime is 6 kB.
43
- Client components are chunked per module, so a route only downloads the ones it
44
- renders — the three routes using TanStack Query are the only ones that pay for
45
- it. Serving a page frozen at build time costs the server about 25 µs, because
46
- it renders nothing.
47
+ React 19.2+ and Vite 8 are peer dependencies. The full walk-through is
48
+ [Installation](https://docs.rsc-kit.dev/installation).
47
49
 
48
- **It compiles to a single binary.** `bun build --compile` with the assets and
49
- frozen pages inside it.
50
+ With a backend, `rscKit({ hostManifest: { command, cwd, watch } })` runs the
51
+ backend's manifest command — `php artisan rsc:host-manifest`, or a Go program
52
+ calling `reg.WriteManifest` — when dev or a build starts, and again under dev
53
+ when something in a `watch` directory changes. It writes `rsc-host.json`
54
+ (`{ actions, functions, types, defs }`), from which the build generates typed
55
+ `rpc()` overloads and server action stubs.
50
56
 
51
57
  ## What you get
52
58
 
53
- - File-based routing with layouts, loading boundaries, parallel routes and
54
- route interception
59
+ - File-based routing with layouts (which receive `params`), loading
60
+ boundaries, parallel routes and route interception
55
61
  - Streaming SSR with Suspense, and partial prerendering — the static shell is
56
62
  stored, the part that needs the request is rendered per visit
57
- - Server actions, with an optional builder that gives you validation
58
- (any Standard Schema library — Zod, Valibot, ArkType) and middleware
59
- - API routes in `app/**/route.ts` — a real `Request` in, a real `Response` out,
60
- frozen at build time when they read nothing from the request
63
+ - Server actions, with an optional builder (`@rsc-kit/core/action`) for
64
+ validation (any Standard Schema library — Zod, Valibot, ArkType) and
65
+ middleware; `<Form>` with field errors and `formError`, and `useAction` for
66
+ calls without a form
67
+ - API routes in `app/**/route.ts` — a `Request` in, a `Response` out, frozen
68
+ at build time when they read nothing from the request
61
69
  - Typed urls: `params`, `searchParams` and request bodies arrive parsed through
62
70
  a schema you export beside the route
63
71
  - Typed routes: every build writes the routes it found, so a link to a page
64
72
  that does not exist stops compiling
65
- - `middleware.ts` that runs before every render below it, on every path
73
+ - `middleware.ts` that runs before every render below it; `notFound()` there
74
+ answers 404
66
75
  - Request and response access — `headers()`, `cookies()`, `responseHeaders()`
67
76
  - Offline and installable: a generated service worker, a web manifest, icons
68
77
  found by name, and a place to put your own push handlers
78
+ - Testing without a browser: `createTestApp({ host, backend })` from
79
+ `@rsc-kit/core/testing` fetches any url, with `hostReply` to fake backend
80
+ answers ([guide](https://docs.rsc-kit.dev/guides/testing))
69
81
  - A build that explains itself — why each route is dynamic, what it ships, and
70
- a report an `@rsc-kit/mcp` server can answer questions from
71
-
72
- ## Status
73
-
74
- **0.x. Early, and honest about it.** The API will change before 1.0, and
75
- `@vitejs/plugin-rsc` — which this builds on — is itself pre-1.0.
82
+ a `build-report.json` that [`@rsc-kit/mcp`](https://www.npmjs.com/package/@rsc-kit/mcp)
83
+ answers questions from
76
84
 
77
- It is tested (500+ tests across the engine, the client router and the build),
78
- it has had an adversarial security review, and it runs a real documentation
79
- site in production. It has not been used by anyone but its author. Issues and
80
- questions are welcome; promises are not being made yet.
85
+ ## Commands
81
86
 
82
- See [SECURITY.md](./SECURITY.md) for the threat model and how to report
83
- something.
87
+ - `rsc-kit-typegen` writes the generated types without starting Vite;
88
+ `--check` exits non-zero when the committed `rsc-host.json` or types are
89
+ stale ([typed routes](https://docs.rsc-kit.dev/guides/typed-routes)).
90
+ - `rsc-kit-conformance` checks a backend adapter against the host-call
91
+ contract ([your own backend](https://docs.rsc-kit.dev/hosts/your-own-backend)):
84
92
 
85
- ## Getting started
93
+ ```sh
94
+ npx -y -p @rsc-kit/core rsc-kit-conformance \
95
+ --endpoint http://127.0.0.1:8123/__rsc/host-call --secret test --manifest rsc-host.json
96
+ ```
86
97
 
87
- ```sh
88
- bun create rsc-kit@latest my-app # a new app
89
- cd my-app && bun run dev
90
- ```
91
-
92
- Adding it to a project you already have:
98
+ ## Status
93
99
 
94
- ```sh
95
- bunx create-rsc-kit@latest --init
96
- ```
100
+ **0.x.** The API will change before 1.0, and `@vitejs/plugin-rsc` — which
101
+ this builds on — is itself pre-1.0. It has had an adversarial security review
102
+ and runs a real documentation site in production. See
103
+ [SECURITY.md](./SECURITY.md) for the threat model and how to report something.
97
104
 
98
105
  Docs: https://docs.rsc-kit.dev
99
106
 
package/dist/events.d.ts CHANGED
@@ -27,11 +27,11 @@ export interface EventsInput<P = Record<string, string>> {
27
27
  request: Request;
28
28
  }
29
29
  /** A message with a name or an id, for a client that listens by name or resumes. Made by `named()`. */
30
- export interface NamedEvent {
30
+ export interface NamedEvent<E extends string = string, D = unknown> {
31
31
  readonly [NAMED]: true;
32
- event?: string;
32
+ event?: E;
33
33
  id?: string;
34
- data: unknown;
34
+ data: D;
35
35
  }
36
36
  declare const NAMED: unique symbol;
37
37
  /**
@@ -41,17 +41,33 @@ declare const NAMED: unique symbol;
41
41
  * have a `data` key is still data, and the generator's own type stays the
42
42
  * type of what it yields.
43
43
  */
44
- export declare function named<D>(event: string, data: D, options?: {
44
+ export declare function named<const E extends string, D>(event: E, data: D, options?: {
45
45
  id?: string;
46
- }): NamedEvent;
46
+ }): NamedEvent<E, D>;
47
47
  /** How often a comment goes out on an idle stream, so a proxy keeps it open. */
48
48
  export declare const KEEPALIVE_MS = 15000;
49
49
  /** One message, framed. */
50
50
  export declare function frame(value: unknown): string;
51
- export declare function events<P = Record<string, string>, T = unknown>(produce: (input: EventsInput<P>) => AsyncIterable<T | NamedEvent>, options?: {
52
- keepaliveMs?: number;
53
- }): (request: Request, input: {
51
+ /**
52
+ * What `events()` returns: a GET handler that also says, as a type only, what
53
+ * its generator yields. Nothing reads `~events` at runtime; the build records
54
+ * it per url, which is how `useEvents('/api/orders/42/events')` types a
55
+ * message without being told - and stops compiling when the route changes
56
+ * what it sends.
57
+ */
58
+ export type EventsRoute<P = Record<string, string>, Y = unknown> = ((request: Request, input: {
54
59
  params: Promise<P>;
55
60
  searchParams: Promise<URLSearchParams>;
56
- }) => Response;
61
+ }) => Response) & {
62
+ readonly "~events"?: {
63
+ yields: Y;
64
+ };
65
+ };
66
+ /** The unnamed messages among a stream's yields: what `useEvents` receives without `event`. */
67
+ export type MessageOf<Y> = Exclude<Y, NamedEvent<string, unknown>>;
68
+ /** The data of the messages named `E`. A name computed at runtime matches any. */
69
+ export type NamedMessageOf<Y, E extends string> = Y extends NamedEvent<infer N, infer D> ? (E extends N ? D : never) : never;
70
+ export declare function events<P = Record<string, string>, Y = unknown>(produce: (input: EventsInput<P>) => AsyncIterable<Y>, options?: {
71
+ keepaliveMs?: number;
72
+ }): EventsRoute<P, Y>;
57
73
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * A route that streams: server-sent events from an async generator.\n *\n * // app/api/orders/[id]/events/route.ts\n * export const GET = events(async function* ({ params, signal }) {\n * for await (const status of orderStatus((await params).id, { signal })) {\n * yield { status }\n * }\n * })\n *\n * An ordinary route.ts, so it lives beside its pages, runs the middleware.ts\n * above it and takes the same params. What this adds is the framing - one\n * `data:` line per yield, JSON - a keepalive comment every fifteen seconds\n * so a proxy does not drop an idle stream, the headers a stream needs, and\n * an end to the generator when the browser goes away, which is what `signal`\n * is for. `useEvents` on the client reads it.\n *\n * Not a query. A query answers once and is cacheable; a stream answers for\n * as long as the tab is open and is not. A server component cannot await a\n * stream, so it has no place in the payload either.\n */\n\nexport interface EventsInput<P = Record<string, string>> {\n params: Promise<P>;\n searchParams: Promise<URLSearchParams>;\n /** Aborted when the browser disconnects. Pass it on, or check `aborted`. */\n signal: AbortSignal;\n request: Request;\n}\n\n/** A message with a name or an id, for a client that listens by name or resumes. Made by `named()`. */\nexport interface NamedEvent {\n readonly [NAMED]: true;\n event?: string;\n id?: string;\n data: unknown;\n}\n\nconst NAMED = Symbol.for(\"@rsc-kit/core.named-event\");\n\n/**\n * A message with a name or an id: `yield named('paid', order)`, or\n * `named('paid', order, { id: order.id })` for one a client can resume from.\n * Built by a function rather than by shape, so a message that happens to\n * have a `data` key is still data, and the generator's own type stays the\n * type of what it yields.\n */\nexport function named<D>(\n event: string,\n data: D,\n options: { id?: string } = {},\n): NamedEvent {\n return { [NAMED]: true, event, id: options.id, data };\n}\n\n/** How often a comment goes out on an idle stream, so a proxy keeps it open. */\nexport const KEEPALIVE_MS = 15_000;\n\nfunction isNamed(value: unknown): value is NamedEvent {\n return (\n typeof value === \"object\" &&\n value !== null &&\n (value as { [NAMED]?: true })[NAMED] === true\n );\n}\n\n/** One message, framed. */\nexport function frame(value: unknown): string {\n const named = isNamed(value) ? value : null;\n const lines: string[] = [];\n\n if (named?.event) lines.push(`event: ${named.event}`);\n if (named?.id) lines.push(`id: ${named.id}`);\n lines.push(`data: ${JSON.stringify(named ? named.data : value)}`);\n\n return lines.join(\"\\n\") + \"\\n\\n\";\n}\n\nexport function events<P = Record<string, string>, T = unknown>(\n produce: (input: EventsInput<P>) => AsyncIterable<T | NamedEvent>,\n options: { keepaliveMs?: number } = {},\n): (\n request: Request,\n input: { params: Promise<P>; searchParams: Promise<URLSearchParams> },\n) => Response {\n const keepaliveMs = options.keepaliveMs ?? KEEPALIVE_MS;\n\n return (request, input) => {\n const controller = new AbortController();\n const encoder = new TextEncoder();\n\n request.signal.addEventListener(\"abort\", () => controller.abort(), {\n once: true,\n });\n\n const stream = new ReadableStream<Uint8Array>({\n async start(sink) {\n // The retry hint first, so a browser that loses the connection waits\n // a moment rather than hammering.\n sink.enqueue(encoder.encode(\"retry: 1000\\n\\n\"));\n\n const keepalive = setInterval(() => {\n try {\n sink.enqueue(encoder.encode(\": keepalive\\n\\n\"));\n } catch {\n // Closed between the check and the write; the loop below ends it.\n }\n }, keepaliveMs);\n\n try {\n for await (const value of produce({\n ...input,\n signal: controller.signal,\n request,\n })) {\n if (controller.signal.aborted) break;\n\n sink.enqueue(encoder.encode(frame(value)));\n }\n } catch (error) {\n if (!controller.signal.aborted) sink.error(error);\n } finally {\n clearInterval(keepalive);\n\n if (!controller.signal.aborted) {\n try {\n sink.close();\n } catch {\n // Already closed by the consumer.\n }\n }\n }\n },\n cancel() {\n controller.abort();\n },\n });\n\n return new Response(stream, {\n headers: {\n \"Content-Type\": \"text/event-stream; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n // Nginx buffers by default, which turns a stream into one late burst.\n \"X-Accel-Buffering\": \"no\",\n },\n });\n };\n}\n"]}
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/events.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * A route that streams: server-sent events from an async generator.\n *\n * // app/api/orders/[id]/events/route.ts\n * export const GET = events(async function* ({ params, signal }) {\n * for await (const status of orderStatus((await params).id, { signal })) {\n * yield { status }\n * }\n * })\n *\n * An ordinary route.ts, so it lives beside its pages, runs the middleware.ts\n * above it and takes the same params. What this adds is the framing - one\n * `data:` line per yield, JSON - a keepalive comment every fifteen seconds\n * so a proxy does not drop an idle stream, the headers a stream needs, and\n * an end to the generator when the browser goes away, which is what `signal`\n * is for. `useEvents` on the client reads it.\n *\n * Not a query. A query answers once and is cacheable; a stream answers for\n * as long as the tab is open and is not. A server component cannot await a\n * stream, so it has no place in the payload either.\n */\n\nexport interface EventsInput<P = Record<string, string>> {\n params: Promise<P>;\n searchParams: Promise<URLSearchParams>;\n /** Aborted when the browser disconnects. Pass it on, or check `aborted`. */\n signal: AbortSignal;\n request: Request;\n}\n\n/** A message with a name or an id, for a client that listens by name or resumes. Made by `named()`. */\nexport interface NamedEvent<E extends string = string, D = unknown> {\n readonly [NAMED]: true;\n event?: E;\n id?: string;\n data: D;\n}\n\nconst NAMED = Symbol.for(\"@rsc-kit/core.named-event\");\n\n/**\n * A message with a name or an id: `yield named('paid', order)`, or\n * `named('paid', order, { id: order.id })` for one a client can resume from.\n * Built by a function rather than by shape, so a message that happens to\n * have a `data` key is still data, and the generator's own type stays the\n * type of what it yields.\n */\nexport function named<const E extends string, D>(\n event: E,\n data: D,\n options: { id?: string } = {},\n): NamedEvent<E, D> {\n return { [NAMED]: true, event, id: options.id, data };\n}\n\n/** How often a comment goes out on an idle stream, so a proxy keeps it open. */\nexport const KEEPALIVE_MS = 15_000;\n\nfunction isNamed(value: unknown): value is NamedEvent {\n return (\n typeof value === \"object\" &&\n value !== null &&\n (value as { [NAMED]?: true })[NAMED] === true\n );\n}\n\n/** One message, framed. */\nexport function frame(value: unknown): string {\n const named = isNamed(value) ? value : null;\n const lines: string[] = [];\n\n if (named?.event) lines.push(`event: ${named.event}`);\n if (named?.id) lines.push(`id: ${named.id}`);\n lines.push(`data: ${JSON.stringify(named ? named.data : value)}`);\n\n return lines.join(\"\\n\") + \"\\n\\n\";\n}\n\n/**\n * What `events()` returns: a GET handler that also says, as a type only, what\n * its generator yields. Nothing reads `~events` at runtime; the build records\n * it per url, which is how `useEvents('/api/orders/42/events')` types a\n * message without being told - and stops compiling when the route changes\n * what it sends.\n */\nexport type EventsRoute<P = Record<string, string>, Y = unknown> = ((\n request: Request,\n input: { params: Promise<P>; searchParams: Promise<URLSearchParams> },\n) => Response) & { readonly \"~events\"?: { yields: Y } };\n\n/** The unnamed messages among a stream's yields: what `useEvents` receives without `event`. */\nexport type MessageOf<Y> = Exclude<Y, NamedEvent<string, unknown>>;\n\n/** The data of the messages named `E`. A name computed at runtime matches any. */\nexport type NamedMessageOf<Y, E extends string> =\n Y extends NamedEvent<infer N, infer D> ? (E extends N ? D : never) : never;\n\nexport function events<P = Record<string, string>, Y = unknown>(\n produce: (input: EventsInput<P>) => AsyncIterable<Y>,\n options: { keepaliveMs?: number } = {},\n): EventsRoute<P, Y> {\n const keepaliveMs = options.keepaliveMs ?? KEEPALIVE_MS;\n\n return (\n request: Request,\n input: { params: Promise<P>; searchParams: Promise<URLSearchParams> },\n ) => {\n const controller = new AbortController();\n const encoder = new TextEncoder();\n\n request.signal.addEventListener(\"abort\", () => controller.abort(), {\n once: true,\n });\n\n const stream = new ReadableStream<Uint8Array>({\n async start(sink) {\n // The retry hint first, so a browser that loses the connection waits\n // a moment rather than hammering.\n sink.enqueue(encoder.encode(\"retry: 1000\\n\\n\"));\n\n const keepalive = setInterval(() => {\n try {\n sink.enqueue(encoder.encode(\": keepalive\\n\\n\"));\n } catch {\n // Closed between the check and the write; the loop below ends it.\n }\n }, keepaliveMs);\n\n try {\n for await (const value of produce({\n ...input,\n signal: controller.signal,\n request,\n })) {\n if (controller.signal.aborted) break;\n\n sink.enqueue(encoder.encode(frame(value)));\n }\n } catch (error) {\n if (!controller.signal.aborted) sink.error(error);\n } finally {\n clearInterval(keepalive);\n\n if (!controller.signal.aborted) {\n try {\n sink.close();\n } catch {\n // Already closed by the consumer.\n }\n }\n }\n },\n cancel() {\n controller.abort();\n },\n });\n\n return new Response(stream, {\n headers: {\n \"Content-Type\": \"text/event-stream; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n // Nginx buffers by default, which turns a stream into one late burst.\n \"X-Accel-Buffering\": \"no\",\n },\n });\n };\n}\n"]}
package/dist/host.js CHANGED
@@ -186,12 +186,15 @@ function refusalStatus(error) {
186
186
  return 401;
187
187
  if (name === "ServerAuthorizationError")
188
188
  return 403;
189
- // A status the host chose — a throttle's 429, a policy's 403 — carried on
190
- // the error by the transport. Bounded to refusals: a host answering 500
191
- // should not be able to make this look like a client's fault, and one
192
- // answering 200 should not turn a failed render into a success.
189
+ // A status the host chose — a throttle's 429, a policy's 403, a 503 while
190
+ // a dependency is down or the app is in maintenance — carried on the error
191
+ // by the transport. Only a deliberate refusal carries one (Go's Refuse,
192
+ // Laravel's abort); a crash never does, so a 5xx here was meant, and
193
+ // collapsing a 503 to a 500 tells a client to give up rather than come
194
+ // back. Bounded to errors: a host answering 200 or 3xx should not turn a
195
+ // failed render into a success or a redirect with no Location.
193
196
  const carried = error?.refusalStatus;
194
- if (typeof carried === "number" && carried >= 400 && carried <= 499)
197
+ if (typeof carried === "number" && carried >= 400 && carried <= 599)
195
198
  return carried;
196
199
  return null;
197
200
  }