jskelet 0.2.5 → 0.3.0

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