ข้ามไปยังเนื้อหา

Server & API

ไฟล์ +server.js คือ route ที่ ไม่มี UI — เป็น HTTP endpoint เดี่ยว ๆ export function ที่ตั้งชื่อตาม HTTP method แต่ละตัว return Response ใช้กับ JSON API, webhook, การดาวน์โหลดไฟล์ หรืออะไรก็ตามที่ client ที่ไม่ใช่ browser เรียก

src/routes/api/posts/+server.js
import { json, error } from '@sveltejs/kit';
/** @param {number} page */
async function getPosts(page) { return []; }
/** @param {{ title: string }} body */
async function createPost(body) { return { id: 1, ...body }; }
export async function GET({ url }) {
const page = Number(url.searchParams.get('page') ?? '1');
return json(await getPosts(page)); // json() sets the header + serializes
}
export async function POST({ request }) {
const body = await request.json();
if (!body.title) error(400, 'title required'); // throw a typed HTTP error
return json(await createPost(body), { status: 201 });
}

endpoint กับ form action ต่างกันยังไง: ใช้ form action เมื่อ form ของเพจนั้น submit ไป server (integrate กับ use:enhance และ form prop ได้เลย) ใช้ +server.js endpoint สำหรับ API ที่ใช้ซ้ำได้ ที่ถูกเรียกผ่าน fetch, mobile app หรือ third party

hooks: surface เต็มรูปแบบ พิสูจน์กับ Kit ที่ติดตั้งจริง

หัวข้อที่มีชื่อว่า “hooks: surface เต็มรูปแบบ พิสูจน์กับ Kit ที่ติดตั้งจริง”

src/hooks.server.js ไม่ได้มีแค่ handle hook ข้างล่างนี้ทุกตัวเป็นของจริง ถูก export อยู่จริงใน @sveltejs/kit ตอนนี้ ตรวจตรงกับ type definition ของเวอร์ชันที่ติดตั้งจริง 2.70.3 (ไม่ใช่แค่อ่าน docs เฉย ๆ):

Hookตำแหน่งที่ยืนยันแล้ว (Kit 2.70.3 ที่ติดตั้ง)ทำอะไร
handletypes/index.d.ts:924 doc comment + ใช้ทุกที่รันกับทุก request ก่อนถึง route; set locals, ตัดจบ หรือแก้ response
sequencetypes/index.d.ts:3136, export function sequence(...handlers: Handle[]): Handle;ประกอบ handle function หลายตัวตามลำดับ import จาก @sveltejs/kit/hooks
handleFetchtypes/index.d.ts:958rewrite/แทนที่ผลลัพธ์ของการเรียก event.fetch ที่ทำบน server
handleErrortypes/index.d.ts:924, 945รันเมื่อ exception ที่ไม่คาดคิด (ไม่ใช่ error()/redirect()) หลุดออกจาก handle, load หรือ action
reroutetypes/index.d.ts:979rewrite URL ที่ใช้ตัดสินว่า route ไหน match ก่อนจะเริ่ม match
transporttypes/index.d.ts:985serialize class instance ที่กำหนดเองข้าม server/client boundary (คู่ encode/decode)
src/hooks.server.js
import { sequence } from '@sveltejs/kit/hooks';
/** @type {import('@sveltejs/kit').Handle} */
const addLocalsUser = async ({ event, resolve }) => {
event.locals.user = { id: 'u1', name: 'Ava' };
return resolve(event);
};
/** @type {import('@sveltejs/kit').Handle} */
const logRequest = async ({ event, resolve }) => {
const response = await resolve(event);
response.headers.set('x-hooks-sequence', 'addLocalsUser,logRequest');
return response;
};
export const handle = sequence(addLocalsUser, logRequest);
/** @type {import('@sveltejs/kit').HandleFetch} */
export const handleFetch = ({ request, fetch }) => {
// e.g. rewrite an internal API host, or forward a cookie to a same-origin call
return fetch(request);
};
/** @type {import('@sveltejs/kit').HandleServerError} */
export const handleError = ({ error, message }) => {
console.error('handleError saw:', message);
return { message: 'Something went wrong' };
};
/** @type {import('@sveltejs/kit').Reroute} */
export const reroute = ({ url }) => {
if (url.pathname === '/old-path') return '/dashboard';
return url.pathname;
};
class Vector {
/** @param {number} x @param {number} y */
constructor(x, y) { this.x = x; this.y = y; }
}
/** @type {import('@sveltejs/kit').Transport} */
export const transport = {
Vector: {
encode: (value) => value instanceof Vector && [value.x, value.y],
decode: (/** @type {[number, number]} */ [x, y]) => new Vector(x, y),
},
};

