jskelet 0.2.3 → 0.2.5

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 (91) hide show
  1. package/AGENTS.md +132 -132
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -21
  4. package/bin/jskelet.mjs +103 -103
  5. package/docs/01-baslangic.md +285 -285
  6. package/docs/02-mimari.md +287 -287
  7. package/docs/03-routing.md +480 -480
  8. package/docs/04-render-ve-sablonlar.md +490 -490
  9. package/docs/05-islands.md +482 -482
  10. package/docs/06-cache.md +1209 -1202
  11. package/docs/08-build.md +366 -366
  12. package/docs/09-dev-araclari.md +335 -335
  13. package/docs/10-dagitim.md +329 -329
  14. package/docs/12-panel-ve-oturum.md +384 -384
  15. package/docs/README.md +105 -105
  16. package/docs/en/01-getting-started.md +292 -292
  17. package/docs/en/02-architecture.md +305 -305
  18. package/docs/en/03-routing.md +497 -497
  19. package/docs/en/04-rendering.md +504 -504
  20. package/docs/en/05-islands.md +492 -492
  21. package/docs/en/06-caching.md +1239 -1232
  22. package/docs/en/07-configuration.md +986 -986
  23. package/docs/en/08-build.md +383 -383
  24. package/docs/en/09-dev-tools.md +342 -342
  25. package/docs/en/10-deployment.md +332 -332
  26. package/docs/en/11-migration.md +359 -359
  27. package/docs/en/12-dashboards-and-sessions.md +392 -392
  28. package/docs/en/README.md +112 -112
  29. package/package.json +102 -102
  30. package/src/build/ensure-build.mjs +15 -15
  31. package/src/build/paths.mjs +143 -143
  32. package/src/build/resolve-peer.mjs +36 -36
  33. package/src/build/tasks/client.mjs +268 -268
  34. package/src/build/tasks/css.mjs +124 -124
  35. package/src/build/tasks/fonts.mjs +146 -146
  36. package/src/build/tasks/icons.mjs +224 -224
  37. package/src/build/tasks/images.mjs +244 -244
  38. package/src/build/tasks/precompress.mjs +78 -78
  39. package/src/client/cache-panel/i18n.js +670 -0
  40. package/src/client/cache-panel/login.html +74 -71
  41. package/src/client/cache-panel/panel.css +756 -740
  42. package/src/client/cache-panel/panel.html +308 -307
  43. package/src/client/cache-panel/panel.js +915 -808
  44. package/src/client/devtools/report.html +185 -185
  45. package/src/client/devtools/report.js +725 -725
  46. package/src/client/dom.js +95 -95
  47. package/src/client/form.js +192 -192
  48. package/src/client/index.js +35 -35
  49. package/src/client/registry.js +297 -297
  50. package/src/client/safe-image.js +91 -91
  51. package/src/client/store.js +36 -36
  52. package/src/client/swap.js +188 -188
  53. package/src/config/pattern.js +107 -107
  54. package/src/http/control-flow.js +71 -71
  55. package/src/http/cookies.js +257 -257
  56. package/src/http/request-cache.js +46 -46
  57. package/src/http/request-context.js +162 -162
  58. package/src/index.js +83 -83
  59. package/src/init.mjs +221 -221
  60. package/src/runtime/alias-hooks.mjs +119 -119
  61. package/src/runtime/register.mjs +4 -4
  62. package/src/server/assets.js +147 -147
  63. package/src/server/cache-deps.js +42 -42
  64. package/src/server/cache-panel.js +759 -738
  65. package/src/server/cloudflare.js +607 -595
  66. package/src/server/create-app.js +291 -291
  67. package/src/server/data-cache.js +462 -462
  68. package/src/server/dev/report.js +369 -369
  69. package/src/server/dev/socket.js +170 -170
  70. package/src/server/dev/version-check.mjs +139 -139
  71. package/src/server/html-cache.js +817 -817
  72. package/src/server/metadata.js +102 -102
  73. package/src/server/middleware/compression.js +205 -205
  74. package/src/server/middleware/csrf.js +134 -134
  75. package/src/server/middleware/dev-gate.js +62 -62
  76. package/src/server/middleware/headers.js +37 -37
  77. package/src/server/middleware/redirects.js +32 -32
  78. package/src/server/middleware/static-precompressed.js +100 -100
  79. package/src/server/middleware/upstream-proxy.js +141 -141
  80. package/src/server/prewarm.js +601 -601
  81. package/src/server/redis.js +569 -569
  82. package/src/server/router.js +128 -128
  83. package/src/server/status-page.js +164 -164
  84. package/src/server/upstream-limiter.js +376 -376
  85. package/src/server/upstream-tracking.js +166 -166
  86. package/src/start.mjs +7 -7
  87. package/src/templates/layout.ejs +44 -44
  88. package/src/version.mjs +31 -31
  89. package/src/views/components/loader.js +85 -85
  90. package/src/views/helpers/html.js +102 -102
  91. package/src/views/helpers/tags.js +245 -245
