jskelet 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/AGENTS.md +136 -136
  2. package/CHANGELOG.md +628 -620
  3. package/LICENSE +21 -21
  4. package/README.md +2 -0
  5. package/bin/jskelet.mjs +130 -130
  6. package/docs/01-baslangic.md +291 -291
  7. package/docs/02-mimari.md +310 -310
  8. package/docs/03-routing.md +515 -515
  9. package/docs/04-render-ve-sablonlar.md +667 -661
  10. package/docs/05-islands.md +486 -486
  11. package/docs/06-cache.md +1467 -1443
  12. package/docs/07-yapilandirma.md +1208 -1197
  13. package/docs/08-build.md +429 -429
  14. package/docs/09-dev-araclari.md +364 -364
  15. package/docs/10-dagitim.md +348 -338
  16. package/docs/12-panel-ve-oturum.md +479 -478
  17. package/docs/README.md +83 -83
  18. package/docs/en/01-getting-started.md +298 -298
  19. package/docs/en/02-architecture.md +329 -329
  20. package/docs/en/03-routing.md +531 -531
  21. package/docs/en/04-rendering.md +675 -669
  22. package/docs/en/05-islands.md +497 -497
  23. package/docs/en/06-caching.md +1476 -1453
  24. package/docs/en/07-configuration.md +1229 -1219
  25. package/docs/en/08-build.md +447 -447
  26. package/docs/en/09-dev-tools.md +373 -373
  27. package/docs/en/10-deployment.md +351 -340
  28. package/docs/en/11-migration.md +398 -398
  29. package/docs/en/12-dashboards-and-sessions.md +489 -488
  30. package/docs/en/README.md +87 -87
  31. package/package.json +137 -137
  32. package/src/build/ensure-build.mjs +19 -19
  33. package/src/build/paths.mjs +153 -153
  34. package/src/build/resolve-peer.mjs +36 -36
  35. package/src/build/tasks/client.mjs +349 -349
  36. package/src/build/tasks/css.mjs +235 -235
  37. package/src/build/tasks/fonts.mjs +146 -146
  38. package/src/build/tasks/icons.mjs +357 -357
  39. package/src/build/tasks/images.mjs +244 -244
  40. package/src/build/tasks/precompress.mjs +78 -78
  41. package/src/build/tasks/templates.mjs +20 -20
  42. package/src/client/admin/i18n.js +764 -764
  43. package/src/client/admin/login.html +74 -74
  44. package/src/client/admin/panel.css +809 -809
  45. package/src/client/admin/panel.html +495 -495
  46. package/src/client/admin/panel.js +1251 -1251
  47. package/src/client/devtools/report.html +185 -185
  48. package/src/client/devtools/report.js +745 -745
  49. package/src/client/devtools/seo.js +628 -628
  50. package/src/client/dom.js +95 -95
  51. package/src/client/form.js +192 -192
  52. package/src/client/index.js +45 -45
  53. package/src/client/registry.js +305 -305
  54. package/src/client/safe-image.js +91 -91
  55. package/src/client/shared-cookie.js +225 -225
  56. package/src/client/store.js +36 -36
  57. package/src/client/swap.js +188 -188
  58. package/src/compile/codegen.js +336 -336
  59. package/src/compile/compile-all.js +149 -149
  60. package/src/compile/errors.js +66 -66
  61. package/src/compile/expr.js +409 -409
  62. package/src/compile/index.js +17 -17
  63. package/src/compile/parse.js +541 -541
  64. package/src/compile/resolve.js +211 -211
  65. package/src/compile/scan-exports.js +51 -51
  66. package/src/config/defaults.js +541 -534
  67. package/src/config/index.js +1500 -1469
  68. package/src/config/pattern.js +107 -107
  69. package/src/generate.mjs +163 -163
  70. package/src/http/control-flow.js +71 -71
  71. package/src/http/cookies-entry.js +21 -21
  72. package/src/http/cookies.js +277 -277
  73. package/src/http/request-cache.js +46 -46
  74. package/src/http/request-context.js +165 -165
  75. package/src/http/shared-cookie.js +178 -178
  76. package/src/index.js +101 -101
  77. package/src/init.mjs +232 -230
  78. package/src/migrate/apply.mjs +262 -262
  79. package/src/migrate/babel.mjs +79 -79
  80. package/src/migrate/classify.mjs +155 -155
  81. package/src/migrate/config.mjs +126 -126
  82. package/src/migrate/fs-walk.mjs +191 -191
  83. package/src/migrate/parse.mjs +26 -26
  84. package/src/migrate/scan.mjs +177 -177
  85. package/src/migrate/transform/expr-source.mjs +168 -168
  86. package/src/migrate/transform/island.mjs +67 -67
  87. package/src/migrate/transform/jsx-to-component.mjs +302 -302
  88. package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
  89. package/src/migrate/transform/page-split.mjs +435 -435
  90. package/src/migrate/write.mjs +81 -81
  91. package/src/migrate.mjs +171 -171
  92. package/src/runtime/alias-hooks.mjs +119 -119
  93. package/src/runtime/register.mjs +4 -4
  94. package/src/server/admin/actions.js +229 -229
  95. package/src/server/admin/auth.js +125 -125
  96. package/src/server/admin/event-log.js +151 -151
  97. package/src/server/admin/gate.js +209 -209
  98. package/src/server/admin/inventory.js +188 -188
  99. package/src/server/admin/mount.js +56 -56
  100. package/src/server/admin/router.js +216 -216
  101. package/src/server/admin/snapshot.js +241 -241
  102. package/src/server/assets.js +147 -147
  103. package/src/server/auth/handoff.js +309 -309
  104. package/src/server/cache-blob.js +70 -70
  105. package/src/server/cache-control.js +45 -0
  106. package/src/server/cache-deps.js +42 -42
  107. package/src/server/cache-vary.js +113 -113
  108. package/src/server/cloudflare.js +607 -607
  109. package/src/server/create-app.js +366 -366
  110. package/src/server/data-cache.js +553 -553
  111. package/src/server/dev/report.js +485 -485
  112. package/src/server/dev/socket.js +170 -170
  113. package/src/server/dev/version-check.mjs +139 -139
  114. package/src/server/disk-cache.js +233 -233
  115. package/src/server/ejs-adapter.js +59 -59
  116. package/src/server/html-cache.js +1196 -1196
  117. package/src/server/image-optimizer.js +500 -500
  118. package/src/server/logs/access-middleware.js +66 -66
  119. package/src/server/logs/file-sink.js +193 -193
  120. package/src/server/logs/pipeline.js +165 -165
  121. package/src/server/logs/s3-put.js +214 -214
  122. package/src/server/logs/s3-sink.js +112 -112
  123. package/src/server/metadata.js +102 -102
  124. package/src/server/middleware/compression.js +205 -205
  125. package/src/server/middleware/csrf.js +134 -134
  126. package/src/server/middleware/dev-gate.js +75 -75
  127. package/src/server/middleware/headers.js +37 -37
  128. package/src/server/middleware/redirects.js +32 -32
  129. package/src/server/middleware/robots-txt.js +341 -341
  130. package/src/server/middleware/static-precompressed.js +121 -121
  131. package/src/server/middleware/trailing-slash.js +53 -53
  132. package/src/server/middleware/upstream-proxy.js +141 -141
  133. package/src/server/og-image.js +369 -356
  134. package/src/server/port-guard.js +255 -255
  135. package/src/server/prewarm.js +1082 -1082
  136. package/src/server/redis.js +588 -588
  137. package/src/server/render.js +910 -910
  138. package/src/server/router.js +157 -157
  139. package/src/server/status-page.js +265 -265
  140. package/src/server/upstream-limiter.js +376 -376
  141. package/src/server/upstream-tracking.js +166 -166
  142. package/src/shared/cookie-domain.js +66 -66
  143. package/src/start.mjs +22 -22
  144. package/src/templates/layout.ejs +30 -30
  145. package/src/templates/layout.jsk +30 -30
  146. package/src/version.mjs +31 -31
  147. package/src/views/components/loader.js +101 -101
  148. package/src/views/helpers/html.js +102 -102
  149. package/src/views/helpers/tags.js +375 -375
  150. package/types/config/defaults.d.ts +6 -0
  151. package/types/config/index.d.ts +6 -0
  152. package/types/server/cache-control.d.ts +28 -0
  153. package/types/server/og-image.d.ts +5 -0