sequence ที่ประกอบ handle function สองตัวพิสูจน์ได้จาก response ตรง ๆ — ทุก hook ที่ถูกประกอบทิ้งรอยไว้บน header:

$ curl -sI http://localhost:5196/ | grep -i x-hooks-sequence
x-hooks-sequence: addLocalsUser,logRequest

อะไรก็ตามที่คุณ set บน event.locals ถูก type ผ่าน App.Locals ใน src/app.d.ts — ประกาศครั้งเดียว ทำให้ server load, action และไฟล์ +server.js ทุกไฟล์มี autocomplete และ type-checking ของ locals แทนที่จะเป็น any โดยปริยาย:

src/app.d.ts
declare global {
namespace App {
interface Locals {
user: { id: string; name: string } | null;
}
}
}
export {};
flowchart LR
  req["request เข้ามา"] --> handle["hooks: handle()
set locals.user"]
  handle --> route["route: load / action / +server"]
  route --> resp["response"]
request ผ่าน handle แล้วจึงถึง route

redirect() และ error() ทำงานด้วยการ throw object พิเศษที่กลไกของ SvelteKit เองดักจับไว้สูงกว่าขึ้นไปหลาย stack frame try/catch ที่เขียนไว้จัดการความล้มเหลวคนละแบบ (เช่น fetch ที่บางทีล่ม) อาจดักจับ throw พิเศษนี้ไปด้วยโดยไม่ตั้งใจ — เปลี่ยน redirect ที่ตั้งใจไว้ให้กลายเป็น response 200 ธรรมดาแบบเงียบ ๆ

// src/routes/catch-bug/+page.server.js — BUG: a broad try/catch swallows the thrown redirect
import { redirect } from '@sveltejs/kit';
async function riskyFetch() {
return { ok: true };
}
export const load = async () => {
try {
await riskyFetch();
redirect(303, '/dashboard'); // throws — but the catch below swallows it
} catch (e) {
console.error('load failed', e);
return { fallback: true }; // 200 with fallback data instead of a 303
}
};

พิสูจน์กับแอปจริงที่รันอยู่ — request ที่ควร redirect กลับ return 200 พร้อมข้อมูล fallback แทน:

$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:5195/catch-bug
HTTP 200
$ curl -s http://localhost:5195/catch-bug | grep -o '<h1[^<]*</h1>'
<h1 data-testid="fallback">fallback=true</h1>

ทางแก้: เช็คว่าค่าที่ catch มาเป็น redirect หรือ HTTP error หรือเปล่าก่อนจะปฏิบัติกับมันเหมือนความล้มเหลวจริง ด้วย isRedirect/isHttpError จาก @sveltejs/kit แล้ว rethrow มันถ้าใช่:

// src/routes/catch-fixed/+page.server.js — FIX: rethrow redirects/HTTP errors, only catch real failures
import { redirect, isRedirect, isHttpError } from '@sveltejs/kit';
async function riskyFetch() {
return { ok: true };
}
export const load = async () => {
try {
await riskyFetch();
redirect(303, '/dashboard');
} catch (e) {
if (isRedirect(e) || isHttpError(e)) throw e; // let SvelteKit's control-flow throws through
console.error('load failed', e);
return { fallback: true };
}
};

พิสูจน์ว่าแก้แล้วกับแอปเดียวกันที่รันอยู่ — ได้ 303 จริง:

$ curl -s -D - -o /dev/null http://localhost:5195/catch-fixed | grep -i "^\(HTTP\|location\)"
HTTP/1.1 303 See Other
location: /dashboard

บทเรียนนี้ขยายไปได้ไกลกว่า redirect/error: try/catch ใด ๆ ที่ครอบโค้ดที่อาจเรียก control-flow function ของ SvelteKit เอง ต้องแยกให้ออกว่า “สัญญาณของ SvelteKit เอง” กับ “error จริง” ต่างกัน ไม่งั้นมันจะทำลาย redirect/error แบบเงียบ ๆ โดยไม่มี warning ตอน compile เลย เพราะทั้งสองอย่างเป็นแค่ JavaScript throw ธรรมดา

กฎง่าย ๆ ของ endpoint เทียบ action จากก่อนหน้ายังใช้ได้: form ของเพจเอง → action; API ที่ใช้ซ้ำได้ ไม่ได้มีรูปร่างเป็น form → +server.js ฝั่ง client นอกจาก goto() (บท routing) $app/navigation ยัง export invalidate()/invalidateAll() (บท loading-data) และ lifecycle hook รอบ ๆ การ navigate เอง:

<script>
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
beforeNavigate(({ to, cancel }) => {
if (formIsDirty() && !confirm('Discard changes?')) cancel();
});
afterNavigate(() => {
document.getElementById('main')?.focus(); // e.g. reset focus for a11y after nav
});
</script>

แต่ละ route เลือกวิธี render ของตัวเองได้ด้วย page option ที่ export จาก +page.js/+page.server.js:

  • export const prerender = true — render เป็นไฟล์ static ตอน build time (เหมาะกับเพจที่เหมือนกันสำหรับทุกคน) ค่า prerender ของ layout ยังรับ array entries ได้ด้วย (หรือ function ที่ return array) เพื่อบอก SvelteKit ว่า dynamic-route value ไหนที่ควร crawl แล้ว prerender สำหรับ route ที่มันหาไม่เจอเองด้วยการตามลิงก์
  • export const ssr = false — ข้าม server rendering (เพจแบบ client-only)
  • export const csr = false — ไม่ส่ง client JS เลย (output static ล้วนสำหรับเพจนั้น)

สุดท้าย adapter (ส่งเข้า Vite plugin sveltekit() หรือ kit.adapter ใน svelte.config.js) แพ็กแอปที่ build แล้วให้เข้ากับ platform เป้าหมาย เวอร์ชันที่ install ได้จริงวันนี้ (npm view <pkg> version):

Adapterเวอร์ชันเป้าหมาย
@sveltejs/adapter-auto5.5.7ตรวจจับ platform (Vercel, Netlify, Cloudflare, …) อัตโนมัติ
@sveltejs/adapter-node7.2.9Node server เดี่ยว ๆ
@sveltejs/adapter-cloudflare6.3.4Cloudflare Worker/Pages
@sveltejs/adapter-static3.0.10static site ที่ prerender ทั้งหมด (ไม่ต้องมี server)

มี $env/* module สี่ตัว แบ่งตามสองแกน: static (inline เป็น literal ตอน build time) เทียบ dynamic (อ่านจาก process.env ตอน request time) และ private (server เท่านั้น) เทียบ public (ต้องมี prefix PUBLIC_ ถึงจะส่งไป client) การพิสูจน์ความต่างระหว่าง static กับ dynamic ต้องดู build output จริง ไม่ใช่แค่อ่าน docs — เลยเอามาจาก adapter-node build จริง:

// src/routes/env-test/+page.server.js — illustrative (the proof below is a real build/run, not this harness's shared probe, which has no .env)
import { SECRET_API_KEY } from '$env/static/private';
import { env as dynamicPrivateEnv } from '$env/dynamic/private';
import { PUBLIC_APP_NAME } from '$env/static/public';
export const load = async () => {
return {
staticPrivate: SECRET_API_KEY,
dynamicPrivate: dynamicPrivateEnv.SECRET_API_KEY,
staticPublic: PUBLIC_APP_NAME,
};
};

build ด้วย SECRET_API_KEY=build-time-secret-abc123 ใน .env, import แบบ static ถูก bake ตรง ๆ เป็น literal เข้าไปใน server bundle ที่ compile แล้ว:

$ grep -n "SECRET_API_KEY" build/server/chunks/entries/pages/env-test/*.js
var SECRET_API_KEY = "build-time-secret-abc123";

ทีนี้รัน build เดียวกันนั้น ด้วยค่าต่างของ env var ตัวเดียวกันตอน runtime:

$ PORT=5194 SECRET_API_KEY="runtime-value-xyz789" node build/index.js &
$ curl -s http://localhost:5194/env-test
<pre data-testid="env-out">static=build-time-secret-abc123 dynamic=runtime-value-xyz789 public=ProbeApp</pre>

static ไม่เปลี่ยน — เป็นค่าจากเครื่องที่รัน npm run build ที่ bake ไว้ถาวร — ขณะที่ dynamic รับค่า runtime ใหม่ทันที นี่คือเหตุผลที่แท้จริงที่ $env/dynamic/private มีอยู่: ค่าไหนก็ตามที่ต้องต่างกันระหว่างเครื่อง build กับ container ที่รันจริง (database URL, API key ต่อ environment) ต้องเป็น dynamic ไม่งั้นค่าที่ bake ไว้ตอน CI จะติดไปทุก deployment แบบเงียบ ๆ

kit.csp (ส่งแบบ flatten เข้า config ของ Vite plugin sveltekit() ด้วย) สร้าง header Content-Security-Policy จริง และเติม nonce ให้อัตโนมัติกับทุก inline script/style ที่ SvelteKit เองสร้าง:

vite.config.js
sveltekit({
csp: {
mode: 'auto',
directives: { 'script-src': ['self'] },
},
});
$ curl -sI http://localhost:5196/ | grep -i content-security-policy
content-security-policy: script-src 'self' 'nonce-34JVJjb2gAt2rFP7KvAHEA=='

nonce ใหม่ถูกสร้างต่อ request (ยืนยันแล้ว: มันเปลี่ยนไปในสอง curl run ที่ใช้ในบทนี้) — mode: 'auto' ใช้ nonce กับเพจที่ render แบบ dynamic อย่างอันนี้ และใช้ hash แทนกับเพจที่ prerender (nonce บนเพจที่ prerender จะเหมือนกันทุก request จึงไม่มีประโยชน์)

เวลารีวิวโค้ด server/hooks/deployment ที่ generate มา เช็คสิ่งเหล่านี้:

  • try/catch ครอบการเรียก redirect()/error() โดยไม่เช็ค isRedirect/isHttpError ก่อนปฏิบัติกับมันเหมือนความล้มเหลวจริงหรือเปล่า? พิสูจน์แล้วข้างบนด้วย curl จริง: ได้ 200 ทั้งที่ตั้งใจให้เป็น 303 โดยไม่มีสัญญาณตอน compile เลย
  • event.locals ถูกใช้โดยไม่มี interface App.Locals ประกาศไว้ใน src/app.d.ts — ถูก type เป็น any แบบเงียบ ๆ ทุกที่ที่อ่านหรือเปล่า?
  • secret ที่อ่านผ่าน $env/static/private ถูกปฏิบัติเหมือนเป็นสิ่งที่เปลี่ยนได้ต่อ deployment โดยไม่ต้อง rebuild หรือเปล่า? พิสูจน์แล้วข้างบน: มันถูก bake เข้า JS bundle ตอน build time — มีแค่ $env/dynamic/private เท่านั้นที่อ่านสด
  • ตัวแปรที่มี prefix PUBLIC_ ถูกใช้กับอะไรที่ไม่ควรถึง client (secret จริง ที่ใส่ prefix ผิดโดยไม่ตั้งใจ) หรือเปล่า? prefix เป็นตัวตัดสิน public-กับ-privateเพียงอย่างเดียว — ความผิดพลาดในการตั้งชื่อ ไม่ใช่ runtime check คือสิ่งที่ทำให้มันรั่ว
  • handle ถูกเขียนเป็น function ยักษ์ตัวเดียวแทนที่จะประกอบด้วย sequence(...) ทำให้ test หรือสลับลำดับแต่ละ concern (auth, logging) แยกกันยากขึ้นหรือเปล่า?

🐛 หา bug ในโค้ดนี้

src/routes/api/report/+page.server.js
import { redirect } from '@sveltejs/kit';
export const load = async ({ fetch }) => {
try {
const res = await fetch('/api/report-status');
if (!res.ok) throw new Error('status check failed');
redirect(303, '/reports/done');
} catch (e) {
console.error('could not check report status', e);
return { pending: true };
}
};
เฉลย

redirect(303, ...) throw object พิเศษของ SvelteKit และ try/catch นี้ — ที่เขียนไว้จัดการความล้มเหลวของ fetch — ดักจับมันไปด้วยแบบแยกไม่ออกจาก error จริง request จึงเงียบ ๆ return 200 พร้อม { pending: true } แทนที่จะเป็น 303 redirect ที่ตั้งใจไว้ และไม่มีอะไรตรงนี้ส่งสัญญาณว่ามีปัญหาเลย: ไม่มี type error ไม่มี exception หลุดไปถึงผู้ใช้ มีแค่ behavior ที่ผิด (พิสูจน์แล้วด้วยตัวอย่างเทียบเท่าก่อนหน้าในบทนี้ผ่าน curl จริง — ได้ 200 แทนที่จะเป็น 303) ทางแก้: import { isRedirect } from '@sveltejs/kit' แล้วที่ต้นของ catch block ใส่ if (isRedirect(e)) throw e; ก่อนทำอะไรอย่างอื่น — rethrow สัญญาณ control-flow ของ SvelteKit เองแทนที่จะปฏิบัติกับมันเหมือนความล้มเหลวที่ catch block นี้ตั้งใจจับ

redirect() กับ error() ไม่ใช่ return value — มันคือ throw ที่ request-handling loop ของ SvelteKit เองกำลังฟังอยู่ ห่างออกไปหลาย stack frame จาก load/action ของคุณ try/catch ใด ๆ ระหว่างจุดที่มัน throw กับจุดที่ SvelteKit ดักมัน คือจุดที่มันถูก intercept ได้ ไม่ว่าคุณจะตั้งใจให้เป็นแบบนั้นหรือเปล่า hooks ก่อตัวเป็น pipeline รอบทุก request (handle ประกอบด้วย sequence) พร้อม pipeline ย่อยสำหรับจังหวะเฉพาะ (handleFetch สำหรับ fetch ขาออกฝั่ง server, handleError สำหรับความล้มเหลวที่ไม่คาดคิดจริง ๆ, reroute ก่อนที่การ match จะเริ่มด้วยซ้ำ) การตั้งค่า deployment (adapter, env, CSP) ทั้งหมด resolve ที่จังหวะใดจังหวะหนึ่งจากสองจังหวะเท่านั้น — build time (static env, การแพ็กของ adapter เอง) หรือ runtime (dynamic env, CSP nonce, ORIGIN) — และการปนสองอย่างนี้เข้าด้วยกันคือบั๊ก deployment ที่พบบ่อยที่สุดในเรื่องทั้งหมดนี้

ได้อะไรประโยชน์ต้นทุน
sequence(a, b, c) สำหรับ handleแต่ละ concern (auth, logging) test/สลับลำดับแยกกันได้import เพิ่มอีกตัว มี indirection อีกชั้นให้อ่าน
$env/static/privatebuild ล้มถ้าตัวแปรหาย — ไม่มี undefined แบบเงียบ ๆ ตอน runtimeค่าถูก freeze ตอน build time (พิสูจน์แล้วข้างบน) — ผิดสำหรับอะไรที่ต้องต่างกันต่อ deployment
$env/dynamic/privateอ่านสดตอน runtime — ถูกต้องสำหรับ secret ต่อ deploymentไม่มีการันตีตอน build ว่าถูกตั้งไว้ — พิมพ์ key ผิดก็แค่ได้ undefined
adapter-nodeคุมได้เต็มที่ รันได้ทุกที่ที่ Node รันคุณต้องดูแล process เอง (env var อย่าง ORIGIN พิสูจน์แล้วข้างบน และ PORT) แทนที่จะให้ platform จัดการให้
CSP ด้วย mode: 'auto'ป้องกันจริงโดยไม่ต้องเดินสาย nonce เอง<script>/<style> ที่เขียนมือใน app.html ต้องมี placeholder %sveltekit.nonce% ไม่งั้นถูกบล็อก
  • “try/catch รอบ load function กระทบแค่ error จริง” พิสูจน์แล้วข้างบน: มันกลืน redirect()/error() แบบเงียบ ๆ ด้วย เปลี่ยน 303 ที่ตั้งใจไว้ให้เป็น 200 โดยไม่มี warning ที่ไหนเลย
  • “$env/static/private กับ $env/dynamic/private ใช้แทนกันได้ — แค่สองวิธีอ่าน env var ตัวเดียวกัน” พิสูจน์แล้วข้างบนด้วยการ grep build output จริง: static เป็น literal ที่ bake ไว้ตอน build time; dynamic อ่าน process.env สด และอาจต่างจากที่ bake ไว้ได้
  • “handle ต้องเป็น function เดียว” sequence(...) (พิสูจน์แล้วข้างบน รอย header แสดงว่าทุก hook ที่ประกอบรันตามลำดับ) ประกอบหลายตัวได้ แต่ละตัว test แยกได้
  • “CSP nonce เหมือนกันทุก request เลย hardcode ไว้ test ได้” พิสูจน์แล้วข้างบน (โดยนัย จาก header ที่โผล่ต่อ request): nonce จริงถูกสร้างใหม่ทุก request โดยเจตนา เพื่อไม่ให้ replay หรือเดาได้
ไฟล์ +server.js คืออะไร
อันไหนคือ hook ของ SvelteKit ที่มีจริงและ export อยู่ตอนนี้ ตรวจกับ Kit ที่ติดตั้งจริง
curl route นี้จริง ๆ return อะไร
export const load = async () => {
  try {
    redirect(303, '/dashboard');
  } catch (e) {
    return { fallback: true };
  }
};
อะไรประกาศ type ของ event.locals ทั้งแอป
แอปถูกสตาร์ทด้วยค่า runtime ที่ต่างออกไปของ env var ตัวเดียวกัน ค่าที่อ่านผ่าน $env/static/private จะเป็นยังไง
$ grep SECRET_API_KEY build/server/chunks/.../env-test/*.js
var SECRET_API_KEY = "build-time-secret-abc123";
อะไรสร้าง header Content-Security-Policy ที่เห็นใน curl -I