@chidchanun/bcp 0.1.6 → 0.1.8

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 CHANGED
@@ -2,6 +2,66 @@
2
2
 
3
3
  All notable framework changes are tracked here before release.
4
4
 
5
+ ## 0.1.8 - JWT cookie sessions and auth preset
6
+
7
+ ### Authentication
8
+
9
+ - Added `createSessionToken()` and `verifySessionToken()` to `bcp/server` for HS256 JWT session tokens.
10
+ - Added `createSession()` to sign a token and store it in an HttpOnly cookie with secure production defaults.
11
+ - Added `getSession()` to read and verify the active session cookie and return typed claims or `null` for invalid, expired or tampered tokens.
12
+ - Added `destroySession()` to expire the configured session cookie.
13
+ - Session tokens use `BCP_SESSION_SECRET` by default and reject secrets shorter than 32 bytes.
14
+ - Token verification validates HS256 signatures with constant-time comparison plus expiration, optional issuer and optional audience checks.
15
+ - JWT cookie defaults are `bcp_session`, 12-hour lifetime, `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Secure` when `NODE_ENV=production`.
16
+ - Added low-level and high-level session option types through the existing server-only `bcp/server` entrypoint.
17
+
18
+ ### create-bcp-app
19
+
20
+ - Added interactive Authentication selection with `None` and `JWT Cookie` presets.
21
+ - Added the non-interactive `--auth <preset>` option with `none` and `jwt-cookie` values.
22
+ - JWT Cookie projects generate `lib/auth.ts` plus `/api/auth/login`, `/api/auth/logout` and `/api/auth/me` route handlers.
23
+ - Generated auth projects add `BCP_SESSION_SECRET=` to `.env.example`.
24
+ - Generated `authenticateCredentials(email, password)` returns `null` until the application connects its own database lookup and password-hash verification, keeping the starter secure by default.
25
+ - MySQL projects now generate a reusable `db` pool configured through `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and `DB_NAME`, with development pool reuse through `globalThis`.
26
+
27
+ ### Reliability
28
+
29
+ - Added unit coverage for token round-trips, signature tampering, wrong secrets, issuer/audience mismatches, expiration, missing/short secrets and cookie lifecycle behavior.
30
+ - Added development integration coverage for login-style session creation, authenticated reads and logout cookie expiration.
31
+ - Added standalone production E2E coverage for the same JWT cookie session lifecycle after production bundling.
32
+ - Added create-app regression coverage for JWT Cookie scaffolding, secure credential-verification defaults and authentication-disabled projects.
33
+ - Added a basic application session API fixture and package-smoke checks for the session runtime and public helper surface.
34
+
35
+ ## 0.1.7 - Server request context and cookies
36
+
37
+ ### Server APIs
38
+
39
+ - Added the server-only `bcp/server` entrypoint for request-scoped helpers.
40
+ - Added `headers()` for reading an isolated copy of the active request headers.
41
+ - Added `requestUrl()` for reading an isolated copy of the active request URL, including pathname and search parameters.
42
+ - Added `requestMethod()` for reading the normalized HTTP method of the active request.
43
+ - Added `requestId()` with incoming `X-Request-Id` reuse and stable per-request UUID generation when no valid ID is supplied.
44
+ - Added `bearerToken()` for extracting Bearer credentials without imposing a JWT implementation.
45
+ - Added `clientIp()` with direct socket addresses by default and explicit `trustProxy` support for `Forwarded`, `X-Forwarded-For` and `X-Real-IP`.
46
+ - Forwarded client IP values are accepted only when they validate as IPv4 or IPv6 addresses, and IPv4-mapped IPv6 addresses are normalized.
47
+ - Added `json()` as a typed convenience wrapper around the standard Web `Response.json()` API.
48
+ - Added `redirect()` with relative URL resolution, HTTP/HTTPS validation and support for 301, 302, 303, 307 and 308 responses; the default redirect status is 307.
49
+ - Added `cookies()` with request-cookie `get`, `getAll` and `has` helpers.
50
+ - Added response-cookie `set` and `delete` support with domain, path, expires, max-age, HttpOnly, Secure and SameSite attributes.
51
+ - Response cookies created through `cookies()` are merged with cookies already present on the returned `Response` and remain separate `Set-Cookie` header values, including redirect responses.
52
+ - Request state is isolated with Node.js `AsyncLocalStorage` so concurrent requests do not share URL, method, request ID, header or cookie data.
53
+ - `bcp/server` is blocked from page/client graphs by the application boundary validator and is currently intended for API route handlers.
54
+
55
+ ### Reliability
56
+
57
+ - Added request-context unit tests for URL/header access, cookie parsing, concurrent request isolation, response cookie serialization, deletion and validation.
58
+ - Added auth-request unit coverage for HTTP methods, incoming/generated request IDs, Bearer parsing, direct IP addresses, trusted proxy precedence and invalid forwarded-IP rejection.
59
+ - Added response-helper unit coverage for JSON responses, relative and absolute redirects, redirect status validation and protocol validation.
60
+ - Added client-boundary coverage for the new `bcp/server` entrypoint.
61
+ - Added development integration coverage for request URL/header helpers, auth request helpers, direct/proxy client IPs, JSON responses, redirects and response cookies through the full dev gateway stack.
62
+ - Added standalone production E2E coverage to verify `bcp/server` resolution, auth request helpers, direct/proxy client IPs, helper responses and multiple `Set-Cookie` values after production bundling.
63
+ - Package smoke checks verify that the `bcp/server` entrypoint and request-context runtime are included in publish artifacts.
64
+
5
65
  ## 0.1.6 - Critical CSS and release visibility
6
66
 
7
67
  ### Performance
@@ -36,7 +96,7 @@ All notable framework changes are tracked here before release.
36
96
 
37
97
  - Tailwind projects now install the official `@tailwindcss/cli` package and compile CSS before starting BCP.
38
98
  - Development runs Tailwind in watch mode alongside `bcp dev` using `concurrently`.
39
- - Production builds minify Tailwind CSS before `bcp build`.
99
+ - Production builds minify Tailwind CSS before the BCP production build.
40
100
  - Generated Tailwind CSS is served through a framework API stylesheet endpoint with cache disabled during development.
41
101
  - Root layout automatically links the generated stylesheet.
42
102
  - Tailwind source detection is explicitly rooted at the generated project so utility classes in `app/` and other project files are discovered.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  BCP Framework is a React full-stack framework with file-based routing, SSR, client navigation, API routes, middleware, metadata, client islands, cache/revalidation, security defaults and standalone production builds.
4
4
 
5
- > Current development version: `0.1.6`. BCP is still pre-1.0 and validates each release candidate before the manual npm publish step.
5
+ > Current development version: `0.1.8`. BCP is still pre-1.0 and validates each release candidate before the manual npm publish step.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -121,6 +121,188 @@ export function GET() {
121
121
 
122
122
  Supported methods include GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS. HEAD falls back to GET when no explicit HEAD handler exists, and OPTIONS is generated automatically when appropriate.
123
123
 
124
+ ## Server request APIs
125
+
126
+ BCP 0.1.7 adds request-scoped server helpers through `bcp/server`. In the current 0.1.x client pipeline these helpers are intended for API routes and are rejected from page/client module graphs.
127
+
128
+ ```ts
129
+ import {
130
+ bearerToken,
131
+ clientIp,
132
+ cookies,
133
+ json,
134
+ requestId,
135
+ requestMethod,
136
+ requestUrl,
137
+ } from "bcp/server";
138
+
139
+ export async function GET() {
140
+ const url =
141
+ await requestUrl();
142
+
143
+ return json({
144
+ pathname:
145
+ url.pathname,
146
+ method:
147
+ await requestMethod(),
148
+ requestId:
149
+ await requestId(),
150
+ bearerToken:
151
+ await bearerToken(),
152
+ clientIp:
153
+ await clientIp(),
154
+ session:
155
+ (
156
+ await cookies()
157
+ ).get(
158
+ "session"
159
+ )?.value ?? null,
160
+ });
161
+ }
162
+ ```
163
+
164
+ `requestId()` reuses a valid incoming `X-Request-Id` or generates one stable UUID for the request. `bearerToken()` extracts a Bearer credential without decoding or verifying it. `clientIp()` uses the direct socket address by default; applications behind a trusted reverse proxy can explicitly use `clientIp({ trustProxy: true })` to read validated `Forwarded`, `X-Forwarded-For` or `X-Real-IP` values.
165
+
166
+ Response cookies and redirects can be composed for login-style flows:
167
+
168
+ ```ts
169
+ import {
170
+ cookies,
171
+ redirect,
172
+ } from "bcp/server";
173
+
174
+ export async function POST() {
175
+ const cookieStore =
176
+ await cookies();
177
+
178
+ cookieStore.set(
179
+ "session",
180
+ "session-token",
181
+ {
182
+ httpOnly: true,
183
+ secure: true,
184
+ sameSite: "lax",
185
+ path: "/",
186
+ maxAge: 60 * 60 * 12,
187
+ }
188
+ );
189
+
190
+ return redirect(
191
+ "/dashboard",
192
+ 303
193
+ );
194
+ }
195
+ ```
196
+
197
+ `redirect()` defaults to status `307`; supported statuses are `301`, `302`, `303`, `307` and `308`. Use `cookieStore.delete("session")` to expire a cookie. See [Server Request APIs](docs/server-request-apis.md) for proxy trust, URL handling, response helpers, request isolation, cookie options and boundary details.
198
+
199
+ ## JWT cookie sessions
200
+
201
+ BCP 0.1.8 adds HS256 JWT cookie sessions directly to `bcp/server`. Configure a server-only secret of at least 32 bytes:
202
+
203
+ ```env
204
+ BCP_SESSION_SECRET=replace-this-with-a-long-random-secret-at-least-32-bytes
205
+ ```
206
+
207
+ Login-style APIs can create an HttpOnly session cookie without manually signing or serializing the JWT:
208
+
209
+ ```ts
210
+ import {
211
+ createSession,
212
+ json,
213
+ } from "bcp/server";
214
+
215
+ export async function POST() {
216
+ await createSession(
217
+ {
218
+ userId: 42,
219
+ email: "user@example.com",
220
+ role: "admin",
221
+ },
222
+ {
223
+ issuer: "my-app",
224
+ audience: "my-app-users",
225
+ }
226
+ );
227
+
228
+ return json({
229
+ success: true,
230
+ });
231
+ }
232
+ ```
233
+
234
+ Protected API routes can verify and read the session:
235
+
236
+ ```ts
237
+ import {
238
+ getSession,
239
+ json,
240
+ } from "bcp/server";
241
+
242
+ export async function GET() {
243
+ const session =
244
+ await getSession<{
245
+ userId: number;
246
+ email: string;
247
+ role: string;
248
+ }>({
249
+ issuer: "my-app",
250
+ audience: "my-app-users",
251
+ });
252
+
253
+ if (!session) {
254
+ return json(
255
+ {
256
+ error: "Unauthorized",
257
+ },
258
+ {
259
+ status: 401,
260
+ }
261
+ );
262
+ }
263
+
264
+ return json({
265
+ userId:
266
+ session.userId,
267
+ email:
268
+ session.email,
269
+ role:
270
+ session.role,
271
+ });
272
+ }
273
+ ```
274
+
275
+ Use `destroySession()` to expire the cookie. Low-level `createSessionToken()` and `verifySessionToken()` helpers are also available when an application needs to manage token storage itself. JWT payloads are signed, not encrypted, so sensitive secrets must not be stored inside them. See [JWT Cookie Sessions](docs/session-auth.md) for options and the security model.
276
+
277
+ ## create-bcp-app auth preset
278
+
279
+ BCP 0.1.8 can scaffold the JWT cookie foundation automatically:
280
+
281
+ ```bash
282
+ npx create-bcp-app my-app --database mysql --auth jwt-cookie
283
+ ```
284
+
285
+ Interactive setup offers `None` and `JWT Cookie`. Selecting JWT Cookie creates:
286
+
287
+ ```text
288
+ lib/auth.ts
289
+ app/api/auth/login/route.ts
290
+ app/api/auth/logout/route.ts
291
+ app/api/auth/me/route.ts
292
+ ```
293
+
294
+ and adds:
295
+
296
+ ```env
297
+ BCP_SESSION_SECRET=
298
+ ```
299
+
300
+ to `.env.example`.
301
+
302
+ The generated `authenticateCredentials(email, password)` returns `null` by default. Applications must connect it to their own database lookup and password-hash verification before login can succeed. This is intentional so a newly generated project does not trust user identity supplied directly by the browser.
303
+
304
+ The generated MySQL preset uses a reusable `db` pool and separate `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and `DB_NAME` settings.
305
+
124
306
  ## Metadata
