@plott/webkit-next0.4.0
Next.js App Router 공통 — 서버 액션 결과(값으로 돌려주는 실패·가려진 오류 문구·Next 제어 흐름 통과), 배포 뒤 사라진 서버 액션 복구, Activity 로 숨겨진 화면 정리, proxy 의 진짜 404·라우트 표, 헬스 체크·robots·Sentry 옵션, 사라진 청크 복구, 오류 화면(error.tsx) 팩토리, JSON-LD·OG 주소, 보안 헤더·CSP, 타입 있는 환경 변수, 목록 상태(page/size/sort/filter)를 주소에
설치
pnpm add @plott/webkit-next@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-next | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-next/proxy | ./dist/proxy/index.d.ts | ./dist/proxy/index.js |
@plott/webkit-next/client | ./dist/client/index.d.ts | ./dist/client/index.js |
@plott/webkit-next/seo | ./dist/seo/index.d.ts | ./dist/seo/index.js |
@plott/webkit-next/headers | ./dist/headers/index.d.ts | ./dist/headers/index.js |
@plott/webkit-next/errors | ./dist/route-error/index.d.ts | ./dist/route-error/index.js |
@plott/webkit-next/env | ./dist/env/index.d.ts | ./dist/env/index.js |
@plott/webkit-next/url-state | ./dist/url-state/index.d.ts | ./dist/url-state/index.js |
@plott/webkit-next/kr | ./dist/kr/index.d.ts | ./dist/kr/index.js |
Next.js(App Router) 앱에 공통으로 붙는 조각입니다. 읽는 사람은 서버 액션·proxy·오류 화면·운영 설정을 만드는 프론트엔드 개발자입니다.
peer next >=16.4.1 <17, react ^19.3 이 필요합니다. 런타임 의존성은 nuqs(주소 쿼리 상태, /url-state 에서만 씁니다)와 같은 저장소의 @plott/webkit-utils(Result·목록 응답 어댑터·StandardSchemaV1) 입니다.
진입점
| import | 어디서 | 주요 export |
|---|---|---|
@plott/webkit-next | 서버 액션·라우트 핸들러·서버 컴포넌트 | runAction·configureActions·ok·fail·failFromResponse·classifyKitError·problemFields(ActionResult), classifyError·userMessage·defaultMessages(영어)·digestOf, healthRoute, robots, sentryOptions |
@plott/webkit-next/proxy | proxy.ts | notFoundRewrite, temporaryRedirect, sameOriginPath, opensScreen, createRouteTable |
@plott/webkit-next/client | 클라이언트 컴포넌트 | installStaleDeploymentGuard·recoverFromStaleDeployment·recoverFromChunkError·isStaleDeployment·isChunkLoadError·reloadOnceFor, classifyError·userMessage·defaultMessages |
@plott/webkit-next/seo | 서버 컴포넌트·generateMetadata·Node (훅 없음) | jsonLdScript, JsonLd, absoluteUrl, ogImages |
@plott/webkit-next/headers | next.config.ts | securityHeaders, csp |
@plott/webkit-next/errors | error.tsx·global-error.tsx ("use client") | createRouteError, createGlobalError, classifyRouteError |
@plott/webkit-next/env | 서버·브라우저 어디서나 | defineEnv, validateEnv, str·url·num·bool·oneOf, StandardSchemaV1 |
@plott/webkit-next/url-state | 서버·클라이언트가 같은 정의를 import | defineListQuery, UrlStateProvider, pageInfo, 쿼리 프리셋 QUERY_PRESETS·springQuery·pageQuery·offsetQuery·resolveQueryAdapter, 타입 PageAdapter·SpringPage, parseAs* |
@plott/webkit-next/kr | 어디서나 | koMessages (defaultMessages 와 같은 모양의 한국어 문구) |
서버 액션
"use server";import { runAction } from "@plott/webkit-next";
type Profile = { name: string };declare const api: { json(path: string, init: { method: string; body: string }): Promise<unknown> };declare const Sentry: { captureException(error: unknown, hint: { extra: Record<string, unknown> }): void };
export async function saveProfile(input: Profile) { return runAction(() => api.json("/me", { method: "PUT", body: JSON.stringify(input) }), { report: (e, { digest }) => Sentry.captureException(e, { extra: { digest } }), });}session_expired 는 입력을 지킨 채 로그인으로 보냅니다.
import type { ActionResult } from "@plott/webkit-next";
declare const saveProfile: (input: { name: string }) => Promise<ActionResult<unknown>>;declare const values: { name: string };declare const toast: (message: string) => void;declare const t: { saved: string; errors: Record<string, string | undefined> & { unexpected: string } };declare const router: { push(href: string): void };declare const loginHref: string;declare const form: { setErrors(fields: Readonly<Record<string, readonly string[]>> | undefined): void };
export async function onSubmit() { const r = await saveProfile(values); if (r.ok) toast(t.saved); else if (r.error.code === "session_expired") router.push(loginHref); else form.setErrors(r.error.fields), toast(r.error.message ?? t.errors[r.error.code] ?? t.errors.unexpected);}| 던진 것 | 결과 |
|---|---|
| 값 | { ok: true, data } |
ApiError(@plott/webkit-api) | { ok: false, error: { code, status, message?, fields? } } — 4xx 는 Problem Details 의 code·detail·필드 오류를 옮깁니다. 5xx 는 report 하고 detail·필드 오류를 옮기지 않습니다(내부 문구입니다). code 는 INVALID_EMAIL·item.not_found 같은 모양일 때만 남기고, 아니면 upstream_error 입니다 |
AuthRequiredError(@plott/webkit-auth) | code: "session_expired" |
ApiNetworkError | code: "network" |
Next 의 redirect()·notFound() | 그대로 던집니다(이동합니다) |
| 그 밖 | report 하고 code: "unexpected" — 메시지는 싣지 않습니다 |
- 로그인 액션의 401 은 세션 만료가 아닙니다. 업스트림이 «잘못된 비밀번호» 에
code없는 401 을 주면 기본값(session_expired)은 화면을 로그인으로 보냅니다. 그런 액션은runAction(fn, { unauthorized: "unauthorized" })로, 앱 전체는configureActions({ unauthorized: "unauthorized" })로 바꿉니다(failFromResponse(status, body, { unauthorized })도 같습니다). - 4xx
detail은 기본으로 그대로 화면에 갑니다. 프레임워크의 400(바인딩·역직렬화 오류)은detail에 클래스 이름·필드 경로가 들어 있을 수 있어, 공개 앱은detail: "drop"으로 두고messages로 code 에 맞는 말을 채우는 편이 안전합니다. 함수(status, detail) => string | undefined로 가공하거나 거를 수도 있습니다. 5xx 의detail은 이 옵션과 상관없이 옮기지 않습니다.
호출마다 { report } 를 넘기지 않으려면 앱 시작 때 한 번 정합니다(instrumentation.ts 의 register, 또는 액션 파일이 공통으로 import 하는 모듈). 호출에 넘긴 옵션이 이깁니다. messages 는 코드를 말로 바꾸며 message 가 없는 실패에만 채웁니다.
import { configureActions } from "@plott/webkit-next";
declare const Sentry: { captureException(error: unknown, hint: { extra: Record<string, unknown> }): void };declare const t: { errors: { unexpected: string; network: string } };
configureActions({ report: (e, { digest }) => Sentry.captureException(e, { extra: { digest } }), messages: { unexpected: t.errors.unexpected, network: t.errors.network },});화면에 보일 말은 userMessage(error, messages) 로 고릅니다. 취소(AbortError)는 null 입니다. 킷은 한 언어를 강제하지 않습니다: messages 는 한 문장, { fallback, network?, timeout? } 표, 또는 종류(network·timeout·server·unknown)를 받아 말을 돌려주는 함수이고, 넘기지 않으면 내장 한국어 표(defaultMessages)를 씁니다. 다국어 앱은 로케일의 말을 넘깁니다.
배포 뒤 사라진 서버 액션
배포 전에 열어 둔 화면이 배포 뒤 서버 액션을 부르면 그 액션이 없습니다(액션 ID 가 빌드마다 바뀝니다). 루트 레이아웃의 클라이언트 컴포넌트에서 한 번 붙입니다.
"use client";import { useEffect } from "react";import { installStaleDeploymentGuard } from "@plott/webkit-next/client";
declare const toast: (message: string) => void;declare const t: { newVersion: string };
export function StaleGuard() { useEffect(() => installStaleDeploymentGuard({ onStale: () => toast(t.newVersion) }), []); return null;}직접 잡는 곳(폼 제출의 catch)에서는 recoverFromStaleDeployment(error) 를 부릅니다. 같은 탭에서는 60초에 한 번만 새로 고칩니다(cooldownMs). 마지막으로 고친 시각은 sessionStorage 의 webkit:stale-deployment-reload 에 적습니다. 같은 오리진의 다른 앱과 겹치면 storageKey 로 바꿉니다.
배포 뒤 사라진 청크(코드 조각)도 같은 가드가 처리합니다. 오래된 HTML 이 이미 지워진 청크를 요청하면 ChunkLoadError(Chrome «Failed to fetch dynamically imported module», Safari «Importing a module script failed», Firefox «error loading dynamically imported module»)가 납니다. installStaleDeploymentGuard 는 이것도 한 번 새로 고치며, 끄려면 { chunkErrors: false } 입니다. 사라진 액션과 사라진 청크는 같은 쿨다운·같은 저장 키를 씁니다(한쪽 때문에 새로 고친 직후 다른 쪽 오류로 또 새로 고치지 않습니다). 직접 부를 때는 recoverFromChunkError(error) 나 reloadOnceFor("내-종류", { cooldownMs, storageKey }) 입니다.
오류 화면 (/errors)
error.tsx 가 없으면 추적 코드의 예외 하나가 화면을 통째로 비웁니다. 앱마다 두 파일을 둡니다. app/global-error.tsx 는 루트 레이아웃을 대신하므로 자기 <html><body> 를 그립니다.
app/error.tsx:
"use client";import { createRouteError } from "@plott/webkit-next/errors";
declare const Sentry: { captureException(error: unknown, hint: { extra: Record<string, unknown> }): void };declare const t: { error: string; offline: string; newVersion: string; retry: string };
export default createRouteError({ messages: { fallback: t.error, network: t.offline, chunk: t.newVersion, retry: t.retry }, report: (e, { digest, kind }) => Sentry.captureException(e, { extra: { digest, kind } }),});app/global-error.tsx:
"use client";import { createGlobalError } from "@plott/webkit-next/errors";export default createGlobalError({ html: { lang: "ko" }, messages: "일시적인 오류가 났습니다." });- 종류는
chunk·network·stale-action·unknown입니다(classify로 바꿉니다). - 화면은
messages(한 문장, 종류별 표, 또는 함수)만 그립니다. 넘기지 않으면 영어 기본 문구입니다.error.message는 그리지 않습니다. render: (view) => …로 화면을 바꿉니다(view는kind·message·digest·retry·reset·retryLabel).retry는 Next 16.3+ 의retryprop(이전 이름unstable_retry, 없으면reset)입니다.next/error의catchError는 컴포넌트 단위 경계(error.tsx와 별개)입니다. 이 모듈은 파일 규약error.tsx만 다룹니다.
SEO (/seo)
import { JsonLd, ogImages } from "@plott/webkit-next/seo";
declare const hotel: { name: string };declare const nonce: string;declare const ORIGIN: string;
export const hotelJsonLd = <JsonLd data={{ "@context": "https://schema.org", "@type": "Hotel", name: hotel.name }} nonce={nonce} />;
export const generateMetadata = () => ({ openGraph: { images: ogImages("/og/home.png", ORIGIN) } });jsonLdScript(data) 는 <script type="application/ld+json"> 안에 넣어도 되는 글자를 만듭니다. JSON.parse 하면 원래 값이 나옵니다. absoluteUrl(path, origin) 은 같은 오리진(또는 allowOrigins)의 절대 주소만 만들고 //host·\host·javascript: 는 던집니다.
보안 헤더 (/headers)
next.config.ts:
import { csp, securityHeaders } from "@plott/webkit-next/headers";
export default { async headers() { return [{ source: "/(.*)", headers: securityHeaders({ csp: csp({ "default-src": ["self"], "img-src": ["self", "data:", "https://cdn.example.com"] }) }) }]; },};securityHeaders() 의 기본: X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, 카메라·마이크·위치를 자기 출처에만 허용하는 Permissions-Policy: camera=(self), microphone=(self), geolocation=(self), frame-ancestors 'none' + X-Frame-Options: DENY, Cross-Origin-Opener-Policy: same-origin-allow-popups(OAuth 팝업이 깨지지 않는 값). HSTS 는 NODE_ENV === "production" 일 때만 1년(includeSubDomains·preload 없음)이고, hsts 옵션으로 켜고 끄거나 조절합니다. csp() 는 지시어 이름을 검증하고(오타는 던집니다) self·none·nonce·해시와 require-trusted-types-for 의 script·trusted-types 의 allow-duplicates 에 따옴표를 붙입니다.
- 다른 사이트가 이 페이지를
<iframe>에 넣게 하려면embedBy.securityHeaders({ embedBy: ["https://partner.com"] })는frame-ancestors 'self' https://partner.com을 보내고X-Frame-Options는 보내지 않습니다.X-Frame-Options는 «실제로 보내는»frame-ancestors에서 따라옵니다:'none'이면 DENY, 정확히'self'이면 SAMEORIGIN, 그 밖이면 보내지 않습니다(DENY 를 같이 보내면 CSP 가 허용한 임베드를 낡은 규칙이 막습니다). CSP 를 직접 적어도 같습니다. - 카메라·마이크·위치는 이 앱 자신에게만 열려 있습니다. QR 스캔·지도 같은 앱 자신의 기능이 되고, 제삼자
<iframe>은 쓰지 못합니다. 전부 막으려면permissionsPolicy: { camera: [], microphone: [], geolocation: [] }를 줍니다. - COOP 기본(
same-origin-allow-popups)의 대가. 이 사이트를window.open으로 열어 준 쪽(결제·로그인 연동 페이지)은window.opener가 끊겨postMessage를 받지 못합니다. 그런 상대가 있으면crossOriginOpenerPolicy: "unsafe-none"으로 바꿉니다.
요청마다 nonce 를 넣는 CSP(script-src 'nonce-…' 'strict-dynamic')는 정적 헤더로 못 만듭니다. proxy.ts 에서 요청마다 헤더를 만들어야 하며, 이 패키지는 그 방법을 다루지 않습니다(Next 문서의 «Content Security Policy» 를 봅니다).
환경 변수 (/env)
env.ts 의 runtimeEnv 는 값을 리터럴(process.env.NEXT_PUBLIC_API)로 적어야 Next 가 번들에 박습니다.
import { defineEnv, num, url } from "@plott/webkit-next/env";
export const env = defineEnv({ server: { DATABASE_URL: url({ protocols: ["postgres"] }), PORT: num({ default: 3000, int: true }) }, client: { NEXT_PUBLIC_API: url() }, runtimeEnv: { NEXT_PUBLIC_API: process.env.NEXT_PUBLIC_API }, skipValidation: !!process.env.SKIP_ENV_VALIDATION,});값은 처음 읽을 때 검증합니다(next build 가 런타임에만 있는 변수 때문에 실패하지 않습니다). 서버가 뜰 때 한꺼번에 확인하려면 instrumentation.ts 의 register 에서 validateEnv(env) 를 부릅니다. 검증기는 Standard Schema(zod 4·valibot·arktype)면 무엇이든 넣을 수 있고, 라이브러리가 없으면 내장 str·url·num·bool·oneOf 를 씁니다(각각 default·optional). 오류 문구에는 키와 이유만 있고 값은 없습니다.
num은 십진 표기만 받습니다(8080,-1,3.5).0x10(16)·1e3·.5·+5는 거절합니다.str({ pattern })의 정규식에g·y가 붙어 있어도 플래그를 뗀 복제본으로 검사하므로 같은 입력의 결과가 번갈아 바뀌지 않습니다.default도 정의할 때 같은 제약으로 검증합니다.num({ min: 1, default: 0 })은 읽을 때가 아니라 정의할 때 던집니다.- 클라이언트 컴포넌트가 이 모듈을 import 하면 서버 스키마(값이 아니라 스키마 정의)가 클라이언트 번들에 실립니다. 값은 서버에서만 읽혀 새지 않지만 변수 이름·검증 규칙이 공개됩니다. 서버용과 클라이언트용 파일을 나누고 서버 쪽에
server-only를 붙입니다.
env.server.ts:
import "server-only";import { defineEnv, num, url } from "@plott/webkit-next/env";export const serverEnv = defineEnv({ server: { DATABASE_URL: url({ protocols: ["postgres"] }), PORT: num({ default: 3000, int: true }) }, runtimeEnv: {} });env.client.ts 는 클라이언트 컴포넌트가 import 하는 유일한 파일입니다.
import { defineEnv, url } from "@plott/webkit-next/env";
export const clientEnv = defineEnv({ client: { NEXT_PUBLIC_API: url() }, runtimeEnv: { NEXT_PUBLIC_API: process.env.NEXT_PUBLIC_API } });목록 상태를 주소에 (/url-state)
lib/orders-query.ts 는 "use client" 를 쓰지 않습니다. 서버와 클라이언트가 같은 정의를 import 합니다.
import { defineListQuery, parseAsString, parseAsStringLiteral } from "@plott/webkit-next/url-state";
export const ordersQuery = defineListQuery({ filters: { q: parseAsString, status: parseAsStringLiteral(["open", "closed"] as const) }, sort: { keys: ["createdAt", "name"], default: "createdAt", defaultDir: "desc" }, pageSize: { default: 20, max: 100 }, textFilters: ["q"],});app/orders/page.tsx(서버)는 아래처럼 읽습니다. 클라이언트에서는 const [state, set] = ordersQuery.useListQuery(); 를 씁니다. toApiQuery(state, 프리셋) 의 프리셋 "spring" 이 만드는 page 는 0 기반이고("page" 는 1 기반 page·size·sort·dir, "offset" 은 offset·limit·sort·order), pageInfo 가 돌려주는 화면의 page 는 1 기반입니다. 프리셋은 필수이고 기본이 없습니다. 직접 만든 어댑터 (params) => 쿼리 객체 도 받습니다. 내보내기 버튼의 주소는 표가 읽는 주소와 같은 규칙으로 serialize 한 쿼리입니다.
import { defineListQuery, pageInfo, parseAsString, type SpringPage } from "@plott/webkit-next/url-state";
const ordersQuery = defineListQuery({ filters: { q: parseAsString }, sort: { keys: ["createdAt", "name"], default: "createdAt", defaultDir: "desc" }, pageSize: { default: 20, max: 100 },});
type Order = { id: string };declare const api: { json(path: string, init: { query: Record<string, unknown> }): Promise<SpringPage<Order>> };
export default async function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) { const state = await ordersQuery.parse(searchParams); const page = await api.json("/orders", { query: ordersQuery.toApiQuery(state, "spring") }); const info = pageInfo<Order>(page, "spring");
return <a href={`/api/orders/export${ordersQuery.serialize(state)}`}>내보내기 {info.total}</a>;}루트 레이아웃에 <UrlStateProvider>(nuqs 의 Next App Router 어댑터)를 한 번 둡니다. serialize 가 만드는 쿼리는 훅이 주소에 쓰는 것과 같은 파라미터·값입니다(훅은 주소창에 이미 있던 파라미터 순서를 지키고 새 것을 뒤에 붙이므로 순서만 다를 수 있습니다).
- 필터·크기·정렬을 바꾸면 page 가 1 로 돌아갑니다. page 를 같이 넘기면 그 값입니다.
- page 는 1 이상
maxPage(기본 10,000) 이하, size 는max이하, 정렬 키는 허용 목록입니다. 어긋난 값은 기본값으로 돌아가고, 상한을 넘는 page 는 상한으로 눌립니다(?page=9007199254740991이 백엔드의 offset 계산을 넘치게 하지 못합니다.page × size도 안전한 정수 안으로 눌립니다). 서버parse·serialize·훅이 같은 규칙입니다. - 정렬은
sort.keys가 필수입니다(영문·숫자·_·.만). 빈 목록은 «아무 값이나 허용» 이 되어?sort=id;drop이 API 로 나가므로 정의할 때 던집니다. - 필터 이름은
page·size·sort·dir와keys로 바꾼 주소 이름 모두와 겹치면 안 됩니다.keys: { page: "p" }일 때 필터page도state.page를 덮으므로 정의할 때 던집니다. - 훅의 설정 객체는 정의마다 하나라서
state는 주소가 바뀔 때만 새 객체가 됩니다(useEffect(…, [state])가 렌더마다 다시 돌지 않습니다). - 기본 옵션:
history: "replace",scroll: false,clearOnDefault: true(기본값은 주소에서 뺍니다),shallow: false(서버 컴포넌트가 다시 가져와야 목록이 갱신되기 때문입니다. 클라이언트에서 가져오는 목록이면refetch: false).textFilters로 지정한 칸만 바꾸는 갱신은 300ms 늦춥니다(입력 칸의 값은 즉시 바뀌고 주소만 늦습니다). pageInfo(응답, 어댑터)의 어댑터는 필수입니다(프리셋"spring"·"items"또는PageAdapter함수. 정의는@plott/webkit-utils). 어댑터가 모양을 알아보지 못하면TypeError입니다.PageView.number는 0부터 셉니다.pageInfo가{ items, total, page(1부터), pageCount, hasNext, hasPrev }로 바꾸고, 어댑터가hasMore를 주면 그것이hasNext입니다.
Cache Components 의 숨겨진 화면
Cache Components 에서는 다른 화면으로 가도 앞 화면이 언마운트되지 않고 숨겨집니다(React <Activity>). 뒤로 오면 열려 있던 메뉴가 그대로 열려 있고, 제출을 마친 폼이 «처리 중» 으로 잠겨 있습니다.
useCloseOnHide 는 메뉴·시트·다이얼로그를 닫고, ResetOnHide 는 제출 흐름을 숨겨질 때 처음 상태로 되돌립니다. 둘과 useOnHide 는 next 에 기대지 않아 @plott/webkit-react 에 있습니다.
"use client";import { useState } from "react";import { ResetOnHide, useCloseOnHide } from "@plott/webkit-react";
declare function CheckoutForm(): React.ReactElement;
export function Menu() { const [open, setOpen] = useState(false); useCloseOnHide(open, () => setOpen(false)); return ( <ResetOnHide> <CheckoutForm /> </ResetOnHide> );}useOnHide 에 넘기는 함수는 상태만 되돌립니다. 숨겨진 화면에서 이동을 부르면 지금 보이는 화면이 움직입니다.
proxy 의 진짜 404
import type { NextRequest } from "next/server";import { createRouteTable, notFoundRewrite, opensScreen, temporaryRedirect } from "@plott/webkit-next/proxy";
declare const isMember: (path: string) => boolean;declare const SESSION: string;declare const loginHref: string;
const routes = createRouteTable(["/", "/search", "/branch/[id]", "/my/reservations"]);
export function proxy(req: NextRequest, appPath: string, locale: string) { if (!routes.has(appPath)) return notFoundRewrite(req, { locale }); if (opensScreen(req) && isMember(appPath) && !req.cookies.has(SESSION)) return temporaryRedirect(req, loginHref);}notFoundRewrite 의 본문은 404 화면이고 상태 코드는 404 입니다. createRouteTable 은 깨진 퍼센트 인코딩(/branch/%)을 «맞는 화면 없음» 으로 다룹니다(던지지 않습니다).
temporaryRedirect 는 기본이 같은 오리진만입니다. 앱 경로나 이 요청과 같은 오리진의 절대 주소를 받고, //host·/\host·javascript:·다른 오리진은 fallback(기본 /)으로 보냅니다(앞의 /·\ 여러 개는 한 개로 모읍니다). 바깥 주소는 켭니다: { allowOrigins: ["https://login.example.com"] }, 또는 고정 주소에만 { external: true }(http·https 전부).
?next= 같은 바깥 입력은 sameOriginPath(value, fallback = "/") 로 먼저 거릅니다. 경로+쿼리+해시를 돌려주고, 이것은 받지 않고 fallback 입니다: //로 시작, 역슬래시, 절대 주소(https:·javascript:), / 로 시작하지 않는 값, 제어 문자(탭·줄바꿈), /a/..//host 처럼 점 구간을 풀면 // 가 되는 값.
import type { NextRequest } from "next/server";import { sameOriginPath, temporaryRedirect } from "@plott/webkit-next/proxy";
export function proxy(req: NextRequest) { return temporaryRedirect(req, sameOriginPath(req.nextUrl.searchParams.get("next")));}notFoundRewrite 의 대상 경로(/<locale>/__not-found, 끝 조각은 { locale, segment } 의 segment 로 바꾸고 문자열 경로를 직접 넘겨도 됩니다)는 catch-all 화면(app/[lang]/[...rest]/page.tsx 에서 notFound())이 받아 not-found.tsx 를 그립니다. Next 16.4.2 + Cache Components 에서는 이 404 응답의 HTML 이 빈 오류 셸이고, 404 화면은 같은 응답의 스트림 페이로드로 와서 브라우저가 그립니다(상태 코드는 404). e2e/tests/kit.test.ts 가 samples/kit 운영 빌드로 확인합니다.
운영 설정
app/healthz/route.ts:
import { healthRoute } from "@plott/webkit-next";
declare const api: (path: string) => Promise<unknown>;
export const { GET } = healthRoute({ checks: [{ name: "api", run: () => api("/health") }] });app/robots.ts:
import { robots } from "@plott/webkit-next";
declare const ORIGIN: string;
export default () => robots({ production: process.env.SITE_ENV === "production", sitemap: `${ORIGIN}/sitemap.xml` });instrumentation-client.ts 와 sentry.server.config.ts:
import { sentryOptions } from "@plott/webkit-next";
declare const release: string;declare const Sentry: { init(options: ReturnType<typeof sentryOptions>): void };
Sentry.init(sentryOptions({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NEXT_PUBLIC_SITE_ENV, release }));healthRoute 의 공개 본문에는 검사마다 "ok"/"fail" 만 싣습니다(연결 오류 글자에는 내부 주소가 있습니다). 사유는 console.error 로 남기고, onError: (name, error) => … 로 바꿉니다. 검사가 먼저 끝나면 제한 시간 타이머를 지웁니다.
컨테이너(standalone)로 배포하면 Node 의 keep-alive(기본 5초)가 로드밸런서의 유휴 시간(ALB 기본 60초)보다 짧아 간헐 502 가 납니다. Dockerfile 에 ENV KEEP_ALIVE_TIMEOUT=65000 을 둡니다.
기본으로 지키는 것
- 예상한 실패는 던지지 않고 값으로 돌려줍니다. 운영 빌드는 서버 액션이 던진 오류의 메시지를 지우고 «Minified React error #441» 만 보냅니다. 호출부가 catch 로 삼키면 오류 수집에도 남지 않습니다.
- 세션 만료는 결과(
session_expired)로 돌려줍니다. 서버 액션 요청은 proxy 의 3xx 를 따라가지 않습니다. 화면이 입력을 지킨 채 로그인으로 보냅니다. - Next 의 제어 흐름은 그대로 던집니다.
redirect()·notFound()를 catch 로 삼키면 이동하지 않습니다(unstable_rethrow). - 가려진 서버 오류 문구·브라우저마다 다른 네트워크 글자(Safari «Load failed», Chrome «Failed to fetch»)를 화면에 쓰지 않습니다.
userMessage가 정해 둔 문구를 고릅니다. - 배포 뒤 사라진 액션은 Next 가 주는 오류(
UnrecognizedActionError)로 판정합니다. 전역fetch를 덮어쓰지 않습니다. 저장소를 쓸 수 없는 창(사파리 개인 창)에서는 새로 고치지 않습니다(무한 새로 고침보다 낫습니다). - 상태 코드가 걸린 판정은 proxy 에서 합니다. Cache Components 에서는 화면의
notFound()·redirect()가 미리 그린 셸(200) 뒤에 와서 상태가 200 으로 나갑니다. 레이아웃 맨 위에서 세션을 읽으면 셸이 거기서 멈춰 200 으로 저장되고 proxy 의 404 를 덮습니다. 운영 빌드로 상태 코드를 확인하는 E2E 를 둡니다. - 보내기는 307 이고 캐시하지 않습니다. 영구 이동(308·301)은 브라우저·CDN 이 기억합니다.
- 로그인으로 보내는 판정은 화면을 여는 요청(GET·HEAD)만 합니다. 서버 액션(POST)을 보내면 액션이 사라집니다.
- 라우트 표에 catch-all 을 넣지 않습니다. 모든 주소가 맞습니다.
- 운영 주소가 아니면 robots 가 전부 막습니다. noindex 헤더만으로는 광고 봇이 샌드박스를 계속 긁습니다.
production은 NODE_ENV 가 아니라 «이 배포가 운영 주소인가» 입니다. - JSON-LD 는
<·>·&·U+2028/2029 를 이스케이프합니다. 데이터의</script>로 태그가 닫히는 일이 없고, 출력은JSON.parse로 원래 값이 나옵니다. - og:image 는 절대 주소이고, 다른 오리진으로 새는 입력(
//host)은 던집니다. - 청크·낡은 액션 오류 화면의 «다시 시도» 는
location.reload()입니다.router.refresh()+reset은 새 배포의 파일이 없는 낡은 탭에서 같은 실패를 되풀이하고, 자동 새로 고침의 쿨다운이 끝난 뒤에는 버튼이 하나도 동작하지 않습니다. Turbopack 의Failed to load chunk …글자(이름ChunkLoadError가 사라진 경우)도 청크 오류로 봅니다. - 오류 화면은
error.message를 그리지 않고, 오류 수집은 digest 당 한 번, 사라진 청크·액션은 한 번만 자동 복구합니다. - 환경 변수는 읽을 때 검증하고, 서버 변수를 브라우저에서 읽으면 던지며,
NEXT_PUBLIC_규칙을 정의 때 확인합니다. 빈 문자열은 «없음» 입니다. - CSP 지시어 이름을 검증하고, 값에
;·,·공백·줄바꿈이 있으면 던집니다. 보안 헤더 값에 줄바꿈이 들어갈 수 없습니다. - Sentry 는 DSN 이 있고 운영 환경일 때만 켜고, 개인 정보(IP·쿠키)를 보내지 않습니다.
- 오류 수집(
report)이 던져도 호출부로 번지지 않습니다.runAction·createRouteError·healthRoute의onError모두 같습니다.
그 밖
runAction옵션:report(예상 밖 오류와 5xx),map(앱 고유 오류를 결과로,undefined면 기본 처리),messages(message 없는 실패에 코드로 말을 채움),unauthorized,detail,statusCodes,fieldsOf,classify(map다음에 내장 분류classifyKitError를 대신할(error, { options }) => 실패 | undefined,false면 내장 분류를 끄고 모두unexpected). 호출 옵션의undefined는configureActions로 정한 값을 덮지 않습니다.failFromResponse(status, body, options)의 셋째 인자는 대체 코드 글자 또는{ fallbackCode, unauthorized, detail, statusCodes, fieldsOf }입니다. 상태별 코드의 기본은 401session_expired, 403forbidden, 404not_found, 409conflict, 400·422validation, 그 밖upstream_error이고,statusCodes: { 403: "no_access" }가 상태마다 코드를 바꿉니다(코드 모양이[A-Za-z][A-Za-z0-9_.-]{0,63}이 아니면 무시). 필드 오류의 기본(problemFields)은errors·fields의 배열(field또는 JSON 포인터pointer,message또는detail)이나 객체이고,fieldsOf(body)가 읽기를 대신합니다(던지면 필드 없이 돌려줍니다). 기본 문구(defaultMessages)는 영어이고 한국어는@plott/webkit-next/kr의koMessages를userMessage(error, koMessages)에 넘깁니다.healthRoute({ checks, timeoutMs = 2000, onError }),robots({ production, sitemap?, disallow? }),sentryOptions({ dsn, environment, release, tracesSampleRate = 0 }).reloadOnceFor(kind, { cooldownMs = 60000, storageKey, onReload, reload, storage, now }):storage를 주지 않으면sessionStorage를 쓰고, 없거나 접근이 막히면 새로 고치지 않습니다.createRouteError/createGlobalError옵션:render,report,classify,messages,recover(false면 자동 복구 끔).handleRouteError·firstSighting은 같은 모듈의 이펙트 본문과 중복 보고 판정이며, 기억하는 digest 는 200개까지입니다.securityHeaders옵션:csp(문자열·객체·false),hsts,frameAncestors(기본"none"),embedBy(frameAncestors와 함께 쓰면 던집니다),referrerPolicy,permissionsPolicy,crossOriginOpenerPolicy,production(기본NODE_ENV === "production").absoluteUrl(path, origin, { allowOrigins }),ogImages(input, origin, opts): 문자열·객체·배열을 받아{ url, width?, height?, alt? }[]를 돌려줍니다.defineListQuery의 결과:parsers,defaults,pageSizeOptions,normalize,applyPatch,serialize,toApiQuery(state, 프리셋 | 어댑터),cache,parse,useListQuery.parseAsBoolean·parseAsInteger·parseAsString·parseAsStringLiteral·parseAsArrayOf·parseAsIsoDate는nuqs/server의 파서를 다시 내보냅니다.- 이 패키지가 프로그래밍 실수로 던지는 오류는
@plott/webkit-next:로 시작합니다. - 코드에는 주석이 없습니다. 각 함수의 동작은
test/의양성·음성테스트가 정의합니다.