jskelet 0.6.1 → 0.6.3

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.
Files changed (155) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +620 -596
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +130 -130
  5. package/docs/01-baslangic.md +291 -291
  6. package/docs/02-mimari.md +310 -309
  7. package/docs/03-routing.md +515 -515
  8. package/docs/04-render-ve-sablonlar.md +661 -661
  9. package/docs/05-islands.md +486 -486
  10. package/docs/06-cache.md +1443 -1423
  11. package/docs/07-yapilandirma.md +12 -6
  12. package/docs/08-build.md +429 -428
  13. package/docs/09-dev-araclari.md +364 -364
  14. package/docs/10-dagitim.md +338 -338
  15. package/docs/12-panel-ve-oturum.md +478 -478
  16. package/docs/README.md +83 -83
  17. package/docs/en/01-getting-started.md +298 -298
  18. package/docs/en/02-architecture.md +329 -328
  19. package/docs/en/03-routing.md +531 -531
  20. package/docs/en/04-rendering.md +669 -669
  21. package/docs/en/05-islands.md +497 -497
  22. package/docs/en/06-caching.md +1453 -1431
  23. package/docs/en/07-configuration.md +1219 -1214
  24. package/docs/en/08-build.md +447 -446
  25. package/docs/en/09-dev-tools.md +373 -373
  26. package/docs/en/10-deployment.md +340 -340
  27. package/docs/en/11-migration.md +398 -398
  28. package/docs/en/12-dashboards-and-sessions.md +488 -488
  29. package/docs/en/README.md +87 -87
  30. package/package.json +137 -137
  31. package/src/build/ensure-build.mjs +19 -19
  32. package/src/build/paths.mjs +153 -153
  33. package/src/build/resolve-peer.mjs +36 -36
  34. package/src/build/tasks/client.mjs +349 -349
  35. package/src/build/tasks/css.mjs +235 -235
  36. package/src/build/tasks/fonts.mjs +146 -146
  37. package/src/build/tasks/icons.mjs +357 -357
  38. package/src/build/tasks/images.mjs +244 -244
  39. package/src/build/tasks/precompress.mjs +78 -78
  40. package/src/build/tasks/templates.mjs +20 -20
  41. package/src/client/admin/i18n.js +764 -764
  42. package/src/client/admin/login.html +74 -74
  43. package/src/client/admin/panel.css +809 -809
  44. package/src/client/admin/panel.html +495 -495
  45. package/src/client/admin/panel.js +1251 -1251
  46. package/src/client/devtools/report.html +185 -185
  47. package/src/client/devtools/report.js +745 -745
  48. package/src/client/devtools/seo.js +628 -628
  49. package/src/client/dom.js +95 -95
  50. package/src/client/form.js +192 -192
  51. package/src/client/index.js +45 -45
  52. package/src/client/registry.js +305 -305
  53. package/src/client/safe-image.js +91 -91
  54. package/src/client/shared-cookie.js +225 -225
  55. package/src/client/store.js +36 -36
  56. package/src/client/swap.js +188 -188
  57. package/src/compile/codegen.js +336 -336
  58. package/src/compile/compile-all.js +149 -149
  59. package/src/compile/errors.js +66 -66
  60. package/src/compile/expr.js +409 -409
  61. package/src/compile/index.js +17 -17
  62. package/src/compile/parse.js +541 -541
  63. package/src/compile/resolve.js +211 -211
  64. package/src/compile/scan-exports.js +51 -51
  65. package/src/config/defaults.js +17 -1
  66. package/src/config/index.js +13 -0
  67. package/src/config/pattern.js +107 -107
  68. package/src/generate.mjs +163 -163
  69. package/src/http/control-flow.js +71 -71
  70. package/src/http/cookies-entry.js +21 -21
  71. package/src/http/cookies.js +277 -277
  72. package/src/http/request-cache.js +46 -46
  73. package/src/http/request-context.js +165 -165
  74. package/src/http/shared-cookie.js +178 -178
  75. package/src/index.js +101 -101
  76. package/src/init.mjs +230 -230
  77. package/src/migrate/apply.mjs +262 -262
  78. package/src/migrate/babel.mjs +79 -79
  79. package/src/migrate/classify.mjs +155 -155
  80. package/src/migrate/config.mjs +126 -126
  81. package/src/migrate/fs-walk.mjs +191 -191
  82. package/src/migrate/parse.mjs +26 -26
  83. package/src/migrate/scan.mjs +177 -177
  84. package/src/migrate/transform/expr-source.mjs +168 -168
  85. package/src/migrate/transform/island.mjs +67 -67
  86. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  87. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  88. package/src/migrate/transform/page-split.mjs +435 -435
  89. package/src/migrate/write.mjs +81 -81
  90. package/src/migrate.mjs +171 -171
  91. package/src/runtime/alias-hooks.mjs +119 -119
  92. package/src/runtime/register.mjs +4 -4
  93. package/src/server/admin/actions.js +229 -229
  94. package/src/server/admin/auth.js +125 -125
  95. package/src/server/admin/event-log.js +151 -151
  96. package/src/server/admin/gate.js +209 -209
  97. package/src/server/admin/inventory.js +188 -188
  98. package/src/server/admin/mount.js +56 -56
  99. package/src/server/admin/router.js +216 -216
  100. package/src/server/admin/snapshot.js +241 -241
  101. package/src/server/assets.js +147 -147
  102. package/src/server/auth/handoff.js +309 -309
  103. package/src/server/cache-blob.js +70 -0
  104. package/src/server/cache-deps.js +42 -42
  105. package/src/server/cache-vary.js +113 -113
  106. package/src/server/cloudflare.js +607 -607
  107. package/src/server/create-app.js +366 -366
  108. package/src/server/data-cache.js +553 -462
  109. package/src/server/dev/report.js +485 -485
  110. package/src/server/dev/socket.js +170 -170
  111. package/src/server/dev/version-check.mjs +139 -139
  112. package/src/server/disk-cache.js +233 -0
  113. package/src/server/ejs-adapter.js +59 -59
  114. package/src/server/html-cache.js +1196 -1122
  115. package/src/server/image-optimizer.js +500 -407
  116. package/src/server/logs/access-middleware.js +66 -66
  117. package/src/server/logs/file-sink.js +193 -66
  118. package/src/server/logs/pipeline.js +165 -158
  119. package/src/server/logs/s3-put.js +214 -214
  120. package/src/server/logs/s3-sink.js +112 -112
  121. package/src/server/metadata.js +102 -102
  122. package/src/server/middleware/compression.js +205 -205
  123. package/src/server/middleware/csrf.js +134 -134
  124. package/src/server/middleware/dev-gate.js +75 -75
  125. package/src/server/middleware/headers.js +37 -37
  126. package/src/server/middleware/redirects.js +32 -32
  127. package/src/server/middleware/robots-txt.js +341 -341
  128. package/src/server/middleware/static-precompressed.js +121 -100
  129. package/src/server/middleware/trailing-slash.js +53 -53
  130. package/src/server/middleware/upstream-proxy.js +141 -141
  131. package/src/server/og-image.js +356 -356
  132. package/src/server/port-guard.js +255 -255
  133. package/src/server/prewarm.js +1082 -1058
  134. package/src/server/redis.js +588 -569
  135. package/src/server/render.js +4 -4
  136. package/src/server/router.js +157 -157
  137. package/src/server/status-page.js +265 -265
  138. package/src/server/upstream-limiter.js +376 -376
  139. package/src/server/upstream-tracking.js +166 -166
  140. package/src/shared/cookie-domain.js +66 -66
  141. package/src/start.mjs +22 -22
  142. package/src/templates/layout.ejs +30 -30
  143. package/src/templates/layout.jsk +30 -30
  144. package/src/version.mjs +31 -31
  145. package/src/views/components/loader.js +101 -101
  146. package/src/views/helpers/html.js +102 -102
  147. package/src/views/helpers/tags.js +375 -375
  148. package/types/config/defaults.d.ts +15 -1
  149. package/types/config/index.d.ts +8 -0
  150. package/types/server/cache-blob.d.ts +13 -0
  151. package/types/server/data-cache.d.ts +9 -0
  152. package/types/server/disk-cache.d.ts +36 -0
  153. package/types/server/html-cache.d.ts +26 -3
  154. package/types/server/logs/file-sink.d.ts +16 -5
  155. package/types/server/redis.d.ts +2 -1
