jskelet 0.1.1 → 0.1.2
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 +63 -0
- 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 +39 -7
- package/docs/07-yapilandirma.md +51 -1
- 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 +454 -0
- package/docs/en/07-configuration.md +736 -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 +34 -0
- package/src/config/index.js +68 -13
- 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 +19 -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/dev/devtools.js +6 -2
- package/src/server/dev/version-check.mjs +139 -0
- package/src/server/head-hints.js +1 -1
- package/src/server/html-cache.js +10 -4
- package/src/server/middleware/csrf.js +134 -0
- package/src/server/prewarm.js +6 -6
- package/src/server/render.js +199 -16
- 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,383 @@
|
|
|
1
|
+
# 08 — Build
|
|
2
|
+
|
|
3
|
+
This document describes every job `jskelet build` does and the order it does them
|
|
4
|
+
in: font copying, icon sprite generation, Tailwind CSS compilation, the island
|
|
5
|
+
bundle via esbuild, image optimisation, writing the manifest and precompression.
|
|
6
|
+
It also covers how hashed assets reach the templates through
|
|
7
|
+
`asset()`/`hasAsset()`, why Tailwind's `@source` directives are mandatory and how
|
|
8
|
+
optional peer dependencies behave. How the output is served at runtime is in
|
|
9
|
+
[02-architecture.md](./02-architecture.md), and the watch flow that triggers the
|
|
10
|
+
build is in [09-dev-tools.md](./09-dev-tools.md).
|
|
11
|
+
|
|
12
|
+
## The pipeline and its order
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
1. Fonts if config.fonts is set
|
|
16
|
+
2. Icon sprite if config.icons !== false
|
|
17
|
+
3. CSS if the styles entry file exists
|
|
18
|
+
4. Client JS if client/entries/ exists
|
|
19
|
+
5. Images if config.images !== false, not watch, and sharp is installed
|
|
20
|
+
6. Manifest .jskelet/manifest.json
|
|
21
|
+
7. Precompress if not watch
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The order is not arbitrary:
|
|
25
|
+
|
|
26
|
+
- **CSS comes after the icon sprite.** The sprite is an asset and produces no
|
|
27
|
+
classes, but it does give a manifest key.
|
|
28
|
+
- **Precompress is last:** everything that gets compressed must already be
|
|
29
|
+
produced.
|
|
30
|
+
- **Images never run on a watch pass:** re-encoding with `sharp` is expensive.
|
|
31
|
+
|
|
32
|
+
Tasks only run if the relevant configuration exists. A project that does not
|
|
33
|
+
define any fonts never sees the font step; this is the build-side counterpart of
|
|
34
|
+
the principle that "the framework does not impose its own assumptions on every
|
|
35
|
+
project".
|
|
36
|
+
|
|
37
|
+
The terminal output gives aligned step lines and an `output` block at the end:
|
|
38
|
+
the raw and brotli size of every asset, largest to smallest.
|
|
39
|
+
|
|
40
|
+
## The manifest and hashed assets
|
|
41
|
+
|
|
42
|
+
The build output is written under `public/assets/` with **content-hashed** names,
|
|
43
|
+
and the logical name → public URL mapping is put in the `.jskelet/manifest.json`
|
|
44
|
+
file:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"app.css": "/assets/app.4f2a1b9c07.css",
|
|
49
|
+
"sprite.svg": "/assets/sprite.dc973997bd.svg",
|
|
50
|
+
"main.js": "/assets/js/main.9E1AB2C3.js",
|
|
51
|
+
"inter-400.woff2": "/fonts/inter-400.woff2"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The hash is the first 10 hex characters of sha256: more than enough against
|
|
56
|
+
collisions and it keeps file names readable. Because they are hashed, these files
|
|
57
|
+
can be given `Cache-Control: public, max-age=31536000, immutable`.
|
|
58
|
+
|
|
59
|
+
### `asset(name)` and `hasAsset(name)`
|
|
60
|
+
|
|
61
|
+
They are passed to templates automatically; in server code,
|
|
62
|
+
`import { asset, hasAsset } from "jskelet"`.
|
|
63
|
+
|
|
64
|
+
```ejs
|
|
65
|
+
<% if (hasAsset('app.css')) { %>
|
|
66
|
+
<link rel="stylesheet" href="<%= asset('app.css') %>">
|
|
67
|
+
<% } %>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
- `asset(name)` returns the hashed URL if it is in the manifest, otherwise
|
|
71
|
+
`/assets/<name>`.
|
|
72
|
+
- `hasAsset(name)` tells you whether it is in the manifest.
|
|
73
|
+
|
|
74
|
+
If the build has not run, the application still comes up: `hasAsset()` is false
|
|
75
|
+
and the layout never emits the stylesheet and script tags. When `jskelet build`
|
|
76
|
+
is forgotten you get an unstyled but working page instead of an error. If the
|
|
77
|
+
manifest is missing entirely, a warning is printed once:
|
|
78
|
+
``[assets] no manifest — run `jskelet build`.``
|
|
79
|
+
|
|
80
|
+
The manifest is re-read **on every request in dev** (watch builds change the
|
|
81
|
+
hashes) and once in prod.
|
|
82
|
+
|
|
83
|
+
### Manifest consistency in watch mode
|
|
84
|
+
|
|
85
|
+
On a watch pass, a recompiled asset is written to a new hash and the old one is
|
|
86
|
+
deleted. That is why the manifest has to be updated too (`patchManifest`):
|
|
87
|
+
otherwise the HTML asks for the deleted file, gets a 404, and the page stays
|
|
88
|
+
unstyled or JS-less for the rest of the dev session. Both the CSS and the client
|
|
89
|
+
tasks patch their own key on every pass; the other keys are preserved.
|
|
90
|
+
|
|
91
|
+
## CSS — Tailwind v4
|
|
92
|
+
|
|
93
|
+
The entry file is `paths.styles` (default `styles/globals.css`). If the file does
|
|
94
|
+
not exist, the step is skipped with a warning.
|
|
95
|
+
|
|
96
|
+
The pipeline: PostCSS + `@tailwindcss/postcss` → minification with lightningcss
|
|
97
|
+
(if present) → `writeAsset("app.css", …)`.
|
|
98
|
+
|
|
99
|
+
- **The PostCSS pipeline is set up once:** Tailwind's own cache lives in the
|
|
100
|
+
plugin instance; recreating it on every compile slows watch passes down
|
|
101
|
+
noticeably.
|
|
102
|
+
- **lightningcss is optional:** without it, Tailwind's own output is used, and it
|
|
103
|
+
is only a few kB bigger.
|
|
104
|
+
- The output is a single file and the layout loads it render-blocking. The
|
|
105
|
+
measurement-based reasoning for not producing a separate "critical CSS" is in
|
|
106
|
+
[02-architecture.md](./02-architecture.md).
|
|
107
|
+
|
|
108
|
+
### `@source` directives are mandatory
|
|
109
|
+
|
|
110
|
+
Tailwind v4's class scanning depends on the `@source` directives inside
|
|
111
|
+
`globals.css`. Automatic detection only scans the directory the stylesheet lives
|
|
112
|
+
in, so the variants used in templates (things like `data-[active=false]:…`) get
|
|
113
|
+
**silently dropped**.
|
|
114
|
+
|
|
115
|
+
```css
|
|
116
|
+
@import "tailwindcss" source(none);
|
|
117
|
+
|
|
118
|
+
@source "../views";
|
|
119
|
+
@source "../client";
|
|
120
|
+
@source "../routes";
|
|
121
|
+
|
|
122
|
+
.wrapper {
|
|
123
|
+
max-width: 48rem;
|
|
124
|
+
margin-inline: auto;
|
|
125
|
+
padding-inline: 1rem;
|
|
126
|
+
padding-block: 2rem;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`source(none)` turns automatic detection off and makes the scanning fully
|
|
131
|
+
explicit. **When you add a new top-level directory, add the `@source` line as
|
|
132
|
+
well** — this is the most common reason for classes "sometimes not working".
|
|
133
|
+
|
|
134
|
+
### CSS watch scope
|
|
135
|
+
|
|
136
|
+
In watch mode three targets are watched: the directory the stylesheet lives in,
|
|
137
|
+
`views` and `client`. Template and island files are watched too because the
|
|
138
|
+
Tailwind classes come from there; watching only `styles/` would not rebuild when
|
|
139
|
+
a new utility is written. Changes are coalesced over 120 ms.
|
|
140
|
+
|
|
141
|
+
## Client JS — esbuild
|
|
142
|
+
|
|
143
|
+
Every `.js` file inside `client/entries/*.js` is an entry. If the directory does
|
|
144
|
+
not exist or is empty, the step is skipped.
|
|
145
|
+
|
|
146
|
+
esbuild settings:
|
|
147
|
+
|
|
148
|
+
| Setting | Value | Reason |
|
|
149
|
+
| --- | --- | --- |
|
|
150
|
+
| `bundle`, `splitting` | `true` | Shared modules move into a shared chunk |
|
|
151
|
+
| `format` | `esm` | `type="module"` scripts |
|
|
152
|
+
| `target` | `chrome111`, `edge111`, `firefox111`, `safari16.4` | The lower bound of the ESM + dynamic import + `IntersectionObserver` island model; transpiling to anything older grows the output without winning a single visitor |
|
|
153
|
+
| `minify` | `true` | — |
|
|
154
|
+
| `sourcemap` | `true` | Diagnostics in the browser |
|
|
155
|
+
| `entryNames` | `[name].[hash]` | `immutable` cache |
|
|
156
|
+
| `chunkNames` | `chunks/[name].[hash]` | — |
|
|
157
|
+
| `legalComments` | `none` | — |
|
|
158
|
+
|
|
159
|
+
The output lands under `public/assets/js/` and is cleaned first on every pass.
|
|
160
|
+
`browserslist` is not read; the target list is hard-coded.
|
|
161
|
+
|
|
162
|
+
### The `@/` alias
|
|
163
|
+
|
|
164
|
+
On the esbuild side, `@/` resolves to the project root and extension completion
|
|
165
|
+
is performed (`.js`, `.mjs`, `.json`, `/index.js`). The same behaviour as
|
|
166
|
+
`alias-hooks.mjs` on the Node side, so the modules under `lib/` can use the same
|
|
167
|
+
import style both on the server and in the browser.
|
|
168
|
+
|
|
169
|
+
### Inlining `clientEnv`
|
|
170
|
+
|
|
171
|
+
There is no `process` in the browser; modules shared with the server still read
|
|
172
|
+
`process.env`. The keys declared through `config.clientEnv` plus `NODE_ENV` are
|
|
173
|
+
defined as a single object at build time, which means that reading a key not in
|
|
174
|
+
the list returns `undefined` instead of crashing. Details:
|
|
175
|
+
[07-configuration.md](./07-configuration.md).
|
|
176
|
+
|
|
177
|
+
### Manifest keys
|
|
178
|
+
|
|
179
|
+
Only **real entries** go into the manifest: dynamic imports also carry an
|
|
180
|
+
`entryPoint`, and if they were not filtered out every island would become a
|
|
181
|
+
separate manifest key. The key is the file name itself (`main.js`, `chart.js`),
|
|
182
|
+
the value is the hashed URL.
|
|
183
|
+
|
|
184
|
+
That is why a controller writing `entries: ["chart.js"]` does not have to know
|
|
185
|
+
the hash ([05-islands.md](./05-islands.md)).
|
|
186
|
+
|
|
187
|
+
### `metafile.json`
|
|
188
|
+
|
|
189
|
+
The esbuild metafile is written to the `.jskelet/metafile.json` file; the chunk
|
|
190
|
+
analysis in the dev panel reads the input/output breakdown from there. If the
|
|
191
|
+
write fails, the build does not go down — the analysis data is best-effort. **The
|
|
192
|
+
runtime does not depend on this file.**
|
|
193
|
+
|
|
194
|
+
## Fonts
|
|
195
|
+
|
|
196
|
+
Self-hosted font files instead of `next/font/google`.
|
|
197
|
+
|
|
198
|
+
The files sit under `public/fonts/` with **fixed names** (no hash), because the
|
|
199
|
+
`url()` paths inside `@font-face` are written by hand; hashing them would force
|
|
200
|
+
the stylesheet to change on every build too.
|
|
201
|
+
|
|
202
|
+
If a file is missing, it is downloaded from Google Fonts **once** and is
|
|
203
|
+
**expected to be committed**: having the build depend on the network is fragile
|
|
204
|
+
in CI. If the download fails, a warning is printed and the page falls back to the
|
|
205
|
+
system font stack — the build does not stop.
|
|
206
|
+
|
|
207
|
+
Only the latin subset (`U+0000-00FF`) is downloaded: the others are dead weight
|
|
208
|
+
for most sites, and without `unicode-range` downloading all of them multiplies
|
|
209
|
+
the font size.
|
|
210
|
+
|
|
211
|
+
Usage is written by hand in the stylesheet:
|
|
212
|
+
|
|
213
|
+
```css
|
|
214
|
+
@font-face {
|
|
215
|
+
font-family: "Inter";
|
|
216
|
+
font-style: normal;
|
|
217
|
+
font-weight: 400;
|
|
218
|
+
font-display: swap;
|
|
219
|
+
src: url("/fonts/inter-400.woff2") format("woff2");
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Because the `.woff2` extension and the `/fonts/` prefix are in the default
|
|
224
|
+
`static` rules, these files automatically get an `immutable` cache.
|
|
225
|
+
|
|
226
|
+
## Icon sprite
|
|
227
|
+
|
|
228
|
+
From the individual SVGs inside `@phosphor-icons/core`, it produces a `<symbol>`
|
|
229
|
+
set for **only the icons actually used in the source**. Shipping the whole set
|
|
230
|
+
means 1500+ icons, i.e. several megabytes; usage scanning typically keeps the
|
|
231
|
+
sprite at 10-30 symbols.
|
|
232
|
+
|
|
233
|
+
- Symbol id: `<kebab-name>-<weight>`, e.g. `arrow-right-bold`.
|
|
234
|
+
- The package is resolved from the **application's** `node_modules` (the icon set
|
|
235
|
+
is the application's devDependency); if it is not installed, the step is
|
|
236
|
+
silently skipped.
|
|
237
|
+
- The scanned directories default to `views`, `client`, `routes`, `lib`; they can
|
|
238
|
+
be changed with `icons.scan`. Scanned extensions: `.ejs`, `.js`, `.mjs`.
|
|
239
|
+
- Weights: `thin`, `light`, `regular`, `bold`, `fill`, `duotone`. An
|
|
240
|
+
unrecognised weight counts as `regular`.
|
|
241
|
+
|
|
242
|
+
### What the scan finds
|
|
243
|
+
|
|
244
|
+
| Form in the source | Is it found |
|
|
245
|
+
| --- | --- |
|
|
246
|
+
| `icon({ name: "ArrowRight", weight: "bold" })` | ✓ name + weight |
|
|
247
|
+
| `icon({ name: cond ? "A" : "B" })` | ✓ both constant names |
|
|
248
|
+
| `data-icon="flag:fill"` or `"data-icon": "flag:fill"` | ✓ |
|
|
249
|
+
| `icon: "XLogo"` / `iconName: "XLogo"` (in configuration lists) | ✓ name; weights are the ones collected from indirect calls |
|
|
250
|
+
| `icon({ name: item.icon })` | ✗ the name is not statically visible |
|
|
251
|
+
|
|
252
|
+
There are two safety nets for the last row: configuration fields that carry a
|
|
253
|
+
name (`icon: "XLogo"`) are also searched, and in development `icon()` reads the
|
|
254
|
+
symbols in the sprite and prints a one-off warning for a missing one:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
[icon] missing from sprite: x-logo-regular — write the name as a literal or add
|
|
258
|
+
it to the build/tasks/icons.mjs scan.
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
If you see this warning, either write the name as a constant, or add the relevant
|
|
262
|
+
directory to the `icons.scan` list, or keep the name in a configuration field in
|
|
263
|
+
the form `icon: "XLogo"`.
|
|
264
|
+
|
|
265
|
+
Names that cannot be found in Phosphor are warned about as a summary at the end
|
|
266
|
+
of the build: `N icons missing → …`
|
|
267
|
+
|
|
268
|
+
## Image optimisation
|
|
269
|
+
|
|
270
|
+
The build-time counterpart of the `next/image` optimizer. For the png/jpg files
|
|
271
|
+
placed by hand under `public/`, it produces webp at a few widths and writes them
|
|
272
|
+
to the `.jskelet/images.json` manifest. `image()` looks at that manifest and adds
|
|
273
|
+
`srcset` plus intrinsic `width`/`height`; the calling side changes nothing
|
|
274
|
+
([04-rendering.md](./04-rendering.md)).
|
|
275
|
+
|
|
276
|
+
- The outputs land hashed under `public/assets/img/`, which means they fall
|
|
277
|
+
within the scope of the `immutable` cache and precompression.
|
|
278
|
+
- **The source files stay where they are:** an image not in the manifest is
|
|
279
|
+
always served as the original.
|
|
280
|
+
- The `assets` and `fonts` directories are always skipped; additional ones with
|
|
281
|
+
`images.skip`.
|
|
282
|
+
- The widths are used with the ones larger than the source dropped, and the
|
|
283
|
+
source's own width (at most 1920) always makes it into the list. Above 1920 is
|
|
284
|
+
wasteful even on retina screens.
|
|
285
|
+
- The variant hash is derived from the **source + the width**: the same content
|
|
286
|
+
gives the same file name on every build, so the `immutable` cache does not go
|
|
287
|
+
stale.
|
|
288
|
+
- The encoder signature is written into the manifest (`webp-q78-e4`). When the
|
|
289
|
+
quality setting changes, the signature changes with it and every image is
|
|
290
|
+
re-encoded; otherwise outputs produced with the old setting would silently
|
|
291
|
+
remain.
|
|
292
|
+
- If the source has not changed and the outputs are still in place, nothing is
|
|
293
|
+
re-encoded. In a large `public/` directory this brings the build time down from
|
|
294
|
+
minutes to seconds.
|
|
295
|
+
- A single corrupt/unreadable image does not bring the build down: a warning is
|
|
296
|
+
printed and, because it is not in the manifest, the original file continues to
|
|
297
|
+
be served.
|
|
298
|
+
- Old outputs that no longer appear in the manifest are deleted.
|
|
299
|
+
|
|
300
|
+
This step requires `sharp`. If it is not installed the step is silently skipped
|
|
301
|
+
and `image()` falls back to the original file. It never runs on a watch pass.
|
|
302
|
+
|
|
303
|
+
## Precompress
|
|
304
|
+
|
|
305
|
+
Produces brotli (quality 11) and gzip (level 9) copies of the built assets:
|
|
306
|
+
`app.<hash>.css.br`, `app.<hash>.css.gz`, …
|
|
307
|
+
|
|
308
|
+
- Only `public/assets/` is covered: the files there are hashed and `immutable`,
|
|
309
|
+
meaning their contents never change and recompressing them on every request is
|
|
310
|
+
wasted CPU. Compressing once at build time with quality 11 both zeroes out the
|
|
311
|
+
server load and gives a ratio you could never afford at runtime (as against
|
|
312
|
+
quality 5 at request time).
|
|
313
|
+
- Files placed by hand under `public/` are left to runtime compression, because
|
|
314
|
+
they are small and requested rarely.
|
|
315
|
+
- Compressed extensions: `.css`, `.js`, `.mjs`, `.svg`, `.json`, `.xml`,
|
|
316
|
+
`.txt`, `.map`. Already-compressed formats (woff2, png, jpg, webp) are skipped.
|
|
317
|
+
- Files under 1 KB are skipped: the gain does not cover the header cost.
|
|
318
|
+
- `.br`/`.gz` copies left over from the previous pass are deleted first, so they
|
|
319
|
+
cannot go stale.
|
|
320
|
+
- It does not run in watch mode: quality-11 brotli on every change is slow.
|
|
321
|
+
|
|
322
|
+
These files are served by the `staticPrecompressed` middleware; if there is no
|
|
323
|
+
copy, the request is handed over to `express.static`
|
|
324
|
+
([02-architecture.md](./02-architecture.md)).
|
|
325
|
+
|
|
326
|
+
## Optional peer dependencies
|
|
327
|
+
|
|
328
|
+
| Package | The step that needs it | What happens without it |
|
|
329
|
+
| --- | --- | --- |
|
|
330
|
+
| `postcss` | CSS | The CSS step **throws** (a required import) |
|
|
331
|
+
| `@tailwindcss/postcss` | CSS | The CSS step **throws** |
|
|
332
|
+
| `tailwindcss` | CSS (peer) | Tailwind directives cannot be resolved |
|
|
333
|
+
| `lightningcss` | CSS minification | Tailwind's output is used, a few kB bigger |
|
|
334
|
+
| `sharp` | Image optimisation | The step is skipped; `image()` uses the original |
|
|
335
|
+
| `@phosphor-icons/core` | Icon sprite | The step is skipped; `icon()` produces an empty `<use>` |
|
|
336
|
+
|
|
337
|
+
If you are not going to use CSS, simply never create the `paths.styles` file: the
|
|
338
|
+
step is skipped with a warning and postcss is not needed.
|
|
339
|
+
|
|
340
|
+
The packages are resolved from the **application's** `node_modules`, not the
|
|
341
|
+
framework's own. If the framework is installed via a `file:` or workspace link,
|
|
342
|
+
its source files run in their own directory and a plain `import "postcss"` looks
|
|
343
|
+
at the framework's tree — not yours. That is why resolution is started from the
|
|
344
|
+
application root.
|
|
345
|
+
|
|
346
|
+
## Suggested `.gitignore`
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
node_modules/
|
|
350
|
+
.jskelet/
|
|
351
|
+
public/assets/
|
|
352
|
+
.env
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`public/fonts/` **should be committed** (so the build does not depend on the
|
|
356
|
+
network), `public/assets/` should not be (it is regenerated on every build).
|
|
357
|
+
|
|
358
|
+
## `jskelet start` and a missing build
|
|
359
|
+
|
|
360
|
+
`jskelet start` first looks at the `.jskelet/manifest.json` file; if it is
|
|
361
|
+
missing, it runs the build itself. In a Docker image the build has already
|
|
362
|
+
happened so this is a no-op; the point is that someone running `npm start`
|
|
363
|
+
directly does not end up facing an unstyled page.
|
|
364
|
+
|
|
365
|
+
## Diagnostics: common situations
|
|
366
|
+
|
|
367
|
+
- **No styles at all.** The build has not run (`hasAsset('app.css')` is false) or
|
|
368
|
+
the `paths.styles` file does not exist. Check the `CSS` line in the build
|
|
369
|
+
output.
|
|
370
|
+
- **Some Tailwind classes do not work.** They were written in a directory whose
|
|
371
|
+
`@source` directive is missing.
|
|
372
|
+
- **An icon looks empty.** That symbol is not in the sprite; in dev, look for the
|
|
373
|
+
`[icon] missing from sprite` warning.
|
|
374
|
+
- **The islands never open.** `main.js` is not in the manifest (the entry
|
|
375
|
+
directory is empty or the build was skipped) or there is a build error.
|
|
376
|
+
- **The page suddenly went unstyled in dev.** The manifest and the file on disk
|
|
377
|
+
have diverged; restarting `jskelet dev` is enough.
|
|
378
|
+
|
|
379
|
+
## What's next
|
|
380
|
+
|
|
381
|
+
- The watch flow and CSS hot-swap: [09-dev-tools.md](./09-dev-tools.md)
|
|
382
|
+
- Prod build + start and Docker: [10-deployment.md](./10-deployment.md)
|
|
383
|
+
- Using `entries` and the island bundle: [05-islands.md](./05-islands.md)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# 09 — Development tools
|
|
2
|
+
|
|
3
|
+
This document explains what `jskelet dev` does and why it does it that way: how
|
|
4
|
+
the two child processes are managed, the shape of the terminal output, the
|
|
5
|
+
watched directories and the reason a custom watcher was written instead of using
|
|
6
|
+
`node --watch`, the distinction between CSS hot-swap and a full reload, the
|
|
7
|
+
devtools overlay opened with `Alt+D`, the detailed report page, and the dev gate
|
|
8
|
+
built on `DEV_TOKEN`. The build steps themselves are in
|
|
9
|
+
[08-build.md](./08-build.md).
|
|
10
|
+
|
|
11
|
+
## The `jskelet dev` flow
|
|
12
|
+
|
|
13
|
+
The command manages two long-lived child processes in a single terminal:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
jskelet dev
|
|
17
|
+
├─ build watch node src/build/build.mjs --watch
|
|
18
|
+
└─ server node [--env-file=.env] --import <register.mjs> src/start.mjs
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`NODE_ENV=development` is assigned here in a platform-independent way — no
|
|
22
|
+
`cross-env` needed. The child processes also receive `JSKELET_CHILD=1`
|
|
23
|
+
(suppresses the build banner) and, if a TTY is present, `JSKELET_COLOR=1`
|
|
24
|
+
(forces color on piped output).
|
|
25
|
+
|
|
26
|
+
Startup order: banner → build steps → server ready → `Ready` summary. The
|
|
27
|
+
summary is printed once both the build and the server are ready; otherwise it
|
|
28
|
+
got buried among the build lines arriving afterwards.
|
|
29
|
+
|
|
30
|
+
The server signals readiness with a single line inside `startServer`:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
jskelet → http://localhost:3000 (development)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The shape of this line is a contract; the dev script parses it and prints the
|
|
37
|
+
summary line accordingly.
|
|
38
|
+
|
|
39
|
+
### The shape of the terminal output
|
|
40
|
+
|
|
41
|
+
Child process output does not stream through as-is. There are two regions and
|
|
42
|
+
they never mix:
|
|
43
|
+
|
|
44
|
+
1. **Startup:** banner, aligned build lines, `Ready` summary.
|
|
45
|
+
2. **Runtime:** timestamped, single-line events (HTTP requests, CSS rebuild,
|
|
46
|
+
server restart).
|
|
47
|
+
|
|
48
|
+
Error stacks are turned into a framed box: because stack lines arrive in
|
|
49
|
+
fragments, they are collected after a short silence (60 ms), the error name and
|
|
50
|
+
message are parsed, the first three frames are shown, and the project root is
|
|
51
|
+
shortened to `.`. When you are developing your own framework, not losing the
|
|
52
|
+
error in the stream is the detail that genuinely makes a difference.
|
|
53
|
+
|
|
54
|
+
Color carries meaning: `✓` green, `✖` red, `⚠` yellow, `↻` cyan; durations and
|
|
55
|
+
paths gray. No decorative color is used. If `NO_COLOR` is set, no color is used
|
|
56
|
+
at all.
|
|
57
|
+
|
|
58
|
+
`Ctrl+C` (SIGINT/SIGTERM) shuts down all child processes. If a child exits with
|
|
59
|
+
a non-zero code (other than an expected restart), an error is printed and the
|
|
60
|
+
dev process exits too.
|
|
61
|
+
|
|
62
|
+
## Watch directories
|
|
63
|
+
|
|
64
|
+
Server restarts are managed by the framework's own watcher.
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
WATCH_DIRS = [
|
|
68
|
+
config.dirs.routes,
|
|
69
|
+
config.dirs.views,
|
|
70
|
+
<root>/lib,
|
|
71
|
+
...config.watch, // jskelet.config.mjs → watch
|
|
72
|
+
]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `jskelet.config.mjs` file itself is watched as well: when the config
|
|
76
|
+
changes, both the server and the build must come up with the new settings.
|
|
77
|
+
|
|
78
|
+
Watched extensions: `.js`, `.mjs`, `.json`, `.ejs`.
|
|
79
|
+
|
|
80
|
+
`views` is watched too, because most components live in
|
|
81
|
+
`views/components/**.js` and, since those modules are imported into the server
|
|
82
|
+
once, changes made without a restart never reached the browser (that "I edited
|
|
83
|
+
the template and nothing changed" feeling comes from here).
|
|
84
|
+
|
|
85
|
+
`client/` and `styles/` are **not** in this list: esbuild and the CSS watchers
|
|
86
|
+
handle them on their own ([08-build.md](./08-build.md)).
|
|
87
|
+
|
|
88
|
+
If a directory cannot be watched, a warning is printed and no automatic restart
|
|
89
|
+
happens for that directory; everything else keeps working.
|
|
90
|
+
|
|
91
|
+
### Why `node --watch` was not used
|
|
92
|
+
|
|
93
|
+
Even when given `--watch-path`, Node watched the project root in this setup.
|
|
94
|
+
Whenever build output (`public/assets`, `manifest.json`) or the dev tooling log
|
|
95
|
+
was written, the server restarted for nothing — in fact a self-feeding loop was
|
|
96
|
+
set up: restart → startup warning → write → restart.
|
|
97
|
+
|
|
98
|
+
The custom watcher does three things:
|
|
99
|
+
|
|
100
|
+
1. **Watches only server sources.**
|
|
101
|
+
2. **Coalesces changes** (250 ms) and reports which files changed.
|
|
102
|
+
3. **Filters out phantom events.** On Windows, `fs.watch` can emit events for a
|
|
103
|
+
file's neighbors when it is written; without comparing `mtime`, a single save
|
|
104
|
+
turned into two restarts. Current timestamps are read up front at startup, so
|
|
105
|
+
the first phantom event is filtered out as well.
|
|
106
|
+
|
|
107
|
+
The restart line shows the changed file, or how many changed:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
21:04:12 ↻ server restarting… routes/10-pages.mjs
|
|
111
|
+
21:04:12 server restarted 412ms
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If `JSKELET_VERBOSE=1` is set, all files are listed when more than one changed.
|
|
115
|
+
|
|
116
|
+
## CSS hot-swap and full reload
|
|
117
|
+
|
|
118
|
+
The dev server watches `.jskelet/manifest.json` and broadcasts events to the
|
|
119
|
+
browser over an SSE channel (`<devBasePath>/events`). Since the manifest is
|
|
120
|
+
rewritten on every build round, change detection is done through the manifest.
|
|
121
|
+
|
|
122
|
+
| What changed | Behavior |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| Only `app.css` | **CSS hot-swap:** the stylesheet is swapped, the page is not reloaded. State and scroll position are preserved. |
|
|
125
|
+
| `main.js`, the sprite, another asset, or more than one key | **Full reload** |
|
|
126
|
+
|
|
127
|
+
In both cases the HTML cache is cleared first: the stored HTML carries the old
|
|
128
|
+
hashed asset URLs and, if not cleared, the page keeps asking for a deleted file.
|
|
129
|
+
|
|
130
|
+
Manifest events are coalesced over 120 ms. If watching is not supported, live
|
|
131
|
+
reload is disabled and everything else keeps working.
|
|
132
|
+
|
|
133
|
+
When the server restarts, the overlay figures it out from the **boot id**: every
|
|
134
|
+
process broadcasts a unique `boot` value, the overlay sees the change, shows the
|
|
135
|
+
"restarted" note and does not reset its own state.
|
|
136
|
+
|
|
137
|
+
## Devtools overlay
|
|
138
|
+
|
|
139
|
+
A floating bubble in the bottom right; opened with `Alt+D`, closed with `Esc` or
|
|
140
|
+
by clicking the backdrop. It is only emitted by the layout when
|
|
141
|
+
`NODE_ENV=development`:
|
|
142
|
+
|
|
143
|
+
```ejs
|
|
144
|
+
<% if (devtools) { %>
|
|
145
|
+
<script type="module" src="<%= devBasePath %>/overlay.js"></script>
|
|
146
|
+
<% } %>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The overlay file is **not part of the build.** The server serves it raw from the
|
|
150
|
+
framework package; that is why there is no bundler involved, it works as a
|
|
151
|
+
single file, and it adds nothing to the production output. The entire UI lives
|
|
152
|
+
inside a shadow DOM and does not mix with the page's CSS.
|
|
153
|
+
|
|
154
|
+
What it shows:
|
|
155
|
+
|
|
156
|
+
- **Errors:** browser-side JS errors, resource loading errors
|
|
157
|
+
(`img`/`script`/`link`), and the server's `console.error` / `console.warn`
|
|
158
|
+
output. On the server side `console` is wrapped so warnings do not get lost in
|
|
159
|
+
the terminal.
|
|
160
|
+
- **Requests:** the method, path, status, duration and `X-JSkelet-Cache` value
|
|
161
|
+
of every HTML request. The same lines are printed to the terminal too.
|
|
162
|
+
- **Web Vitals:** TTFB, FCP, LCP, CLS, INP, DCL, load, long task count and
|
|
163
|
+
blocking time.
|
|
164
|
+
- **Prewarm:** the progress of the warming round; it can be triggered manually
|
|
165
|
+
from the panel, and individual paths can be retried.
|
|
166
|
+
- **Process:** pid, Node version, uptime, RSS and heap usage.
|
|
167
|
+
- **Version:** the installed JSkelet version compared against the `latest` tag
|
|
168
|
+
on npm. When a newer release exists, the **Server** tab grows an `update` chip
|
|
169
|
+
and a line that copies the upgrade command. The lookup runs once, 1.5 seconds
|
|
170
|
+
after boot, is cached for six hours in `os.tmpdir()` and is skipped silently
|
|
171
|
+
when the registry is unreachable. Set `JSKELET_VERSION_CHECK=0` to disable it.
|
|
172
|
+
|
|
173
|
+
Warming requests (`user-agent: jskelet-prewarm`) are filtered out of both the
|
|
174
|
+
terminal and the request list: hundreds of requests should not flood the view.
|
|
175
|
+
Progress appears in the badge next to the bubble.
|
|
176
|
+
|
|
177
|
+
### Why state is written to `os.tmpdir()`
|
|
178
|
+
|
|
179
|
+
If request and error records lived in process memory, history would be erased on
|
|
180
|
+
every restart and the overlay would come up empty. So the records are carried
|
|
181
|
+
across restarts in a file.
|
|
182
|
+
|
|
183
|
+
The file is **not written into the project tree**: every write triggered the
|
|
184
|
+
watcher and restarted the server, which set up a self-feeding loop (restart →
|
|
185
|
+
startup warning → write → restart). Instead, the file is written to
|
|
186
|
+
`os.tmpdir()/jskelet-devtools-<hash of the project root>.json`; thanks to the
|
|
187
|
+
hash, multiple JSkelet projects on the same machine do not overwrite each
|
|
188
|
+
other's records.
|
|
189
|
+
|
|
190
|
+
The write happens after 300 ms of silence rather than on every request, and its
|
|
191
|
+
failure does not stop the dev flow. At most 50 requests and 50 errors are kept.
|
|
192
|
+
|
|
193
|
+
The panel's open/closed state, its active tab and the browser error log are kept
|
|
194
|
+
in tab memory (`sessionStorage`), so after a reload the panel comes back with
|
|
195
|
+
the same tab.
|
|
196
|
+
|
|
197
|
+
## Report page
|
|
198
|
+
|
|
199
|
+
The bubble shows the current state; the report page produces a view of the whole
|
|
200
|
+
site. The address:
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
http://localhost:3000/__jskelet/dev/report
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
(Or wherever `brand.devBasePath` points, if you changed it.)
|
|
207
|
+
|
|
208
|
+
Its contents:
|
|
209
|
+
|
|
210
|
+
- **Pages:** the Web Vitals measurements of every visited page, resource count
|
|
211
|
+
and total bytes (broken down by type), island status (how many are ready, and
|
|
212
|
+
their names), API calls made in the browser, and the size/duration/cache
|
|
213
|
+
status of the SSR output. Pages that were never visited but were warmed are
|
|
214
|
+
listed too: the SSR side is known, the client measurements stay empty.
|
|
215
|
+
- **Server API calls:** outbound `fetch` calls made during SSR — URL, host,
|
|
216
|
+
method, status, duration, bytes. `globalThis.fetch` is only wrapped in
|
|
217
|
+
development; the production path is left untouched. Requests to our own server
|
|
218
|
+
(warming, health check) do not count as API calls.
|
|
219
|
+
- **Build output:** the raw/gzip/brotli size of every asset in the manifest, and
|
|
220
|
+
chunk analysis from esbuild's metafile — the size of each output, which
|
|
221
|
+
sources it is made of, which chunks it imports. Sources are reduced to
|
|
222
|
+
readable groups (package name or parent folder), so the question "which
|
|
223
|
+
library accounts for 40 kB of this chunk" can be answered.
|
|
224
|
+
- **HTML cache:** entry count and a dump (key, bytes, status, whether it is
|
|
225
|
+
stale, how many seconds until it expires, which encodings are stored).
|
|
226
|
+
- **Prewarm:** the full result of the last round.
|
|
227
|
+
- **Request and error logs.**
|
|
228
|
+
|
|
229
|
+
Measurements live on the server, not in the browser tab; resetting is done from
|
|
230
|
+
the server as well. Size calculations are not repeated unless the file changed.
|
|
231
|
+
|
|
232
|
+
The report layer is only loaded in development and never enters the production
|
|
233
|
+
output.
|
|
234
|
+
|
|
235
|
+
## Dev endpoints
|
|
236
|
+
|
|
237
|
+
Under `brand.devBasePath` (default `/__jskelet/dev`):
|
|
238
|
+
|
|
239
|
+
| Path | Method | Job |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| `/overlay.js` | GET | The overlay script |
|
|
242
|
+
| `/logo.png` | GET | The overlay logo |
|
|
243
|
+
| `/events` | GET | SSE: live reload and CSS hot-swap events |
|
|
244
|
+
| `/stats` | GET | Current statistics (the overlay polls every 2 seconds) |
|
|
245
|
+
| `/report` | GET | The report page (HTML) |
|
|
246
|
+
| `/report.js` | GET | The report page's script |
|
|
247
|
+
| `/report/data` | GET | The report's single data source (JSON) |
|
|
248
|
+
| `/vitals` | POST | The measurement bundle sent by the overlay |
|
|
249
|
+
| `/report/clear` | POST | Resets page measurements and server API records |
|
|
250
|
+
| `/prewarm` | POST | Triggers warming manually. If the body has `paths`, only those paths; 409 if warming is already running. |
|
|
251
|
+
| `/clear` | POST | Resets the request and error logs |
|
|
252
|
+
|
|
253
|
+
All of these endpoints are mounted by `mountDevtools()` only when
|
|
254
|
+
`NODE_ENV=development`; thanks to the dynamic import, nothing is loaded into the
|
|
255
|
+
production process.
|
|
256
|
+
|
|
257
|
+
## Dev gate — `DEV_TOKEN`
|
|
258
|
+
|
|
259
|
+
To hide an environment that is not public yet: while `DEV_TOKEN` is set,
|
|
260
|
+
**every** request without the token gets a 404.
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
DEV_TOKEN=a-long-random-string npm start
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Access:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
https://staging.example.com/?dev_token=a-long-random-string
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Behavior:
|
|
273
|
+
|
|
274
|
+
- **404, not 403.** A 403 confirms the environment exists; a 404 acts as if it
|
|
275
|
+
never did.
|
|
276
|
+
- Once the token arrives as a query parameter, it is written to a cookie
|
|
277
|
+
(`Path=/`, `SameSite=Lax`, 14 days), so sharing the link is enough. The cookie
|
|
278
|
+
and parameter name is `brand.devTokenCookie` (default `dev_token`).
|
|
279
|
+
- The **exact** paths in the `devGateBypass` list are open under all conditions.
|
|
280
|
+
Default: `/api/healthcheck`, `/robots.txt`, `/sitemap.xml`,
|
|
281
|
+
`/site.webmanifest`, `/favicon.ico`. If your health check lives at a different
|
|
282
|
+
path, remember to add it to this list, otherwise your orchestrator will see a
|
|
283
|
+
404.
|
|
284
|
+
- Without `DEV_TOKEN` the middleware is entirely disabled and costs nothing in
|
|
285
|
+
production.
|
|
286
|
+
- Since warming makes requests to its own server, it carries the token as a
|
|
287
|
+
cookie; without it every page gets a 404 and the cache never fills up
|
|
288
|
+
([06-caching.md](./06-caching.md)).
|
|
289
|
+
|
|
290
|
+
In the middleware chain the gate sits after `headers` and **before**
|
|
291
|
+
`redirects`: an environment that is not public yet should not leak even its
|
|
292
|
+
redirect rules.
|
|
293
|
+
|
|
294
|
+
## Differences between development and production
|
|
295
|
+
|
|
296
|
+
| Topic | Development | Production |
|
|
297
|
+
| --- | --- | --- |
|
|
298
|
+
| EJS template cache | Off | On |
|
|
299
|
+
| Manifest reading | On every request | Once |
|
|
300
|
+
| Image manifest | On every call | Once |
|
|
301
|
+
| Broken route module | Warn + skip | Throw |
|
|
302
|
+
| Devtools and report | Mounted | Never loaded |
|
|
303
|
+
| `globalThis.fetch` | Wrapped (measurement) | Untouched |
|
|
304
|
+
| Prewarm concurrency | 2 | 4 |
|
|
305
|
+
| Prewarm delay | 3000 ms | 500 ms |
|
|
306
|
+
| Missing icon warning | Emitted | Not emitted |
|
|
307
|
+
| Precompress | Does not run in watch | Runs |
|
|
308
|
+
| Image optimization | Does not run in watch | Runs |
|
|
309
|
+
|
|
310
|
+
## What's next
|
|
311
|
+
|
|
312
|
+
- Details of the build steps: [08-build.md](./08-build.md)
|
|
313
|
+
- Going to production: [10-deployment.md](./10-deployment.md)
|
|
314
|
+
- Reading and clearing the cache: [06-caching.md](./06-caching.md)
|