@@ -1,492 +1,492 @@
1
- # 05 — Islands
2
-
3
- This document explains how interactivity is added: the `data-island` contract,
4
- passing props, the three hydration strategies and the IntersectionObserver
5
- logic, the structure of `client/entries/*` and loading extra entries per page,
6
- the runtime API (`register`, `registerAll`, `hydrate`, `observeDocument`,
7
- `start`), `createStore` for sharing state between islands, the DOM helpers,
8
- `startSafeImages` and the deferred panel (fragment) pattern. *Why* the model
9
- looks like this is in [02-architecture.md](./02-architecture.md), and how the
10
- bundle is produced is in [08-build.md](./08-build.md).
11
-
12
- ## The contract
13
-
14
- The server HTML is complete; an island only adds behaviour. There are three
15
- pieces.
16
-
17
- **1. A marker in the template.**
18
-
19
- ```ejs
20
- <div data-island="counter" data-island-props='{"start":5}'></div>
21
- ```
22
-
23
- **2. The island module — a named export called `mount`.**
24
-
25
- ```js
26
- // client/islands/counter.js
27
- /**
28
- * @param {HTMLElement} element
29
- * @param {{ start?: number }} props
30
- * @returns {void | (() => void)} cleanup function (optional)
31
- */
32
- export function mount(element, props) {
33
- let value = props.start ?? 0;
34
- // …
35
- }
36
- ```
37
-
38
- **3. Registration in an entry.**
39
-
40
- ```js
41
- // client/entries/main.js
42
- import { registerAll, start } from "jskelet/client";
43
-
44
- registerAll({
45
- counter: () => import("../islands/counter.js"),
46
- });
47
-
48
- start();
49
- ```
50
-
51
- The loader being a dynamic import is the core of the model: the module is
52
- downloaded only if that island actually exists on the page **and** its mount
53
- condition is met. Growing this map does not grow the initial load.
54
-
55
- ## HTML attributes
56
-
57
- | Attribute | Meaning |
58
- | --- | --- |
59
- | `data-island="name"` | The registered name of the island to mount. Required. |
60
- | `data-island-props='{"…":…}'` | JSON props. If it cannot be parsed an error is printed to the console and `{}` is passed. |
61
- | `data-island-eager` | Mount immediately, independent of visibility. |
62
- | `data-island-idle` | Wait until `load` plus idle time even if it is visible. |
63
- | `data-island-ready="true"` | **Written by the framework.** Added after `mount()` returns successfully; CSS and tests can read it. |
64
-
65
- Since `data-island-props` is an HTML attribute, wrapping it in single quotes is
66
- the easiest way. If you produce the values on the server, using `jsonScript()`
67
- or `attrs()` avoids escaping mistakes:
68
-
69
- ```ejs
70
- <div <%- attrs({ "data-island": "chart", "data-island-props": JSON.stringify({ symbol }) }) %>></div>
71
- ```
72
-
73
- ## Hydration strategies
74
-
75
- ### The default: tied to visibility
76
-
77
- Every island is handed to an `IntersectionObserver`
78
- (`rootMargin: "200px 0px"`). Ones already on screen fire on the first
79
- observation anyway; ones off screen are never downloaded until they are
80
- scrolled to. Once an element becomes visible it is unobserved.
81
-
82
- The mounting work is also deferred to idle time (`requestIdleCallback`,
83
- `timeout: 500`; `setTimeout(fn, 0)` if it is not supported): if many islands
84
- that become visible at the same time turn into a single long task, TBT and INP
85
- suffer.
86
-
87
- ### `data-island-eager`
88
-
89
- Visibility is not awaited, it mounts directly. For islands that apply to the
90
- whole page, such as header behaviour, a cookie banner or a theme switcher.
91
-
92
- ```ejs
93
- <header data-island="header" data-island-eager></header>
94
- ```
95
-
96
- ### `data-island-idle`
97
-
98
- Held back until the `load` event completes and the main thread frees up, even
99
- if it is visible. For heavy but non-critical modules that appear in the first
100
- viewport — for example a mini chart that pulls in a charting library — so that
101
- they do not compete with LCP.
102
-
103
- ```ejs
104
- <div data-island="sparkline" data-island-idle></div>
105
- ```
106
-
107
- If `document.readyState` is already `complete` when the page loads, the wait is
108
- skipped and it is deferred straight to idle time.
109
-
110
- ### Hidden elements
111
-
112
- A `hidden` drawer or dialog has no layout box, and `IntersectionObserver`
113
- **never** reports it. That is why `hydrate()` reads its measurements in one go
114
- (`getClientRects().length > 0`) and mounts elements without a box directly
115
- instead of handing them to the observer. Reading the measurements in one go is
116
- deliberate too: since no write comes in between there is only a single reflow.
117
-
118
- The practical consequence: you can start a modal as `hidden` and its island
119
- will still mount.
120
-
121
- ## The `client/` directory
122
-
123
- ```
124
- client/
125
- ├── entries/
126
- │ ├── main.js the shared bootstrap loaded on every page
127
- │ └── chart.js only on the pages that ask for it
128
- └── islands/
129
- ├── counter.js
130
- └── chart.js
131
- ```
132
-
133
- **Every file** under `client/entries/*.js` **is an esbuild entry**. `main.js`
134
- is loaded by the layout on every page (if it is in the manifest). Extra entries
135
- are loaded only on the pages that ask for them:
136
-
137
- ```js
138
- // controller
139
- return { view: "pages/markets", entries: ["chart.js"] };
140
- ```
141
-
142
- The layout resolves every name in the `entries` array with `asset(entry)` and
143
- emits a `<script type="module">`. The name is the manifest key, that is, the
144
- file name itself (`chart.js`), not its hashed form.
145
-
146
- Code splitting (`splitting: true`) is on: modules shared by two entries end up
147
- in a common chunk and are not downloaded twice.
148
-
149
- `client/islands/` is not a requirement, only the common layout; island modules
150
- can live anywhere reachable from an entry. The `@/` alias works both on the
151
- server and in the bundle, so shared modules under `lib/` can use the same
152
- import style.
153
-
154
- ## Runtime API — `jskelet/client`
155
-
156
- ### `register(name, loader)`
157
-
158
- Registers a single island. `loader` must be a function returning
159
- `Promise<{ mount }>`.
160
-
161
- ```js
162
- import { register } from "jskelet/client";
163
-
164
- register("counter", () => import("../islands/counter.js"));
165
- ```
166
-
167
- ### `registerAll(entries)`
168
-
169
- Bulk registration in object form. The preferred form in practice.
170
-
171
- ```js
172
- registerAll({
173
- counter: () => import("../islands/counter.js"),
174
- drawer: () => import("../islands/drawer.js"),
175
- });
176
- ```
177
-
178
- ### `hydrate(root?)`
179
-
180
- Scans all `[data-island]` elements under `root` (defaults to `document`) and
181
- processes them according to their mount strategy. Already mounted elements are
182
- skipped.
183
-
184
- You can call it directly when you need to rescan for islands by hand (for
185
- example if you added DOM with your own code):
186
-
187
- ```js
188
- container.innerHTML = html;
189
- hydrate(container);
190
- ```
191
-
192
- ### `observeDocument()`
193
-
194
- Sets up a `MutationObserver` on `document.body` and also catches islands added
195
- to the DOM later (infinite scroll, portals, fragment loading). It returns the
196
- `MutationObserver` instance so it can be `disconnect()`ed if needed.
197
-
198
- ### `start()`
199
-
200
- The typical bootstrap: it waits for `DOMContentLoaded` (if necessary), then
201
- calls `hydrate()` and `observeDocument()`.
202
-
203
- ```js
204
- registerAll({ /* … */ });
205
- start();
206
- ```
207
-
208
- ### Mount behaviour and errors
209
-
210
- - An element is **not mounted twice** with the same island name; the record is
211
- kept per element in a `WeakMap`.
212
- - A warning is printed to the console for a name that is not registered:
213
- `[island] not registered: <name>`.
214
- - If the module import or `mount()` throws, an error is printed to the console
215
- (`[island] <name> failed to load`) and **the rest of the page is unaffected**.
216
- - If `mount()` returns successfully, `data-island-ready="true"` is written on
217
- the element.
218
- - `mount()` may return a cleanup function; the framework stores it and runs it
219
- when `unmount()` is called (see below).
220
-
221
- ### `unmount(root?)`
222
-
223
- Unmounts the islands under `root`: it runs the stored cleanup functions, removes
224
- the `data-island-ready` marker and clears the registration, so the same node can
225
- be hydrated again if it re-enters the DOM. `root` itself may be an island.
226
-
227
- It is **required** when you replace a region of the DOM:
228
-
229
- ```js
230
- import { hydrate, unmount } from "jskelet/client";
231
-
232
- unmount(container);
233
- container.innerHTML = html;
234
- hydrate(container);
235
- ```
236
-
237
- Skipping it produces the leak that is easiest to miss. The islands inside a
238
- region replaced with `innerHTML` leave the DOM, but the listeners they installed
239
- on `document`/`window` and their `setInterval` timers keep running; after a few
240
- swaps the same work runs dozens of times.
241
-
242
- ```js
243
- export function mount(element) {
244
- const timer = setInterval(() => tick(element), 1000);
245
- const onResize = () => layout(element);
246
- window.addEventListener("resize", onResize);
247
-
248
- return () => {
249
- clearInterval(timer);
250
- window.removeEventListener("resize", onResize);
251
- };
252
- }
253
- ```
254
-
255
- `swap()` and the form helpers call `unmount()` themselves; you call it wherever
256
- you change the DOM by hand.
257
-
258
- ### `swap(target, url, options?)` and `startSwapLinks(root?)`
259
-
260
- Replaces a region with a partial from the server: it unmounts the old subtree,
261
- writes the content, hydrates it again and restores focus if it was lost.
262
-
263
- ```html
264
- <a href="/_fragment/rows?page=2" data-swap="#rows">Next</a>
265
- ```
266
-
267
- The server side and the full set of options are in
268
- [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
269
-
270
- ### `enhanceForm(form)` and `startForms(root?)`
271
-
272
- Submits forms carrying `data-enhance` without a page reload, while the normal
273
- POST + redirect flow keeps working with JavaScript disabled. The whole contract
274
- is in [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
275
-
276
- ## Sharing state: `createStore`
277
-
278
- A minimal pub/sub used in place of React Context. It replaces the
279
- `useSyncExternalStore` bridge: you just `subscribe`.
280
-
281
- ```js
282
- // client/stores/theme.js
283
- import { createStore } from "jskelet/client";
284
-
285
- export const theme = createStore("light");
286
- ```
287
-
288
- ```js
289
- // client/islands/theme-toggle.js
290
- import { theme } from "../stores/theme.js";
291
-
292
- export function mount(element) {
293
- const paint = (value) => {
294
- element.textContent = value === "light" ? "Dark theme" : "Light theme";
295
- };
296
-
297
- const unsubscribe = theme.subscribe(paint);
298
- paint(theme.get());
299
-
300
- element.addEventListener("click", () => {
301
- theme.set((prev) => (prev === "light" ? "dark" : "light"));
302
- });
303
-
304
- return unsubscribe;
305
- }
306
- ```
307
-
308
- API:
309
-
310
- | Member | Behaviour |
311
- | --- | --- |
312
- | `get()` | The current value |
313
- | `set(next)` | A value or a `(prev) => next` function. If the value is **the same** (`===`) listeners are not fired. |
314
- | `subscribe(listener)` | Adds a listener and returns the function that removes it. It is not called with the current value on subscribe — do the first paint yourself. |
315
-
316
- ## DOM helpers
317
-
318
- `jskelet/client` provides a small set of helpers that islands share.
319
-
320
- | Function | Signature | Behaviour |
321
- | --- | --- | --- |
322
- | `qs` | `(root, selector) => HTMLElement \| null` | `querySelector` |
323
- | `qsa` | `(root, selector) => HTMLElement[]` | `querySelectorAll`, as a real array |
324
- | `on` | `(target, type, handler, options?) => () => void` | Adds a listener and **returns the function that removes it** |
325
- | `onClick` | `(root, selector, handler) => () => void` | Delegated click; `handler(event, target)` |
326
- | `debounce` | `(ms, fn) => fn` | Runs `ms` after the last call |
327
- | `raf` | `(fn) => fn` | Coalesces calls into a single `requestAnimationFrame` |
328
- | `toggleClass` | `(element, name, active) => void` | `classList.toggle` |
329
- | `getOverlayRoot` | `() => HTMLElement` | `#jskelet-overlays` or `body` |
330
-
331
- `on()` and `onClick()` returning a remover pairs naturally with `mount()`'s
332
- cleanup function:
333
-
334
- ```js
335
- import { on, onClick, raf } from "jskelet/client";
336
-
337
- export function mount(element) {
338
- const offClick = onClick(element, "[data-tab]", (event, target) => {
339
- selectTab(target.dataset.tab);
340
- });
341
-
342
- const offScroll = on(window, "scroll", raf(() => updateShadow(element)), {
343
- passive: true,
344
- });
345
-
346
- return () => {
347
- offClick();
348
- offScroll();
349
- };
350
- }
351
- ```
352
-
353
- `getOverlayRoot()` is for moving modal/drawer content: if the layout has
354
- `<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
355
- portal prevents an ancestor element carrying `overflow` or `transform` from
356
- clipping a `position: fixed` overlay.
357
-
358
- ## `startSafeImages()`
359
-
360
- A single document listener for images that fail to load. It is **deliberately
361
- not an island:** an image-heavy page can have 80+ `<img>` elements, and
362
- attaching a separate island to each one (observer + dynamic import + mount) is
363
- a serious hydration cost just for the possibility of an error.
364
-
365
- ```js
366
- // client/entries/main.js
367
- import { registerAll, start, startSafeImages } from "jskelet/client";
368
-
369
- registerAll({ /* … */ });
370
- startSafeImages();
371
- start();
372
- ```
373
-
374
- Usage, on the template side:
375
-
376
- ```ejs
377
- <%# 1. Minimal: the framework swaps in a block that preserves the dimensions %>
378
- <img src="/kapak.png" alt="Kapak" width="640" height="360" data-safe-image>
379
-
380
- <%# 2. Your own error view %>
381
- <div data-safe-image-host>
382
- <img src="/kapak.png" alt="Kapak" data-safe-image>
383
- <template data-safe-image-fallback>
384
- <div class="flex h-40 items-center justify-center bg-slate-100">No image</div>
385
- </template>
386
- </div>
387
- ```
388
-
389
- How it works:
390
-
391
- - A single `error` listener is installed on the document **in the capture
392
- phase**. The `error` event does not bubble but it can be seen in the capture
393
- phase; that is why a single listener covers all images and ones added to the
394
- DOM later are covered automatically.
395
- - If there is a `data-safe-image-host` wrapper **and** a
396
- `<template data-safe-image-fallback>` inside it, the whole wrapper is
397
- replaced with the template content. The framework imposes no styling.
398
- - Otherwise a minimal block is put in place of the image: `role="img"`, the
399
- `alt` (or `data-fallback-label`) value as `aria-label`, the image's
400
- `className` plus `data-fallback-class`, and, if `width`/`height` exist, the
401
- same dimensions as an inline style. Preserving the dimensions prevents layout
402
- shift (CLS) during the swap.
403
- - Images that failed before JS ran produce no event; that is why a single scan
404
- is performed (`requestIdleCallback`, `timeout: 2000`): the ones that are
405
- `complete` with `naturalWidth === 0` are replaced.
406
-
407
- ## The deferred panel (fragment) pattern
408
-
409
- When you want to remove a heavy, secondary section (comments, related articles,
410
- a long table) from the initial HTML response entirely, the combination of an
411
- island plus a layout-less render is used. The framework has no special API for
412
- this; it is a combination of two pieces you already have:
413
-
414
- **1. A layout-less fragment endpoint on the server** (`renderView`, see
415
- [03-routing.md](./03-routing.md)):
416
-
417
- ```js
418
- // routes/80-fragments.mjs
419
- export default function register(app, { renderView }) {
420
- app.get("/_fragment/comments/:id", async (req, res) => {
421
- const comments = await getComments(req.params.id);
422
- res.type("html").send(await renderView("fragments/comments", { comments }));
423
- });
424
- }
425
- ```
426
-
427
- **2. A placeholder island on the page.** Because it mounts on visibility,
428
- neither the module nor the fragment is downloaded if the visitor never scrolls
429
- to that section:
430
-
431
- ```ejs
432
- <div data-island="deferred" data-island-props='{"src":"/_fragment/comments/42"}'></div>
433
- ```
434
-
435
- **3. The island fetches the fragment, inserts it and hydrates the islands
436
- inside it:**
437
-
438
- ```js
439
- // client/islands/deferred.js
440
- import { hydrate } from "jskelet/client";
441
-
442
- export async function mount(element, { src }) {
443
- try {
444
- const response = await fetch(src, { headers: { accept: "text/html" } });
445
- if (!response.ok) return;
446
-
447
- element.innerHTML = await response.text();
448
- hydrate(element);
449
- } catch {
450
- // Secondary content: give up silently, don't affect the rest of the page.
451
- }
452
- }
453
- ```
454
-
455
- If `observeDocument()` is already running the `hydrate()` call on the last line
456
- is unnecessary; still, calling it explicitly makes it behave correctly in a
457
- setup that does not use `start()` either.
458
-
459
- The `/_fragment/` prefix is recommended for fragment paths: because it is in
460
- the default `prewarmSkip` list, the prewarm round does not scan those endpoints
461
- ([06-caching.md](./06-caching.md)).
462
-
463
- ## Environment variables and `clientEnv`
464
-
465
- There is no `process` in the browser, but modules shared with the server may
466
- still read `process.env`. The keys declared through `jskelet.config.mjs` →
467
- `clientEnv` are inlined into the bundle at build time:
468
-
469
- ```js
470
- export default {
471
- clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN"],
472
- };
473
- ```
474
-
475
- The same contract as `NEXT_PUBLIC_*` in Next, except which key is public is
476
- clear from the config rather than from the name. All of `process.env` is
477
- defined as a single object, so reading a key that is not in the list returns
478
- `undefined` instead of crashing. `NODE_ENV` is always inlined.
479
-
480
- ## Browser support
481
-
482
- The bundle target is fixed: `chrome111`, `edge111`, `firefox111`,
483
- `safari16.4`. ESM + dynamic import + `IntersectionObserver` is already the
484
- lower bound of the island model; transpiling to anything older grows the output
485
- and gains no visitors. Even if JS never runs, the page stays readable because
486
- the server HTML is complete.
487
-
488
- ## What's next
489
-
490
- - The bundle, hashes and the `entries` manifest: [08-build.md](./08-build.md)
491
- - The controller side of the `entries` field: [03-routing.md](./03-routing.md)
492
- - Watching island state from the dev panel: [09-dev-tools.md](./09-dev-tools.md)
1
+ # 05 — Islands
2
+
3
+ This document explains how interactivity is added: the `data-island` contract,
4
+ passing props, the three hydration strategies and the IntersectionObserver
5
+ logic, the structure of `client/entries/*` and loading extra entries per page,
6
+ the runtime API (`register`, `registerAll`, `hydrate`, `observeDocument`,
7
+ `start`), `createStore` for sharing state between islands, the DOM helpers,
8
+ `startSafeImages` and the deferred panel (fragment) pattern. *Why* the model
9
+ looks like this is in [02-architecture.md](./02-architecture.md), and how the
10
+ bundle is produced is in [08-build.md](./08-build.md).
11
+
12
+ ## The contract
13
+
14
+ The server HTML is complete; an island only adds behaviour. There are three
15
+ pieces.
16
+
17
+ **1. A marker in the template.**
18
+
19
+ ```ejs
20
+ <div data-island="counter" data-island-props='{"start":5}'></div>
21
+ ```
22
+
23
+ **2. The island module — a named export called `mount`.**
24
+
25
+ ```js
26
+ // client/islands/counter.js
27
+ /**
28
+ * @param {HTMLElement} element
29
+ * @param {{ start?: number }} props
30
+ * @returns {void | (() => void)} cleanup function (optional)
31
+ */
32
+ export function mount(element, props) {
33
+ let value = props.start ?? 0;
34
+ // …
35
+ }
36
+ ```
37
+
38
+ **3. Registration in an entry.**
39
+
40
+ ```js
41
+ // client/entries/main.js
42
+ import { registerAll, start } from "jskelet/client";
43
+
44
+ registerAll({
45
+ counter: () => import("../islands/counter.js"),
46
+ });
47
+
48
+ start();
49
+ ```
50
+
51
+ The loader being a dynamic import is the core of the model: the module is
52
+ downloaded only if that island actually exists on the page **and** its mount
53
+ condition is met. Growing this map does not grow the initial load.
54
+
55
+ ## HTML attributes
56
+
57
+ | Attribute | Meaning |
58
+ | --- | --- |
59
+ | `data-island="name"` | The registered name of the island to mount. Required. |
60
+ | `data-island-props='{"…":…}'` | JSON props. If it cannot be parsed an error is printed to the console and `{}` is passed. |
61
+ | `data-island-eager` | Mount immediately, independent of visibility. |
62
+ | `data-island-idle` | Wait until `load` plus idle time even if it is visible. |
63
+ | `data-island-ready="true"` | **Written by the framework.** Added after `mount()` returns successfully; CSS and tests can read it. |
64
+
65
+ Since `data-island-props` is an HTML attribute, wrapping it in single quotes is
66
+ the easiest way. If you produce the values on the server, using `jsonScript()`
67
+ or `attrs()` avoids escaping mistakes:
68
+
69
+ ```ejs
70
+ <div <%- attrs({ "data-island": "chart", "data-island-props": JSON.stringify({ symbol }) }) %>></div>
71
+ ```
72
+
73
+ ## Hydration strategies
74
+
75
+ ### The default: tied to visibility
76
+
77
+ Every island is handed to an `IntersectionObserver`
78
+ (`rootMargin: "200px 0px"`). Ones already on screen fire on the first
79
+ observation anyway; ones off screen are never downloaded until they are
80
+ scrolled to. Once an element becomes visible it is unobserved.
81
+
82
+ The mounting work is also deferred to idle time (`requestIdleCallback`,
83
+ `timeout: 500`; `setTimeout(fn, 0)` if it is not supported): if many islands
84
+ that become visible at the same time turn into a single long task, TBT and INP
85
+ suffer.
86
+
87
+ ### `data-island-eager`
88
+
89
+ Visibility is not awaited, it mounts directly. For islands that apply to the
90
+ whole page, such as header behaviour, a cookie banner or a theme switcher.
91
+
92
+ ```ejs
93
+ <header data-island="header" data-island-eager></header>
94
+ ```
95
+
96
+ ### `data-island-idle`
97
+
98
+ Held back until the `load` event completes and the main thread frees up, even
99
+ if it is visible. For heavy but non-critical modules that appear in the first
100
+ viewport — for example a mini chart that pulls in a charting library — so that
101
+ they do not compete with LCP.
102
+
103
+ ```ejs
104
+ <div data-island="sparkline" data-island-idle></div>
105
+ ```
106
+
107
+ If `document.readyState` is already `complete` when the page loads, the wait is
108
+ skipped and it is deferred straight to idle time.
109
+
110
+ ### Hidden elements
111
+
112
+ A `hidden` drawer or dialog has no layout box, and `IntersectionObserver`
113
+ **never** reports it. That is why `hydrate()` reads its measurements in one go
114
+ (`getClientRects().length > 0`) and mounts elements without a box directly
115
+ instead of handing them to the observer. Reading the measurements in one go is
116
+ deliberate too: since no write comes in between there is only a single reflow.
117
+
118
+ The practical consequence: you can start a modal as `hidden` and its island
119
+ will still mount.
120
+
121
+ ## The `client/` directory
122
+
123
+ ```
124
+ client/
125
+ ├── entries/
126
+ │ ├── main.js the shared bootstrap loaded on every page
127
+ │ └── chart.js only on the pages that ask for it
128
+ └── islands/
129
+ ├── counter.js
130
+ └── chart.js
131
+ ```
132
+
133
+ **Every file** under `client/entries/*.js` **is an esbuild entry**. `main.js`
134
+ is loaded by the layout on every page (if it is in the manifest). Extra entries
135
+ are loaded only on the pages that ask for them:
136
+
137
+ ```js
138
+ // controller
139
+ return { view: "pages/markets", entries: ["chart.js"] };
140
+ ```
141
+
142
+ The layout resolves every name in the `entries` array with `asset(entry)` and
143
+ emits a `<script type="module">`. The name is the manifest key, that is, the
144
+ file name itself (`chart.js`), not its hashed form.
145
+
146
+ Code splitting (`splitting: true`) is on: modules shared by two entries end up
147
+ in a common chunk and are not downloaded twice.
148
+
149
+ `client/islands/` is not a requirement, only the common layout; island modules
150
+ can live anywhere reachable from an entry. The `@/` alias works both on the
151
+ server and in the bundle, so shared modules under `lib/` can use the same
152
+ import style.
153
+
154
+ ## Runtime API — `jskelet/client`
155
+
156
+ ### `register(name, loader)`
157
+
158
+ Registers a single island. `loader` must be a function returning
159
+ `Promise<{ mount }>`.
160
+
161
+ ```js
162
+ import { register } from "jskelet/client";
163
+
164
+ register("counter", () => import("../islands/counter.js"));
165
+ ```
166
+
167
+ ### `registerAll(entries)`
168
+
169
+ Bulk registration in object form. The preferred form in practice.
170
+
171
+ ```js
172
+ registerAll({
173
+ counter: () => import("../islands/counter.js"),
174
+ drawer: () => import("../islands/drawer.js"),
175
+ });
176
+ ```
177
+
178
+ ### `hydrate(root?)`
179
+
180
+ Scans all `[data-island]` elements under `root` (defaults to `document`) and
181
+ processes them according to their mount strategy. Already mounted elements are
182
+ skipped.
183
+
184
+ You can call it directly when you need to rescan for islands by hand (for
185
+ example if you added DOM with your own code):
186
+
187
+ ```js
188
+ container.innerHTML = html;
189
+ hydrate(container);
190
+ ```
191
+
192
+ ### `observeDocument()`
193
+
194
+ Sets up a `MutationObserver` on `document.body` and also catches islands added
195
+ to the DOM later (infinite scroll, portals, fragment loading). It returns the
196
+ `MutationObserver` instance so it can be `disconnect()`ed if needed.
197
+
198
+ ### `start()`
199
+
200
+ The typical bootstrap: it waits for `DOMContentLoaded` (if necessary), then
201
+ calls `hydrate()` and `observeDocument()`.
202
+
203
+ ```js
204
+ registerAll({ /* … */ });
205
+ start();
206
+ ```
207
+
208
+ ### Mount behaviour and errors
209
+
210
+ - An element is **not mounted twice** with the same island name; the record is
211
+ kept per element in a `WeakMap`.
212
+ - A warning is printed to the console for a name that is not registered:
213
+ `[island] not registered: <name>`.
214
+ - If the module import or `mount()` throws, an error is printed to the console
215
+ (`[island] <name> failed to load`) and **the rest of the page is unaffected**.
216
+ - If `mount()` returns successfully, `data-island-ready="true"` is written on
217
+ the element.
218
+ - `mount()` may return a cleanup function; the framework stores it and runs it
219
+ when `unmount()` is called (see below).
220
+
221
+ ### `unmount(root?)`
222
+
223
+ Unmounts the islands under `root`: it runs the stored cleanup functions, removes
224
+ the `data-island-ready` marker and clears the registration, so the same node can
225
+ be hydrated again if it re-enters the DOM. `root` itself may be an island.
226
+
227
+ It is **required** when you replace a region of the DOM:
228
+
229
+ ```js
230
+ import { hydrate, unmount } from "jskelet/client";
231
+
232
+ unmount(container);
233
+ container.innerHTML = html;
234
+ hydrate(container);
235
+ ```
236
+
237
+ Skipping it produces the leak that is easiest to miss. The islands inside a
238
+ region replaced with `innerHTML` leave the DOM, but the listeners they installed
239
+ on `document`/`window` and their `setInterval` timers keep running; after a few
240
+ swaps the same work runs dozens of times.
241
+
242
+ ```js
243
+ export function mount(element) {
244
+ const timer = setInterval(() => tick(element), 1000);
245
+ const onResize = () => layout(element);
246
+ window.addEventListener("resize", onResize);
247
+
248
+ return () => {
249
+ clearInterval(timer);
250
+ window.removeEventListener("resize", onResize);
251
+ };
252
+ }
253
+ ```
254
+
255
+ `swap()` and the form helpers call `unmount()` themselves; you call it wherever
256
+ you change the DOM by hand.
257
+
258
+ ### `swap(target, url, options?)` and `startSwapLinks(root?)`
259
+
260
+ Replaces a region with a partial from the server: it unmounts the old subtree,
261
+ writes the content, hydrates it again and restores focus if it was lost.
262
+
263
+ ```html
264
+ <a href="/_fragment/rows?page=2" data-swap="#rows">Next</a>
265
+ ```
266
+
267
+ The server side and the full set of options are in
268
+ [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
269
+
270
+ ### `enhanceForm(form)` and `startForms(root?)`
271
+
272
+ Submits forms carrying `data-enhance` without a page reload, while the normal
273
+ POST + redirect flow keeps working with JavaScript disabled. The whole contract
274
+ is in [12-dashboards-and-sessions.md](./12-dashboards-and-sessions.md).
275
+
276
+ ## Sharing state: `createStore`
277
+
278
+ A minimal pub/sub used in place of React Context. It replaces the
279
+ `useSyncExternalStore` bridge: you just `subscribe`.
280
+
281
+ ```js
282
+ // client/stores/theme.js
283
+ import { createStore } from "jskelet/client";
284
+
285
+ export const theme = createStore("light");
286
+ ```
287
+
288
+ ```js
289
+ // client/islands/theme-toggle.js
290
+ import { theme } from "../stores/theme.js";
291
+
292
+ export function mount(element) {
293
+ const paint = (value) => {
294
+ element.textContent = value === "light" ? "Dark theme" : "Light theme";
295
+ };
296
+
297
+ const unsubscribe = theme.subscribe(paint);
298
+ paint(theme.get());
299
+
300
+ element.addEventListener("click", () => {
301
+ theme.set((prev) => (prev === "light" ? "dark" : "light"));
302
+ });
303
+
304
+ return unsubscribe;
305
+ }
306
+ ```
307
+
308
+ API:
309
+
310
+ | Member | Behaviour |
311
+ | --- | --- |
312
+ | `get()` | The current value |
313
+ | `set(next)` | A value or a `(prev) => next` function. If the value is **the same** (`===`) listeners are not fired. |
314
+ | `subscribe(listener)` | Adds a listener and returns the function that removes it. It is not called with the current value on subscribe — do the first paint yourself. |
315
+
316
+ ## DOM helpers
317
+
318
+ `jskelet/client` provides a small set of helpers that islands share.
319
+
320
+ | Function | Signature | Behaviour |
321
+ | --- | --- | --- |
322
+ | `qs` | `(root, selector) => HTMLElement \| null` | `querySelector` |
323
+ | `qsa` | `(root, selector) => HTMLElement[]` | `querySelectorAll`, as a real array |
324
+ | `on` | `(target, type, handler, options?) => () => void` | Adds a listener and **returns the function that removes it** |
325
+ | `onClick` | `(root, selector, handler) => () => void` | Delegated click; `handler(event, target)` |
326
+ | `debounce` | `(ms, fn) => fn` | Runs `ms` after the last call |
327
+ | `raf` | `(fn) => fn` | Coalesces calls into a single `requestAnimationFrame` |
328
+ | `toggleClass` | `(element, name, active) => void` | `classList.toggle` |
329
+ | `getOverlayRoot` | `() => HTMLElement` | `#jskelet-overlays` or `body` |
330
+
331
+ `on()` and `onClick()` returning a remover pairs naturally with `mount()`'s
332
+ cleanup function:
333
+
334
+ ```js
335
+ import { on, onClick, raf } from "jskelet/client";
336
+
337
+ export function mount(element) {
338
+ const offClick = onClick(element, "[data-tab]", (event, target) => {
339
+ selectTab(target.dataset.tab);
340
+ });
341
+
342
+ const offScroll = on(window, "scroll", raf(() => updateShadow(element)), {
343
+ passive: true,
344
+ });
345
+
346
+ return () => {
347
+ offClick();
348
+ offScroll();
349
+ };
350
+ }
351
+ ```
352
+
353
+ `getOverlayRoot()` is for moving modal/drawer content: if the layout has
354
+ `<div id="jskelet-overlays"></div>` it goes there, otherwise into `body`. The
355
+ portal prevents an ancestor element carrying `overflow` or `transform` from
356
+ clipping a `position: fixed` overlay.
357
+
358
+ ## `startSafeImages()`
359
+
360
+ A single document listener for images that fail to load. It is **deliberately
361
+ not an island:** an image-heavy page can have 80+ `<img>` elements, and
362
+ attaching a separate island to each one (observer + dynamic import + mount) is
363
+ a serious hydration cost just for the possibility of an error.
364
+
365
+ ```js
366
+ // client/entries/main.js
367
+ import { registerAll, start, startSafeImages } from "jskelet/client";
368
+
369
+ registerAll({ /* … */ });
370
+ startSafeImages();
371
+ start();
372
+ ```
373
+
374
+ Usage, on the template side:
375
+
376
+ ```ejs
377
+ <%# 1. Minimal: the framework swaps in a block that preserves the dimensions %>
378
+ <img src="/kapak.png" alt="Kapak" width="640" height="360" data-safe-image>
379
+
380
+ <%# 2. Your own error view %>
381
+ <div data-safe-image-host>
382
+ <img src="/kapak.png" alt="Kapak" data-safe-image>
383
+ <template data-safe-image-fallback>
384
+ <div class="flex h-40 items-center justify-center bg-slate-100">No image</div>
385
+ </template>
386
+ </div>
387
+ ```
388
+
389
+ How it works:
390
+
391
+ - A single `error` listener is installed on the document **in the capture
392
+ phase**. The `error` event does not bubble but it can be seen in the capture
393
+ phase; that is why a single listener covers all images and ones added to the
394
+ DOM later are covered automatically.
395
+ - If there is a `data-safe-image-host` wrapper **and** a
396
+ `<template data-safe-image-fallback>` inside it, the whole wrapper is
397
+ replaced with the template content. The framework imposes no styling.
398
+ - Otherwise a minimal block is put in place of the image: `role="img"`, the
399
+ `alt` (or `data-fallback-label`) value as `aria-label`, the image's
400
+ `className` plus `data-fallback-class`, and, if `width`/`height` exist, the
401
+ same dimensions as an inline style. Preserving the dimensions prevents layout
402
+ shift (CLS) during the swap.
403
+ - Images that failed before JS ran produce no event; that is why a single scan
404
+ is performed (`requestIdleCallback`, `timeout: 2000`): the ones that are
405
+ `complete` with `naturalWidth === 0` are replaced.
406
+
407
+ ## The deferred panel (fragment) pattern
408
+
409
+ When you want to remove a heavy, secondary section (comments, related articles,
410
+ a long table) from the initial HTML response entirely, the combination of an
411
+ island plus a layout-less render is used. The framework has no special API for
412
+ this; it is a combination of two pieces you already have:
413
+
414
+ **1. A layout-less fragment endpoint on the server** (`renderView`, see
415
+ [03-routing.md](./03-routing.md)):
416
+
417
+ ```js
418
+ // routes/80-fragments.mjs
419
+ export default function register(app, { renderView }) {
420
+ app.get("/_fragment/comments/:id", async (req, res) => {
421
+ const comments = await getComments(req.params.id);
422
+ res.type("html").send(await renderView("fragments/comments", { comments }));
423
+ });
424
+ }
425
+ ```
426
+
427
+ **2. A placeholder island on the page.** Because it mounts on visibility,
428
+ neither the module nor the fragment is downloaded if the visitor never scrolls
429
+ to that section:
430
+
431
+ ```ejs
432
+ <div data-island="deferred" data-island-props='{"src":"/_fragment/comments/42"}'></div>
433
+ ```
434
+
435
+ **3. The island fetches the fragment, inserts it and hydrates the islands
436
+ inside it:**
437
+
438
+ ```js
439
+ // client/islands/deferred.js
440
+ import { hydrate } from "jskelet/client";
441
+
442
+ export async function mount(element, { src }) {
443
+ try {
444
+ const response = await fetch(src, { headers: { accept: "text/html" } });
445
+ if (!response.ok) return;
446
+
447
+ element.innerHTML = await response.text();
448
+ hydrate(element);
449
+ } catch {
450
+ // Secondary content: give up silently, don't affect the rest of the page.
451
+ }
452
+ }
453
+ ```
454
+
455
+ If `observeDocument()` is already running the `hydrate()` call on the last line
456
+ is unnecessary; still, calling it explicitly makes it behave correctly in a
457
+ setup that does not use `start()` either.
458
+
459
+ The `/_fragment/` prefix is recommended for fragment paths: because it is in
460
+ the default `prewarmSkip` list, the prewarm round does not scan those endpoints
461
+ ([06-caching.md](./06-caching.md)).
462
+
463
+ ## Environment variables and `clientEnv`
464
+
465
+ There is no `process` in the browser, but modules shared with the server may
466
+ still read `process.env`. The keys declared through `jskelet.config.mjs` →
467
+ `clientEnv` are inlined into the bundle at build time:
468
+
469
+ ```js
470
+ export default {
471
+ clientEnv: ["PUBLIC_WS_URL", "PUBLIC_CDN"],
472
+ };
473
+ ```
474
+
475
+ The same contract as `NEXT_PUBLIC_*` in Next, except which key is public is
476
+ clear from the config rather than from the name. All of `process.env` is
477
+ defined as a single object, so reading a key that is not in the list returns
478
+ `undefined` instead of crashing. `NODE_ENV` is always inlined.
479
+
480
+ ## Browser support
481
+
482
+ The bundle target is fixed: `chrome111`, `edge111`, `firefox111`,
483
+ `safari16.4`. ESM + dynamic import + `IntersectionObserver` is already the
484
+ lower bound of the island model; transpiling to anything older grows the output
485
+ and gains no visitors. Even if JS never runs, the page stays readable because
486
+ the server HTML is complete.
487
+
488
+ ## What's next
489
+
490
+ - The bundle, hashes and the `entries` manifest: [08-build.md](./08-build.md)
491
+ - The controller side of the `entries` field: [03-routing.md](./03-routing.md)
492
+ - Watching island state from the dev panel: [09-dev-tools.md](./09-dev-tools.md)