jskelet 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +129 -2
- package/README.md +21 -7
- package/bin/jskelet.mjs +6 -6
- package/docs/03-routing.md +48 -9
- package/docs/04-render-ve-sablonlar.md +2 -2
- package/docs/05-islands.md +59 -6
- package/docs/06-cache.md +240 -26
- package/docs/07-yapilandirma.md +108 -7
- package/docs/08-build.md +4 -4
- package/docs/09-dev-araclari.md +5 -0
- package/docs/12-panel-ve-oturum.md +384 -0
- package/docs/README.md +25 -2
- package/docs/en/01-getting-started.md +292 -0
- package/docs/en/02-architecture.md +305 -0
- package/docs/en/03-routing.md +493 -0
- package/docs/en/04-rendering.md +504 -0
- package/docs/en/05-islands.md +492 -0
- package/docs/en/06-caching.md +640 -0
- package/docs/en/07-configuration.md +789 -0
- package/docs/en/08-build.md +383 -0
- package/docs/en/09-dev-tools.md +314 -0
- package/docs/en/10-deployment.md +332 -0
- package/docs/en/11-migration.md +360 -0
- package/docs/en/12-dashboards-and-sessions.md +392 -0
- package/docs/en/README.md +112 -0
- package/package.json +4 -2
- package/src/build/build.mjs +1 -1
- package/src/build/tasks/client.mjs +2 -2
- package/src/build/tasks/fonts.mjs +3 -3
- package/src/build/tasks/icons.mjs +1 -1
- package/src/build/tasks/images.mjs +2 -2
- package/src/client/devtools/overlay.js +196 -164
- package/src/client/devtools/report.js +96 -96
- package/src/client/form.js +192 -0
- package/src/client/index.js +10 -1
- package/src/client/registry.js +78 -4
- package/src/client/swap.js +188 -0
- package/src/config/defaults.js +83 -0
- package/src/config/index.js +129 -18
- package/src/config/pattern.js +1 -1
- package/src/dev-server.mjs +1 -1
- package/src/http/control-flow.js +16 -1
- package/src/http/cookies.js +257 -0
- package/src/http/request-context.js +162 -0
- package/src/index.js +26 -2
- package/src/init.mjs +32 -31
- package/src/log.mjs +8 -2
- package/src/logo.png +0 -0
- package/src/runtime/alias-hooks.mjs +1 -1
- package/src/server/assets.js +1 -1
- package/src/server/create-app.js +12 -4
- package/src/server/data-cache.js +244 -0
- package/src/server/dev/devtools.js +6 -2
- package/src/server/dev/report.js +8 -1
- package/src/server/dev/version-check.mjs +139 -0
- package/src/server/head-hints.js +1 -1
- package/src/server/html-cache.js +32 -6
- package/src/server/middleware/csrf.js +134 -0
- package/src/server/prewarm.js +164 -19
- package/src/server/render.js +256 -20
- package/src/server/router.js +14 -7
- package/src/server/status-page.js +1 -1
- package/src/version.mjs +9 -4
- package/src/views/components/loader.js +1 -1
- package/src/views/helpers/tags.js +53 -1
|
@@ -0,0 +1,392 @@
|
|
|
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
|
+
## CSRF
|
|
135
|
+
|
|
136
|
+
The framework parses the request body (`express.urlencoded` + `express.json`),
|
|
137
|
+
which makes it the layer that accepts state-changing requests. The protection
|
|
138
|
+
has two layers.
|
|
139
|
+
|
|
140
|
+
### Layer 1 — origin check (on by default)
|
|
141
|
+
|
|
142
|
+
Unsafe methods get a 403 when `Origin` does not match our host, or when
|
|
143
|
+
`Sec-Fetch-Site: cross-site` arrives. **If neither header is present the request
|
|
144
|
+
passes**: browsers always send `Origin` on a cross-origin POST, while webhooks
|
|
145
|
+
and server-to-server calls send neither. That distinction keeps the protection
|
|
146
|
+
on without breaking integrations.
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
security: {
|
|
150
|
+
csrf: {
|
|
151
|
+
// Legitimate exceptions, such as a panel on a separate domain.
|
|
152
|
+
allowedOrigins: ["https://admin.example.com"],
|
|
153
|
+
// Endpoints that do not come from a browser.
|
|
154
|
+
exclude: ["/webhook/:path*"],
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Layer 2 — double-submit token (optional)
|
|
160
|
+
|
|
161
|
+
Enabled with `security.csrf.token: true`. Forms print the token with
|
|
162
|
+
`csrfField()`:
|
|
163
|
+
|
|
164
|
+
```ejs
|
|
165
|
+
<form method="post" action="/dashboard/notes">
|
|
166
|
+
<%- csrfField() %>
|
|
167
|
+
…
|
|
168
|
+
</form>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The token is **not** produced by the middleware but by `csrfField()` — that is,
|
|
172
|
+
at the moment it is actually printed into a form. The reason is concrete: if the
|
|
173
|
+
token were written on every response, a public and cacheable page would carry a
|
|
174
|
+
`Set-Cookie` too, a CDN would store that response, and every visitor would share
|
|
175
|
+
the same token.
|
|
176
|
+
|
|
177
|
+
On the server the token in the signed cookie must match the submitted field; an
|
|
178
|
+
`X-CSRF-Token` header is accepted in place of the field.
|
|
179
|
+
|
|
180
|
+
`csrfField()` returns an empty string while `security.csrf.token` is off, so the
|
|
181
|
+
template renders under any configuration.
|
|
182
|
+
|
|
183
|
+
## Fragment endpoints
|
|
184
|
+
|
|
185
|
+
`fragment()` produces a partial response with a fixed policy: no layout, sent
|
|
186
|
+
with `private, no-store` and no ETag, never touching the HTML cache.
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
export default function register(app, { fragment }) {
|
|
190
|
+
app.get(
|
|
191
|
+
"/_fragment/orders",
|
|
192
|
+
fragment(async ({ req, query }) => {
|
|
193
|
+
const user = currentUser(req);
|
|
194
|
+
if (!user) return { view: "partials/session-expired", status: 401 };
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
view: "partials/order-table",
|
|
198
|
+
data: { orders: getOrders(user.username, Number(query.page ?? 1)) },
|
|
199
|
+
};
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The controller returns either `{ view, data?, status? }` or an HTML string
|
|
206
|
+
directly. On failure it responds with a small partial rather than a whole page
|
|
207
|
+
(`<div role="alert" data-fragment-error>`), because the swapped region must not
|
|
208
|
+
end up containing an entire error page.
|
|
209
|
+
|
|
210
|
+
Using the same template both inside the page and at the fragment endpoint is the
|
|
211
|
+
central idea: the markup has a single source on the server and the client does
|
|
212
|
+
not carry a second template.
|
|
213
|
+
|
|
214
|
+
## Client: swapping a region
|
|
215
|
+
|
|
216
|
+
```js
|
|
217
|
+
import { registerAll, start, startForms, startSwapLinks } from "jskelet/client";
|
|
218
|
+
|
|
219
|
+
registerAll({ "live-clock": () => import("../islands/live-clock.js") });
|
|
220
|
+
|
|
221
|
+
start();
|
|
222
|
+
startSwapLinks();
|
|
223
|
+
startForms();
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`startSwapLinks()` binds links carrying `data-swap`:
|
|
227
|
+
|
|
228
|
+
```html
|
|
229
|
+
<a href="/_fragment/orders?page=2" data-swap="#orders">Next</a>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Because `href` is a real URL, the link falls back to normal navigation without
|
|
233
|
+
JavaScript. For programmatic use there is `swap()`:
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
import { swap } from "jskelet/client";
|
|
237
|
+
|
|
238
|
+
await swap("#orders", "/_fragment/orders?page=2", { history: true });
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
In order, `swap()` unmounts the islands in the old subtree, replaces the
|
|
242
|
+
content, hydrates the new subtree and restores focus if it was lost. While the
|
|
243
|
+
request is in flight the region gets `aria-busy="true"` — binding the pending
|
|
244
|
+
indicator to the accessibility state instead of a separate class also keeps the
|
|
245
|
+
two from drifting apart.
|
|
246
|
+
|
|
247
|
+
If it meets a redirect (the session expired and the server points at the sign-in
|
|
248
|
+
page), it navigates there instead of swapping the partial in.
|
|
249
|
+
|
|
250
|
+
### Unmounting islands
|
|
251
|
+
|
|
252
|
+
This is the half of swapping that is easiest to skip. A `mount()` function may
|
|
253
|
+
return a cleanup callback:
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
export function mount(element) {
|
|
257
|
+
const timer = setInterval(() => tick(element), 1000);
|
|
258
|
+
return () => clearInterval(timer);
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The islands inside a region replaced with `innerHTML` leave the DOM, but the
|
|
263
|
+
listeners they installed on `document`/`window` and their `setInterval` timers
|
|
264
|
+
keep running; after a few swaps the same work runs dozens of times. `swap()` and
|
|
265
|
+
the form helpers call `unmount()` for you; when you change the DOM by hand, you
|
|
266
|
+
call it:
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
import { hydrate, unmount } from "jskelet/client";
|
|
270
|
+
|
|
271
|
+
unmount(region);
|
|
272
|
+
region.innerHTML = html;
|
|
273
|
+
hydrate(region);
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Forms
|
|
277
|
+
|
|
278
|
+
`startForms()` binds forms carrying `data-enhance`. The contract is progressive
|
|
279
|
+
enhancement: the form is a normal `<form method="post" action="…">` and
|
|
280
|
+
JavaScript only removes the full page round trip in between.
|
|
281
|
+
|
|
282
|
+
```html
|
|
283
|
+
<form method="post" action="/dashboard/notes" data-enhance data-target="#notes">
|
|
284
|
+
<%- csrfField() %>
|
|
285
|
+
<textarea name="text" required minlength="3"></textarea>
|
|
286
|
+
<button type="submit">Save</button>
|
|
287
|
+
</form>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The server answers with one of three things:
|
|
291
|
+
|
|
292
|
+
- **a redirect** → followed with `location.assign` (successful mutation, and the
|
|
293
|
+
no-JavaScript path)
|
|
294
|
+
- **4xx + a partial** → swapped in place of the form (validation errors)
|
|
295
|
+
- **2xx + a partial** → swapped into the `data-target` region, and the form is
|
|
296
|
+
reset
|
|
297
|
+
|
|
298
|
+
The server side handles both clients:
|
|
299
|
+
|
|
300
|
+
```js
|
|
301
|
+
app.post(
|
|
302
|
+
"/dashboard/notes",
|
|
303
|
+
fragment(async ({ req }) => {
|
|
304
|
+
const user = currentUser(req);
|
|
305
|
+
if (!user) seeOther("/sign-in?next=%2Fdashboard");
|
|
306
|
+
|
|
307
|
+
const result = addNote(user.username, req.body?.text);
|
|
308
|
+
|
|
309
|
+
// Without JavaScript the client sends no `X-Requested-With`: full round trip.
|
|
310
|
+
if (req.get("X-Requested-With") !== "fragment") {
|
|
311
|
+
seeOther(result.ok ? "/dashboard" : "/dashboard?note=error");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return result.ok
|
|
315
|
+
? { view: "partials/note-list", data: { notes: getNotes(user.username) } }
|
|
316
|
+
: { view: "partials/note-form", data: { error: result.error }, status: 422 };
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Using `redirect()` instead of `seeOther()` here would be a bug: `redirect()`
|
|
322
|
+
sends 307 and 307 preserves the method, so the browser POSTs to the target
|
|
323
|
+
again. The post-form flow needs 303.
|
|
324
|
+
|
|
325
|
+
Routing the POST handler through `fragment()` has a reason too: alongside the
|
|
326
|
+
layout-less render and `no-store`, it establishes the **request context**.
|
|
327
|
+
Without the context, `csrfField()` in a form re-printed after a validation error
|
|
328
|
+
comes out empty and the user's second attempt gets a 403.
|
|
329
|
+
|
|
330
|
+
On a validation error focus moves to the first invalid field; the markers looked
|
|
331
|
+
for are `aria-invalid="true"` and `data-field-error`.
|
|
332
|
+
|
|
333
|
+
## Configuration
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
export default {
|
|
337
|
+
security: {
|
|
338
|
+
/**
|
|
339
|
+
* Turn this off when you are not behind a reverse proxy: while it is on, a
|
|
340
|
+
* client can forge its own `X-Forwarded-For`.
|
|
341
|
+
*/
|
|
342
|
+
trustProxy: true,
|
|
343
|
+
|
|
344
|
+
cookieSecret: process.env.JSKELET_SECRET,
|
|
345
|
+
|
|
346
|
+
csrf: {
|
|
347
|
+
enabled: true,
|
|
348
|
+
token: false,
|
|
349
|
+
allowedOrigins: [],
|
|
350
|
+
exclude: [],
|
|
351
|
+
cookieName: "csrf_token",
|
|
352
|
+
fieldName: "_csrf",
|
|
353
|
+
headerName: "x-csrf-token",
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Two more settings pay off for dashboard paths:
|
|
360
|
+
|
|
361
|
+
```js
|
|
362
|
+
// Speculatively fetching a link with side effects can sign the user out.
|
|
363
|
+
navigation: { exclude: ["/dashboard/:path*", "/sign-out"] },
|
|
364
|
+
|
|
365
|
+
// The warmer has no session; protected pages cannot be warmed.
|
|
366
|
+
prewarmSkip: ["/api/", "/_fragment/", "/dashboard", "/sign-out"],
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
And indexing is turned off through `headers()` — `no-store` prevents caching,
|
|
370
|
+
but indexing has to be said separately:
|
|
371
|
+
|
|
372
|
+
```js
|
|
373
|
+
{
|
|
374
|
+
source: "/dashboard/:path*",
|
|
375
|
+
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Checklist
|
|
380
|
+
|
|
381
|
+
When adding a per-visitor section:
|
|
382
|
+
|
|
383
|
+
- [ ] Pages are registered with `route(fn, { private: true })`.
|
|
384
|
+
- [ ] Fragment endpoints are registered with `fragment()`.
|
|
385
|
+
- [ ] `security.cookieSecret` comes from the environment, not from the source.
|
|
386
|
+
- [ ] Mutation forms contain `csrfField()` and `security.csrf.token` is on.
|
|
387
|
+
- [ ] Signing out is a POST, not a GET.
|
|
388
|
+
- [ ] The `next` parameter after sign-in only accepts same-site paths.
|
|
389
|
+
- [ ] `prewarmSkip` and `navigation.exclude` exclude the protected prefix.
|
|
390
|
+
- [ ] `X-Robots-Tag: noindex` under `headers()`.
|
|
391
|
+
- [ ] The smoke test checks the `no-store` header and the rejection of a POST
|
|
392
|
+
without a token — nothing on screen changes when those break.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# JSkelet documentation
|
|
2
|
+
|
|
3
|
+
JSkelet is a framework that "feels frameworkless", built for SEO- and
|
|
4
|
+
speed-focused sites: it produces complete HTML on the server with Express 5 +
|
|
5
|
+
EJS, adds interactivity with vanilla JS islands, compiles CSS into a single
|
|
6
|
+
stylesheet with Tailwind v4, and instead of ISR uses an HTML TTL cache that
|
|
7
|
+
lives in process memory with stale-while-revalidate. No React, no TypeScript;
|
|
8
|
+
plain JavaScript and JSDoc.
|
|
9
|
+
|
|
10
|
+
This directory is the full reference for the framework. To read it in order,
|
|
11
|
+
start from the beginning; if you are looking for a specific topic, go straight
|
|
12
|
+
to the relevant entry.
|
|
13
|
+
|
|
14
|
+
The Turkish edition of the same documents lives one directory up, in
|
|
15
|
+
[`docs/`](../README.md). Both editions are kept in sync by hand, so if you
|
|
16
|
+
change one, change the other.
|
|
17
|
+
|
|
18
|
+
## Read in order
|
|
19
|
+
|
|
20
|
+
| Document | Topic |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| [01-getting-started.md](./01-getting-started.md) | Installation, `jskelet init`, first route, first island, directory structure, CLI commands |
|
|
23
|
+
| [02-architecture.md](./02-architecture.md) | Architectural decisions and their rationale: the island model, complete server HTML, cache strategy, middleware order |
|
|
24
|
+
| [03-routing.md](./03-routing.md) | The route module contract, load order, the controller contract, `ctx`, `notFound`/`redirect`, config redirects/rewrites |
|
|
25
|
+
| [04-rendering.md](./04-rendering.md) | EJS layout, pages, automatic component registration, `html`/`tags` helpers, metadata → `<head>`, hooks |
|
|
26
|
+
| [05-islands.md](./05-islands.md) | The `data-island` contract, hydration strategies, `client/entries/*`, `createStore`, DOM helpers, `startSafeImages` |
|
|
27
|
+
| [06-caching.md](./06-caching.md) | `withHtmlCache`, `revalidate`, stale-while-revalidate, the cache key, `X-JSkelet-Cache`, in-request cache, degraded render, prewarm |
|
|
28
|
+
| [07-configuration.md](./07-configuration.md) | Full `jskelet.config.mjs` reference, the `source` pattern syntax, environment variable table |
|
|
29
|
+
| [08-build.md](./08-build.md) | The build pipeline, the manifest, hashed assets, CSS/Tailwind `@source`, fonts, icon sprite, image optimization, precompress |
|
|
30
|
+
| [09-dev-tools.md](./09-dev-tools.md) | The `jskelet dev` flow, watch directories, CSS hot-swap, devtools overlay (Alt+D), the report page, the dev gate |
|
|
31
|
+
| [10-deployment.md](./10-deployment.md) | Prod build + start, environment variables, Docker, reverse proxy, health check |
|
|
32
|
+
| [11-migration.md](./11-migration.md) | Migrating from Next.js: the equivalence table and a step-by-step plan |
|
|
33
|
+
| [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md) | Per-visitor pages: `private: true`, signed cookie sessions, CSRF, `fragment()`, swapping regions and the form loop |
|
|
34
|
+
|
|
35
|
+
## Quick access by topic
|
|
36
|
+
|
|
37
|
+
- **How do I add a page?** → [03-routing.md](./03-routing.md) and
|
|
38
|
+
[04-rendering.md](./04-rendering.md)
|
|
39
|
+
- **I want something to happen when a button is clicked** →
|
|
40
|
+
[05-islands.md](./05-islands.md)
|
|
41
|
+
- **Why is the page returning `MISS` / why am I seeing stale data?** →
|
|
42
|
+
[06-caching.md](./06-caching.md)
|
|
43
|
+
- **How do I write a page that depends on the session?** →
|
|
44
|
+
[12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md)
|
|
45
|
+
- **What does each config field do?** → [07-configuration.md](./07-configuration.md)
|
|
46
|
+
- **Styles are missing / icons don't show up** → [08-build.md](./08-build.md)
|
|
47
|
+
- **Going live** → [10-deployment.md](./10-deployment.md)
|
|
48
|
+
|
|
49
|
+
## Runnable examples
|
|
50
|
+
|
|
51
|
+
All four are in working order; most of the examples in the docs were taken from
|
|
52
|
+
them.
|
|
53
|
+
|
|
54
|
+
**`examples/minimal/`** — two routes, one component, one island, minimal config.
|
|
55
|
+
The smallest working form of the framework.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm --prefix examples/minimal install
|
|
59
|
+
npm --prefix examples/minimal run dev
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**`examples/blog/`** — a dynamic route (`/blog/:slug`), tag pages, the whole of
|
|
63
|
+
the `redirects`/`rewrites`/`headers`/`cache` configuration, tab panels arriving
|
|
64
|
+
as fragments, form submission, prewarm,
|
|
65
|
+
`robots.txt`/`sitemap.xml`/`rss.xml` and four islands (theme, tabs, search,
|
|
66
|
+
form).
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm --prefix examples/blog install
|
|
70
|
+
npm --prefix examples/blog run dev
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**`examples/marketing/`** — the framework's own marketing site: hero, comparison
|
|
74
|
+
table, live latency measurement, FAQ, docs index, release notes and a download
|
|
75
|
+
page. The byte counts on the page are read in `lib/payload.js` from the site's
|
|
76
|
+
**own** build output, and the release info in `lib/release.js` from the
|
|
77
|
+
installed package's `package.json`; the latency numbers are measured in the
|
|
78
|
+
browser by the `latency` island. With a long TTL (one hour) and a prewarm that
|
|
79
|
+
warms every page, it shows the profile in which the cache works most
|
|
80
|
+
efficiently.
|
|
81
|
+
|
|
82
|
+
It also serves **these documents**: `/docs/<chapter>` reads the markdown files
|
|
83
|
+
in `node_modules/jskelet/docs/` and renders them with a sidebar, an "on this
|
|
84
|
+
page" list and sequential navigation. The renderer is a small module in
|
|
85
|
+
`lib/markdown.js` — no dependency — and the source of truth stays the package,
|
|
86
|
+
so the site never drifts from the installed version.
|
|
87
|
+
|
|
88
|
+
The site is also **bilingual**: English by default at the root, Turkish under
|
|
89
|
+
`/tr`, with the same route names in both languages. There is no i18n in the
|
|
90
|
+
framework; language resolution lives in `lib/i18n.js` as the application's own
|
|
91
|
+
contract and is wired to a dictionary via `hooks.layoutContext`. This is the
|
|
92
|
+
place to look if you want to see how to build a multilingual site with this
|
|
93
|
+
surface.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm --prefix examples/marketing install
|
|
97
|
+
npm --prefix examples/marketing run dev
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**`examples/dashboard/`** — the opposite axis from the other three: per-visitor
|
|
101
|
+
pages. Sign-in with a signed cookie session, a `private: true` protected panel,
|
|
102
|
+
a paginated table fragment, a CSRF-protected mutation form and an island that
|
|
103
|
+
returns a cleanup function. It also has a public landing page, so a cached
|
|
104
|
+
response and a `no-store` one sit side by side in the same application.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm --prefix examples/dashboard install
|
|
108
|
+
npm --prefix examples/dashboard run dev
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
In all four examples, `node smoke.mjs` verifies that the endpoints respond as
|
|
112
|
+
expected while the server is up.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jskelet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "A framework that feels like no framework: Express 5 + EJS server rendering, vanilla JS islands, Tailwind v4 and an in-process HTML TTL cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"./client": "./src/client/index.js",
|
|
34
34
|
"./html": "./src/views/helpers/html.js",
|
|
35
35
|
"./tags": "./src/views/helpers/tags.js",
|
|
36
|
+
"./cookies": "./src/http/cookies.js",
|
|
36
37
|
"./log": "./src/log.mjs",
|
|
37
38
|
"./register": "./src/runtime/register.mjs",
|
|
38
39
|
"./layout": "./src/templates/layout.ejs"
|
|
@@ -53,7 +54,8 @@
|
|
|
53
54
|
"lint": "eslint",
|
|
54
55
|
"test": "node --test \"test/**/*.test.mjs\"",
|
|
55
56
|
"example:minimal": "npm --prefix examples/minimal run dev",
|
|
56
|
-
"example:blog": "npm --prefix examples/blog run dev"
|
|
57
|
+
"example:blog": "npm --prefix examples/blog run dev",
|
|
58
|
+
"example:dashboard": "npm --prefix examples/dashboard run dev"
|
|
57
59
|
},
|
|
58
60
|
"dependencies": {
|
|
59
61
|
"ejs": "^6.0.1",
|
package/src/build/build.mjs
CHANGED
|
@@ -76,7 +76,7 @@ if (fs.existsSync(config.dirs.styles)) {
|
|
|
76
76
|
Object.assign(manifest, await task("CSS", () => buildCss(config, { watch })));
|
|
77
77
|
} else {
|
|
78
78
|
log.warn(
|
|
79
|
-
`stylesheet
|
|
79
|
+
`no stylesheet entry: ${path.relative(config.root, config.dirs.styles)} — CSS step skipped`,
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -85,7 +85,7 @@ export async function buildClient(config, { watch = false } = {}) {
|
|
|
85
85
|
const outDir = path.join(paths.assets, "js");
|
|
86
86
|
|
|
87
87
|
if (!fs.existsSync(entryDir)) {
|
|
88
|
-
log.detail("
|
|
88
|
+
log.detail("no entries, skipped");
|
|
89
89
|
return {};
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -95,7 +95,7 @@ export async function buildClient(config, { watch = false } = {}) {
|
|
|
95
95
|
.map((file) => path.join(entryDir, file));
|
|
96
96
|
|
|
97
97
|
if (!entryPoints.length) {
|
|
98
|
-
log.detail("
|
|
98
|
+
log.detail("no entries, skipped");
|
|
99
99
|
return {};
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -89,7 +89,7 @@ export async function copyFonts(config) {
|
|
|
89
89
|
);
|
|
90
90
|
if (stillMissing.length) {
|
|
91
91
|
log.warn(
|
|
92
|
-
`${family.family} ${stillMissing.join(", ")}
|
|
92
|
+
`${family.family} ${stillMissing.join(", ")} could not be downloaded — falling back to the system font stack.`,
|
|
93
93
|
);
|
|
94
94
|
}
|
|
95
95
|
}
|
|
@@ -118,7 +118,7 @@ async function downloadFromGoogle(url, weights) {
|
|
|
118
118
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
119
119
|
css = await response.text();
|
|
120
120
|
} catch (error) {
|
|
121
|
-
log.warn(`Google Fonts CSS
|
|
121
|
+
log.warn(`could not fetch Google Fonts CSS (${error.message})`);
|
|
122
122
|
return out;
|
|
123
123
|
}
|
|
124
124
|
|
|
@@ -138,7 +138,7 @@ async function downloadFromGoogle(url, weights) {
|
|
|
138
138
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
139
139
|
out.push([weight, Buffer.from(await response.arrayBuffer())]);
|
|
140
140
|
} catch (error) {
|
|
141
|
-
log.warn(`${weight}
|
|
141
|
+
log.warn(`${weight} could not be downloaded (${error.message})`);
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
144
|
|
|
@@ -184,7 +184,7 @@ function readIconBody(coreAssets, name, weight) {
|
|
|
184
184
|
export async function buildIconSprite(config) {
|
|
185
185
|
const coreAssets = resolveIconAssets(config.root);
|
|
186
186
|
if (!coreAssets) {
|
|
187
|
-
log.detail("@phosphor-icons/core
|
|
187
|
+
log.detail("@phosphor-icons/core not installed, skipped");
|
|
188
188
|
return {};
|
|
189
189
|
}
|
|
190
190
|
|
|
@@ -100,7 +100,7 @@ export async function buildImages(config, sharp) {
|
|
|
100
100
|
|
|
101
101
|
const sources = collect(paths.public, skip);
|
|
102
102
|
if (!sources.length) {
|
|
103
|
-
log.detail("
|
|
103
|
+
log.detail("no images, skipped");
|
|
104
104
|
return {};
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -168,7 +168,7 @@ export async function buildImages(config, sharp) {
|
|
|
168
168
|
} catch (error) {
|
|
169
169
|
// Bozuk/okunamayan tek bir görsel build'i düşürmesin: manifest'te yer
|
|
170
170
|
// almazsa orijinal dosya servis edilmeye devam eder.
|
|
171
|
-
log.warn(`${url}
|
|
171
|
+
log.warn(`${url} could not be optimized (${error.message})`);
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|