@rsc-kit/mcp 0.18.1 → 0.20.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/dist/recipes.js +242 -17
- package/dist/recipes.js.map +1 -1
- package/guides/api-routes.md +22 -5
- package/guides/authorization.md +22 -2
- package/guides/backend-answered-pages.md +27 -3
- package/guides/coming-from-next.md +17 -3
- package/guides/deployment.md +25 -0
- package/guides/emails.md +13 -1
- package/guides/feature-flags.md +63 -0
- package/guides/fonts.md +25 -0
- package/guides/forms.md +103 -0
- package/guides/index.json +10 -0
- package/guides/installation.md +15 -9
- package/guides/laravel.md +27 -2
- package/guides/offline.md +9 -4
- package/guides/openapi.md +99 -0
- package/guides/quick-start.md +3 -1
- package/guides/redirects.md +19 -4
- package/guides/routing.md +4 -4
- package/guides/server-actions.md +51 -7
- package/guides/testing.md +22 -1
- package/guides/typed-routes.md +15 -9
- package/guides/where-it-runs.md +74 -9
- package/guides/your-own-backend.md +28 -7
- package/package.json +1 -1
package/dist/recipes.js
CHANGED
|
@@ -15,8 +15,15 @@
|
|
|
15
15
|
const RECIPES = [
|
|
16
16
|
{
|
|
17
17
|
topic: 'forms',
|
|
18
|
-
summary: 'Submitting to a server action, with pending state and field errors',
|
|
19
|
-
body: `
|
|
18
|
+
summary: 'Submitting to a server action, with pending state and field errors. Uncontrolled by default - no useState per field',
|
|
19
|
+
body: `THE RULE: forms are UNCONTROLLED. Inputs keep their value in the DOM,
|
|
20
|
+
an initial value is defaultValue, the action reads FormData. Do NOT write
|
|
21
|
+
useState + value/onChange per input, and do NOT reach for TanStack Form.
|
|
22
|
+
Control ONE field only when the UI must react as the user types (a character
|
|
23
|
+
count, a live preview, a dependent select) - bind it with useField, which
|
|
24
|
+
scopes the re-render to that field. Everything else stays uncontrolled.
|
|
25
|
+
|
|
26
|
+
Use <Form>. It takes the server action itself, not a url.
|
|
20
27
|
|
|
21
28
|
\`\`\`tsx
|
|
22
29
|
'use client'
|
|
@@ -178,7 +185,7 @@ import Link from '@rsc-kit/core/Link'
|
|
|
178
185
|
\`\`\`
|
|
179
186
|
|
|
180
187
|
\`href\` is typed to the routes the build found, so a link to a page that no
|
|
181
|
-
longer exists stops compiling. Cast with \`as
|
|
188
|
+
longer exists stops compiling. Cast with \`as Route\` only when the destination
|
|
182
189
|
is genuinely computed.
|
|
183
190
|
|
|
184
191
|
To prefetch from code — a row about to be clicked, a wizard's next step:
|
|
@@ -403,7 +410,15 @@ so:
|
|
|
403
410
|
|
|
404
411
|
A boundary does NOT fix a frozen \`Date.now()\`. Prerendering renders straight
|
|
405
412
|
through a component that never awaits, so the value is captured exactly as
|
|
406
|
-
before. A boundary becomes a hole only when something inside it waits
|
|
413
|
+
before. A boundary becomes a hole only when something inside it waits.
|
|
414
|
+
|
|
415
|
+
redirect() and notFound() inside a boundary still work: the shell has gone
|
|
416
|
+
out, so the redirect travels in the row's error digest and the browser
|
|
417
|
+
performs it as a navigation, layouts kept. An error.tsx on the route never
|
|
418
|
+
sees it - a redirect is the page's answer, not a failure - and the same holds
|
|
419
|
+
for a component under its own <Suspense> and for a parallel route slot. Only
|
|
420
|
+
an authorization check should NOT be there: the layouts above already
|
|
421
|
+
rendered. See the redirects guide.`,
|
|
407
422
|
},
|
|
408
423
|
{
|
|
409
424
|
topic: 'offline',
|
|
@@ -564,17 +579,31 @@ build found:
|
|
|
564
579
|
import { apiUrl } from '@rsc-kit/core/routes'
|
|
565
580
|
await fetch(apiUrl('/api/posts/' + id))
|
|
566
581
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
582
|
+
A route.ts is a Route too (type Route from '@rsc-kit/core/routes' covers pages
|
|
583
|
+
AND route.ts files, as Next's does): Link, visit and redirect accept it. The
|
|
584
|
+
client treats a link to a route as an anchor - never prefetched, a full
|
|
585
|
+
navigation, not a payload fetch. ApiRoute is the narrower union for apiUrl:
|
|
586
|
+
apiUrl refuses a page, because fetching one gets html. It checks the PATH, not
|
|
587
|
+
the response type - for types across the boundary use a server action or a
|
|
588
|
+
query, where the return type is the function's because it is the same function.
|
|
571
589
|
|
|
572
590
|
They run their directory's \`middleware.ts\`, so an endpoint under a guarded
|
|
573
591
|
path is guarded.
|
|
574
592
|
|
|
575
593
|
A \`GET\` that reads nothing from the request is answered from disk. Awaiting
|
|
576
594
|
\`searchParams\` says the answer depends on the query; never touching it means
|
|
577
|
-
the stored answer is served for any query at all.
|
|
595
|
+
the stored answer is served for any query at all. NEVER read the query with
|
|
596
|
+
new URL(request.url).searchParams (the Next way): the build cannot see that
|
|
597
|
+
read, and reading request.url at all makes the route dynamic (the table says
|
|
598
|
+
"reads the request - url"). A webhook verification handshake (hub.mode,
|
|
599
|
+
hub.challenge) reads the awaited searchParams. A GET that answers 4xx/5xx to
|
|
600
|
+
the build is never stored either (the table says "answered 403 to the build").
|
|
601
|
+
|
|
602
|
+
redirect() thrown from a handler is the route's answer: a real 3xx Location
|
|
603
|
+
for whoever asked (a signed-url export, a moved endpoint); notFound() is its
|
|
604
|
+
404. A guard's redirect above a route.ts is a refusal: a browser that
|
|
605
|
+
navigated there gets the Location, code that fetched it gets 401 +
|
|
606
|
+
X-RSC-Redirect (fetch would follow a Location and hand back the login page).
|
|
578
607
|
|
|
579
608
|
Exporting a \`body\` schema consumes the stream, so \`request.json()\` inside the
|
|
580
609
|
handler will find it already read. Use the parsed value.
|
|
@@ -602,6 +631,10 @@ export default async function guard() {
|
|
|
602
631
|
}
|
|
603
632
|
\`\`\`
|
|
604
633
|
|
|
634
|
+
Several checks in one directory: export default [signedIn, verified, admin] -
|
|
635
|
+
run in order, stopping at the first refusal; reuse a check by importing it
|
|
636
|
+
from one place. Directories still compose outermost first.
|
|
637
|
+
|
|
605
638
|
**An action or a query**: middleware does NOT run — they render no route. Build
|
|
606
639
|
them from an action client so the check cannot be forgotten. See the
|
|
607
640
|
\`action-client\` topic.
|
|
@@ -622,7 +655,7 @@ opts out, and the accessors are async:
|
|
|
622
655
|
\`\`\`ts
|
|
623
656
|
import { cookies, headers, searchParams, connection } from '@rsc-kit/core/request'
|
|
624
657
|
|
|
625
|
-
const theme = (await cookies()).get('theme')
|
|
658
|
+
const theme = (await cookies()).get('theme')?.value // { name, value } | undefined, as in Next
|
|
626
659
|
await connection() // "render this per visitor", said deliberately
|
|
627
660
|
\`\`\`
|
|
628
661
|
|
|
@@ -758,6 +791,61 @@ scores 99. Three moves:
|
|
|
758
791
|
range you ask for (font-weight: 400 500) so nothing requests a missing file.
|
|
759
792
|
Put the preloads before the <style> with the faces. Measured on rsc-kit.dev:
|
|
760
793
|
Speed Index 1.7s to 0.9s, 99 to a steady 100, fonts 114 kB to 74 kB.
|
|
794
|
+
THE RULE: a font never blocks the page - text paints in the fallback before
|
|
795
|
+
the web font arrives. Three ways to break it, all avoided: a fonts.googleapis
|
|
796
|
+
<link> (render-blocking CSS from a cold origin - self-host via Fontsource
|
|
797
|
+
instead), font-display: block or unset (invisible text for up to 3s - every
|
|
798
|
+
rule says swap or optional), preloading every file (preload only what the
|
|
799
|
+
first paint needs).
|
|
800
|
+
|
|
801
|
+
AFTER THE ANSWER: after(() => sendEmail(user)) from @rsc-kit/core/request
|
|
802
|
+
queues work to run once the response is on its way - from an action, a
|
|
803
|
+
component, middleware or an api route. Do NOT use a detached promise: on a
|
|
804
|
+
Worker the isolate dies with the response unless work is handed to
|
|
805
|
+
waitUntil, which after() does; on a process it runs detached. Rejections
|
|
806
|
+
are logged, never surfaced.
|
|
807
|
+
|
|
808
|
+
WRITE THE SCHEMA FOR THE SHAPE IT WANTS. The form is read the way the schema
|
|
809
|
+
means it, on both sides (Zod 4 / ArkType describe themselves as JSON Schema;
|
|
810
|
+
Valibot not yet - its values arrive as strings):
|
|
811
|
+
notify: z.boolean() // unchecked posts nothing -> false; "on" -> true
|
|
812
|
+
limit: z.number().int() // "5" -> 5. NOT z.coerce.number()
|
|
813
|
+
tags: z.array(z.string()) // one -> ['a'], none ticked -> []
|
|
814
|
+
policy: z.string().optional() // hidden behind a switch -> absent when off
|
|
815
|
+
+ .refine((s) => !s.notify || s.policy) for "required when the switch is on"
|
|
816
|
+
Nested names nest: fields[0][name] / fields[0].name -> { fields: [{ name }] };
|
|
817
|
+
auth[kind] picks a discriminated union's branch. No per-checkbox transform,
|
|
818
|
+
no checkbox() helper. The action decodes the same object the form validated.
|
|
819
|
+
Offline (rscKit({ offline: true })): the precache is what boots the app - js,
|
|
820
|
+
css, fonts, manifest, icons, / and /offline with their boot payloads; images,
|
|
821
|
+
wasm and the share card are cached on first use. A frozen page never visited
|
|
822
|
+
falls back to /offline like any other navigation. Do not add a second service
|
|
823
|
+
worker or a precache list; app/sw.js is importScripts'd into this one.
|
|
824
|
+
|
|
825
|
+
An action that redirect()s RESOLVES with { redirected: '/where' } on the
|
|
826
|
+
client once the navigation starts - it does not throw, so a plain
|
|
827
|
+
startTransition(async () => await logOut()) needs no catch (a rejection
|
|
828
|
+
there unmounts the root). The result is always an object (exactly one of
|
|
829
|
+
data / validationErrors / serverError / redirected is set), so reading any
|
|
830
|
+
field of it is safe. Do NOT wrap actions in a hook to catch
|
|
831
|
+
ServerRedirectError; nothing throws.
|
|
832
|
+
|
|
833
|
+
Before hydration a submit is a native POST to the page's url (React's hidden
|
|
834
|
+
$ACTION_ fields); the host runs the action and re-renders the page with the
|
|
835
|
+
result seated in the <Form> that posted - a refusal shows on its fields
|
|
836
|
+
without javascript; a redirect() is followed, a cookie lands. Nothing to
|
|
837
|
+
configure; <Form> uses useActionState under a wrapper so the action keeps
|
|
838
|
+
its (formData) signature.
|
|
839
|
+
A blank control is absent for any optional field (z.email().optional()
|
|
840
|
+
accepts it; an optional union is not read as its first branch) and "" for a
|
|
841
|
+
required one (z.string().min(1) refuses it). A leaf
|
|
842
|
+
with no JSON Schema (z.date()) arrives as posted; its siblings still coerce.
|
|
843
|
+
<Form ref={...}> is fine: the caller's ref is filled beside the form's own.
|
|
844
|
+
Render props also carry dirty (anything differs from mount, uncontrolled
|
|
845
|
+
fields included; baseline moves on a successful submit; reset() clears it):
|
|
846
|
+
{({ dirty, reset }) => <Button disabled={!dirty}>Save</Button>} - the RHF
|
|
847
|
+
isDirty gate for Save/Discard.
|
|
848
|
+
|
|
761
849
|
Full guide: read_guide({ slug: 'fonts' }).`,
|
|
762
850
|
},
|
|
763
851
|
{
|
|
@@ -780,15 +868,23 @@ IMPORTS
|
|
|
780
868
|
Metadata -> @rsc-kit/core/metadata (metadataBase, openGraph, twitter, icons as-is)
|
|
781
869
|
app/robots.ts, app/sitemap.ts -> the same files and shapes; app/llms.ts beside them (how_to seo-files)
|
|
782
870
|
middleware.ts subdomain rewrite -> nothing: a host is a route segment (how_to domains)
|
|
871
|
+
flags/next (Vercel Flags SDK) -> unchanged: the build aliases next/headers to @rsc-kit/core/request
|
|
872
|
+
(how_to feature-flags); precompute() does not carry over
|
|
783
873
|
next/font -> Fontsource (how_to fonts)
|
|
784
874
|
next/image -> unpic or vite-imagetools (how_to images)
|
|
785
875
|
next/script -> a <script> tag (how_to scripts)
|
|
786
876
|
NEXT_PUBLIC_* -> PUBLIC_* in src/env.ts (how_to env); server vars typed there too
|
|
877
|
+
import 'server-only' -> keep it (the build honours it). Under bun test the real package throws on
|
|
878
|
+
import, so the scaffold's tests/preload.ts stubs it: bunfig.toml
|
|
879
|
+
[test] preload = ["./tests/preload.ts"], mock.module('server-only', () => ({})).
|
|
880
|
+
A project without those two files adds them before unit-testing an action.
|
|
787
881
|
next-safe-action -> createActionClient() (how_to action-client); returnValidationErrors -> return fieldErrors({...})
|
|
788
882
|
cache from 'react' -> cache from @rsc-kit/core/cache: React's dedupes only inside a render; this one
|
|
789
883
|
spans the request (guards, actions, api routes). The build names files still on React's
|
|
790
884
|
@react-email/render, renderToString in an action -> the same call, in a module that starts with "use ssr"
|
|
885
|
+
(the build warns naming the app file and the package; the stub throws when called)
|
|
791
886
|
(how_to emails). Next gets away with it only for externalised packages; here it is explicit
|
|
887
|
+
import type { Route } from 'next' -> import type { Route } from '@rsc-kit/core/routes' (pages AND route.ts; a link to a route.ts is an anchor, never prefetched)
|
|
792
888
|
|
|
793
889
|
DIFFERENT ON PURPOSE
|
|
794
890
|
- No export const dynamic / revalidate = 60. A page is frozen unless it READS
|
|
@@ -798,7 +894,9 @@ DIFFERENT ON PURPOSE
|
|
|
798
894
|
- Actions return failures ({ validationErrors }, { serverError }), not throw.
|
|
799
895
|
- No image optimizer, no opengraph-image.tsx - put opengraph-image.png in src/app.
|
|
800
896
|
- Tests need no browser: createTestApp() is the deployed handler. It builds
|
|
801
|
-
with the project's own build script on the runtime the tests run under
|
|
897
|
+
with the project's own build script on the runtime the tests run under, and
|
|
898
|
+
answers files the build wrote to .output/public (assets, sw.js, the
|
|
899
|
+
manifest, icons) as production does - app.fetch('/sw.js') is a real test.
|
|
802
900
|
- A component library (base-ui, Radix) imports as it did, from server
|
|
803
901
|
components too. A shadcn-style components/ui/ folder keeps "use client" at
|
|
804
902
|
the top of each file, as shipped; without it the server evaluates the
|
|
@@ -815,7 +913,40 @@ route that is not ○ names what streams and from which component (a cookies()
|
|
|
815
913
|
in a layout reaches every page; the build says so) -> decide each action the
|
|
816
914
|
build lists as running no middleware -> check.
|
|
817
915
|
|
|
916
|
+
CONVERT THE FORMS AND ACTIONS - do not carry them. useActionState +
|
|
917
|
+
useFormStatus, react-hook-form, TanStack Form and useState-per-input all
|
|
918
|
+
still COMPILE here, which is why a port leaves them. Each becomes
|
|
919
|
+
<Form action={…} schema={…}> (how_to forms) and a createActionClient()
|
|
920
|
+
handler (how_to action-client). Remove the form library when the last form
|
|
921
|
+
is converted. A port that keeps two form systems has ported nothing.
|
|
922
|
+
|
|
818
923
|
Full guide: read_guide({ slug: 'coming-from-next' }).`,
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
topic: 'feature-flags',
|
|
927
|
+
summary: "Vercel's Flags SDK (flags/next) runs unchanged: next/headers is answered by headers()/cookies() here",
|
|
928
|
+
body: `bun add flags. Then flags/next as written for Next:
|
|
929
|
+
|
|
930
|
+
import { flag, dedupe } from 'flags/next'
|
|
931
|
+
const visitor = dedupe(async ({ cookies, headers }) => ({ id: cookies.get('visitor')?.value ?? 'anon' }))
|
|
932
|
+
export const showBanner = flag<boolean, { id: string }>({ key: 'show-banner', identify: visitor, decide: ({ entities }) => entities?.id === 'ada' })
|
|
933
|
+
|
|
934
|
+
// page.tsx (server component)
|
|
935
|
+
const on = await showBanner()
|
|
936
|
+
|
|
937
|
+
The build aliases next/headers to @rsc-kit/core/request - same names, same
|
|
938
|
+
shapes (cookies().get(name)?.value), one object per request, which the SDK's
|
|
939
|
+
dedupe keys on. Nothing to configure, no shim to write.
|
|
940
|
+
|
|
941
|
+
A flag reads the request, so the page renders per visitor: put a <Suspense>
|
|
942
|
+
or loading.tsx above the read and the build stores the rest as a shell (the
|
|
943
|
+
table says "headers() in run, cookies() in run stream per request").
|
|
944
|
+
|
|
945
|
+
Discovery endpoint: a route.ts -
|
|
946
|
+
export const GET = createFlagsDiscoveryEndpoint(async () => getProviderData(flags))
|
|
947
|
+
export const openapi = false
|
|
948
|
+
precompute() does NOT carry over (it rewrites urls in Next middleware); read
|
|
949
|
+
the flag in the page.`,
|
|
819
950
|
},
|
|
820
951
|
{
|
|
821
952
|
topic: 'emails',
|
|
@@ -1024,6 +1155,15 @@ Laravel: composer require rsc-kit/laravel, then php artisan rsc:install. It
|
|
|
1024
1155
|
runs rsc-kit init, which writes ONE vite.config.ts (laravel-vite-plugin is
|
|
1025
1156
|
moved aside - the renderer owns the frontend). Source is resources/js (the route tree is resources/js/app).
|
|
1026
1157
|
|
|
1158
|
+
rpc() is a global the renderer installs in its own process (never imported,
|
|
1159
|
+
never in the browser bundle): one POST to the backend's host-call endpoint
|
|
1160
|
+
with { function, args }, the secret and the visitor's cookie; the answer is
|
|
1161
|
+
the return value as JSON, typed by rpc<T>(). Refusals arrive as their kind
|
|
1162
|
+
(422/401/403/redirect), never a 500; sibling calls in one tick are batched
|
|
1163
|
+
and each resolves the moment the backend answers it.
|
|
1164
|
+
A BAP server bundle carries no database driver, ORM or auth library - the
|
|
1165
|
+
backend owns those.
|
|
1166
|
+
|
|
1027
1167
|
Reach PHP from a server component - rpc() is a global, typed in
|
|
1028
1168
|
.rsc-kit/rsc-env.d.ts, server render only:
|
|
1029
1169
|
|
|
@@ -1040,12 +1180,23 @@ Guard a route in Laravel's vocabulary, no route declared in PHP:
|
|
|
1040
1180
|
// resources/js/app/admin/middleware.ts
|
|
1041
1181
|
export const middleware = ['auth', 'verified', 'can:update,post']
|
|
1042
1182
|
|
|
1183
|
+
Make one: php artisan make:rsc-action Orders --method=cancel --auth
|
|
1184
|
+
--can=update,Order --middleware=throttle:60,1 --revalidate=orders (--rpc for
|
|
1185
|
+
an rpc() class under app/Rsc; no --method = invokable). Do NOT hand-write the
|
|
1186
|
+
attributes from memory; the command writes the ones the registry reads.
|
|
1043
1187
|
Server actions are classes in app/Rsc/Actions; \`php artisan
|
|
1044
1188
|
rsc:action-manifest\` (already in the dev/build scripts) writes the map and
|
|
1045
1189
|
the build writes server-actions.generated.ts beside the app - import
|
|
1046
1190
|
ordersCancel from it in a client component. Rsc::revalidate('orders') in the
|
|
1047
1191
|
action returns the re-rendered region with the answer.
|
|
1048
1192
|
|
|
1193
|
+
Per url: if the React tree has it, React renders it, otherwise Laravel does
|
|
1194
|
+
- including / : the welcome route in routes/web.php answers nothing while
|
|
1195
|
+
resources/js/app/page.tsx exists (the package registers the tree's urls from
|
|
1196
|
+
bootstrap/rsc/vite/routes.json after routes/web.php). Do NOT tell the user
|
|
1197
|
+
to delete the welcome route to make the page show; do not add Laravel routes
|
|
1198
|
+
for React pages.
|
|
1199
|
+
|
|
1049
1200
|
php artisan serve is one worker, which deadlocks the proxy - unless
|
|
1050
1201
|
PHP_CLI_SERVER_WORKERS=4 in .env AND serve --no-reload (Laravel ignores
|
|
1051
1202
|
the variable otherwise). Herd, Valet, FPM, Octane are fine as they are. Production: put the renderer in front (bun
|
|
@@ -1055,10 +1206,13 @@ the web server.
|
|
|
1055
1206
|
Any other language implements the same endpoint - request { function, args },
|
|
1056
1207
|
reply { result | validationErrors | unauthenticated | unauthorized | redirect
|
|
1057
1208
|
| error, revalidate }, answers '__rsc.middleware' with true or a refusal, and
|
|
1058
|
-
a batch { calls: [...] }
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1209
|
+
a batch { calls: [...] } as application/x-ndjson - one line per call AS IT
|
|
1210
|
+
FINISHES, { index, status, ...reply }, in any order, flushed each time
|
|
1211
|
+
(X-Accel-Buffering: no) - or, less good, one JSON { replies: [...] } in
|
|
1212
|
+
order (every call then waits for the slowest). Calls issued in the same
|
|
1213
|
+
render tick travel as one batch, so parallel reads are one backend request
|
|
1214
|
+
and a fast read still resolves while a slow sibling runs; the renderer falls
|
|
1215
|
+
back to single calls for a backend without batches.
|
|
1062
1216
|
|
|
1063
1217
|
Full guides: read_guide({ slug: 'backend-answered-pages' }), read_guide({ slug: 'laravel' }), read_guide({ slug: 'go' }), read_guide({ slug: 'your-own-backend' }).`,
|
|
1064
1218
|
},
|
|
@@ -1113,6 +1267,15 @@ Native dependencies (sharp, bcrypt, better-sqlite3, @prisma/client, puppeteer,
|
|
|
1113
1267
|
.output/server/node_modules with their binaries. Add one:
|
|
1114
1268
|
rscKit({ serverExternalPackages: ['@acme/native'] }). Same as Next's option.
|
|
1115
1269
|
|
|
1270
|
+
The other direction: a dependency with "use client" files that does NOT
|
|
1271
|
+
declare react as a peerDependency (generated wrappers, workspace packages
|
|
1272
|
+
with react under dependencies) would be left external by plugin-rsc and its
|
|
1273
|
+
directive never read - hooks then run on the server. The build detects direct
|
|
1274
|
+
dependencies in that state and bundles them, printing
|
|
1275
|
+
"[rsc-kit] bundling <pkg>: it has "use client" files but does not declare
|
|
1276
|
+
react as a peer dependency". Nothing to configure; fix the package's
|
|
1277
|
+
peerDependencies when it is yours.
|
|
1278
|
+
|
|
1116
1279
|
Bun's, not the framework's: bun test loads the package .env (use
|
|
1117
1280
|
--env-file=/dev/null to isolate); Stripe's constructEvent throws on Bun
|
|
1118
1281
|
(no sync WebCrypto) - use constructEventAsync; Bun's pg puts SQLSTATE in
|
|
@@ -1121,7 +1284,65 @@ errno where Node's pg uses code.
|
|
|
1121
1284
|
Never NODE_ENV in a .env (the build refuses it, naming the line). Build
|
|
1122
1285
|
machines without secrets: SKIP_ENV_VALIDATION=1.
|
|
1123
1286
|
|
|
1287
|
+
reflect-metadata (tsyringe, typeorm, inversify - often under
|
|
1288
|
+
@simplewebauthn/server): nothing to import; when the graph has it the build
|
|
1289
|
+
loads it in a Nitro plugin ahead of the app, so the "tsyringe requires a
|
|
1290
|
+
reflect polyfill" boot error does not happen in a directory or a binary.
|
|
1291
|
+
|
|
1292
|
+
Build, then start - NEVER run vite build while bun/node .output/server/index.mjs
|
|
1293
|
+
is serving from that .output: services load lazily and a failed import of a
|
|
1294
|
+
half-written chunk is cached by the runtime (ENOENT 500s until restart).
|
|
1295
|
+
|
|
1296
|
+
Single binary (Bun): bun build --compile .output/server/compile.mjs
|
|
1297
|
+
--outfile dist/app (the scaffold's "compile" script). compile.mjs is written
|
|
1298
|
+
by the build and embeds the frozen pages; with serveStatic: 'inline' in the
|
|
1299
|
+
Nitro plugin the assets (and their .br/.gz) are inside too. Ship dist/app
|
|
1300
|
+
alone - a Dockerfile copies nothing else, not .output/public. A production
|
|
1301
|
+
app ported from Next measured the binary image at 50.12 MiB against the
|
|
1302
|
+
Next image's 104.56 MiB, the docker build at 2m45s against 5m13s, and
|
|
1303
|
+
Lighthouse at 99 mobile / 100 desktop - nothing tuned for the numbers.
|
|
1304
|
+
|
|
1305
|
+
Nothing is sent raw: a built bun/node server gzips what it answers
|
|
1306
|
+
(documents, streams flushed per chunk, payloads, stored pages, api routes)
|
|
1307
|
+
for a request that accepts it, and the build writes .br/.gz beside every
|
|
1308
|
+
public asset, served by Nitro. Nothing to configure; a Worker leaves it to
|
|
1309
|
+
the platform. Off: compress: false on the handler / compressPublicAssets:
|
|
1310
|
+
false in Nitro config; Cache-Control: no-transform exempts one answer. Do
|
|
1311
|
+
NOT add a compression middleware or precompress assets yourself.
|
|
1312
|
+
|
|
1124
1313
|
Full guide: read_guide({ slug: 'bun' }).`,
|
|
1314
|
+
},
|
|
1315
|
+
{
|
|
1316
|
+
topic: 'openapi',
|
|
1317
|
+
summary: 'An OpenAPI document derived from route.ts files - rscKit({ openapi }) - and Scalar\'s page over it, mounted as a route',
|
|
1318
|
+
body: `Do NOT hand-write an OpenAPI spec. rscKit({ openapi: true }) in
|
|
1319
|
+
vite.config.ts answers /openapi.json, derived from every route.ts: the
|
|
1320
|
+
directory is the path ([id] -> {id}), each method export an operation,
|
|
1321
|
+
params/searchParams/body schemas the parameters and request body (Zod 4 and
|
|
1322
|
+
ArkType describe themselves as JSON Schema; Valibot not yet), a middleware.ts
|
|
1323
|
+
above a route a security requirement + 401/403. Stored at build, no middleware.
|
|
1324
|
+
|
|
1325
|
+
Document-level parts go on the option:
|
|
1326
|
+
rscKit({ openapi: { info, servers, security, components: { securitySchemes } } })
|
|
1327
|
+
What a route says about itself, beside its handler:
|
|
1328
|
+
export const openapi = { summary, tags, responses: { 200: {...} }, POST: { summary } }
|
|
1329
|
+
export const openapi = false // leave this route out (the reference page, a webhook)
|
|
1330
|
+
export const openapi = { DELETE: false } // one method out; HEAD/OPTIONS never documented
|
|
1331
|
+
Webhook-heavy app: rscKit({ openapi: { include: 'declared' } }) documents only
|
|
1332
|
+
routes that export openapi, so callbacks need no opt-out line.
|
|
1333
|
+
Response bodies are declared in openapi.responses until a typed helper exists.
|
|
1334
|
+
|
|
1335
|
+
The page: Scalar's own package, one route, nothing shipped by the engine:
|
|
1336
|
+
// src/app/reference/route.ts
|
|
1337
|
+
import { ApiReference } from '@scalar/nextjs-api-reference'
|
|
1338
|
+
export const GET = ApiReference({ url: '/openapi.json' })
|
|
1339
|
+
export const openapi = false
|
|
1340
|
+
|
|
1341
|
+
Porting a spec file: delete its paths (they are the routes now, and body
|
|
1342
|
+
validates at runtime), move info/servers/security to the option, move a
|
|
1343
|
+
route's summary/tags/responses to its openapi export.
|
|
1344
|
+
|
|
1345
|
+
Full guide: read_guide({ slug: 'openapi' }).`,
|
|
1125
1346
|
},
|
|
1126
1347
|
{
|
|
1127
1348
|
topic: 'env',
|
|
@@ -1143,8 +1364,11 @@ export const env = createEnv({
|
|
|
1143
1364
|
},
|
|
1144
1365
|
clientPrefix: 'PUBLIC_',
|
|
1145
1366
|
client: { PUBLIC_SITE_URL: z.url() },
|
|
1146
|
-
|
|
1367
|
+
// No bare process.env: a "use client" file importing this for a PUBLIC_
|
|
1368
|
+
// value has no process, and the spread throws before the first render.
|
|
1369
|
+
runtimeEnv: { ...(typeof process === 'undefined' ? {} : process.env), ...import.meta.env },
|
|
1147
1370
|
emptyStringAsUndefined: true,
|
|
1371
|
+
skipValidation: typeof process !== 'undefined' && !!process.env.SKIP_ENV_VALIDATION,
|
|
1148
1372
|
})
|
|
1149
1373
|
\`\`\`
|
|
1150
1374
|
|
|
@@ -1152,7 +1376,8 @@ Read env.DATABASE_URL, never process.env.DATABASE_URL: the first is typed and
|
|
|
1152
1376
|
was checked at startup (a missing or malformed one fails then, with its name),
|
|
1153
1377
|
the second is string | undefined. A server variable never reaches the browser;
|
|
1154
1378
|
a browser-readable one MUST start with PUBLIC_ and is read from import.meta.env
|
|
1155
|
-
(Vite
|
|
1379
|
+
(Vite; the engine registers PUBLIC_ beside VITE_ as a client prefix, nothing
|
|
1380
|
+
to configure), which is why runtimeEnv merges both. Commit .env.example, not .env.
|
|
1156
1381
|
|
|
1157
1382
|
Next: NEXT_PUBLIC_* becomes PUBLIC_*; @t3-oss/env-nextjs becomes
|
|
1158
1383
|
@t3-oss/env-core with runtimeEnv as above (env-nextjs's experimental__runtimeEnv
|