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.
- package/AGENTS.md +136 -136
- package/CHANGELOG.md +628 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +667 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +348 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +675 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +351 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +369 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/og-image.d.ts +5 -0
package/docs/en/10-deployment.md
CHANGED
|
@@ -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
|
|
280
|
-
|
|
281
|
-
```
|
|
282
|
-
Cache-Control: public, max-age=0
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
The
|
|
300
|
-
|
|
301
|
-
-
|
|
302
|
-
|
|
303
|
-
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
-
|
|
328
|
-
|
|
329
|
-
- [ ]
|
|
330
|
-
- [ ]
|
|
331
|
-
- [ ]
|
|
332
|
-
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
-
|
|
339
|
-
-
|
|
340
|
-
-
|
|
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)
|