jskelet 0.6.3 → 0.6.4

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 (153) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +628 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +667 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +348 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +675 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +351 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +369 -356
  134. package/src/server/port-guard.js +255 -255
  135. package/src/server/prewarm.js +1082 -1082
  136. package/src/server/redis.js +588 -588
  137. package/src/server/render.js +910 -910
  138. package/src/server/router.js +157 -157
  139. package/src/server/status-page.js +265 -265
  140. package/src/server/upstream-limiter.js +376 -376
  141. package/src/server/upstream-tracking.js +166 -166
  142. package/src/shared/cookie-domain.js +66 -66
  143. package/src/start.mjs +22 -22
  144. package/src/templates/layout.ejs +30 -30
  145. package/src/templates/layout.jsk +30 -30
  146. package/src/version.mjs +31 -31
  147. package/src/views/components/loader.js +101 -101
  148. package/src/views/helpers/html.js +102 -102
  149. package/src/views/helpers/tags.js +375 -375
  150. package/types/config/defaults.d.ts +6 -0
  151. package/types/config/index.d.ts +6 -0
  152. package/types/server/cache-control.d.ts +28 -0
  153. package/types/server/og-image.d.ts +5 -0
@@ -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)