본문으로 건너뛰기
언어

@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
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/proxyproxy.tsnotFoundRewrite, 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/headersnext.config.tssecurityHeaders, csp
@plott/webkit-next/errorserror.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서버·클라이언트가 같은 정의를 importdefineListQuery, UrlStateProvider, pageInfo, 쿼리 프리셋 QUERY_PRESETS·springQuery·pageQuery·offsetQuery·resolveQueryAdapter, 타입 PageAdapter·SpringPage, parseAs*
@plott/webkit-next/kr어디서나koMessages (defaultMessages 와 같은 모양의 한국어 문구)

서버 액션

ts
"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 는 입력을 지킨 채 로그인으로 보냅니다.

tsx
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"
ApiNetworkErrorcode: "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 가 없는 실패에만 채웁니다.

ts
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 가 빌드마다 바뀝니다). 루트 레이아웃의 클라이언트 컴포넌트에서 한 번 붙입니다.

tsx
"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:

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:

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+ 의 retry prop(이전 이름 unstable_retry, 없으면 reset)입니다.
  • next/error 의 catchError 는 컴포넌트 단위 경계(error.tsx 와 별개)입니다. 이 모듈은 파일 규약 error.tsx 만 다룹니다.

SEO (/seo)

tsx
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:

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 가 번들에 박습니다.

ts
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:

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 하는 유일한 파일입니다.

ts
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 합니다.

ts
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 한 쿼리입니다.

tsx
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 에 있습니다.

tsx
"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

ts
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 처럼 점 구간을 풀면 // 가 되는 값.

ts
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:

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:

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:

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 } 입니다. 상태별 코드의 기본은 401 session_expired, 403 forbidden, 404 not_found, 409 conflict, 400·422 validation, 그 밖 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/ 의 양성·음성 테스트가 정의합니다.