@@ -1,531 +1,531 @@
1
- # 03 — Routing
2
-
3
- This document explains every mechanism that determines which controller a
4
- request lands on: the route module contract and its load order, the `route()`
5
- wrapper, the page definition the controller returns, the `ctx` object, `params`,
6
- the `notFound()` and `redirect()` control flow, and the redirect/rewrite rules
7
- that come from `jskelet.config.mjs`. The template side of the page definition is
8
- covered in [04-rendering.md](./04-rendering.md), and the `revalidate` behaviour
9
- in [06-caching.md](./06-caching.md).
10
-
11
- ## The route module contract
12
-
13
- A route module exposes a function with the signature
14
- `(app, api) => void | Promise<void>`, either as a **default export** or as a
15
- **named export** called `register`.
16
-
17
- ```js
18
- // routes/10-pages.mjs
19
- export default function register(app, { route }) {
20
- app.get("/", route(async () => ({ view: "pages/home" })));
21
- }
22
- ```
23
-
24
- `app` is the Express application directly: `app.get`, `app.post`, `app.use`,
25
- `app.all` — the whole surface of Express 5 is available. `api`, on the other
26
- hand, is the ready-made surface the framework passes to route files, so that you
27
- don't have to import things one by one in every file:
28
-
29
- | Field | Equivalent |
30
- | --- | --- |
31
- | `route` | `jskelet` → `route` |
32
- | `fragment` | `jskelet` → `fragment` |
33
- | `renderView` | `jskelet` → `renderView` |
34
- | `renderPage` | `jskelet` → `renderPage` |
35
- | `notFound` | `jskelet` → `notFound` |
36
- | `redirect` | `jskelet` → `redirect` |
37
- | `permanentRedirect` | `jskelet` → `permanentRedirect` |
38
- | `seeOther` | `jskelet` → `seeOther` |
39
- | `ogHandler` | `jskelet` → `ogHandler` |
40
- | `ogImage` | `jskelet` → `ogImage` |
41
- | `sendOgImage` | `jskelet` → `sendOgImage` |
42
- | `ImageResponse` | `jskelet` → `ImageResponse` |
43
-
44
- You can also import directly if you prefer; `api` is only a convenience:
45
-
46
- ```js
47
- import { route, notFound } from "jskelet";
48
-
49
- export function register(app) {
50
- app.get("/news/:slug", route(async ({ params }) => { /* … */ }));
51
- }
52
- ```
53
-
54
- If a module does not expose a valid function, a warning is printed and it is
55
- skipped: `[router] <file> exports neither a default nor a 'register' function,
56
- skipped`.
57
-
58
- ## Load order
59
-
60
- There is **no** automatic URL derivation based on the file system. The order is
61
- determined in one of two ways:
62
-
63
- **1. An explicit list (`jskelet.config.mjs` → `routes`).** Paths relative to the
64
- project root, loaded in the order you give:
65
-
66
- ```js
67
- export default {
68
- routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"],
69
- };
70
- ```
71
-
72
- **2. If there is no list, the `routes/` directory is scanned alphabetically**,
73
- then each `features/<name>/index.js` (or `.mjs`) is appended alphabetically.
74
- The scan under `routes/` is recursive (subdirectories included), only `.js` and
75
- `.mjs` files are picked up, and files whose name begins with `_` are skipped
76
- (for shared modules like `_helpers.js`).
77
-
78
- Feature-first layout is optional:
79
-
80
- ```
81
- features/markets/
82
- index.js # register(app, api) — URLs are still explicit
83
- server/
84
- views/pages/… # .jsk or .ejs
85
- views/components/
86
- client/ # islands; register from client/entries
87
- ```
88
-
89
- `jskelet generate feature|page|island` scaffolds this. There is no filesystem
90
- URL routing.
91
-
92
- In that case, give the file names a numeric prefix:
93
-
94
- ```
95
- routes/
96
- ├── 10-pages.mjs
97
- ├── 50-blog.mjs
98
- └── 99-catch-all.mjs
99
- ```
100
-
101
- Making the order explicit is a design decision: if a single-segment catch-all
102
- such as `/:slug` is registered before the `/about` route, "about" is mistaken
103
- for a slug. Making the order visible instead of hiding it in file names makes
104
- diagnosis easier ([02-architecture.md](./02-architecture.md)).
105
-
106
- If no route module is found at all, a warning is printed and the server comes up
107
- with static files + 404 only.
108
-
109
- ### Behaviour with a broken module
110
-
111
- - **Development:** if the module cannot be imported a warning is printed and it
112
- is skipped; the server stays up.
113
- - **Production:** an error is thrown and the process does not start. Going live
114
- with a half-built route table means pages that silently return 404.
115
-
116
- ## `route()` — the controller wrapper
117
-
118
- `route(controller, options?)` returns an Express request handler and takes on
119
- the following work:
120
-
121
- - Builds the `ctx` object and calls the controller.
122
- - Applies the HTML TTL cache (if `revalidate` is set and the method is `GET`).
123
- - Catches the `notFound()` / `redirect()` control flow.
124
- - Writes the response headers: `Content-Type` and, depending on the cache
125
- decision, `Cache-Control` (plus `X-JSkelet-Cache` on cacheable responses).
126
- - Sends the response using the compressed body stored in the cache.
127
-
128
- ```js
129
- app.get(
130
- "/about",
131
- route(
132
- async () => ({
133
- view: "pages/about",
134
- metadata: { title: "About", canonical: "/about" },
135
- }),
136
- { revalidate: 300 },
137
- ),
138
- );
139
- ```
140
-
141
- `options` accepts two fields:
142
-
143
- | Field | Type | Meaning |
144
- | --- | --- | --- |
145
- | `revalidate` | `number` (seconds) | The HTML cache TTL. If it is not given, or is 0, this route is not cached. A matching rule in `jskelet.config.mjs` → `cache().html` **overrides** this value. |
146
- | `private` | `boolean` | The page depends on the visitor. The cache is disabled, a `cache().html` pattern **cannot** override that, and the response is sent with `private, no-store` and `Vary: Cookie`, without an ETag. |
147
-
148
- Even with `revalidate` given, **a request that carries a query parameter is
149
- dynamic by default**; that path needs an allowlist under `cache().query`
150
- ([06-caching.md](./06-caching.md)).
151
-
152
- Every session-dependent page needs `private: true`; because identity is not part
153
- of the cache key, without the flag one user's HTML is served to another. The
154
- framework also catches this at runtime (a render that reads cookies is never
155
- stored), but the flag is the right place. Details in
156
- [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
157
-
158
- ## `fragment()` — a partial without the layout
159
-
160
- For endpoints that refresh a region. No layout is printed, the response is sent
161
- with `private, no-store` and no ETag, and it never touches the HTML cache. The
162
- `/_fragment/` prefix is appended to `robots.txt`, so partial endpoints are not
163
- indexed ([04](./04-rendering.md#robotstxt)).
164
-
165
- ```js
166
- app.get(
167
- "/_fragment/rows",
168
- fragment(async ({ query }) => ({
169
- view: "partials/rows",
170
- data: { rows: getRows(Number(query.page ?? 1)) },
171
- })),
172
- );
173
- ```
174
-
175
- The controller returns either `{ view, data?, status? }` or an HTML string. On
176
- failure it responds with a small alert partial
177
- (`<div role="alert" data-fragment-error>`) rather than a whole page, because the
178
- swapped region must not end up containing an entire error page.
179
-
180
- `fragment()` works for POST too: it is how you return an updated partial as the
181
- answer to a form submission, and it establishes the request context that
182
- `csrfField()` needs in the template.
183
-
184
- ## `ctx` — the controller context
185
-
186
- The controller takes a single argument:
187
-
188
- ```js
189
- {
190
- params, // Express route parameters (req.params)
191
- query, // The parsed query string (req.query)
192
- pathname, // req.path — the path without the query
193
- req, // Express Request; full access if you need it
194
- }
195
- ```
196
-
197
- `params` uses Express's own pattern syntax (Express 5 / `path-to-regexp`), not
198
- the `source` syntax from the config:
199
-
200
- ```js
201
- app.get("/news/:slug", route(async ({ params }) => {
202
- const article = await getArticle(params.slug);
203
- if (!article) notFound();
204
- return { view: "pages/article", data: { article } };
205
- }));
206
- ```
207
-
208
- `pathname` is used both in the cache key and in the `pathname` local passed to
209
- `renderPage`; decisions in the layout like "is this the home page" look at it.
210
-
211
- ## The page definition the controller returns
212
-
213
- The controller has the form `async (ctx) => sayfa` and can return the following
214
- fields:
215
-
216
- | Field | Type | Default | Meaning |
217
- | --- | --- | --- | --- |
218
- | `view` | `string` | — | The template path under `views/`, without the extension: `"pages/home"` → `views/pages/home.jsk` (else legacy `.ejs`). |
219
- | `data` | `object` | `{}` | Data passed to the template as locals. |
220
- | `metadata` | `object` | `{}` | Turned into `<head>` tags; it overrides the output of `hooks.metadata()`. Schema: [04-rendering.md](./04-rendering.md). |
221
- | `status` | `number` | `200` | The HTTP status code. Only 200 is written to the cache. |
222
- | `head` | `string` | `""` | Raw HTML to be printed into `<head>` as-is (e.g. the LCP preload). |
223
- | `bodyClass` | `string` | `hooks.layoutContext().bodyClass ?? ""` | `<body class="…">`. |
224
- | `entries` | `string[]` | `[]` | The names of client entries to be loaded additionally on this page: `["chart.js"]`. |
225
- | `styles` | `string[]` | `[]` | Extra stylesheets for this page: `["home.css"]` → `styles/pages/home.css`. |
226
-
227
- `revalidate` is **the second argument of `route()`**, not a field of the object
228
- the controller returns.
229
-
230
- An example with everything together:
231
-
232
- ```js
233
- import { headHints } from "jskelet";
234
-
235
- app.get(
236
- "/markets",
237
- route(
238
- async ({ query }) => {
239
- const data = await getMarkets(query.tab ?? "stocks");
240
-
241
- return {
242
- view: "pages/markets",
243
- data: { markets: data.items, tab: query.tab ?? "stocks" },
244
- metadata: {
245
- title: "Markets",
246
- canonical: "/markets",
247
- openGraph: { image: data.cover },
248
- },
249
- head: headHints({ href: data.cover }),
250
- bodyClass: "bg-slate-50",
251
- entries: ["chart.js"],
252
- styles: ["markets.css"],
253
- };
254
- },
255
- { revalidate: 30 },
256
- ),
257
- );
258
- ```
259
-
260
- ## `notFound()` and `redirect()`
261
-
262
- The equivalent of the control flow in `next/navigation`: a function deep down
263
- does a `throw`, the framework catches it. That way a function in the data layer
264
- can produce a 404 without having to carry a return value up to the controller.
265
-
266
- ```js
267
- import { notFound, redirect, permanentRedirect, seeOther } from "jskelet";
268
-
269
- notFound(); // 404 → the hooks.notFound() page
270
- redirect("/new-address"); // 307 (temporary, preserves the method)
271
- permanentRedirect("/new"); // 308 (permanent, preserves the method)
272
- seeOther("/dashboard"); // 303 (after a POST)
273
- ```
274
-
275
- All four return `never` (they always throw). In detail:
276
-
277
- - `notFound()` → `NotFoundError` (`statusCode: 404`)
278
- - `redirect(location)` → `RedirectError` (`statusCode: 307`)
279
- - `permanentRedirect(location)` → `RedirectError` (`statusCode: 308`)
280
- - `seeOther(location)` → `RedirectError` (`statusCode: 303`)
281
-
282
- In a POST handler use `seeOther()` rather than `redirect()`: 307 preserves the
283
- method, so the browser POSTs to the target again. The post/redirect/get flow —
284
- the one where the back button does not resubmit the form — needs 303.
285
-
286
- If you need a custom status code you can use the class directly:
287
-
288
- ```js
289
- import { RedirectError } from "jskelet";
290
-
291
- throw new RedirectError("/legacy-install-compat", 301);
292
- ```
293
-
294
- To tell them apart, `isNotFoundError(error)` and `isRedirectError(error)` are
295
- exported.
296
-
297
- Where they are caught:
298
-
299
- 1. **Inside `route()`:** the redirect is written straight to the response;
300
- notFound is caught inside `produce()` and the 404 page is produced (this
301
- output is **not** written to the cache, because only 200 is stored).
302
- 2. **In the Express error handler:** if it was thrown in a middleware or in code
303
- outside a route, it is met here.
304
-
305
- ## The 404 page
306
-
307
- If a request does not land on any route, the framework calls the
308
- `hooks.notFound()` hook and renders the returned page definition with
309
- `pathname: "/404"`.
310
-
311
- ```js
312
- // jskelet.config.mjs
313
- export default {
314
- hooks: {
315
- notFound() {
316
- return {
317
- view: "pages/not-found",
318
- metadata: { title: "Page not found", robots: { index: false } },
319
- };
320
- },
321
- },
322
- };
323
- ```
324
-
325
- If the hook is not defined, or if the 404 render throws as well, the framework
326
- returns a minimal, template-free HTML. This fallback is deliberately
327
- template-free: if the 404 render blows up too, the visitor should not see an
328
- empty response.
329
-
330
- ## Error pages (500 and others)
331
-
332
- When a controller or a middleware throws an unexpected error, Express's error
333
- handler kicks in, logs the error and returns the framework's own error page with
334
- `Cache-Control: no-store`. The status code is read from the error's `statusCode`
335
- (or `status`) field; if it is not in the 400–599 range, 500 is used.
336
-
337
- **Development** (`NODE_ENV=development`, i.e. `jskelet dev`): for 5xx responses
338
- the built-in 500 page and `hooks.error()` are skipped; a diagnostic page with
339
- the message, stack trace, and any `cause` chain is returned instead. 4xx (404
340
- and friends) still use the usual status page in development.
341
-
342
- **Production**: the framework's page is deliberately plain — status code, a
343
- one-line heading and a one-line description. It carries no brand name, no
344
- navigation and no error detail; the innards of the server are not opened up to
345
- the visitor. The language comes from `brand.lang` (`tr` and `en` are built in,
346
- others fall back to `en`).
347
-
348
- To provide your own page, `hooks.error()` (production / 4xx only):
349
-
350
- ```js
351
- // jskelet.config.mjs
352
- export default {
353
- hooks: {
354
- error({ status }) {
355
- return {
356
- view: "pages/error",
357
- data: { status },
358
- metadata: { title: "Something went wrong", robots: { index: false } },
359
- };
360
- },
361
- },
362
- };
363
- ```
364
-
365
- The hook can also return an HTML string directly instead of a page definition;
366
- if you want an error page that does not depend on the layout, that route is
367
- safer, because if the layout itself throws then the page definition cannot be
368
- rendered either. If there is no hook, if it returns `null`, or if its render
369
- blows up, the framework falls back to the built-in page.
370
-
371
- For 404, `hooks.notFound()` takes precedence; `hooks.error()` is called with
372
- `status: 404` only if that one is not defined.
373
-
374
- You can also produce the page programmatically:
375
-
376
- ```js
377
- import { renderStatusPage } from "jskelet";
378
-
379
- const html = await renderStatusPage(503);
380
- ```
381
-
382
- ## Rendering without a layout: `renderView`
383
-
384
- `renderView(view, data)` renders a single template without the layout and
385
- returns a string. For fragment endpoints, email templates and HTML pieces that
386
- islands fetch later:
387
-
388
- ```js
389
- export default function register(app, { renderView }) {
390
- app.get("/_fragment/comments/:id", async (req, res) => {
391
- const comments = await getComments(req.params.id);
392
- res.type("html").send(await renderView("fragments/comments", { comments }));
393
- });
394
- }
395
- ```
396
-
397
- The `/_fragment/` prefix is in the default `prewarmSkip` list, meaning the
398
- warm-up round does not scan these endpoints
399
- ([06-caching.md](./06-caching.md)).
400
-
401
- ## Config: `redirects()`
402
-
403
- `jskelet.config.mjs` → `redirects()` returns an array and runs **before** the
404
- routes in the middleware chain (see
405
- [02-architecture.md](./02-architecture.md)).
406
-
407
- ```js
408
- export default {
409
- async redirects() {
410
- return [
411
- { source: "/old-blog/:slug", destination: "/blog/:slug", permanent: true },
412
- { source: "/campaign", destination: "/campaigns" },
413
- { source: "/legacy", destination: "/", statusCode: 301 },
414
- ];
415
- },
416
- };
417
- ```
418
-
419
- Behaviour:
420
-
421
- - **The first matching rule wins**, the rest are not tried. The ordering is the
422
- order written in the config.
423
- - **The query string is preserved:** `/old-blog/x?utm=a` → `/blog/x?utm=a`. If
424
- a redirect drops the campaign parameters, the traffic source is lost.
425
- - **Status code:** `permanent: true` → 308, otherwise 307 (Next semantics).
426
- Anyone who wants a different code can give `statusCode`; for example 301 for
427
- compatibility with old setups.
428
- - If `source` or `destination` is invalid, the rule does not drop silently; a
429
- warning is printed.
430
-
431
- ## Config: `trailingSlash`
432
-
433
- With `trailingSlash: true`, canonical page URLs end with `/` and return **200**;
434
- a request without the slash is sent to the slashed form with a **308**. Details
435
- and exceptions: [07-configuration.md](./07-configuration.md#trailingslash).
436
-
437
- ## Config: `rewrites()`
438
-
439
- A rewrite moves a request somewhere else without changing the browser's address
440
- bar. There are two phases:
441
-
442
- ```js
443
- export default {
444
- async rewrites() {
445
- return {
446
- beforeFiles: [
447
- { source: "/sitemap-:page.xml", destination: "/sitemap?page=:page" },
448
- ],
449
- afterFiles: [
450
- { source: "/api/:path*", destination: "https://api.example.com/:path*" },
451
- ],
452
- };
453
- },
454
- };
455
- ```
456
-
457
- If you return an array, all of it counts as `afterFiles`:
458
-
459
- ```js
460
- async rewrites() {
461
- return [{ source: "/api/:path*", destination: "https://api.example.com/:path*" }];
462
- }
463
- ```
464
-
465
- - **`beforeFiles`** runs even before static files. If you need to rewrite paths
466
- like `/assets/…`, it has to go here.
467
- - **`afterFiles`** runs after static has been tried, before the routes.
468
-
469
- The form of the destination determines the behaviour:
470
-
471
- - **Absolute (`http://` / `https://`):** the request is carried outwards through
472
- the built-in reverse proxy. No external package; a thin layer that streams
473
- with `fetch`. Hop-by-hop headers (`host`, `connection`, `content-length`,
474
- `accept-encoding`) are cleaned; on the response, `content-encoding`,
475
- `content-length`, `transfer-encoding` and `connection` are dropped. Thanks to
476
- `redirect: "manual"` the upstream's 302 is not consumed here, it is forwarded
477
- to the browser.
478
- - **Relative:** only `req.url` is changed and the request continues in its own
479
- route table. In this phase the first matching rule breaks the loop.
480
-
481
- The typical use is moving the `/api/*` path to a backend. Because the browser
482
- calls it same-origin, there are no CORS or third-party cookie problems.
483
-
484
- ### Proxying by hand: `createProxy`
485
-
486
- You can use the same proxy in your own route as well:
487
-
488
- ```js
489
- import { createProxy } from "jskelet";
490
-
491
- export default function register(app) {
492
- app.use("/ws-api", createProxy((req) => `${process.env.API_ORIGIN}${req.url}`));
493
- }
494
- ```
495
-
496
- If `resolveTarget` throws or returns nothing, the request is not proxied and
497
- continues down the chain: in a setup where the target origin has not been
498
- configured, getting a normal 404 instead of a 500 is more correct.
499
-
500
- ## The `source` pattern syntax
501
-
502
- `redirects()`, `rewrites()`, `headers()` and `cache().html` use the same small
503
- pattern compiler. This is not Next's full `path-to-regexp` surface; the subset
504
- actually used in config was chosen deliberately.
505
-
506
- | Pattern | Meaning |
507
- | --- | --- |
508
- | `/news/:slug` | Captures a single segment (`[^/]+`) |
509
- | `/:path*` | Captures zero or more segments; the leading `/` is optional, so `/blog/:path*` also covers `/blog` |
510
- | `/:path*.svg` | Wildcard + fixed suffix; this is how extension rules are written |
511
- | `/tag-:slug` | A parameter in the middle of a segment |
512
-
513
- The captured values are written into the `:param`s of the same name inside
514
- `destination`. A parameter name must match the pattern
515
- `[A-Za-z_][A-Za-z0-9_]*`.
516
-
517
- `source` must begin with `/`; if it does not, the rule is ignored and a warning
518
- is printed (``[config] invalid source (must start with `/`): …``). An
519
- unrecognized syntax is not silently accepted as a literal.
520
-
521
- The full pattern list and the config reference:
522
- [07-configuration.md](./07-configuration.md).
523
-
524
- ## What's next
525
-
526
- - The template layer, components and metadata:
527
- [04-rendering.md](./04-rendering.md)
528
- - `revalidate`, the cache key and `X-JSkelet-Cache`:
529
- [06-caching.md](./06-caching.md)
530
- - The full reference of the config fields:
531
- [07-configuration.md](./07-configuration.md)
1
+ # 03 — Routing
2
+
3
+ This document explains every mechanism that determines which controller a
4
+ request lands on: the route module contract and its load order, the `route()`
5
+ wrapper, the page definition the controller returns, the `ctx` object, `params`,
6
+ the `notFound()` and `redirect()` control flow, and the redirect/rewrite rules
7
+ that come from `jskelet.config.mjs`. The template side of the page definition is
8
+ covered in [04-rendering.md](./04-rendering.md), and the `revalidate` behaviour
9
+ in [06-caching.md](./06-caching.md).
10
+
11
+ ## The route module contract
12
+
13
+ A route module exposes a function with the signature
14
+ `(app, api) => void | Promise<void>`, either as a **default export** or as a
15
+ **named export** called `register`.
16
+
17
+ ```js
18
+ // routes/10-pages.mjs
19
+ export default function register(app, { route }) {
20
+ app.get("/", route(async () => ({ view: "pages/home" })));
21
+ }
22
+ ```
23
+
24
+ `app` is the Express application directly: `app.get`, `app.post`, `app.use`,
25
+ `app.all` — the whole surface of Express 5 is available. `api`, on the other
26
+ hand, is the ready-made surface the framework passes to route files, so that you
27
+ don't have to import things one by one in every file:
28
+
29
+ | Field | Equivalent |
30
+ | --- | --- |
31
+ | `route` | `jskelet` → `route` |
32
+ | `fragment` | `jskelet` → `fragment` |
33
+ | `renderView` | `jskelet` → `renderView` |
34
+ | `renderPage` | `jskelet` → `renderPage` |
35
+ | `notFound` | `jskelet` → `notFound` |
36
+ | `redirect` | `jskelet` → `redirect` |
37
+ | `permanentRedirect` | `jskelet` → `permanentRedirect` |
38
+ | `seeOther` | `jskelet` → `seeOther` |
39
+ | `ogHandler` | `jskelet` → `ogHandler` |
40
+ | `ogImage` | `jskelet` → `ogImage` |
41
+ | `sendOgImage` | `jskelet` → `sendOgImage` |
42
+ | `ImageResponse` | `jskelet` → `ImageResponse` |
43
+
44
+ You can also import directly if you prefer; `api` is only a convenience:
45
+
46
+ ```js
47
+ import { route, notFound } from "jskelet";
48
+
49
+ export function register(app) {
50
+ app.get("/news/:slug", route(async ({ params }) => { /* … */ }));
51
+ }
52
+ ```
53
+
54
+ If a module does not expose a valid function, a warning is printed and it is
55
+ skipped: `[router] <file> exports neither a default nor a 'register' function,
56
+ skipped`.
57
+
58
+ ## Load order
59
+
60
+ There is **no** automatic URL derivation based on the file system. The order is
61
+ determined in one of two ways:
62
+
63
+ **1. An explicit list (`jskelet.config.mjs` → `routes`).** Paths relative to the
64
+ project root, loaded in the order you give:
65
+
66
+ ```js
67
+ export default {
68
+ routes: ["./routes/api.js", "./routes/pages.js", "./routes/catch-all.js"],
69
+ };
70
+ ```
71
+
72
+ **2. If there is no list, the `routes/` directory is scanned alphabetically**,
73
+ then each `features/<name>/index.js` (or `.mjs`) is appended alphabetically.
74
+ The scan under `routes/` is recursive (subdirectories included), only `.js` and
75
+ `.mjs` files are picked up, and files whose name begins with `_` are skipped
76
+ (for shared modules like `_helpers.js`).
77
+
78
+ Feature-first layout is optional:
79
+
80
+ ```
81
+ features/markets/
82
+ index.js # register(app, api) — URLs are still explicit
83
+ server/
84
+ views/pages/… # .jsk or .ejs
85
+ views/components/
86
+ client/ # islands; register from client/entries
87
+ ```
88
+
89
+ `jskelet generate feature|page|island` scaffolds this. There is no filesystem
90
+ URL routing.
91
+
92
+ In that case, give the file names a numeric prefix:
93
+
94
+ ```
95
+ routes/
96
+ ├── 10-pages.mjs
97
+ ├── 50-blog.mjs
98
+ └── 99-catch-all.mjs
99
+ ```
100
+
101
+ Making the order explicit is a design decision: if a single-segment catch-all
102
+ such as `/:slug` is registered before the `/about` route, "about" is mistaken
103
+ for a slug. Making the order visible instead of hiding it in file names makes
104
+ diagnosis easier ([02-architecture.md](./02-architecture.md)).
105
+
106
+ If no route module is found at all, a warning is printed and the server comes up
107
+ with static files + 404 only.
108
+
109
+ ### Behaviour with a broken module
110
+
111
+ - **Development:** if the module cannot be imported a warning is printed and it
112
+ is skipped; the server stays up.
113
+ - **Production:** an error is thrown and the process does not start. Going live
114
+ with a half-built route table means pages that silently return 404.
115
+
116
+ ## `route()` — the controller wrapper
117
+
118
+ `route(controller, options?)` returns an Express request handler and takes on
119
+ the following work:
120
+
121
+ - Builds the `ctx` object and calls the controller.
122
+ - Applies the HTML TTL cache (if `revalidate` is set and the method is `GET`).
123
+ - Catches the `notFound()` / `redirect()` control flow.
124
+ - Writes the response headers: `Content-Type` and, depending on the cache
125
+ decision, `Cache-Control` (plus `X-JSkelet-Cache` on cacheable responses).
126
+ - Sends the response using the compressed body stored in the cache.
127
+
128
+ ```js
129
+ app.get(
130
+ "/about",
131
+ route(
132
+ async () => ({
133
+ view: "pages/about",
134
+ metadata: { title: "About", canonical: "/about" },
135
+ }),
136
+ { revalidate: 300 },
137
+ ),
138
+ );
139
+ ```
140
+
141
+ `options` accepts two fields:
142
+
143
+ | Field | Type | Meaning |
144
+ | --- | --- | --- |
145
+ | `revalidate` | `number` (seconds) | The HTML cache TTL. If it is not given, or is 0, this route is not cached. A matching rule in `jskelet.config.mjs` → `cache().html` **overrides** this value. |
146
+ | `private` | `boolean` | The page depends on the visitor. The cache is disabled, a `cache().html` pattern **cannot** override that, and the response is sent with `private, no-store` and `Vary: Cookie`, without an ETag. |
147
+
148
+ Even with `revalidate` given, **a request that carries a query parameter is
149
+ dynamic by default**; that path needs an allowlist under `cache().query`
150
+ ([06-caching.md](./06-caching.md)).
151
+
152
+ Every session-dependent page needs `private: true`; because identity is not part
153
+ of the cache key, without the flag one user's HTML is served to another. The
154
+ framework also catches this at runtime (a render that reads cookies is never
155
+ stored), but the flag is the right place. Details in
156
+ [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
157
+
158
+ ## `fragment()` — a partial without the layout
159
+
160
+ For endpoints that refresh a region. No layout is printed, the response is sent
161
+ with `private, no-store` and no ETag, and it never touches the HTML cache. The
162
+ `/_fragment/` prefix is appended to `robots.txt`, so partial endpoints are not
163
+ indexed ([04](./04-rendering.md#robotstxt)).
164
+
165
+ ```js
166
+ app.get(
167
+ "/_fragment/rows",
168
+ fragment(async ({ query }) => ({
169
+ view: "partials/rows",
170
+ data: { rows: getRows(Number(query.page ?? 1)) },
171
+ })),
172
+ );
173
+ ```
174
+
175
+ The controller returns either `{ view, data?, status? }` or an HTML string. On
176
+ failure it responds with a small alert partial
177
+ (`<div role="alert" data-fragment-error>`) rather than a whole page, because the
178
+ swapped region must not end up containing an entire error page.
179
+
180
+ `fragment()` works for POST too: it is how you return an updated partial as the
181
+ answer to a form submission, and it establishes the request context that
182
+ `csrfField()` needs in the template.
183
+
184
+ ## `ctx` — the controller context
185
+
186
+ The controller takes a single argument:
187
+
188
+ ```js
189
+ {
190
+ params, // Express route parameters (req.params)
191
+ query, // The parsed query string (req.query)
192
+ pathname, // req.path — the path without the query
193
+ req, // Express Request; full access if you need it
194
+ }
195
+ ```
196
+
197
+ `params` uses Express's own pattern syntax (Express 5 / `path-to-regexp`), not
198
+ the `source` syntax from the config:
199
+
200
+ ```js
201
+ app.get("/news/:slug", route(async ({ params }) => {
202
+ const article = await getArticle(params.slug);
203
+ if (!article) notFound();
204
+ return { view: "pages/article", data: { article } };
205
+ }));
206
+ ```
207
+
208
+ `pathname` is used both in the cache key and in the `pathname` local passed to
209
+ `renderPage`; decisions in the layout like "is this the home page" look at it.
210
+
211
+ ## The page definition the controller returns
212
+
213
+ The controller has the form `async (ctx) => sayfa` and can return the following
214
+ fields:
215
+
216
+ | Field | Type | Default | Meaning |
217
+ | --- | --- | --- | --- |
218
+ | `view` | `string` | — | The template path under `views/`, without the extension: `"pages/home"` → `views/pages/home.jsk` (else legacy `.ejs`). |
219
+ | `data` | `object` | `{}` | Data passed to the template as locals. |
220
+ | `metadata` | `object` | `{}` | Turned into `<head>` tags; it overrides the output of `hooks.metadata()`. Schema: [04-rendering.md](./04-rendering.md). |
221
+ | `status` | `number` | `200` | The HTTP status code. Only 200 is written to the cache. |
222
+ | `head` | `string` | `""` | Raw HTML to be printed into `<head>` as-is (e.g. the LCP preload). |
223
+ | `bodyClass` | `string` | `hooks.layoutContext().bodyClass ?? ""` | `<body class="…">`. |
224
+ | `entries` | `string[]` | `[]` | The names of client entries to be loaded additionally on this page: `["chart.js"]`. |
225
+ | `styles` | `string[]` | `[]` | Extra stylesheets for this page: `["home.css"]` → `styles/pages/home.css`. |
226
+
227
+ `revalidate` is **the second argument of `route()`**, not a field of the object
228
+ the controller returns.
229
+
230
+ An example with everything together:
231
+
232
+ ```js
233
+ import { headHints } from "jskelet";
234
+
235
+ app.get(
236
+ "/markets",
237
+ route(
238
+ async ({ query }) => {
239
+ const data = await getMarkets(query.tab ?? "stocks");
240
+
241
+ return {
242
+ view: "pages/markets",
243
+ data: { markets: data.items, tab: query.tab ?? "stocks" },
244
+ metadata: {
245
+ title: "Markets",
246
+ canonical: "/markets",
247
+ openGraph: { image: data.cover },
248
+ },
249
+ head: headHints({ href: data.cover }),
250
+ bodyClass: "bg-slate-50",
251
+ entries: ["chart.js"],
252
+ styles: ["markets.css"],
253
+ };
254
+ },
255
+ { revalidate: 30 },
256
+ ),
257
+ );
258
+ ```
259
+
260
+ ## `notFound()` and `redirect()`
261
+
262
+ The equivalent of the control flow in `next/navigation`: a function deep down
263
+ does a `throw`, the framework catches it. That way a function in the data layer
264
+ can produce a 404 without having to carry a return value up to the controller.
265
+
266
+ ```js
267
+ import { notFound, redirect, permanentRedirect, seeOther } from "jskelet";
268
+
269
+ notFound(); // 404 → the hooks.notFound() page
270
+ redirect("/new-address"); // 307 (temporary, preserves the method)
271
+ permanentRedirect("/new"); // 308 (permanent, preserves the method)
272
+ seeOther("/dashboard"); // 303 (after a POST)
273
+ ```
274
+
275
+ All four return `never` (they always throw). In detail:
276
+
277
+ - `notFound()` → `NotFoundError` (`statusCode: 404`)
278
+ - `redirect(location)` → `RedirectError` (`statusCode: 307`)
279
+ - `permanentRedirect(location)` → `RedirectError` (`statusCode: 308`)
280
+ - `seeOther(location)` → `RedirectError` (`statusCode: 303`)
281
+
282
+ In a POST handler use `seeOther()` rather than `redirect()`: 307 preserves the
283
+ method, so the browser POSTs to the target again. The post/redirect/get flow —
284
+ the one where the back button does not resubmit the form — needs 303.
285
+
286
+ If you need a custom status code you can use the class directly:
287
+
288
+ ```js
289
+ import { RedirectError } from "jskelet";
290
+
291
+ throw new RedirectError("/legacy-install-compat", 301);
292
+ ```
293
+
294
+ To tell them apart, `isNotFoundError(error)` and `isRedirectError(error)` are
295
+ exported.
296
+
297
+ Where they are caught:
298
+
299
+ 1. **Inside `route()`:** the redirect is written straight to the response;
300
+ notFound is caught inside `produce()` and the 404 page is produced (this
301
+ output is **not** written to the cache, because only 200 is stored).
302
+ 2. **In the Express error handler:** if it was thrown in a middleware or in code
303
+ outside a route, it is met here.
304
+
305
+ ## The 404 page
306
+
307
+ If a request does not land on any route, the framework calls the
308
+ `hooks.notFound()` hook and renders the returned page definition with
309
+ `pathname: "/404"`.
310
+
311
+ ```js
312
+ // jskelet.config.mjs
313
+ export default {
314
+ hooks: {
315
+ notFound() {
316
+ return {
317
+ view: "pages/not-found",
318
+ metadata: { title: "Page not found", robots: { index: false } },
319
+ };
320
+ },
321
+ },
322
+ };
323
+ ```
324
+
325
+ If the hook is not defined, or if the 404 render throws as well, the framework
326
+ returns a minimal, template-free HTML. This fallback is deliberately
327
+ template-free: if the 404 render blows up too, the visitor should not see an
328
+ empty response.
329
+
330
+ ## Error pages (500 and others)
331
+
332
+ When a controller or a middleware throws an unexpected error, Express's error
333
+ handler kicks in, logs the error and returns the framework's own error page with
334
+ `Cache-Control: no-store`. The status code is read from the error's `statusCode`
335
+ (or `status`) field; if it is not in the 400–599 range, 500 is used.
336
+
337
+ **Development** (`NODE_ENV=development`, i.e. `jskelet dev`): for 5xx responses
338
+ the built-in 500 page and `hooks.error()` are skipped; a diagnostic page with
339
+ the message, stack trace, and any `cause` chain is returned instead. 4xx (404
340
+ and friends) still use the usual status page in development.
341
+
342
+ **Production**: the framework's page is deliberately plain — status code, a
343
+ one-line heading and a one-line description. It carries no brand name, no
344
+ navigation and no error detail; the innards of the server are not opened up to
345
+ the visitor. The language comes from `brand.lang` (`tr` and `en` are built in,
346
+ others fall back to `en`).
347
+
348
+ To provide your own page, `hooks.error()` (production / 4xx only):
349
+
350
+ ```js
351
+ // jskelet.config.mjs
352
+ export default {
353
+ hooks: {
354
+ error({ status }) {
355
+ return {
356
+ view: "pages/error",
357
+ data: { status },
358
+ metadata: { title: "Something went wrong", robots: { index: false } },
359
+ };
360
+ },
361
+ },
362
+ };
363
+ ```
364
+
365
+ The hook can also return an HTML string directly instead of a page definition;
366
+ if you want an error page that does not depend on the layout, that route is
367
+ safer, because if the layout itself throws then the page definition cannot be
368
+ rendered either. If there is no hook, if it returns `null`, or if its render
369
+ blows up, the framework falls back to the built-in page.
370
+
371
+ For 404, `hooks.notFound()` takes precedence; `hooks.error()` is called with
372
+ `status: 404` only if that one is not defined.
373
+
374
+ You can also produce the page programmatically:
375
+
376
+ ```js
377
+ import { renderStatusPage } from "jskelet";
378
+
379
+ const html = await renderStatusPage(503);
380
+ ```
381
+
382
+ ## Rendering without a layout: `renderView`
383
+
384
+ `renderView(view, data)` renders a single template without the layout and
385
+ returns a string. For fragment endpoints, email templates and HTML pieces that
386
+ islands fetch later:
387
+
388
+ ```js
389
+ export default function register(app, { renderView }) {
390
+ app.get("/_fragment/comments/:id", async (req, res) => {
391
+ const comments = await getComments(req.params.id);
392
+ res.type("html").send(await renderView("fragments/comments", { comments }));
393
+ });
394
+ }
395
+ ```
396
+
397
+ The `/_fragment/` prefix is in the default `prewarmSkip` list, meaning the
398
+ warm-up round does not scan these endpoints
399
+ ([06-caching.md](./06-caching.md)).
400
+
401
+ ## Config: `redirects()`
402
+
403
+ `jskelet.config.mjs` → `redirects()` returns an array and runs **before** the
404
+ routes in the middleware chain (see
405
+ [02-architecture.md](./02-architecture.md)).
406
+
407
+ ```js
408
+ export default {
409
+ async redirects() {
410
+ return [
411
+ { source: "/old-blog/:slug", destination: "/blog/:slug", permanent: true },
412
+ { source: "/campaign", destination: "/campaigns" },
413
+ { source: "/legacy", destination: "/", statusCode: 301 },
414
+ ];
415
+ },
416
+ };
417
+ ```
418
+
419
+ Behaviour:
420
+
421
+ - **The first matching rule wins**, the rest are not tried. The ordering is the
422
+ order written in the config.
423
+ - **The query string is preserved:** `/old-blog/x?utm=a` → `/blog/x?utm=a`. If
424
+ a redirect drops the campaign parameters, the traffic source is lost.
425
+ - **Status code:** `permanent: true` → 308, otherwise 307 (Next semantics).
426
+ Anyone who wants a different code can give `statusCode`; for example 301 for
427
+ compatibility with old setups.
428
+ - If `source` or `destination` is invalid, the rule does not drop silently; a
429
+ warning is printed.
430
+
431
+ ## Config: `trailingSlash`
432
+
433
+ With `trailingSlash: true`, canonical page URLs end with `/` and return **200**;
434
+ a request without the slash is sent to the slashed form with a **308**. Details
435
+ and exceptions: [07-configuration.md](./07-configuration.md#trailingslash).
436
+
437
+ ## Config: `rewrites()`
438
+
439
+ A rewrite moves a request somewhere else without changing the browser's address
440
+ bar. There are two phases:
441
+
442
+ ```js
443
+ export default {
444
+ async rewrites() {
445
+ return {
446
+ beforeFiles: [
447
+ { source: "/sitemap-:page.xml", destination: "/sitemap?page=:page" },
448
+ ],
449
+ afterFiles: [
450
+ { source: "/api/:path*", destination: "https://api.example.com/:path*" },
451
+ ],
452
+ };
453
+ },
454
+ };
455
+ ```
456
+
457
+ If you return an array, all of it counts as `afterFiles`:
458
+
459
+ ```js
460
+ async rewrites() {
461
+ return [{ source: "/api/:path*", destination: "https://api.example.com/:path*" }];
462
+ }
463
+ ```
464
+
465
+ - **`beforeFiles`** runs even before static files. If you need to rewrite paths
466
+ like `/assets/…`, it has to go here.
467
+ - **`afterFiles`** runs after static has been tried, before the routes.
468
+
469
+ The form of the destination determines the behaviour:
470
+
471
+ - **Absolute (`http://` / `https://`):** the request is carried outwards through
472
+ the built-in reverse proxy. No external package; a thin layer that streams
473
+ with `fetch`. Hop-by-hop headers (`host`, `connection`, `content-length`,
474
+ `accept-encoding`) are cleaned; on the response, `content-encoding`,
475
+ `content-length`, `transfer-encoding` and `connection` are dropped. Thanks to
476
+ `redirect: "manual"` the upstream's 302 is not consumed here, it is forwarded
477
+ to the browser.
478
+ - **Relative:** only `req.url` is changed and the request continues in its own
479
+ route table. In this phase the first matching rule breaks the loop.
480
+
481
+ The typical use is moving the `/api/*` path to a backend. Because the browser
482
+ calls it same-origin, there are no CORS or third-party cookie problems.
483
+
484
+ ### Proxying by hand: `createProxy`
485
+
486
+ You can use the same proxy in your own route as well:
487
+
488
+ ```js
489
+ import { createProxy } from "jskelet";
490
+
491
+ export default function register(app) {
492
+ app.use("/ws-api", createProxy((req) => `${process.env.API_ORIGIN}${req.url}`));
493
+ }
494
+ ```
495
+
496
+ If `resolveTarget` throws or returns nothing, the request is not proxied and
497
+ continues down the chain: in a setup where the target origin has not been
498
+ configured, getting a normal 404 instead of a 500 is more correct.
499
+
500
+ ## The `source` pattern syntax
501
+
502
+ `redirects()`, `rewrites()`, `headers()` and `cache().html` use the same small
503
+ pattern compiler. This is not Next's full `path-to-regexp` surface; the subset
504
+ actually used in config was chosen deliberately.
505
+
506
+ | Pattern | Meaning |
507
+ | --- | --- |
508
+ | `/news/:slug` | Captures a single segment (`[^/]+`) |
509
+ | `/:path*` | Captures zero or more segments; the leading `/` is optional, so `/blog/:path*` also covers `/blog` |
510
+ | `/:path*.svg` | Wildcard + fixed suffix; this is how extension rules are written |
511
+ | `/tag-:slug` | A parameter in the middle of a segment |
512
+
513
+ The captured values are written into the `:param`s of the same name inside
514
+ `destination`. A parameter name must match the pattern
515
+ `[A-Za-z_][A-Za-z0-9_]*`.
516
+
517
+ `source` must begin with `/`; if it does not, the rule is ignored and a warning
518
+ is printed (``[config] invalid source (must start with `/`): …``). An
519
+ unrecognized syntax is not silently accepted as a literal.
520
+
521
+ The full pattern list and the config reference:
522
+ [07-configuration.md](./07-configuration.md).
523
+
524
+ ## What's next
525
+
526
+ - The template layer, components and metadata:
527
+ [04-rendering.md](./04-rendering.md)
528
+ - `revalidate`, the cache key and `X-JSkelet-Cache`:
529
+ [06-caching.md](./06-caching.md)
530
+ - The full reference of the config fields:
531
+ [07-configuration.md](./07-configuration.md)