jskelet 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +628 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +667 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +348 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +675 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +351 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +369 -356
  134. package/src/server/port-guard.js +255 -255
  135. package/src/server/prewarm.js +1082 -1082
  136. package/src/server/redis.js +588 -588
  137. package/src/server/render.js +910 -910
  138. package/src/server/router.js +157 -157
  139. package/src/server/status-page.js +265 -265
  140. package/src/server/upstream-limiter.js +376 -376
  141. package/src/server/upstream-tracking.js +166 -166
  142. package/src/shared/cookie-domain.js +66 -66
  143. package/src/start.mjs +22 -22
  144. package/src/templates/layout.ejs +30 -30
  145. package/src/templates/layout.jsk +30 -30
  146. package/src/version.mjs +31 -31
  147. package/src/views/components/loader.js +101 -101
  148. package/src/views/helpers/html.js +102 -102
  149. package/src/views/helpers/tags.js +375 -375
  150. package/types/config/defaults.d.ts +6 -0
  151. package/types/config/index.d.ts +6 -0
  152. package/types/server/cache-control.d.ts +28 -0
  153. package/types/server/og-image.d.ts +5 -0
@@ -1,488 +1,489 @@
1
- # 12 — Dashboards, sessions and per-visitor pages
2
-
3
- JSkelet's center of gravity is public, cacheable pages. A dashboard sits on the
4
- opposite axis: different HTML for every visitor, no cache, heavy interaction.
5
- This document covers that axis — `private: true`, session cookies, CSRF,
6
- fragment endpoints and region swapping.
7
-
8
- One thing is deliberately out of scope: **live data transport**. Choosing
9
- between SSE, WebSocket and polling belongs to the application; the framework
10
- only provides the "refresh this region from the server" step. The compression
11
- layer skips `text/event-stream` responses and streams that write their own
12
- headers, so wiring up SSE by hand does not break anything either.
13
-
14
- The runnable counterpart is `examples/dashboard/`, which is where the snippets
15
- below come from.
16
-
17
- ## Why a separate path is needed
18
-
19
- The HTML cache key is only the path and the query string:
20
-
21
- ```
22
- `${req.path}?${new URLSearchParams(query).toString()}`
23
- ```
24
-
25
- Identity is not part of the key. So if a session-dependent page is registered
26
- with a plain `route()`, the first visitor's HTML is served to **everyone** for
27
- the length of the TTL. Nothing about this mistake fails loudly: the page works,
28
- the tests pass, and the problem only appears once a second user arrives —
29
- usually in production.
30
-
31
- ## `private: true`
32
-
33
- ```js
34
- export default function register(app, { route, redirect }) {
35
- app.get(
36
- "/dashboard",
37
- route(
38
- async ({ req }) => {
39
- const user = currentUser(req);
40
- if (!user) redirect("/sign-in?next=%2Fdashboard");
41
-
42
- return { view: "pages/overview", data: { user } };
43
- },
44
- { private: true },
45
- ),
46
- );
47
- }
48
- ```
49
-
50
- What the flag does:
51
-
52
- | Behaviour | Public `route()` | `private: true` |
53
- | --- | --- | --- |
54
- | HTML cache | On when a TTL exists | Off, cannot be turned on |
55
- | `cache.html` pattern | Overrides the TTL | Ignored |
56
- | `Cache-Control` | `public, s-maxage=…` | `private, no-store` |
57
- | `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
58
- | ETag | Present | Absent |
59
- | `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Not written |
60
-
61
- Dropping the ETag looks like a detail but is not: a strong ETag over a
62
- per-visitor body is a fingerprint of that visitor, and a layer that ignores
63
- `no-store` could use it to tell them apart.
64
-
65
- A redirect thrown from a per-visitor page is not cacheable either. "You need to
66
- sign in" is a session-dependent decision; storing it means sending signed-in
67
- users to the sign-in page too.
68
-
69
- ## When you forget the flag
70
-
71
- The framework watches for reads that touch identity. The `req` passed to the
72
- controller is wrapped in a thin Proxy that marks these accesses:
73
-
74
- - `req.headers.cookie`, `req.headers.authorization`,
75
- `req.headers["proxy-authorization"]`
76
- - `req.get("Cookie")` / `req.header("Authorization")`
77
- - `req.cookies`, `req.signedCookies`, `req.session`, `req.user`
78
- - `parseCookies(req)` and `getSignedCookie(req, …)`, which report it directly
79
-
80
- A marked render is **never stored**. In production the response is sent with
81
- `no-store` and this line is logged:
82
-
83
- ```
84
- [render] /dashboard read identity-bound data (req.headers.cookie), not cached.
85
- Register the route with 'private: true'.
86
- ```
87
-
88
- In development the same situation fails the request. The reason it is not silent
89
- is simple: this mistake produces a working page, so it is never noticed on its
90
- own.
91
-
92
- `csrfField()` marks the render the same way. A page that prints a token cannot
93
- come from the cache — if it did, every visitor would share the same token and
94
- the double-submit check would verify nothing.
95
-
96
- ## Sessions: signed cookies
97
-
98
- The framework does not provide identity. The only thing it gives you is the
99
- guarantee that "I wrote this value and it has not been tampered with":
100
-
101
- ```js
102
- import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
103
-
104
- export function startSession(res, username) {
105
- setSignedCookie(res, "session", username, { maxAge: 60 * 60 * 8 });
106
- }
107
-
108
- export function currentUser(req) {
109
- const username = getSignedCookie(req, "session");
110
- return username ? findUser(username) : null;
111
- }
112
-
113
- export function endSession(res) {
114
- clearCookie(res, "session");
115
- }
116
- ```
117
-
118
- The signature is HMAC-SHA256 and the comparison is constant time. If the
119
- signature does not match, `getSignedCookie` returns `null` — a tampered value is
120
- never used on the assumption that it "might be valid".
121
-
122
- The secret comes from `security.cookieSecret` or the `JSKELET_SECRET`
123
- environment variable. Without a secret the signed API **throws**; the rule that
124
- a configuration error must not take the site down does not apply here, because
125
- the silent alternative would be trusting an unsigned cookie.
126
-
127
- The defaults are on the restrictive side: `HttpOnly`, `SameSite=Lax`, `Secure`
128
- outside development, `Path=/`. `SameSite=Lax` alone closes most of CSRF — the
129
- cookie is simply not sent on cross-site POSTs.
130
-
131
- Cookies are **signed, not encrypted**. The value is readable, so store the
132
- identifier of a secret rather than the secret itself.
133
-
134
- ## Cross-subdomain: shared cookies
135
-
136
- Host-based i18n (`tr.example.com` / `en.example.com`) often needs a session on
137
- every locale host. The supported pattern is a **short session id** plus an
138
- optional `Domain=.example.com` — do not put a JWT or a large access token in a
139
- shared cookie (`large token ≠ shared cookie`).
140
-
141
- ```js
142
- // jskelet.config.mjs
143
- export default {
144
- brand: {
145
- sharedCookieRoots: [".investvio.com", ".localhost"],
146
- },
147
- auth: {
148
- crossSubdomainHandoff: {
149
- allowedCookieNames: ["sid"], // required allowlist
150
- },
151
- },
152
- };
153
- ```
154
-
155
- ### Server
156
-
157
- ```js
158
- import { writeSharedCookie } from "jskelet/cookies";
159
-
160
- export function startSession(res, req, sessionId) {
161
- const result = writeSharedCookie(res, "sid", sessionId, {
162
- req,
163
- maxAge: 60 * 60 * 8,
164
- });
165
- // result.ok === false → result.handoff; the client should use handoff
166
- return result;
167
- }
168
- ```
169
-
170
- `Secure` follows the **protocol** (`https` / `x-forwarded-proto`), not
171
- `NODE_ENV`. The Domain is set when the request Host matches
172
- `sharedCookieRoots`. Values larger than ~512 bytes are refused with
173
- `handoff: true`.
174
-
175
- ### Client
176
-
177
- ```js
178
- import {
179
- writeSharedCookie,
180
- createHandoffUrl,
181
- handoffViaWindowName,
182
- consumeWindowNameHandoff,
183
- } from "jskelet/client";
184
-
185
- const result = writeSharedCookie("sid", sessionId, {
186
- roots: [".investvio.com", ".localhost"],
187
- maxAge: 60 * 60 * 8,
188
- });
189
-
190
- if (!result.ok && result.handoff) {
191
- const url = await createHandoffUrl({
192
- name: "sid",
193
- value: sessionId,
194
- next: "https://tr.investvio.com/panel",
195
- });
196
- if (url) location.assign(url);
197
- else handoffViaWindowName("https://tr.investvio.com/panel", {
198
- name: "sid",
199
- value: sessionId,
200
- });
201
- }
202
-
203
- // On the target host (layout / island bootstrap):
204
- consumeWindowNameHandoff();
205
- ```
206
-
207
- If `roots` is omitted, the client reads
208
- `<html data-jskelet-cookie-roots=".investvio.com,.localhost">`. After writing,
209
- a **read-back** runs; if the browser rejected the Domain, `handoff: true`.
210
-
211
- ### Handoff ticket
212
-
213
- With `auth.crossSubdomainHandoff` on:
214
-
215
- 1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (with
216
- `?handoff=`). Mint is mounted **after** the CSRF middleware; `name` must be
217
- in `allowedCookieNames` and an RFC 6265 token.
218
- 2. On the target host a GET middleware redeems the one-time ticket, sets the
219
- cookie (shared Domain first, else host-only), and 303-redirects without
220
- `handoff`
221
-
222
- `next` must be under the same `sharedCookieRoots`. Tickets live ~60s in process
223
- memory, with pending-ticket and per-IP mint limits. Do not put a JWT in the URL.
224
-
225
- The `window.name` bridge is the cookie-less fallback:
226
- `handoffViaWindowName` on the source page, `consumeWindowNameHandoff` on the
227
- target. Prefer the server handoff when possible — `window.name` remains readable
228
- across origins in the same tab.
229
-
230
- ## CSRF
231
-
232
- The framework parses the request body (`express.urlencoded` + `express.json`),
233
- which makes it the layer that accepts state-changing requests. The protection
234
- has two layers.
235
-
236
- ### Layer 1 — origin check (on by default)
237
-
238
- Unsafe methods get a 403 when `Origin` does not match our host, or when
239
- `Sec-Fetch-Site: cross-site` arrives. **If neither header is present the request
240
- passes**: browsers always send `Origin` on a cross-origin POST, while webhooks
241
- and server-to-server calls send neither. That distinction keeps the protection
242
- on without breaking integrations.
243
-
244
- ```js
245
- security: {
246
- csrf: {
247
- // Legitimate exceptions, such as a panel on a separate domain.
248
- allowedOrigins: ["https://admin.example.com"],
249
- // Endpoints that do not come from a browser.
250
- exclude: ["/webhook/:path*"],
251
- },
252
- }
253
- ```
254
-
255
- ### Layer 2 — double-submit token (optional)
256
-
257
- Enabled with `security.csrf.token: true`. Forms print the token with
258
- `csrfField()`:
259
-
260
- ```ejs
261
- <form method="post" action="/dashboard/notes">
262
- <%- csrfField() %>
263
- …
264
- </form>
265
- ```
266
-
267
- The token is **not** produced by the middleware but by `csrfField()` — that is,
268
- at the moment it is actually printed into a form. The reason is concrete: if the
269
- token were written on every response, a public and cacheable page would carry a
270
- `Set-Cookie` too, a CDN would store that response, and every visitor would share
271
- the same token.
272
-
273
- On the server the token in the signed cookie must match the submitted field; an
274
- `X-CSRF-Token` header is accepted in place of the field.
275
-
276
- `csrfField()` returns an empty string while `security.csrf.token` is off, so the
277
- template renders under any configuration.
278
-
279
- ## Fragment endpoints
280
-
281
- `fragment()` produces a partial response with a fixed policy: no layout, sent
282
- with `private, no-store` and no ETag, never touching the HTML cache.
283
-
284
- ```js
285
- export default function register(app, { fragment }) {
286
- app.get(
287
- "/_fragment/orders",
288
- fragment(async ({ req, query }) => {
289
- const user = currentUser(req);
290
- if (!user) return { view: "partials/session-expired", status: 401 };
291
-
292
- return {
293
- view: "partials/order-table",
294
- data: { orders: getOrders(user.username, Number(query.page ?? 1)) },
295
- };
296
- }),
297
- );
298
- }
299
- ```
300
-
301
- The controller returns either `{ view, data?, status? }` or an HTML string
302
- directly. On failure it responds with a small partial rather than a whole page
303
- (`<div role="alert" data-fragment-error>`), because the swapped region must not
304
- end up containing an entire error page.
305
-
306
- Using the same template both inside the page and at the fragment endpoint is the
307
- central idea: the markup has a single source on the server and the client does
308
- not carry a second template.
309
-
310
- ## Client: swapping a region
311
-
312
- ```js
313
- import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
314
-
315
- registerAll({ "live-clock": () => import("../islands/live-clock.js") });
316
-
317
- start();
318
- startSwapLinks();
319
- startForms();
320
- ```
321
-
322
- `startSwapLinks()` binds links carrying `data-swap`:
323
-
324
- ```html
325
- <a href="/_fragment/orders?page=2" data-swap="#orders">Next</a>
326
- ```
327
-
328
- Because `href` is a real URL, the link falls back to normal navigation without
329
- JavaScript. For programmatic use there is `swap()`:
330
-
331
- ```js
332
- import { swap } from "jskelet/client";
333
-
334
- await swap("#orders", "/_fragment/orders?page=2", { history: true });
335
- ```
336
-
337
- In order, `swap()` unmounts the islands in the old subtree, replaces the
338
- content, hydrates the new subtree and restores focus if it was lost. While the
339
- request is in flight the region gets `aria-busy="true"` — binding the pending
340
- indicator to the accessibility state instead of a separate class also keeps the
341
- two from drifting apart.
342
-
343
- If it meets a redirect (the session expired and the server points at the sign-in
344
- page), it navigates there instead of swapping the partial in.
345
-
346
- ### Unmounting islands
347
-
348
- This is the half of swapping that is easiest to skip. A `mount()` function may
349
- return a cleanup callback:
350
-
351
- ```js
352
- export function mount(element) {
353
- const timer = setInterval(() => tick(element), 1000);
354
- return () => clearInterval(timer);
355
- }
356
- ```
357
-
358
- The islands inside a region replaced with `innerHTML` leave the DOM, but the
359
- listeners they installed on `document`/`window` and their `setInterval` timers
360
- keep running; after a few swaps the same work runs dozens of times. `swap()` and
361
- the form helpers call `unmount()` for you; when you change the DOM by hand, you
362
- call it:
363
-
364
- ```js
365
- import { hydrate, unmount } from "jskelet/client";
366
-
367
- unmount(region);
368
- region.innerHTML = html;
369
- hydrate(region);
370
- ```
371
-
372
- ## Forms
373
-
374
- `startForms()` binds forms carrying `data-enhance`. The contract is progressive
375
- enhancement: the form is a normal `<form method="post" action="…">` and
376
- JavaScript only removes the full page round trip in between.
377
-
378
- ```html
379
- <form method="post" action="/dashboard/notes" data-enhance data-target="#notes">
380
- <%- csrfField() %>
381
- <textarea name="text" required minlength="3"></textarea>
382
- <button type="submit">Save</button>
383
- </form>
384
- ```
385
-
386
- The server answers with one of three things:
387
-
388
- - **a redirect** → followed with `location.assign` (successful mutation, and the
389
- no-JavaScript path)
390
- - **4xx + a partial** → swapped in place of the form (validation errors)
391
- - **2xx + a partial** → swapped into the `data-target` region, and the form is
392
- reset
393
-
394
- The server side handles both clients:
395
-
396
- ```js
397
- app.post(
398
- "/dashboard/notes",
399
- fragment(async ({ req }) => {
400
- const user = currentUser(req);
401
- if (!user) seeOther("/sign-in?next=%2Fdashboard");
402
-
403
- const result = addNote(user.username, req.body?.text);
404
-
405
- // Without JavaScript the client sends no `X-Requested-With`: full round trip.
406
- if (req.get("X-Requested-With") !== "fragment") {
407
- seeOther(result.ok ? "/dashboard" : "/dashboard?note=error");
408
- }
409
-
410
- return result.ok
411
- ? { view: "partials/note-list", data: { notes: getNotes(user.username) } }
412
- : { view: "partials/note-form", data: { error: result.error }, status: 422 };
413
- }),
414
- );
415
- ```
416
-
417
- Using `redirect()` instead of `seeOther()` here would be a bug: `redirect()`
418
- sends 307 and 307 preserves the method, so the browser POSTs to the target
419
- again. The post-form flow needs 303.
420
-
421
- Routing the POST handler through `fragment()` has a reason too: alongside the
422
- layout-less render and `no-store`, it establishes the **request context**.
423
- Without the context, `csrfField()` in a form re-printed after a validation error
424
- comes out empty and the user's second attempt gets a 403.
425
-
426
- On a validation error focus moves to the first invalid field; the markers looked
427
- for are `aria-invalid="true"` and `data-field-error`.
428
-
429
- ## Configuration
430
-
431
- ```js
432
- export default {
433
- security: {
434
- /**
435
- * Turn this off when you are not behind a reverse proxy: while it is on, a
436
- * client can forge its own `X-Forwarded-For`.
437
- */
438
- trustProxy: true,
439
-
440
- cookieSecret: process.env.JSKELET_SECRET,
441
-
442
- csrf: {
443
- enabled: true,
444
- token: false,
445
- allowedOrigins: [],
446
- exclude: [],
447
- cookieName: "csrf_token",
448
- fieldName: "_csrf",
449
- headerName: "x-csrf-token",
450
- },
451
- },
452
- };
453
- ```
454
-
455
- Two more settings pay off for dashboard paths:
456
-
457
- ```js
458
- // Speculatively fetching a link with side effects can sign the user out.
459
- navigation: { exclude: ["/dashboard/:path*", "/sign-out"] },
460
-
461
- // The warmer has no session; protected pages cannot be warmed.
462
- prewarmSkip: ["/api/", "/_fragment/", "/dashboard", "/sign-out"],
463
- ```
464
-
465
- And indexing is turned off through `headers()` — `no-store` prevents caching,
466
- but indexing has to be said separately:
467
-
468
- ```js
469
- {
470
- source: "/dashboard/:path*",
471
- headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
472
- }
473
- ```
474
-
475
- ## Checklist
476
-
477
- When adding a per-visitor section:
478
-
479
- - [ ] Pages are registered with `route(fn, { private: true })`.
480
- - [ ] Fragment endpoints are registered with `fragment()`.
481
- - [ ] `security.cookieSecret` comes from the environment, not from the source.
482
- - [ ] Mutation forms contain `csrfField()` and `security.csrf.token` is on.
483
- - [ ] Signing out is a POST, not a GET.
484
- - [ ] The `next` parameter after sign-in only accepts same-site paths.
485
- - [ ] `prewarmSkip` and `navigation.exclude` exclude the protected prefix.
486
- - [ ] `X-Robots-Tag: noindex` under `headers()`.
487
- - [ ] The smoke test checks the `no-store` header and the rejection of a POST
488
- without a token — nothing on screen changes when those break.
1
+ # 12 — Dashboards, sessions and per-visitor pages
2
+
3
+ JSkelet's center of gravity is public, cacheable pages. A dashboard sits on the
4
+ opposite axis: different HTML for every visitor, no cache, heavy interaction.
5
+ This document covers that axis — `private: true`, session cookies, CSRF,
6
+ fragment endpoints and region swapping.
7
+
8
+ One thing is deliberately out of scope: **live data transport**. Choosing
9
+ between SSE, WebSocket and polling belongs to the application; the framework
10
+ only provides the "refresh this region from the server" step. The compression
11
+ layer skips `text/event-stream` responses and streams that write their own
12
+ headers, so wiring up SSE by hand does not break anything either.
13
+
14
+ The runnable counterpart is `examples/dashboard/`, which is where the snippets
15
+ below come from.
16
+
17
+ ## Why a separate path is needed
18
+
19
+ The HTML cache key is only the path and the query string:
20
+
21
+ ```
22
+ `${req.path}?${new URLSearchParams(query).toString()}`
23
+ ```
24
+
25
+ Identity is not part of the key. So if a session-dependent page is registered
26
+ with a plain `route()`, the first visitor's HTML is served to **everyone** for
27
+ the length of the TTL. Nothing about this mistake fails loudly: the page works,
28
+ the tests pass, and the problem only appears once a second user arrives —
29
+ usually in production.
30
+
31
+ ## `private: true`
32
+
33
+ ```js
34
+ export default function register(app, { route, redirect }) {
35
+ app.get(
36
+ "/dashboard",
37
+ route(
38
+ async ({ req }) => {
39
+ const user = currentUser(req);
40
+ if (!user) redirect("/sign-in?next=%2Fdashboard");
41
+
42
+ return { view: "pages/overview", data: { user } };
43
+ },
44
+ { private: true },
45
+ ),
46
+ );
47
+ }
48
+ ```
49
+
50
+ What the flag does:
51
+
52
+ | Behaviour | Public `route()` | `private: true` |
53
+ | --- | --- | --- |
54
+ | HTML cache | On when a TTL exists | Off, cannot be turned on |
55
+ | `cache.html` pattern | Overrides the TTL | Ignored |
56
+ | `Cache-Control` | `public, max-age=0` | `private, no-store` |
57
+ | `CDN-Cache-Control` | `max-age=<ttl>, stale-while-revalidate=…` | Not written |
58
+ | `Vary` | `Accept-Encoding` | `Cookie, Accept-Encoding` |
59
+ | ETag | Present | Absent |
60
+ | `X-JSkelet-Cache` | `HIT`/`STALE`/`MISS` | Not written |
61
+
62
+ Dropping the ETag looks like a detail but is not: a strong ETag over a
63
+ per-visitor body is a fingerprint of that visitor, and a layer that ignores
64
+ `no-store` could use it to tell them apart.
65
+
66
+ A redirect thrown from a per-visitor page is not cacheable either. "You need to
67
+ sign in" is a session-dependent decision; storing it means sending signed-in
68
+ users to the sign-in page too.
69
+
70
+ ## When you forget the flag
71
+
72
+ The framework watches for reads that touch identity. The `req` passed to the
73
+ controller is wrapped in a thin Proxy that marks these accesses:
74
+
75
+ - `req.headers.cookie`, `req.headers.authorization`,
76
+ `req.headers["proxy-authorization"]`
77
+ - `req.get("Cookie")` / `req.header("Authorization")`
78
+ - `req.cookies`, `req.signedCookies`, `req.session`, `req.user`
79
+ - `parseCookies(req)` and `getSignedCookie(req, …)`, which report it directly
80
+
81
+ A marked render is **never stored**. In production the response is sent with
82
+ `no-store` and this line is logged:
83
+
84
+ ```
85
+ [render] /dashboard read identity-bound data (req.headers.cookie), not cached.
86
+ Register the route with 'private: true'.
87
+ ```
88
+
89
+ In development the same situation fails the request. The reason it is not silent
90
+ is simple: this mistake produces a working page, so it is never noticed on its
91
+ own.
92
+
93
+ `csrfField()` marks the render the same way. A page that prints a token cannot
94
+ come from the cache — if it did, every visitor would share the same token and
95
+ the double-submit check would verify nothing.
96
+
97
+ ## Sessions: signed cookies
98
+
99
+ The framework does not provide identity. The only thing it gives you is the
100
+ guarantee that "I wrote this value and it has not been tampered with":
101
+
102
+ ```js
103
+ import { clearCookie, getSignedCookie, setSignedCookie } from "jskelet/cookies";
104
+
105
+ export function startSession(res, username) {
106
+ setSignedCookie(res, "session", username, { maxAge: 60 * 60 * 8 });
107
+ }
108
+
109
+ export function currentUser(req) {
110
+ const username = getSignedCookie(req, "session");
111
+ return username ? findUser(username) : null;
112
+ }
113
+
114
+ export function endSession(res) {
115
+ clearCookie(res, "session");
116
+ }
117
+ ```
118
+
119
+ The signature is HMAC-SHA256 and the comparison is constant time. If the
120
+ signature does not match, `getSignedCookie` returns `null` — a tampered value is
121
+ never used on the assumption that it "might be valid".
122
+
123
+ The secret comes from `security.cookieSecret` or the `JSKELET_SECRET`
124
+ environment variable. Without a secret the signed API **throws**; the rule that
125
+ a configuration error must not take the site down does not apply here, because
126
+ the silent alternative would be trusting an unsigned cookie.
127
+
128
+ The defaults are on the restrictive side: `HttpOnly`, `SameSite=Lax`, `Secure`
129
+ outside development, `Path=/`. `SameSite=Lax` alone closes most of CSRF — the
130
+ cookie is simply not sent on cross-site POSTs.
131
+
132
+ Cookies are **signed, not encrypted**. The value is readable, so store the
133
+ identifier of a secret rather than the secret itself.
134
+
135
+ ## Cross-subdomain: shared cookies
136
+
137
+ Host-based i18n (`tr.example.com` / `en.example.com`) often needs a session on
138
+ every locale host. The supported pattern is a **short session id** plus an
139
+ optional `Domain=.example.com` — do not put a JWT or a large access token in a
140
+ shared cookie (`large token ≠ shared cookie`).
141
+
142
+ ```js
143
+ // jskelet.config.mjs
144
+ export default {
145
+ brand: {
146
+ sharedCookieRoots: [".investvio.com", ".localhost"],
147
+ },
148
+ auth: {
149
+ crossSubdomainHandoff: {
150
+ allowedCookieNames: ["sid"], // required allowlist
151
+ },
152
+ },
153
+ };
154
+ ```
155
+
156
+ ### Server
157
+
158
+ ```js
159
+ import { writeSharedCookie } from "jskelet/cookies";
160
+
161
+ export function startSession(res, req, sessionId) {
162
+ const result = writeSharedCookie(res, "sid", sessionId, {
163
+ req,
164
+ maxAge: 60 * 60 * 8,
165
+ });
166
+ // result.ok === false → result.handoff; the client should use handoff
167
+ return result;
168
+ }
169
+ ```
170
+
171
+ `Secure` follows the **protocol** (`https` / `x-forwarded-proto`), not
172
+ `NODE_ENV`. The Domain is set when the request Host matches
173
+ `sharedCookieRoots`. Values larger than ~512 bytes are refused with
174
+ `handoff: true`.
175
+
176
+ ### Client
177
+
178
+ ```js
179
+ import {
180
+ writeSharedCookie,
181
+ createHandoffUrl,
182
+ handoffViaWindowName,
183
+ consumeWindowNameHandoff,
184
+ } from "jskelet/client";
185
+
186
+ const result = writeSharedCookie("sid", sessionId, {
187
+ roots: [".investvio.com", ".localhost"],
188
+ maxAge: 60 * 60 * 8,
189
+ });
190
+
191
+ if (!result.ok && result.handoff) {
192
+ const url = await createHandoffUrl({
193
+ name: "sid",
194
+ value: sessionId,
195
+ next: "https://tr.investvio.com/panel",
196
+ });
197
+ if (url) location.assign(url);
198
+ else handoffViaWindowName("https://tr.investvio.com/panel", {
199
+ name: "sid",
200
+ value: sessionId,
201
+ });
202
+ }
203
+
204
+ // On the target host (layout / island bootstrap):
205
+ consumeWindowNameHandoff();
206
+ ```
207
+
208
+ If `roots` is omitted, the client reads
209
+ `<html data-jskelet-cookie-roots=".investvio.com,.localhost">`. After writing,
210
+ a **read-back** runs; if the browser rejected the Domain, `handoff: true`.
211
+
212
+ ### Handoff ticket
213
+
214
+ With `auth.crossSubdomainHandoff` on:
215
+
216
+ 1. `POST /_jskelet/auth/handoff` `{ name, value, next }` → `{ url }` (with
217
+ `?handoff=`). Mint is mounted **after** the CSRF middleware; `name` must be
218
+ in `allowedCookieNames` and an RFC 6265 token.
219
+ 2. On the target host a GET middleware redeems the one-time ticket, sets the
220
+ cookie (shared Domain first, else host-only), and 303-redirects without
221
+ `handoff`
222
+
223
+ `next` must be under the same `sharedCookieRoots`. Tickets live ~60s in process
224
+ memory, with pending-ticket and per-IP mint limits. Do not put a JWT in the URL.
225
+
226
+ The `window.name` bridge is the cookie-less fallback:
227
+ `handoffViaWindowName` on the source page, `consumeWindowNameHandoff` on the
228
+ target. Prefer the server handoff when possible — `window.name` remains readable
229
+ across origins in the same tab.
230
+
231
+ ## CSRF
232
+
233
+ The framework parses the request body (`express.urlencoded` + `express.json`),
234
+ which makes it the layer that accepts state-changing requests. The protection
235
+ has two layers.
236
+
237
+ ### Layer 1 — origin check (on by default)
238
+
239
+ Unsafe methods get a 403 when `Origin` does not match our host, or when
240
+ `Sec-Fetch-Site: cross-site` arrives. **If neither header is present the request
241
+ passes**: browsers always send `Origin` on a cross-origin POST, while webhooks
242
+ and server-to-server calls send neither. That distinction keeps the protection
243
+ on without breaking integrations.
244
+
245
+ ```js
246
+ security: {
247
+ csrf: {
248
+ // Legitimate exceptions, such as a panel on a separate domain.
249
+ allowedOrigins: ["https://admin.example.com"],
250
+ // Endpoints that do not come from a browser.
251
+ exclude: ["/webhook/:path*"],
252
+ },
253
+ }
254
+ ```
255
+
256
+ ### Layer 2 — double-submit token (optional)
257
+
258
+ Enabled with `security.csrf.token: true`. Forms print the token with
259
+ `csrfField()`:
260
+
261
+ ```ejs
262
+ <form method="post" action="/dashboard/notes">
263
+ <%- csrfField() %>
264
+ …
265
+ </form>
266
+ ```
267
+
268
+ The token is **not** produced by the middleware but by `csrfField()` — that is,
269
+ at the moment it is actually printed into a form. The reason is concrete: if the
270
+ token were written on every response, a public and cacheable page would carry a
271
+ `Set-Cookie` too, a CDN would store that response, and every visitor would share
272
+ the same token.
273
+
274
+ On the server the token in the signed cookie must match the submitted field; an
275
+ `X-CSRF-Token` header is accepted in place of the field.
276
+
277
+ `csrfField()` returns an empty string while `security.csrf.token` is off, so the
278
+ template renders under any configuration.
279
+
280
+ ## Fragment endpoints
281
+
282
+ `fragment()` produces a partial response with a fixed policy: no layout, sent
283
+ with `private, no-store` and no ETag, never touching the HTML cache.
284
+
285
+ ```js
286
+ export default function register(app, { fragment }) {
287
+ app.get(
288
+ "/_fragment/orders",
289
+ fragment(async ({ req, query }) => {
290
+ const user = currentUser(req);
291
+ if (!user) return { view: "partials/session-expired", status: 401 };
292
+
293
+ return {
294
+ view: "partials/order-table",
295
+ data: { orders: getOrders(user.username, Number(query.page ?? 1)) },
296
+ };
297
+ }),
298
+ );
299
+ }
300
+ ```
301
+
302
+ The controller returns either `{ view, data?, status? }` or an HTML string
303
+ directly. On failure it responds with a small partial rather than a whole page
304
+ (`<div role="alert" data-fragment-error>`), because the swapped region must not
305
+ end up containing an entire error page.
306
+
307
+ Using the same template both inside the page and at the fragment endpoint is the
308
+ central idea: the markup has a single source on the server and the client does
309
+ not carry a second template.
310
+
311
+ ## Client: swapping a region
312
+
313
+ ```js
314
+ import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
315
+
316
+ registerAll({ "live-clock": () => import("../islands/live-clock.js") });
317
+
318
+ start();
319
+ startSwapLinks();
320
+ startForms();
321
+ ```
322
+
323
+ `startSwapLinks()` binds links carrying `data-swap`:
324
+
325
+ ```html
326
+ <a href="/_fragment/orders?page=2" data-swap="#orders">Next</a>
327
+ ```
328
+
329
+ Because `href` is a real URL, the link falls back to normal navigation without
330
+ JavaScript. For programmatic use there is `swap()`:
331
+
332
+ ```js
333
+ import { swap } from "jskelet/client";
334
+
335
+ await swap("#orders", "/_fragment/orders?page=2", { history: true });
336
+ ```
337
+
338
+ In order, `swap()` unmounts the islands in the old subtree, replaces the
339
+ content, hydrates the new subtree and restores focus if it was lost. While the
340
+ request is in flight the region gets `aria-busy="true"` — binding the pending
341
+ indicator to the accessibility state instead of a separate class also keeps the
342
+ two from drifting apart.
343
+
344
+ If it meets a redirect (the session expired and the server points at the sign-in
345
+ page), it navigates there instead of swapping the partial in.
346
+
347
+ ### Unmounting islands
348
+
349
+ This is the half of swapping that is easiest to skip. A `mount()` function may
350
+ return a cleanup callback:
351
+
352
+ ```js
353
+ export function mount(element) {
354
+ const timer = setInterval(() => tick(element), 1000);
355
+ return () => clearInterval(timer);
356
+ }
357
+ ```
358
+
359
+ The islands inside a region replaced with `innerHTML` leave the DOM, but the
360
+ listeners they installed on `document`/`window` and their `setInterval` timers
361
+ keep running; after a few swaps the same work runs dozens of times. `swap()` and
362
+ the form helpers call `unmount()` for you; when you change the DOM by hand, you
363
+ call it:
364
+
365
+ ```js
366
+ import { hydrate, unmount } from "jskelet/client";
367
+
368
+ unmount(region);
369
+ region.innerHTML = html;
370
+ hydrate(region);
371
+ ```
372
+
373
+ ## Forms
374
+
375
+ `startForms()` binds forms carrying `data-enhance`. The contract is progressive
376
+ enhancement: the form is a normal `<form method="post" action="…">` and
377
+ JavaScript only removes the full page round trip in between.
378
+
379
+ ```html
380
+ <form method="post" action="/dashboard/notes" data-enhance data-target="#notes">
381
+ <%- csrfField() %>
382
+ <textarea name="text" required minlength="3"></textarea>
383
+ <button type="submit">Save</button>
384
+ </form>
385
+ ```
386
+
387
+ The server answers with one of three things:
388
+
389
+ - **a redirect** → followed with `location.assign` (successful mutation, and the
390
+ no-JavaScript path)
391
+ - **4xx + a partial** → swapped in place of the form (validation errors)
392
+ - **2xx + a partial** → swapped into the `data-target` region, and the form is
393
+ reset
394
+
395
+ The server side handles both clients:
396
+
397
+ ```js
398
+ app.post(
399
+ "/dashboard/notes",
400
+ fragment(async ({ req }) => {
401
+ const user = currentUser(req);
402
+ if (!user) seeOther("/sign-in?next=%2Fdashboard");
403
+
404
+ const result = addNote(user.username, req.body?.text);
405
+
406
+ // Without JavaScript the client sends no `X-Requested-With`: full round trip.
407
+ if (req.get("X-Requested-With") !== "fragment") {
408
+ seeOther(result.ok ? "/dashboard" : "/dashboard?note=error");
409
+ }
410
+
411
+ return result.ok
412
+ ? { view: "partials/note-list", data: { notes: getNotes(user.username) } }
413
+ : { view: "partials/note-form", data: { error: result.error }, status: 422 };
414
+ }),
415
+ );
416
+ ```
417
+
418
+ Using `redirect()` instead of `seeOther()` here would be a bug: `redirect()`
419
+ sends 307 and 307 preserves the method, so the browser POSTs to the target
420
+ again. The post-form flow needs 303.
421
+
422
+ Routing the POST handler through `fragment()` has a reason too: alongside the
423
+ layout-less render and `no-store`, it establishes the **request context**.
424
+ Without the context, `csrfField()` in a form re-printed after a validation error
425
+ comes out empty and the user's second attempt gets a 403.
426
+
427
+ On a validation error focus moves to the first invalid field; the markers looked
428
+ for are `aria-invalid="true"` and `data-field-error`.
429
+
430
+ ## Configuration
431
+
432
+ ```js
433
+ export default {
434
+ security: {
435
+ /**
436
+ * Turn this off when you are not behind a reverse proxy: while it is on, a
437
+ * client can forge its own `X-Forwarded-For`.
438
+ */
439
+ trustProxy: true,
440
+
441
+ cookieSecret: process.env.JSKELET_SECRET,
442
+
443
+ csrf: {
444
+ enabled: true,
445
+ token: false,
446
+ allowedOrigins: [],
447
+ exclude: [],
448
+ cookieName: "csrf_token",
449
+ fieldName: "_csrf",
450
+ headerName: "x-csrf-token",
451
+ },
452
+ },
453
+ };
454
+ ```
455
+
456
+ Two more settings pay off for dashboard paths:
457
+
458
+ ```js
459
+ // Speculatively fetching a link with side effects can sign the user out.
460
+ navigation: { exclude: ["/dashboard/:path*", "/sign-out"] },
461
+
462
+ // The warmer has no session; protected pages cannot be warmed.
463
+ prewarmSkip: ["/api/", "/_fragment/", "/dashboard", "/sign-out"],
464
+ ```
465
+
466
+ And indexing is turned off through `headers()` — `no-store` prevents caching,
467
+ but indexing has to be said separately:
468
+
469
+ ```js
470
+ {
471
+ source: "/dashboard/:path*",
472
+ headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
473
+ }
474
+ ```
475
+
476
+ ## Checklist
477
+
478
+ When adding a per-visitor section:
479
+
480
+ - [ ] Pages are registered with `route(fn, { private: true })`.
481
+ - [ ] Fragment endpoints are registered with `fragment()`.
482
+ - [ ] `security.cookieSecret` comes from the environment, not from the source.
483
+ - [ ] Mutation forms contain `csrfField()` and `security.csrf.token` is on.
484
+ - [ ] Signing out is a POST, not a GET.
485
+ - [ ] The `next` parameter after sign-in only accepts same-site paths.
486
+ - [ ] `prewarmSkip` and `navigation.exclude` exclude the protected prefix.
487
+ - [ ] `X-Robots-Tag: noindex` under `headers()`.
488
+ - [ ] The smoke test checks the `no-store` header and the rejection of a POST
489
+ without a token — nothing on screen changes when those break.