toiljs 0.0.34 → 0.0.37
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/CHANGELOG.md +15 -0
- package/README.md +1 -0
- package/as-pect.config.js +8 -2
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +97 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +42 -0
- package/build/client/auth.js +182 -0
- package/build/client/index.d.ts +5 -1
- package/build/client/index.js +3 -1
- package/build/client/routing/loader.d.ts +1 -0
- package/build/client/routing/loader.js +37 -0
- package/build/client/routing/mount.js +32 -1
- package/build/client/ssr/markers.d.ts +34 -0
- package/build/client/ssr/markers.js +49 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/docs.js +88 -1
- package/build/compiler/generate.d.ts +2 -0
- package/build/compiler/generate.js +2 -2
- package/build/compiler/index.js +2 -0
- package/build/compiler/ssr-codegen.d.ts +2 -0
- package/build/compiler/ssr-codegen.js +36 -0
- package/build/compiler/template-build.d.ts +29 -0
- package/build/compiler/template-build.js +150 -0
- package/build/compiler/template.d.ts +22 -0
- package/build/compiler/template.js +169 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +15 -0
- package/build/devserver/host.js +1 -0
- package/build/devserver/module.d.ts +1 -0
- package/build/devserver/module.js +23 -1
- package/docs/README.md +56 -0
- package/docs/auth.md +261 -0
- package/docs/caching.md +115 -0
- package/docs/cookies.md +457 -0
- package/docs/crypto.md +130 -0
- package/docs/data.md +131 -0
- package/docs/getting-started.md +128 -0
- package/docs/routing.md +259 -0
- package/docs/rpc.md +149 -0
- package/docs/ssr.md +184 -0
- package/docs/time.md +43 -0
- package/examples/basic/client/routes/auth.tsx +198 -0
- package/examples/basic/client/routes/cookies.tsx +199 -0
- package/examples/basic/client/routes/features/index.tsx +34 -10
- package/examples/basic/client/routes/hello.tsx +43 -0
- package/examples/basic/client/routes/pq.tsx +260 -0
- package/examples/basic/server/AuthTestHandler.ts +15 -0
- package/examples/basic/server/AuthVerifyHandler.ts +23 -0
- package/examples/basic/server/CacheHandler.ts +25 -0
- package/examples/basic/server/DecoCache.ts +18 -0
- package/examples/basic/server/FastTrapHandler.ts +8 -0
- package/examples/basic/server/SpinHandler.ts +18 -0
- package/examples/basic/server/SsrGreetingRender.ts +27 -0
- package/examples/basic/server/authexample-main.ts +8 -0
- package/examples/basic/server/authtest-main.ts +8 -0
- package/examples/basic/server/authverify-main.ts +8 -0
- package/examples/basic/server/cache-main.ts +8 -0
- package/examples/basic/server/core/AppHandler.ts +243 -0
- package/examples/basic/server/deco-main.ts +18 -0
- package/examples/basic/server/main.ts +2 -0
- package/examples/basic/server/routes/Auth.ts +184 -0
- package/examples/basic/server/routes/PqDemo.ts +130 -0
- package/examples/basic/server/routes/Session.ts +74 -0
- package/examples/basic/server/spin-main.ts +13 -0
- package/examples/basic/server/ssr/greeting.slots.ts +19 -0
- package/examples/basic/server/ssr-main.ts +18 -0
- package/examples/basic/server/toil-server-env.d.ts +94 -0
- package/examples/basic/server/trap-main.ts +8 -0
- package/package.json +5 -3
- package/server/globals/auth.ts +281 -0
- package/server/runtime/README.md +61 -0
- package/server/runtime/env/Server.ts +12 -0
- package/server/runtime/exports/index.ts +17 -0
- package/server/runtime/exports/render.ts +51 -0
- package/server/runtime/http/base64.ts +104 -0
- package/server/runtime/http/cookie.ts +416 -0
- package/server/runtime/http/cookies.ts +197 -0
- package/server/runtime/http/date.ts +72 -0
- package/server/runtime/http/percent.ts +76 -0
- package/server/runtime/http/securecookies.ts +224 -0
- package/server/runtime/index.ts +17 -0
- package/server/runtime/request.ts +24 -0
- package/server/runtime/response.ts +29 -0
- package/server/runtime/ssr/Ssr.ts +43 -0
- package/server/runtime/ssr/encode.ts +110 -0
- package/server/runtime/ssr/escape.ts +83 -0
- package/server/runtime/ssr/slots.ts +144 -0
- package/server/runtime/time.ts +29 -0
- package/src/cli/create.ts +105 -0
- package/src/client/auth.ts +327 -0
- package/src/client/index.ts +5 -1
- package/src/client/routing/loader.ts +56 -0
- package/src/client/routing/mount.tsx +37 -1
- package/src/client/ssr/markers.tsx +140 -0
- package/src/compiler/docs.ts +88 -1
- package/src/compiler/generate.ts +2 -2
- package/src/compiler/index.ts +5 -0
- package/src/compiler/ssr-codegen.ts +85 -0
- package/src/compiler/template-build.ts +275 -0
- package/src/compiler/template.ts +265 -0
- package/src/devserver/crypto.ts +23 -0
- package/src/devserver/host.ts +4 -0
- package/src/devserver/module.ts +39 -1
- package/test/assembly/cookie.spec.ts +302 -0
- package/test/assembly/example.spec.ts +5 -1
- package/test/assembly/ssr.spec.ts +94 -0
- package/test/devserver.test.ts +42 -0
- package/test/ssr-render.test.ts +128 -0
- package/test/ssr-template.test.tsx +348 -0
package/docs/auth.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Auth, sessions, and `@user`
|
|
2
|
+
|
|
3
|
+
toiljs ships a complete authentication primitive: a post-quantum login
|
|
4
|
+
(ML-DSA-44, password-derived), HMAC-signed session cookies, a `@auth` route
|
|
5
|
+
guard, and a `@user` type that makes the signed-in user available — fully typed,
|
|
6
|
+
with no type argument — on both the server (`AuthService.getUser()`) and the
|
|
7
|
+
generated client (`getUser()`).
|
|
8
|
+
|
|
9
|
+
`AuthService` is an ambient global (no import). The pieces:
|
|
10
|
+
|
|
11
|
+
- **`@user`** — declares the authenticated user's shape and registers it as
|
|
12
|
+
*the* user type.
|
|
13
|
+
- **`@auth`** — guards a route (or a whole `@rest` class): a valid session is
|
|
14
|
+
required or the request is rejected with `401`.
|
|
15
|
+
- **`AuthService`** — the server runtime: verify a login, mint/read/clear a
|
|
16
|
+
session, and `getUser()`.
|
|
17
|
+
- **client `Auth` + generated `getUser()`** — derive the keypair from a
|
|
18
|
+
password, register/login, and read the user for display.
|
|
19
|
+
|
|
20
|
+
## Flow at a glance
|
|
21
|
+
|
|
22
|
+
Register sends only a public key; login proves identity with a one-time
|
|
23
|
+
signature the server verifies (it never holds a secret); `@auth` then checks the
|
|
24
|
+
HMAC-signed session cookie on every guarded request.
|
|
25
|
+
|
|
26
|
+
```mermaid
|
|
27
|
+
sequenceDiagram
|
|
28
|
+
autonumber
|
|
29
|
+
actor U as User
|
|
30
|
+
participant C as Browser<br/>toiljs/client Auth
|
|
31
|
+
participant S as Edge wasm<br/>AuthService
|
|
32
|
+
participant DB as Your store<br/>accounts + challenges
|
|
33
|
+
|
|
34
|
+
rect rgb(14, 21, 32)
|
|
35
|
+
Note over U,DB: Register, the password never leaves the browser
|
|
36
|
+
U->>C: Auth.register(username, password)
|
|
37
|
+
C->>C: Argon2id, then ML-DSA-44 keypair,<br/>keep public key, zeroize secret key
|
|
38
|
+
C->>S: POST /auth/register { username, publicKey 1312 B }
|
|
39
|
+
S->>DB: store Account { username, publicKey }
|
|
40
|
+
S-->>C: 200 ok
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
rect rgb(22, 15, 31)
|
|
44
|
+
Note over U,DB: Login, the server holds no secret
|
|
45
|
+
U->>C: Auth.login(username, password)
|
|
46
|
+
C->>S: GET /auth/challenge { username }
|
|
47
|
+
S->>DB: create + store challenge (cid, nonce, iat, exp)
|
|
48
|
+
S-->>C: { cid, aud, nonce, iat, exp }
|
|
49
|
+
C->>C: re-derive keypair,<br/>sign buildLoginMessage(...) once
|
|
50
|
+
C->>S: POST /auth/login { cid, signature 2420 B }
|
|
51
|
+
S->>DB: atomic consume challenge(cid)
|
|
52
|
+
S->>S: rebuild message from stored values,<br/>verifyLogin via crypto.mldsa_verify
|
|
53
|
+
alt signature valid
|
|
54
|
+
S-->>C: 200 + Set-Cookie __Host-toil_sess (HttpOnly, signed)<br/>and __Secure-toil_user (readable, display only)
|
|
55
|
+
else invalid or unknown user
|
|
56
|
+
S-->>C: 401, constant time, anti-enumeration
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
rect rgb(13, 25, 18)
|
|
61
|
+
Note over U,DB: Guarded request, the @auth guard
|
|
62
|
+
U->>C: open or call an @auth route
|
|
63
|
+
C->>S: request, cookies sent automatically
|
|
64
|
+
S->>S: @auth checks AuthService.hasSession(),<br/>verify HMAC + expiry on __Host-toil_sess
|
|
65
|
+
alt valid session
|
|
66
|
+
S->>S: handler runs,<br/>AuthService.getUser() returns the @user
|
|
67
|
+
S-->>C: 200 response
|
|
68
|
+
else missing or invalid session
|
|
69
|
+
S-->>C: 401 before the handler and body-decode
|
|
70
|
+
end
|
|
71
|
+
C->>C: getUser() reads __Secure-toil_user,<br/>untrusted, UI only
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The two cookies are the trust boundary: the HttpOnly `__Host-toil_sess` is the
|
|
76
|
+
only one the server trusts (it re-verifies its signature and expiry every
|
|
77
|
+
request), while the readable `__Secure-toil_user` exists solely so the client
|
|
78
|
+
`getUser()` can show a name without a round-trip and must never gate anything.
|
|
79
|
+
|
|
80
|
+
## `@user`
|
|
81
|
+
|
|
82
|
+
Mark one class per program as the user type. It becomes a `@data` codec (so it
|
|
83
|
+
serializes into the session) and the return type of `getUser()` everywhere.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
@user
|
|
87
|
+
class Account {
|
|
88
|
+
username: string = '';
|
|
89
|
+
admin: bool = false;
|
|
90
|
+
score: u64 = 0;
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
There is exactly one `@user` per program; a second one is a compile error.
|
|
95
|
+
|
|
96
|
+
## `@auth`
|
|
97
|
+
|
|
98
|
+
Put `@auth` on a route, or on the `@rest` class to guard every route in it. The
|
|
99
|
+
generated dispatcher checks for a valid, unexpired session **before** the
|
|
100
|
+
handler runs (and before any body-decode or cache write); without one it returns
|
|
101
|
+
`401 unauthorized`.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
@rest('session')
|
|
105
|
+
class Session {
|
|
106
|
+
@auth
|
|
107
|
+
@get('/me')
|
|
108
|
+
public me(): Response {
|
|
109
|
+
const u = AuthService.getUser(); // Account | null, auto-typed
|
|
110
|
+
if (u == null) return Response.text('no session\n', 401);
|
|
111
|
+
return Response.bytes(new DataWriter()
|
|
112
|
+
.writeString(u.username)
|
|
113
|
+
.writeBool(u.admin)
|
|
114
|
+
.writeU64(u.score)
|
|
115
|
+
.toBytes());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@auth
|
|
119
|
+
@post('/logout')
|
|
120
|
+
public logout(): Response {
|
|
121
|
+
return Response.text('bye\n', 200)
|
|
122
|
+
.setCookie(AuthService.clearSession())
|
|
123
|
+
.setCookie(AuthService.clearUserCookie());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`@auth` on the class form guards all routes:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
@auth
|
|
132
|
+
@rest('admin')
|
|
133
|
+
class Admin { /* every route requires a session */ }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## `AuthService` (server)
|
|
137
|
+
|
|
138
|
+
`AuthService` is a global namespace. The session methods read the ambient
|
|
139
|
+
request (`Server.currentRequest`), so `getUser()`/`hasSession()` take no
|
|
140
|
+
argument and are only meaningful during a dispatch.
|
|
141
|
+
|
|
142
|
+
### Sessions
|
|
143
|
+
|
|
144
|
+
| Member | Signature | Notes |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| `getUser()` | `getUser(): AuthUser \| null` | The signed-in user, decoded from the verified session, auto-typed to your `@user` class. |
|
|
147
|
+
| `hasSession()` | `hasSession(): bool` | Whether the request carries a valid, unexpired session. What `@auth` calls. |
|
|
148
|
+
| `getSessionBytes()` | `getSessionBytes(): Uint8Array \| null` | The raw verified `@user` codec bytes, or `null`. |
|
|
149
|
+
| `mintSession(userData, ttlSecs?)` | `mintSession(userData: Uint8Array, ttlSecs: u64 = 86400): Cookie` | Build the signed `__Host-toil_sess` cookie carrying `userData` (i.e. `user.encode()`). HttpOnly, Secure, SameSite=Lax. |
|
|
150
|
+
| `clearSession()` | `clearSession(): Cookie` | A `Set-Cookie` that clears the session. |
|
|
151
|
+
| `userCookie(userData, ttlSecs?)` | `userCookie(userData: Uint8Array, ttlSecs: u64 = 86400): Cookie` | The readable `__Secure-toil_user` companion cookie for the client's `getUser()`. Secure, **not** HttpOnly. Display-only. |
|
|
152
|
+
| `clearUserCookie()` | `clearUserCookie(): Cookie` | Clears the companion cookie. |
|
|
153
|
+
| `setSecret(secret)` | `setSecret(secret: Uint8Array): void` | Set the HMAC secret used to sign sessions. Call once at startup. |
|
|
154
|
+
| `DEFAULT_SESSION_TTL_SECS` | `u64 = 86400` | 24h default lifetime. |
|
|
155
|
+
| `SESSION_COOKIE` / `USER_COOKIE` | `string` | `__Host-toil_sess` / `__Secure-toil_user`. |
|
|
156
|
+
|
|
157
|
+
To log a user in, mint both cookies on the success response:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
@post('/dev-login')
|
|
161
|
+
public devLogin(ctx: RouteContext): Response {
|
|
162
|
+
const u = new Account();
|
|
163
|
+
u.username = new DataReader(ctx.request.body).readString();
|
|
164
|
+
const data = u.encode();
|
|
165
|
+
return Response.text('ok\n', 200)
|
|
166
|
+
.setCookie(AuthService.mintSession(data, 3600)) // HttpOnly signed session
|
|
167
|
+
.setCookie(AuthService.userCookie(data, 3600)); // readable companion
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The session payload is `u8 version | u64 iat | u64 exp | bytes userData`, sealed
|
|
172
|
+
with HMAC-SHA256 via `SecureCookies.signed`. `getSessionBytes()` verifies the
|
|
173
|
+
signature, checks expiry against `Time.nowSeconds()`, and returns the `userData`
|
|
174
|
+
bytes; `getUser()` decodes those into your `@user` class.
|
|
175
|
+
|
|
176
|
+
### The server secret
|
|
177
|
+
|
|
178
|
+
Sessions are signed with a server secret that must be **identical on every edge
|
|
179
|
+
instance** and **never shipped to the client**. There is no host-config secret
|
|
180
|
+
mechanism yet, so set it at startup:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// in main.ts, once
|
|
184
|
+
AuthService.setSecret(/* 32 bytes, build-time constant or deployment secret */);
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
If you do not call `setSecret`, a well-known **DEV placeholder** is used so local
|
|
188
|
+
development works out of the box. That default is insecure by design — a real
|
|
189
|
+
deployment must override it.
|
|
190
|
+
|
|
191
|
+
### Post-quantum login
|
|
192
|
+
|
|
193
|
+
The login primitive proves identity without the server ever holding a secret:
|
|
194
|
+
|
|
195
|
+
- The client derives an **ML-DSA-44** (FIPS 204) keypair from the password via
|
|
196
|
+
**Argon2id**, keeps only the 1312-byte public key on the account, and signs a
|
|
197
|
+
login challenge.
|
|
198
|
+
- The server rebuilds the exact signed message from its own stored values and
|
|
199
|
+
verifies the 2420-byte signature with `crypto.mldsa_verify` (verify-only; the
|
|
200
|
+
host never holds a secret key).
|
|
201
|
+
|
|
202
|
+
`AuthService` provides the message construction and verification:
|
|
203
|
+
|
|
204
|
+
| Member | Signature | Notes |
|
|
205
|
+
| --- | --- | --- |
|
|
206
|
+
| `LOGIN_CONTEXT` | `string = 'qauth:login:v1'` | FIPS 204 signing context (domain separator). Byte-identical on client and server. |
|
|
207
|
+
| `PUBLIC_KEY_LEN` / `SIGNATURE_LEN` | `i32` | `1312` / `2420`. |
|
|
208
|
+
| `buildLoginMessage(sub, aud, cid, nonce, iat, exp)` | `(string, string, Uint8Array, Uint8Array, u64, u64): Uint8Array` | The canonical login message `M`, fixed binary layout. Call it with the server's **own** stored values, never client-echoed fields. |
|
|
209
|
+
| `verifyLogin(publicKey, message, signature)` | `(Uint8Array, Uint8Array, Uint8Array): bool` | Verifies under `LOGIN_CONTEXT`; size-checks first. |
|
|
210
|
+
|
|
211
|
+
The challenge → verify flow (see `examples/basic/server/routes/Auth.ts` for the
|
|
212
|
+
full template, including the anti-enumeration and atomic challenge-consume
|
|
213
|
+
requirements) ends with: rebuild the message, `verifyLogin(...)`, then
|
|
214
|
+
`mintSession(account.encode())` on success. Account and challenge **storage is
|
|
215
|
+
the app's** — a tenant's wasm memory is wiped per request, so back them with an
|
|
216
|
+
external store and make challenge-consume an atomic fetch-and-delete.
|
|
217
|
+
|
|
218
|
+
## The client half
|
|
219
|
+
|
|
220
|
+
The client SDK lives in `toiljs/client` and never lets the password leave the
|
|
221
|
+
browser:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { Auth } from 'toiljs/client';
|
|
225
|
+
|
|
226
|
+
await Auth.register(username, password); // derive keypair, send only the public key
|
|
227
|
+
await Auth.login(username, password); // fetch challenge, sign once, submit {cid, sig}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`register`/`login` stretch the password with Argon2id into a 32-byte seed,
|
|
231
|
+
expand it to the ML-DSA-44 keypair, and zeroize the secret key and seed the
|
|
232
|
+
instant signing is done. There is no recovery: the password *is* the key.
|
|
233
|
+
|
|
234
|
+
### `getUser()` on the client
|
|
235
|
+
|
|
236
|
+
The generated `shared/server.ts` exports a typed, no-argument `getUser()` that
|
|
237
|
+
reads the readable `__Secure-toil_user` companion cookie and decodes it with the
|
|
238
|
+
generated `@user` codec:
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { getUser } from './shared/server';
|
|
242
|
+
|
|
243
|
+
const user = getUser(); // Account | null, fully typed
|
|
244
|
+
if (user) showName(user.username);
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
This is **display-only and untrusted**: a client can forge the companion cookie,
|
|
248
|
+
fooling only its own UI. The server re-verifies the HttpOnly signed session on
|
|
249
|
+
every `@auth` request, so authorization never depends on the readable cookie.
|
|
250
|
+
|
|
251
|
+
## Security checklist
|
|
252
|
+
|
|
253
|
+
- Set a real `AuthService.setSecret(...)` in production; the same value on every
|
|
254
|
+
instance; never in a client bundle.
|
|
255
|
+
- The session cookie is HttpOnly + Secure + `__Host-` scoped; the companion
|
|
256
|
+
cookie is readable and untrusted — only ever use it for display.
|
|
257
|
+
- Always verify server-side. `getUser()` (server) decodes a signature-verified,
|
|
258
|
+
expiry-checked session; the client `getUser()` does not and must not gate
|
|
259
|
+
anything sensitive.
|
|
260
|
+
- Login storage (accounts, challenges) is yours to provide and must consume
|
|
261
|
+
challenges atomically to defeat signature replay.
|
package/docs/caching.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Caching
|
|
2
|
+
|
|
3
|
+
toiljs can cache a response at the edge (shared, across users) and instruct the
|
|
4
|
+
browser to cache it too. You opt in per route, either declaratively with the
|
|
5
|
+
`@cache` decorator or imperatively with `Response.cache(...)`. The edge keys a
|
|
6
|
+
cached entry by host, method, path, and body hash, and honors a per-entry TTL.
|
|
7
|
+
|
|
8
|
+
## `@cache` decorator
|
|
9
|
+
|
|
10
|
+
Annotate a route method; the compiler appends the cache directive to whatever
|
|
11
|
+
`Response` the route returns, so it composes with every return shape (a
|
|
12
|
+
`Response`, a `void` 204, or an auto-encoded `@data` value).
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
@cache(60) // 60 minutes at the edge
|
|
16
|
+
@cache(60, 300) // + 5 minutes (300s) in the browser
|
|
17
|
+
@cache(60, 300, true) // + private scope (per-user caches only)
|
|
18
|
+
@cache(60, 300, true, true) // + cache even for authenticated requests
|
|
19
|
+
@get('/leaderboard')
|
|
20
|
+
public top(): Standings { /* … */ }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Arguments must be integer or boolean literals; a non-literal argument makes the
|
|
24
|
+
decorator degrade safely to "not cached" rather than miscompile.
|
|
25
|
+
|
|
26
|
+
## `Response.cache(...)`
|
|
27
|
+
|
|
28
|
+
The same controls are available imperatively, which is what `@cache` lowers to:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
public cache(
|
|
32
|
+
edgeTtlMinutes: u16,
|
|
33
|
+
browserTtlSeconds: u32 = 0,
|
|
34
|
+
privateScope: bool = false,
|
|
35
|
+
allowAuth: bool = false,
|
|
36
|
+
): Response
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
return Response.json(body).cache(60, 300);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`cacheFor(minutes)` is the common shorthand for "edge only, no browser caching":
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
return Response.bytes(blob).cacheFor(5);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Parameters
|
|
50
|
+
|
|
51
|
+
| Parameter | Meaning |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `edgeTtlMinutes` | How long the edge may serve the cached response. Clamped to a 24-hour maximum. |
|
|
54
|
+
| `browserTtlSeconds` | `max-age` for the browser. `0` (default) means the browser does not cache. |
|
|
55
|
+
| `privateScope` | Marks the response `private`: only per-user caches (the browser), never a shared edge/CDN cache. |
|
|
56
|
+
| `allowAuth` | Permit caching a response to an authenticated request. Off by default (see safety rails). |
|
|
57
|
+
|
|
58
|
+
## Safety rails
|
|
59
|
+
|
|
60
|
+
The cache layer refuses to store anything unsafe, regardless of the directive:
|
|
61
|
+
|
|
62
|
+
- **5xx** responses are never cached — a server error is transient, and `@cache`
|
|
63
|
+
wraps the whole route, so a `@cache`d route that hits a blip returns its 500
|
|
64
|
+
carrying the directive; caching it would serve the failure for the full TTL.
|
|
65
|
+
**2xx, 3xx, and 4xx are cacheable** (a redirect or a `404`/`410` is a
|
|
66
|
+
deterministic function of the request key);
|
|
67
|
+
- a response that sets a **`Set-Cookie`** is never cached;
|
|
68
|
+
- a response to an **authenticated** request is not cached unless you pass
|
|
69
|
+
`allowAuth = true` — this prevents one user's personalized response from being
|
|
70
|
+
served to another;
|
|
71
|
+
- the edge TTL is **clamped to 24 hours**.
|
|
72
|
+
|
|
73
|
+
Because `@auth` guards and body-decode run before the cache directive is applied,
|
|
74
|
+
an unauthorized request is rejected with 401 before anything is cached, and a
|
|
75
|
+
cached entry is only ever produced from a handler that actually ran.
|
|
76
|
+
|
|
77
|
+
Caching is **always opt-in.** A response with no `Toil-Cache-Control` directive
|
|
78
|
+
(i.e. no `@cache` / `Response.cache(...)`) is never stored — there is no blind
|
|
79
|
+
"cache every GET" mode, because an automatic window cannot tell a personalized
|
|
80
|
+
response from a public one and would key it without a per-user component.
|
|
81
|
+
|
|
82
|
+
## Memory bounds and disk spill
|
|
83
|
+
|
|
84
|
+
The edge cache is per-core and hard-capped so it can never exhaust node memory.
|
|
85
|
+
It has two tiers:
|
|
86
|
+
|
|
87
|
+
- **RAM tier** — small, short-TTL responses. Bounded by a per-core byte budget
|
|
88
|
+
(each core holds at most ~128 MB) plus an entry-count cap; an insert that would
|
|
89
|
+
exceed the budget drops expired entries first, then evicts the soonest-to-expire
|
|
90
|
+
ones. A response over ~256 KB does not go in the RAM tier.
|
|
91
|
+
- **Disk tier (spill)** — when the operator enables `--spill-dir`, a **big**
|
|
92
|
+
(over the ~256 KB RAM cap) or **long-TTL** (≥ 10 min) cacheable response is
|
|
93
|
+
written to disk instead and served back zero-RAM via a memory map, the same way
|
|
94
|
+
static files are served. This keeps the RAM tier for the hot working set while
|
|
95
|
+
still caching large bodies and long-lived entries. Writes (and unlinks) are
|
|
96
|
+
offloaded to a sibling thread so they never stall the request path; a separate
|
|
97
|
+
per-core disk budget caps total spilled bytes, with the same expiry + eviction.
|
|
98
|
+
If spill is not enabled, a big response is simply not cached (reported as not
|
|
99
|
+
stored by the `Toil-Cache` tag).
|
|
100
|
+
|
|
101
|
+
From a tenant's point of view nothing changes: you still just set a
|
|
102
|
+
`Toil-Cache-Control` directive (via `@cache` / `Response.cache(...)`). The edge
|
|
103
|
+
decides RAM vs disk; both honor the same TTL and the same safety rails above.
|
|
104
|
+
Expiry is enforced on read (a past-TTL entry is a miss) and reclaimed on the next
|
|
105
|
+
insert that needs room. Nothing persists across a process restart.
|
|
106
|
+
|
|
107
|
+
## Choosing TTLs
|
|
108
|
+
|
|
109
|
+
- Public, slow-changing data (a leaderboard, a catalog): a few minutes of edge
|
|
110
|
+
TTL plus a short browser TTL removes most of the load.
|
|
111
|
+
- Per-user data: set `privateScope` so it never lands in a shared cache, and
|
|
112
|
+
prefer a small or zero edge TTL.
|
|
113
|
+
- Anything with a `Set-Cookie` or behind `@auth`: leave it uncached unless you
|
|
114
|
+
have thought through `allowAuth` and are certain the body is identical for
|
|
115
|
+
every authorized caller.
|