@@ -1,340 +1,351 @@
1
- # 10 — Deployment
2
-
3
- This document explains how to put a JSkelet application into production: the
4
- prod build and start flow, the environment variables you should set, a working
5
- Docker setup, reverse proxy and `trust proxy` notes, how a health check endpoint
6
- is added, and how the cache behaves when you scale out. What the build steps do
7
- is in [08-build.md](./08-build.md), cache behavior in
8
- [06-caching.md](./06-caching.md).
9
-
10
- ## The prod flow
11
-
12
- ```bash
13
- npm ci
14
- npm run build # jskelet build
15
- npm start # jskelet start
16
- ```
17
-
18
- If `NODE_ENV` is not given, `jskelet build` sets it to `production` and runs all
19
- steps: fonts, icon sprite, CSS, client JS, images, manifest, precompress.
20
-
21
- `jskelet start` first looks for `.jskelet/manifest.json`; if it is missing, it
22
- runs the build itself. In a Docker image the build has already happened, so this
23
- is a no-op; the point is that someone running `npm start` directly does not end
24
- up with an unstyled page.
25
-
26
- If the listen port is already taken, the process **does not start** (PID + hint).
27
- `jskelet start --murder` kills that listener and binds — useful for a leftover
28
- dev process; production orchestrators usually do not need it.
29
-
30
- When the server is ready it prints a single line:
31
-
32
- ```
33
- jskelet → http://localhost:3000 (production)
34
- ```
35
-
36
- The process is protected by two safety nets: `unhandledRejection` and
37
- `uncaughtException` are logged and the process stays up. On a news site, an
38
- error on a single page should not take the whole site down. If you want to hook
39
- this up to your own error tracking tool (Sentry etc.), you can add your own
40
- listener to the same events.
41
-
42
- ## Environment variables
43
-
44
- No variable is required; all of them have a sensible default. The ones worth
45
- considering in production:
46
-
47
- | Variable | Recommendation | Why |
48
- | --- | --- | --- |
49
- | `NODE_ENV` | `production` | Template cache, reading the manifest once, throwing on a broken route module |
50
- | `PORT` | `3000` | The port your orchestrator expects |
51
- | `HOST` | `0.0.0.0` | Only if you need to listen on IPv4 alone; the `::` default already listens dual-stack |
52
- | `PREWARM_MAX` | Depends on site size | Number of pages warmed at startup |
53
- | `PREWARM_INTERVAL_SECONDS` | `0` or a long value | If you want to keep never-visited pages warm |
54
- | `DEV_GATE` + `DEV_TOKEN` | Staging only | Hides an environment that is not public yet. The token alone does not lock the site |
55
- | `JSKELET_S3_*` | If you write access logs to S3 | Bucket + credentials; details in [07](./07-configuration.md) |
56
-
57
- When a file or S3 sink is enabled in production, the HTTP access log middleware
58
- mounts automatically (if `http` is in `logs.kinds`). The admin panel ring is
59
- separate — lines written to disk/S3 do not stream into the panel.
60
-
61
- The full list and the precedence order of the prewarm settings:
62
- [07-configuration.md](./07-configuration.md).
63
-
64
- Since the CLI runs with `--env-file-if-exists=.env`, a `.env` file is loaded
65
- automatically if it exists; if not, no error is raised. In a container,
66
- environment variables are usually injected directly instead of using this file.
67
- Using both sources together blurs which value actually applies; not shipping a
68
- `.env` in the prod image is the cleanest option.
69
-
70
- **Secret keys must not go into the `clientEnv` list:** those values are embedded
71
- into the client bundle as plain text ([08-build.md](./08-build.md)). Secret-like
72
- names (`SECRET`, `API_KEY`, …) now fail the build.
73
-
74
- ## Docker
75
-
76
- A multi-stage image: the build stage compiles with dev dependencies, the runtime
77
- stage carries only production dependencies and the build output.
78
-
79
- ```dockerfile
80
- # syntax=docker/dockerfile:1
81
-
82
- # ---------- build ----------
83
- FROM node:22-bookworm-slim AS build
84
- WORKDIR /app
85
-
86
- # Dependencies in a separate layer: don't reinstall when sources change.
87
- COPY package.json package-lock.json ./
88
- RUN npm ci
89
-
90
- # `public/fonts/` must be committed: the build should not need network access.
91
- COPY . .
92
-
93
- ENV NODE_ENV=production
94
- RUN npx jskelet build
95
-
96
- # ---------- runtime ----------
97
- FROM node:22-bookworm-slim AS runtime
98
- WORKDIR /app
99
-
100
- ENV NODE_ENV=production
101
- ENV PORT=3000
102
- ENV HOST=0.0.0.0
103
-
104
- COPY package.json package-lock.json ./
105
- # sharp and tailwind are only needed at build time; keep them out of the runtime image.
106
- # If you use images.remote, move sharp to production dependencies.
107
- RUN npm ci --omit=dev && npm cache clean --force
108
-
109
- COPY --from=build /app/jskelet.config.mjs ./jskelet.config.mjs
110
- COPY --from=build /app/jsconfig.json ./jsconfig.json
111
- COPY --from=build /app/routes ./routes
112
- COPY --from=build /app/views ./views
113
- COPY --from=build /app/lib ./lib
114
- COPY --from=build /app/public ./public
115
- COPY --from=build /app/.jskelet ./.jskelet
116
-
117
- # Non-root user.
118
- USER node
119
-
120
- EXPOSE 3000
121
-
122
- # Health check: assumes you added the route below.
123
- HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
124
- CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/healthcheck').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
125
-
126
- CMD ["npx", "jskelet", "start"]
127
- ```
128
-
129
- Notes:
130
-
131
- - **`client/` and `styles/` are not needed in the runtime image:** their output
132
- is under `public/assets/`. `views/` and `routes/` are needed, because
133
- rendering happens at runtime. Copy `lib/` only if your project has one.
134
- - **`.jskelet/` is needed:** without `manifest.json`, `asset()` cannot find the
135
- hashed URLs and `jskelet start` will try to run the build from scratch.
136
- - **`sharp` is usually not needed in the runtime image:** it is only for
137
- build-time image optimization. `--omit=dev` leaves it out (if it was installed
138
- as a devDependency). **If `images.remote` is enabled**, sharp is a runtime
139
- dependency — move it to production `dependencies` or install it in the
140
- runtime image; otherwise the optimizer 302-redirects to the source URL.
141
- - If you would rather call `jskelet start` without `npx`,
142
- `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` works too.
143
-
144
- `.dockerignore`:
145
-
146
- ```
147
- node_modules
148
- .git
149
- .jskelet
150
- public/assets
151
- .env
152
- ```
153
-
154
- The build stage produces these itself with `npx jskelet build`.
155
-
156
- ### Deploying from a subdirectory of the repo
157
-
158
- The examples in this repo pull jskelet with `"jskelet": "file:../.."` rather
159
- than from npm. In tools like Coolify, Railway or Render, if you set the "base
160
- directory" to `examples/blog`, the build context becomes only that directory,
161
- `../..` falls outside the context, and installation fails at `npm ci`. The
162
- correct setting: **base directory `/`** (the repo root) and adapt the
163
- multi-stage Dockerfile above to the app directory — or install jskelet as a
164
- normal npm dependency and use the app directory as the context.
165
-
166
- In your own application jskelet will be an ordinary dependency, so this
167
- constraint does not apply; the multi-stage image above is enough.
168
-
169
- ## Health check
170
-
171
- The framework does **not** add a ready-made health check endpoint; you have to
172
- put it in your own route. Since the default `devGateBypass` list contains
173
- `/api/healthcheck`, using that name is the least surprising option: it stays
174
- reachable even while the dev gate is on.
175
-
176
- ```js
177
- // routes/00-health.mjs
178
- import { getHtmlCacheSize } from "jskelet";
179
-
180
- export default function register(app) {
181
- app.get("/api/healthcheck", (req, res) => {
182
- res.setHeader("Cache-Control", "no-store");
183
- res.json({
184
- ok: true,
185
- uptime: process.uptime(),
186
- cache: getHtmlCacheSize(),
187
- });
188
- });
189
- }
190
- ```
191
-
192
- The `00-` prefix in the file name makes sure this route is registered before any
193
- catch-all ([03-routing.md](./03-routing.md)).
194
-
195
- If you are going to use a different path, update the `devGateBypass` list,
196
- otherwise your orchestrator will see a 404 on staging:
197
-
198
- ```js
199
- devGateBypass: ["/healthz", "/robots.txt", "/sitemap.xml", "/favicon.ico"]
200
- ```
201
-
202
- The warming round does not affect the health check: even if prewarm fails, the
203
- process stays up and pages are served (cold, but served).
204
-
205
- If you need to separate readiness from liveness, you can report the warming
206
- status too:
207
-
208
- ```js
209
- import { prewarmProgress } from "jskelet";
210
-
211
- app.get("/api/ready", (req, res) => {
212
- const warmedUp = !prewarmProgress.active && prewarmProgress.finishedAt !== null;
213
- res.status(warmedUp ? 200 : 503).json({ warmedUp, ...prewarmProgress });
214
- });
215
- ```
216
-
217
- Remember to exclude this endpoint's path from warming with `prewarmSkip` (the
218
- default `/api/` prefix already covers it).
219
-
220
- ## Reverse proxy
221
-
222
- The Express application sets `trust proxy` to **on**
223
- (`app.set("trust proxy", true)`). The consequences:
224
-
225
- - `req.protocol` is read from the `X-Forwarded-Proto` header, so if the proxy
226
- terminates TLS, `https` is returned correctly.
227
- - `req.ip` is resolved from the `X-Forwarded-For` chain.
228
- - Absolute URLs produced by `res.redirect()` carry the correct scheme.
229
-
230
- This setting **assumes the proxy writes these headers reliably.** If you are
231
- going to expose the application directly to the internet, remember that a client
232
- can fabricate `X-Forwarded-*` headers; always run behind a proxy or load
233
- balancer and make sure the proxy overwrites the incoming `X-Forwarded-For`
234
- header.
235
-
236
- An example nginx configuration:
237
-
238
- ```nginx
239
- upstream jskelet {
240
- server 127.0.0.1:3000;
241
- keepalive 32;
242
- }
243
-
244
- server {
245
- listen 443 ssl http2;
246
- server_name example.com;
247
-
248
- # Response bodies already arrive compressed; don't compress a second time.
249
- gzip off;
250
-
251
- location / {
252
- proxy_pass http://jskelet;
253
- proxy_http_version 1.1;
254
-
255
- proxy_set_header Host $host;
256
- proxy_set_header X-Real-IP $remote_addr;
257
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
258
- proxy_set_header X-Forwarded-Proto $scheme;
259
- proxy_set_header Connection "";
260
-
261
- # Forward it to the upstream so we can get a compressed response.
262
- proxy_set_header Accept-Encoding $http_accept_encoding;
263
- }
264
- }
265
- ```
266
-
267
- Key points:
268
-
269
- - **Do not compress twice.** JSkelet does the brotli/gzip negotiation itself and
270
- stores the compressed body for cached pages. Leaving nginx's own `gzip` on can
271
- lead to decompressing brotli and re-gzipping it.
272
- - **Forward `Accept-Encoding`**, otherwise the application will not compress and
273
- the ready-made compressed bodies in the cache go unused.
274
- - `Vary: Accept-Encoding` is written by the application; proxy caches take it
275
- into account.
276
-
277
- ### Together with a CDN
278
-
279
- The header written on cacheable pages:
280
-
281
- ```
282
- Cache-Control: public, max-age=0, s-maxage=<revalidate>, stale-while-revalidate=60
283
- ```
284
-
285
- `max-age=0` disables browser storage, `s-maxage` tells the CDN the duration. So
286
- the same freshness model works across two layers together: the CDN serves its
287
- own copy for the duration of `s-maxage`, asks the origin when it expires, and
288
- the origin answers instantly from its own cache.
289
-
290
- The `X-JSkelet-Cache` header makes it easier to diagnose which layer answered;
291
- read it together with the CDN's own cache header
292
- ([06-caching.md](./06-caching.md)).
293
-
294
- Static assets (`/assets/`, `/fonts/`) are marked `immutable`, so they can be
295
- held indefinitely on the CDN; when the hash changes, so does the URL.
296
-
297
- ## Scaling
298
-
299
- The HTML cache lives **in process memory**. When you run more than one replica:
300
-
301
- - Each replica has its own cache; memory usage is multiplied by the replica
302
- count (at most 500 entries plus their compressed copies).
303
- - Each replica runs its own warming round at startup. Set `PREWARM_MAX` and
304
- `PREWARM_CONCURRENCY` so that your upstream API can handle the load
305
- multiplied by the replica count.
306
- - `clearHtmlCache()` only affects the process it is called in. If you need to
307
- clear all replicas, you have to solve it at the orchestrator level (a restart)
308
- or with a broadcast mechanism you write yourself.
309
- - If there is a CDN in front, most requests never reach the origin and the
310
- per-replica cache difference becomes invisible.
311
-
312
- To increase the capacity of a single replica, raising the `revalidate`
313
- durations is usually more effective than adding replicas: as the cache hit rate
314
- goes up, the work per request drops to almost zero.
315
-
316
- ## Pre-release checklist
317
-
318
- - [ ] `NODE_ENV=production`
319
- - [ ] `npm run build` ran and produced `.jskelet/manifest.json`
320
- - [ ] The woff2 files under `public/fonts/` are committed
321
- ([08-build.md](./08-build.md))
322
- - [ ] The `@source` directives in `styles/globals.css` cover all template
323
- directories
324
- - [ ] `hooks.notFound()` is defined and there is a 404 template
325
- - [ ] `hooks.metadata()` contains `siteUrl` (so relative `canonical`s become
326
- absolute)
327
- - [ ] The `cache().html` patterns match the site's freshness profile
328
- - [ ] `hooks.prewarmPaths()` puts the most important pages first
329
- - [ ] CSP and security headers are defined in `headers()`
330
- - [ ] A health check endpoint exists and is in the `devGateBypass` list
331
- - [ ] Staging has `DEV_GATE=1` and `DEV_TOKEN`; production leaves the gate **off**
332
- - [ ] The reverse proxy forwards `Accept-Encoding` and does not do its own
333
- compression
334
- - [ ] There are no secret keys in the `clientEnv` list
335
-
336
- ## What's next
337
-
338
- - Cache settings and prewarm: [06-caching.md](./06-caching.md)
339
- - All environment variables: [07-configuration.md](./07-configuration.md)
340
- - Migrating from Next.js: [11-migration.md](./11-migration.md)
1
+ # 10 — Deployment
2
+
3
+ This document explains how to put a JSkelet application into production: the
4
+ prod build and start flow, the environment variables you should set, a working
5
+ Docker setup, reverse proxy and `trust proxy` notes, how a health check endpoint
6
+ is added, and how the cache behaves when you scale out. What the build steps do
7
+ is in [08-build.md](./08-build.md), cache behavior in
8
+ [06-caching.md](./06-caching.md).
9
+
10
+ ## The prod flow
11
+
12
+ ```bash
13
+ npm ci
14
+ npm run build # jskelet build
15
+ npm start # jskelet start
16
+ ```
17
+
18
+ If `NODE_ENV` is not given, `jskelet build` sets it to `production` and runs all
19
+ steps: fonts, icon sprite, CSS, client JS, images, manifest, precompress.
20
+
21
+ `jskelet start` first looks for `.jskelet/manifest.json`; if it is missing, it
22
+ runs the build itself. In a Docker image the build has already happened, so this
23
+ is a no-op; the point is that someone running `npm start` directly does not end
24
+ up with an unstyled page.
25
+
26
+ If the listen port is already taken, the process **does not start** (PID + hint).
27
+ `jskelet start --murder` kills that listener and binds — useful for a leftover
28
+ dev process; production orchestrators usually do not need it.
29
+
30
+ When the server is ready it prints a single line:
31
+
32
+ ```
33
+ jskelet → http://localhost:3000 (production)
34
+ ```
35
+
36
+ The process is protected by two safety nets: `unhandledRejection` and
37
+ `uncaughtException` are logged and the process stays up. On a news site, an
38
+ error on a single page should not take the whole site down. If you want to hook
39
+ this up to your own error tracking tool (Sentry etc.), you can add your own
40
+ listener to the same events.
41
+
42
+ ## Environment variables
43
+
44
+ No variable is required; all of them have a sensible default. The ones worth
45
+ considering in production:
46
+
47
+ | Variable | Recommendation | Why |
48
+ | --- | --- | --- |
49
+ | `NODE_ENV` | `production` | Template cache, reading the manifest once, throwing on a broken route module |
50
+ | `PORT` | `3000` | The port your orchestrator expects |
51
+ | `HOST` | `0.0.0.0` | Only if you need to listen on IPv4 alone; the `::` default already listens dual-stack |
52
+ | `PREWARM_MAX` | Depends on site size | Number of pages warmed at startup |
53
+ | `PREWARM_INTERVAL_SECONDS` | `0` or a long value | If you want to keep never-visited pages warm |
54
+ | `DEV_GATE` + `DEV_TOKEN` | Staging only | Hides an environment that is not public yet. The token alone does not lock the site |
55
+ | `JSKELET_S3_*` | If you write access logs to S3 | Bucket + credentials; details in [07](./07-configuration.md) |
56
+
57
+ When a file or S3 sink is enabled in production, the HTTP access log middleware
58
+ mounts automatically (if `http` is in `logs.kinds`). The admin panel ring is
59
+ separate — lines written to disk/S3 do not stream into the panel.
60
+
61
+ The full list and the precedence order of the prewarm settings:
62
+ [07-configuration.md](./07-configuration.md).
63
+
64
+ Since the CLI runs with `--env-file-if-exists=.env`, a `.env` file is loaded
65
+ automatically if it exists; if not, no error is raised. In a container,
66
+ environment variables are usually injected directly instead of using this file.
67
+ Using both sources together blurs which value actually applies; not shipping a
68
+ `.env` in the prod image is the cleanest option.
69
+
70
+ **Secret keys must not go into the `clientEnv` list:** those values are embedded
71
+ into the client bundle as plain text ([08-build.md](./08-build.md)). Secret-like
72
+ names (`SECRET`, `API_KEY`, …) now fail the build.
73
+
74
+ ## Docker
75
+
76
+ A multi-stage image: the build stage compiles with dev dependencies, the runtime
77
+ stage carries only production dependencies and the build output.
78
+
79
+ ```dockerfile
80
+ # syntax=docker/dockerfile:1
81
+
82
+ # ---------- build ----------
83
+ FROM node:22-bookworm-slim AS build
84
+ WORKDIR /app
85
+
86
+ # Dependencies in a separate layer: don't reinstall when sources change.
87
+ COPY package.json package-lock.json ./
88
+ RUN npm ci
89
+
90
+ # `public/fonts/` must be committed: the build should not need network access.
91
+ COPY . .
92
+
93
+ ENV NODE_ENV=production
94
+ RUN npx jskelet build
95
+
96
+ # ---------- runtime ----------
97
+ FROM node:22-bookworm-slim AS runtime
98
+ WORKDIR /app
99
+
100
+ ENV NODE_ENV=production
101
+ ENV PORT=3000
102
+ ENV HOST=0.0.0.0
103
+
104
+ COPY package.json package-lock.json ./
105
+ # sharp and tailwind are only needed at build time; keep them out of the runtime image.
106
+ # If you use images.remote, move sharp to production dependencies.
107
+ RUN npm ci --omit=dev && npm cache clean --force
108
+
109
+ COPY --from=build /app/jskelet.config.mjs ./jskelet.config.mjs
110
+ COPY --from=build /app/jsconfig.json ./jsconfig.json
111
+ COPY --from=build /app/routes ./routes
112
+ COPY --from=build /app/views ./views
113
+ COPY --from=build /app/lib ./lib
114
+ COPY --from=build /app/public ./public
115
+ COPY --from=build /app/.jskelet ./.jskelet
116
+
117
+ # Non-root user.
118
+ USER node
119
+
120
+ EXPOSE 3000
121
+
122
+ # Health check: assumes you added the route below.
123
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
124
+ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/healthcheck').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
125
+
126
+ CMD ["npx", "jskelet", "start"]
127
+ ```
128
+
129
+ Notes:
130
+
131
+ - **`client/` and `styles/` are not needed in the runtime image:** their output
132
+ is under `public/assets/`. `views/` and `routes/` are needed, because
133
+ rendering happens at runtime. Copy `lib/` only if your project has one.
134
+ - **`.jskelet/` is needed:** without `manifest.json`, `asset()` cannot find the
135
+ hashed URLs and `jskelet start` will try to run the build from scratch.
136
+ - **`sharp` is usually not needed in the runtime image:** it is only for
137
+ build-time image optimization. `--omit=dev` leaves it out (if it was installed
138
+ as a devDependency). **If `images.remote` is enabled**, sharp is a runtime
139
+ dependency — move it to production `dependencies` or install it in the
140
+ runtime image; otherwise the optimizer 302-redirects to the source URL.
141
+ - If you would rather call `jskelet start` without `npx`,
142
+ `CMD ["node", "node_modules/jskelet/bin/jskelet.mjs", "start"]` works too.
143
+
144
+ `.dockerignore`:
145
+
146
+ ```
147
+ node_modules
148
+ .git
149
+ .jskelet
150
+ public/assets
151
+ .env
152
+ ```
153
+
154
+ The build stage produces these itself with `npx jskelet build`.
155
+
156
+ ### Deploying from a subdirectory of the repo
157
+
158
+ The examples in this repo pull jskelet with `"jskelet": "file:../.."` rather
159
+ than from npm. In tools like Coolify, Railway or Render, if you set the "base
160
+ directory" to `examples/blog`, the build context becomes only that directory,
161
+ `../..` falls outside the context, and installation fails at `npm ci`. The
162
+ correct setting: **base directory `/`** (the repo root) and adapt the
163
+ multi-stage Dockerfile above to the app directory — or install jskelet as a
164
+ normal npm dependency and use the app directory as the context.
165
+
166
+ In your own application jskelet will be an ordinary dependency, so this
167
+ constraint does not apply; the multi-stage image above is enough.
168
+
169
+ ## Health check
170
+
171
+ The framework does **not** add a ready-made health check endpoint; you have to
172
+ put it in your own route. Since the default `devGateBypass` list contains
173
+ `/api/healthcheck`, using that name is the least surprising option: it stays
174
+ reachable even while the dev gate is on.
175
+
176
+ ```js
177
+ // routes/00-health.mjs
178
+ import { getHtmlCacheSize } from "jskelet";
179
+
180
+ export default function register(app) {
181
+ app.get("/api/healthcheck", (req, res) => {
182
+ res.setHeader("Cache-Control", "no-store");
183
+ res.json({
184
+ ok: true,
185
+ uptime: process.uptime(),
186
+ cache: getHtmlCacheSize(),
187
+ });
188
+ });
189
+ }
190
+ ```
191
+
192
+ The `00-` prefix in the file name makes sure this route is registered before any
193
+ catch-all ([03-routing.md](./03-routing.md)).
194
+
195
+ If you are going to use a different path, update the `devGateBypass` list,
196
+ otherwise your orchestrator will see a 404 on staging:
197
+
198
+ ```js
199
+ devGateBypass: ["/healthz", "/robots.txt", "/sitemap.xml", "/favicon.ico"]
200
+ ```
201
+
202
+ The warming round does not affect the health check: even if prewarm fails, the
203
+ process stays up and pages are served (cold, but served).
204
+
205
+ If you need to separate readiness from liveness, you can report the warming
206
+ status too:
207
+
208
+ ```js
209
+ import { prewarmProgress } from "jskelet";
210
+
211
+ app.get("/api/ready", (req, res) => {
212
+ const warmedUp = !prewarmProgress.active && prewarmProgress.finishedAt !== null;
213
+ res.status(warmedUp ? 200 : 503).json({ warmedUp, ...prewarmProgress });
214
+ });
215
+ ```
216
+
217
+ Remember to exclude this endpoint's path from warming with `prewarmSkip` (the
218
+ default `/api/` prefix already covers it).
219
+
220
+ ## Reverse proxy
221
+
222
+ The Express application sets `trust proxy` to **on**
223
+ (`app.set("trust proxy", true)`). The consequences:
224
+
225
+ - `req.protocol` is read from the `X-Forwarded-Proto` header, so if the proxy
226
+ terminates TLS, `https` is returned correctly.
227
+ - `req.ip` is resolved from the `X-Forwarded-For` chain.
228
+ - Absolute URLs produced by `res.redirect()` carry the correct scheme.
229
+
230
+ This setting **assumes the proxy writes these headers reliably.** If you are
231
+ going to expose the application directly to the internet, remember that a client
232
+ can fabricate `X-Forwarded-*` headers; always run behind a proxy or load
233
+ balancer and make sure the proxy overwrites the incoming `X-Forwarded-For`
234
+ header.
235
+
236
+ An example nginx configuration:
237
+
238
+ ```nginx
239
+ upstream jskelet {
240
+ server 127.0.0.1:3000;
241
+ keepalive 32;
242
+ }
243
+
244
+ server {
245
+ listen 443 ssl http2;
246
+ server_name example.com;
247
+
248
+ # Response bodies already arrive compressed; don't compress a second time.
249
+ gzip off;
250
+
251
+ location / {
252
+ proxy_pass http://jskelet;
253
+ proxy_http_version 1.1;
254
+
255
+ proxy_set_header Host $host;
256
+ proxy_set_header X-Real-IP $remote_addr;
257
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
258
+ proxy_set_header X-Forwarded-Proto $scheme;
259
+ proxy_set_header Connection "";
260
+
261
+ # Forward it to the upstream so we can get a compressed response.
262
+ proxy_set_header Accept-Encoding $http_accept_encoding;
263
+ }
264
+ }
265
+ ```
266
+
267
+ Key points:
268
+
269
+ - **Do not compress twice.** JSkelet does the brotli/gzip negotiation itself and
270
+ stores the compressed body for cached pages. Leaving nginx's own `gzip` on can
271
+ lead to decompressing brotli and re-gzipping it.
272
+ - **Forward `Accept-Encoding`**, otherwise the application will not compress and
273
+ the ready-made compressed bodies in the cache go unused.
274
+ - `Vary: Accept-Encoding` is written by the application; proxy caches take it
275
+ into account.
276
+
277
+ ### Together with a CDN
278
+
279
+ The headers written on cacheable pages:
280
+
281
+ ```
282
+ Cache-Control: public, max-age=0
283
+ CDN-Cache-Control: max-age=<html ttl>, stale-while-revalidate=<staleWhileRevalidate>
284
+ ```
285
+
286
+ `max-age=0` disables browser storage. The edge duration is `max-age` on
287
+ `CDN-Cache-Control`, and it is the existing HTML TTL. `stale-while-revalidate`
288
+ is `cache().staleWhileRevalidate` (default 60; `0` omits the directive).
289
+ `s-maxage` is not written: Cloudflare treats it as `EXPIRED` together with
290
+ `max-age=0`. `must-revalidate`, `proxy-revalidate` and `no-cache` are not on
291
+ the same response.
292
+
293
+ The CDN serves its own copy for `max-age`, then serves the stale HTML for
294
+ `stale-while-revalidate` while it asks the origin. The origin answers instantly
295
+ from its own cache.
296
+
297
+ **Breaking.** An intermediate layer that only reads `Cache-Control` /
298
+ `s-maxage` (nginx `proxy_cache`) no longer caches this HTML. Cloudflare reads
299
+ `CDN-Cache-Control`. The in-process cache and `X-JSkelet-Cache` stay the same.
300
+
301
+ The `X-JSkelet-Cache` header makes it easier to diagnose which layer answered;
302
+ read it together with the CDN's own cache header
303
+ ([06-caching.md](./06-caching.md)).
304
+
305
+ Static assets (`/assets/`, `/fonts/`) are marked `immutable`, so they can be
306
+ held indefinitely on the CDN; when the hash changes, so does the URL.
307
+
308
+ ## Scaling
309
+
310
+ The HTML cache lives **in process memory**. When you run more than one replica:
311
+
312
+ - Each replica has its own cache; memory usage is multiplied by the replica
313
+ count (at most 500 entries plus their compressed copies).
314
+ - Each replica runs its own warming round at startup. Set `PREWARM_MAX` and
315
+ `PREWARM_CONCURRENCY` so that your upstream API can handle the load
316
+ multiplied by the replica count.
317
+ - `clearHtmlCache()` only affects the process it is called in. If you need to
318
+ clear all replicas, you have to solve it at the orchestrator level (a restart)
319
+ or with a broadcast mechanism you write yourself.
320
+ - If there is a CDN in front, most requests never reach the origin and the
321
+ per-replica cache difference becomes invisible.
322
+
323
+ To increase the capacity of a single replica, raising the `revalidate`
324
+ durations is usually more effective than adding replicas: as the cache hit rate
325
+ goes up, the work per request drops to almost zero.
326
+
327
+ ## Pre-release checklist
328
+
329
+ - [ ] `NODE_ENV=production`
330
+ - [ ] `npm run build` ran and produced `.jskelet/manifest.json`
331
+ - [ ] The woff2 files under `public/fonts/` are committed
332
+ ([08-build.md](./08-build.md))
333
+ - [ ] The `@source` directives in `styles/globals.css` cover all template
334
+ directories
335
+ - [ ] `hooks.notFound()` is defined and there is a 404 template
336
+ - [ ] `hooks.metadata()` contains `siteUrl` (so relative `canonical`s become
337
+ absolute)
338
+ - [ ] The `cache().html` patterns match the site's freshness profile
339
+ - [ ] `hooks.prewarmPaths()` puts the most important pages first
340
+ - [ ] CSP and security headers are defined in `headers()`
341
+ - [ ] A health check endpoint exists and is in the `devGateBypass` list
342
+ - [ ] Staging has `DEV_GATE=1` and `DEV_TOKEN`; production leaves the gate **off**
343
+ - [ ] The reverse proxy forwards `Accept-Encoding` and does not do its own
344
+ compression
345
+ - [ ] There are no secret keys in the `clientEnv` list
346
+
347
+ ## What's next
348
+
349
+ - Cache settings and prewarm: [06-caching.md](./06-caching.md)
350
+ - All environment variables: [07-configuration.md](./07-configuration.md)
351
+ - Migrating from Next.js: [11-migration.md](./11-migration.md)