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.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +628 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +667 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +348 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +675 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +351 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +369 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/og-image.d.ts +5 -0
package/docs/en/03-routing.md
CHANGED
|
@@ -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)
|