125
307
 
126
308
  ```ts
@@ -262,6 +444,8 @@ No real npm publish command is run automatically by the repository.
262
444
 
263
445
  - [Getting Started](docs/getting-started.md)
264
446
  - [Application Modules](docs/application-modules.md)
447
+ - [Server Request APIs](docs/server-request-apis.md)
448
+ - [JWT Cookie Sessions](docs/session-auth.md)
265
449
  - [Routing](docs/routing.md)
266
450
  - [Configuration](docs/configuration.md)
267
451
  - [Caching](docs/caching.md)
@@ -0,0 +1,404 @@
1
+ # Server Request APIs
2
+
3
+ BCP Framework 0.1.7 introduces request-scoped server helpers through `bcp/server`.
4
+
5
+ These APIs are server-only and are intended for API route handlers in the current 0.1.x client pipeline. Importing `bcp/server` from a page, layout, client island or another module reachable from the browser bundle is rejected by the client-boundary validator.
6
+
7
+ ## Headers
8
+
9
+ Use `headers()` to read a copy of the current request headers:
10
+
11
+ ```ts
12
+ import {
13
+ headers,
14
+ } from "bcp/server";
15
+
16
+ export async function GET() {
17
+ const requestHeaders =
18
+ await headers();
19
+
20
+ return Response.json({
21
+ userAgent:
22
+ requestHeaders.get(
23
+ "user-agent"
24
+ ),
25
+ requestId:
26
+ requestHeaders.get(
27
+ "x-request-id"
28
+ ),
29
+ });
30
+ }
31
+ ```
32
+
33
+ The returned `Headers` object is a copy. Mutating it does not modify the original incoming request.
34
+
35
+ ## Request URL
36
+
37
+ Use `requestUrl()` to read the complete URL of the current request, including pathname and search parameters:
38
+
39
+ ```ts
40
+ import {
41
+ json,
42
+ requestUrl,
43
+ } from "bcp/server";
44
+
45
+ export async function GET() {
46
+ const url =
47
+ await requestUrl();
48
+
49
+ return json({
50
+ pathname:
51
+ url.pathname,
52
+ query:
53
+ url.searchParams.get(
54
+ "query"
55
+ ),
56
+ });
57
+ }
58
+ ```
59
+
60
+ The returned `URL` is a copy. Mutating it does not change the request context seen by another helper call.
61
+
62
+ ## Request method
63
+
64
+ `requestMethod()` returns the normalized uppercase HTTP method for the active request:
65
+
66
+ ```ts
67
+ import {
68
+ requestMethod,
69
+ } from "bcp/server";
70
+
71
+ export async function POST() {
72
+ return Response.json({
73
+ method:
74
+ await requestMethod(),
75
+ });
76
+ }
77
+ ```
78
+
79
+ A POST request returns `"POST"`, a GET request returns `"GET"`, and so on.
80
+
81
+ ## Request ID
82
+
83
+ `requestId()` provides one stable identifier for the active request:
84
+
85
+ ```ts
86
+ import {
87
+ requestId,
88
+ } from "bcp/server";
89
+
90
+ export async function GET() {
91
+ const id =
92
+ await requestId();
93
+
94
+ return Response.json({
95
+ requestId:
96
+ id,
97
+ });
98
+ }
99
+ ```
100
+
101
+ When the incoming request includes a valid `X-Request-Id`, BCP preserves that value. If the header is missing, blank, longer than 256 characters or contains control characters, BCP generates a UUID once and every `requestId()` call in that request returns the same generated value.
102
+
103
+ ## Bearer tokens
104
+
105
+ Use `bearerToken()` to extract an HTTP Bearer credential from the `Authorization` header:
106
+
107
+ ```ts
108
+ import {
109
+ bearerToken,
110
+ } from "bcp/server";
111
+
112
+ export async function GET() {
113
+ const token =
114
+ await bearerToken();
115
+
116
+ if (!token) {
117
+ return Response.json(
118
+ {
119
+ error:
120
+ "Unauthorized",
121
+ },
122
+ {
123
+ status:
124
+ 401,
125
+ }
126
+ );
127
+ }
128
+
129
+ return Response.json({
130
+ authenticated:
131
+ true,
132
+ });
133
+ }
134
+ ```
135
+
136
+ The `Bearer` scheme is matched case-insensitively. BCP returns the credential string after the scheme and does not decode, verify or assume JWT. Missing authorization and non-Bearer schemes return `null`.
137
+
138
+ ## Client IP
139
+
140
+ `clientIp()` reads the direct connection address attached to the BCP request context:
141
+
142
+ ```ts
143
+ import {
144
+ clientIp,
145
+ } from "bcp/server";
146
+
147
+ export async function GET() {
148
+ return Response.json({
149
+ ip:
150
+ await clientIp(),
151
+ });
152
+ }
153
+ ```
154
+
155
+ Proxy forwarding headers are not trusted by default. Applications behind a trusted reverse proxy can opt in explicitly:
156
+
157
+ ```ts
158
+ const ip =
159
+ await clientIp({
160
+ trustProxy:
161
+ true,
162
+ });
163
+ ```
164
+
165
+ When `trustProxy` is enabled, BCP checks client-address sources in this order:
166
+
167
+ 1. the standardized `Forwarded` header (`for=`)
168
+ 2. the first usable value in `X-Forwarded-For`
169
+ 3. `X-Real-IP`
170
+ 4. the direct connection address
171
+
172
+ Do not enable `trustProxy` when clients can connect directly without a trusted proxy stripping or replacing forwarding headers, because forwarded client-IP headers can otherwise be spoofed.
173
+
174
+ IPv4-mapped IPv6 addresses such as `::ffff:127.0.0.1` are normalized to `127.0.0.1`.
175
+
176
+ ## JSON responses
177
+
178
+ `json()` is a small wrapper around the standard Web `Response.json()` API. It accepts the response body and an optional `ResponseInit`:
179
+
180
+ ```ts
181
+ import {
182
+ json,
183
+ } from "bcp/server";
184
+
185
+ export function POST() {
186
+ return json(
187
+ {
188
+ success: true,
189
+ },
190
+ {
191
+ status: 201,
192
+ headers: {
193
+ "x-created": "yes",
194
+ },
195
+ }
196
+ );
197
+ }
198
+ ```
199
+
200
+ BCP keeps the Web Response model, so existing `Response` objects and `Response.json()` continue to work alongside the helper.
201
+
202
+ ## Redirect responses
203
+
204
+ Use `redirect()` to create HTTP redirects. Relative destinations are resolved against the current request URL:
205
+
206
+ ```ts
207
+ import {
208
+ redirect,
209
+ } from "bcp/server";
210
+
211
+ export function POST() {
212
+ return redirect(
213
+ "/dashboard",
214
+ 303
215
+ );
216
+ }
217
+ ```
218
+
219
+ The default status is `307`. Supported redirect statuses are `301`, `302`, `303`, `307` and `308`. Redirect destinations must use HTTP or HTTPS. Absolute HTTP/HTTPS URLs are also supported.
220
+
221
+ A common login-style flow can combine response cookies and redirects:
222
+
223
+ ```ts
224
+ import {
225
+ cookies,
226
+ redirect,
227
+ } from "bcp/server";
228
+
229
+ export async function POST() {
230
+ const cookieStore =
231
+ await cookies();
232
+
233
+ cookieStore.set(
234
+ "session",
235
+ "session-token",
236
+ {
237
+ httpOnly: true,
238
+ secure: true,
239
+ sameSite: "lax",
240
+ path: "/",
241
+ }
242
+ );
243
+
244
+ return redirect(
245
+ "/dashboard",
246
+ 303
247
+ );
248
+ }
249
+ ```
250
+
251
+ BCP applies pending response cookies after the handler resolves, so cookies created before `redirect()` are preserved on the redirect response.
252
+
253
+ ## Request cookies
254
+
255
+ Use `cookies()` to inspect cookies sent by the client:
256
+
257
+ ```ts
258
+ import {
259
+ cookies,
260
+ } from "bcp/server";
261
+
262
+ export async function GET() {
263
+ const cookieStore =
264
+ await cookies();
265
+
266
+ return Response.json({
267
+ session:
268
+ cookieStore.get(
269
+ "session"
270
+ )?.value ?? null,
271
+ hasTheme:
272
+ cookieStore.has(
273
+ "theme"
274
+ ),
275
+ all:
276
+ cookieStore.getAll(),
277
+ });
278
+ }
279
+ ```
280
+
281
+ Request cookie values are percent-decoded when possible. Invalid percent encoding is preserved instead of failing the request.
282
+
283
+ ## Setting response cookies
284
+
285
+ `cookies().set()` records a `Set-Cookie` value that BCP applies to the API response:
286
+
287
+ ```ts
288
+ import {
289
+ cookies,
290
+ } from "bcp/server";
291
+
292
+ export async function POST() {
293
+ const cookieStore =
294
+ await cookies();
295
+
296
+ cookieStore.set(
297
+ "session",
298
+ "session-token",
299
+ {
300
+ httpOnly: true,
301
+ secure: true,
302
+ sameSite: "lax",
303
+ path: "/",
304
+ maxAge: 60 * 60 * 12,
305
+ }
306
+ );
307
+
308
+ return Response.json({
309
+ success: true,
310
+ });
311
+ }
312
+ ```
313
+
314
+ The object form is also supported:
315
+
316
+ ```ts
317
+ cookieStore.set({
318
+ name: "refreshToken",
319
+ value: "token-value",
320
+ httpOnly: true,
321
+ secure: true,
322
+ sameSite: "strict",
323
+ path: "/api",
324
+ expires:
325
+ new Date(
326
+ Date.now() +
327
+ 7 * 24 * 60 * 60 * 1000
328
+ ),
329
+ });
330
+ ```
331
+
332
+ Supported response-cookie options are:
333
+
334
+ - `domain`
335
+ - `path`
336
+ - `expires`
337
+ - `maxAge`
338
+ - `httpOnly`
339
+ - `secure`
340
+ - `sameSite`: `"lax"`, `"strict"` or `"none"`
341
+
342
+ Multiple response cookies remain separate `Set-Cookie` header values. Cookies already present on the returned `Response` are preserved.
343
+
344
+ ## Deleting cookies
345
+
346
+ Deleting a cookie emits an expired cookie with `Max-Age=0`:
347
+
348
+ ```ts
349
+ export async function DELETE() {
350
+ const cookieStore =
351
+ await cookies();
352
+
353
+ cookieStore.delete(
354
+ "session"
355
+ );
356
+
357
+ return Response.json({
358
+ success: true,
359
+ });
360
+ }
361
+ ```
362
+
363
+ When the original cookie uses a custom path or domain, supply the same values when deleting it:
364
+
365
+ ```ts
366
+ cookieStore.delete(
367
+ "session",
368
+ {
369
+ path: "/admin",
370
+ domain: "example.com",
371
+ }
372
+ );
373
+ ```
374
+
375
+ ## Request isolation
376
+
377
+ BCP stores request state with Node.js `AsyncLocalStorage`. Concurrent requests receive independent URL, method, request ID, headers and cookies, so request data from one user is not shared with another request.
378
+
379
+ Calling a request-scoped helper without an active BCP request context throws an error. A relative `redirect()` also requires the active request context because it resolves the destination against the current URL.
380
+
381
+ ## Client boundary
382
+
383
+ This is invalid in the current 0.1.x page/client pipeline:
384
+
385
+ ```tsx
386
+ import {
387
+ cookies,
388
+ } from "bcp/server";
389
+
390
+ export default function Page() {
391
+ return null;
392
+ }
393
+ ```
394
+
395
+ Move the server operation behind an API route instead:
396
+
397
+ ```text
398
+ page/client
399
+ -> fetch("/api/session")
400
+ -> API route
401
+ -> bcp/server
402
+ ```
403
+
404
+ Future server-rendered data primitives can extend these helpers to page-level server execution without exposing them to the browser bundle.