@jay-framework/jay-stack-cli 0.25.4 → 0.26.0

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.
@@ -15,7 +15,15 @@ return phaseOutput(
15
15
  );
16
16
  ```
17
17
 
18
- CarryForward is available in the next phase via `props.carryForward` but is not part of the ViewState.
18
+ CarryForward is passed to the next phase as a **positional argument**, not a field on `props`,
19
+ and is not part of the ViewState. The fast render receives it as its **second parameter**
20
+ (before services): `withFastRender(async (props, carryForward, ...services) => …)`. The
21
+ interactive phase receives it as its **fourth** parameter, after `props`, `refs` and the fast
22
+ ViewState signals.
23
+
24
+ For query-param pages, use this to precompute the no-params default in the slow phase and
25
+ forward it to the fast phase instead of querying on every request — see
26
+ [query-param-pages.md](query-param-pages.md).
19
27
 
20
28
  ### Response Headers (fast phase only)
21
29
 
@@ -31,6 +39,25 @@ return phaseOutput(
31
39
 
32
40
  Use this when the page renders per-user data that must not be cached by CDN or browser. Can be combined with `headTags` in the same options object.
33
41
 
42
+ ### Freshness — `ttl` and `grace` (slow phase only)
43
+
44
+ A slow render runs at build time, so its data ages. Return `ttl` (and optionally `grace`) — both in **seconds** — to tell the production server when the data goes stale:
45
+
46
+ ```typescript
47
+ return phaseOutput(
48
+ { name: product.name, price: product.price },
49
+ { productId: product.id },
50
+ { ttl: 300, grace: 6 * 60 * 60 }, // fresh for 5 minutes, usable for 6 hours
51
+ );
52
+ ```
53
+
54
+ - **`ttl`** — the data is `fresh` for `ttl` seconds after it was rendered. After that it is `stale`: the server serves it immediately (`Cache-Control: no-store`) and re-runs the slow render in the background. No `ttl` → the data never goes stale (today's behavior). Minimum effective `ttl` is 10 seconds.
55
+ - **`grace`** — absolute age after which the data is `expired`: the server waits (up to `JAY_STALE_WAIT_MS`, default 2000) for the refresh before answering. Default `max(12h, ttl)`.
56
+ - Responses carry `jay-freshness: fresh | stale | expired, <age>ms`.
57
+ - A page with several slow parts (page, keyed headless components, headless instances) gets the **minimum positive** `ttl` and the minimum positive `grace` across all of them; `0` / `null` are ignored.
58
+ - With `RenderPipeline`, return `ttl` / `grace` from the `toPhaseOutput` callback next to `viewState` and `carryForward`.
59
+ - Pick a `ttl` that matches how quickly the underlying data changes, not how fast you want pages to be: every `ttl` expiry costs one slow render per instance.
60
+
34
61
  ### Cookies (fast phase only)
35
62
 
36
63
  The fast phase receives `props.cookies` — a `Record<string, string>` parsed from the HTTP `Cookie` header:
@@ -35,24 +35,28 @@ When running `jay-stack-cli setup` without `--interactive`, plugins that need us
35
35
 
36
36
  ```yaml
37
37
  setup-needs-answer:
38
- plugin: wix-server-client
39
- key: api-key
38
+ plugin: '@jay-framework/some-plugin'
39
+ key: apiKey
40
40
  type: input
41
41
  message: 'Enter your API key'
42
+ choices: # present only for select-type questions
43
+ - us: United States
42
44
  ```
43
45
 
44
- To provide the answer, create a YAML file and re-run:
46
+ To provide the answer, create a YAML file keyed by the `key` field and re-run:
45
47
 
46
48
  ```yaml
47
49
  # answers.yaml
48
- api-key: 'IST.abc123...'
50
+ apiKey: 'your-answer'
49
51
  ```
50
52
 
51
53
  ```bash
52
54
  jay-stack-cli setup --answers answers.yaml
53
55
  ```
54
56
 
55
- Repeat until all plugins report `configured`. The flow is iterative — each run may reveal the next question.
57
+ Repeat until all plugins report `configured`. The flow is iterative — each run may reveal the next question. The `key` is the stable join field between a question and its answer.
58
+
59
+ > **Exception — Wix login is not answerable via a file.** The Wix plugins' credential setup shells out to the Wix CLI for a browser-based OAuth login (`npx @wix/cli login`). In non-interactive mode it reports `needs-config` ("requires interactive mode") rather than a `setup-needs-answer:` — there is no key you can put in `answers.yaml`. A human must run `jay-stack-cli setup --interactive` in a real terminal once; after that the app secret, client ID, and site ID are fetched/derived automatically. `wix-deploy` also needs a live Wix CLI session at deploy time (`jay-stack run wix-deploy/deploy`) — re-run `npx @wix/cli login` if it reports the session is missing.
56
60
 
57
61
  ### Setup order
58
62
 
@@ -64,11 +68,84 @@ jay-stack-cli agent-kit # 2. Generate contracts and discovery data
64
68
  jay-stack-cli build # 3. Production build
65
69
  ```
66
70
 
71
+ ## Running in cloud / remote environments
72
+
73
+ Running the dev server behind a cloud preview proxy (sandboxes, Codespaces, Gitpod, etc.) has three
74
+ non-obvious requirements.
75
+
76
+ ### 1. Allow the proxy's host (Vite host check)
77
+
78
+ The dev server runs Vite in middleware mode, and Vite's host check 403s any request whose `Host` header
79
+ isn't `localhost` ("Blocked request. This host is not allowed."). Allow the external host via `.jay`:
80
+
81
+ ```yaml
82
+ # .jay
83
+ devServer:
84
+ allowedHosts:
85
+ - .mysandbox.dev # Vite host-suffix syntax — matches any subdomain
86
+ # or, to disable the check entirely (dev only):
87
+ # allowedHosts: true
88
+ ```
89
+
90
+ Platforms that already set Vite's `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` env var (comma-separated
91
+ hosts, or `true`) need **no** `.jay` change — the dev server reads and merges it. Do **not** add a
92
+ `src/pages/vite.config.ts` to work around this; use the config above (DL#220).
93
+
94
+ > HMR over a proxy may additionally need Vite's `server.hmr` client settings — not required for serving
95
+ > pages, only for live reload through the proxy.
96
+
97
+ ### 2. Binding and port
98
+
99
+ `jay-stack-cli dev` takes **no** `--port` / `--host` flags. The Express server binds all interfaces, so
100
+ the container is reachable as-is; set the port in `.jay` (`devServer.portRange`, e.g. `[3000, 3000]` to
101
+ pin it).
102
+
103
+ ### 3. Credentials are read from files, not env vars
104
+
105
+ Plugins read credentials from files written by `setup`, not from `process.env`. Wix, for example, uses
106
+ `config/.wix.yaml` (backend services) and `wix.config.json` (deploy target) — both gitignored. A
107
+ container that only has secrets as env vars must **materialize them into those files before** starting
108
+ the dev server (e.g. a startup script that writes `config/.wix.yaml` from `WIX_*` env vars). Putting
109
+ `WIX_*` under a compose `environment:` block does **not** configure the plugins.
110
+
111
+ ### 4. AIditor (visual editor) in a container
112
+
113
+ The `/aiditor` route runs an agent **server-side** via the Claude Agent SDK, so the environment running
114
+ the dev server — not the user's laptop — must have Claude Code available:
115
+
116
+ - **Install the `claude` CLI in the image.** It is not part of the Node base image.
117
+ - **Provide `ANTHROPIC_API_KEY` as a secret/env var.** `claude login` is interactive and cannot complete
118
+ headless, so in a container the API key is the only viable auth. (Locally, `claude login` also works.)
119
+ - **It's dev-only.** The `/aiditor` route is served by the dev server; it is excluded from production
120
+ deploy (`--exclude-plugins aiditor`). If you don't need visual editing in a given environment, you can
121
+ ignore it — the rest of the site scaffolds, builds, runs, and deploys without it; only the `/aiditor`
122
+ route's agent stays inert when the CLI/key are absent.
123
+
124
+ A cloud platform that boots the dev server but neither installs the CLI nor injects the key will serve a
125
+ working site with a non-functional `/aiditor` — the single most common surprise. Install both, or
126
+ document that visual editing is unavailable there.
127
+
128
+ ### Verbose diagnostics (`-v`)
129
+
130
+ `jay-stack dev -v` (verbose) additionally **writes each rendered page's generated client HTML** to
131
+ `<buildFolder>/debug/client-entry/<route>.html` (e.g. `build/dev/debug/client-entry/products-[slug].html`).
132
+ This is a write-only debugging aid — inspect it when a page renders wrong, hydration mismatches, or a
133
+ client bundle looks off; it mirrors exactly what the browser receives before Vite transforms it. It is
134
+ **off by default** (no per-request disk writes in normal dev) and **best-effort** (a failed debug write
135
+ never fails the page request). The filename is the route path only — the query string is dropped — so a
136
+ proxy that carries a long auth token as a query param can't blow past the OS filename limit (DL#229).
137
+
138
+ ### Health / shutdown (containers)
139
+
140
+ Run with `--test-mode` to expose `GET /_jay/health` (readiness probe) and `POST /_jay/shutdown` (graceful
141
+ stop) — the sanctioned hooks for container healthchecks and lifecycle, instead of signal-guessing.
142
+
67
143
  ## Guides
68
144
 
69
- | File | Topic |
70
- | ------------------------------------------ | ---------------------------------------------------------- |
71
- | [production-build.md](production-build.md) | Build pipeline, output structure, frontend/backend split |
72
- | [serving-modes.md](serving-modes.md) | Self-hosted, CDN, BaaS (fetch handler), CLI flags |
73
- | [fetch-handler.md](fetch-handler.md) | Fetch handler, ArtifactStore interface, BaaS custom stores |
74
- | [invalidation.md](invalidation.md) | Rebuild, renderer server, cleanup |
145
+ | File | Topic |
146
+ | ------------------------------------------ | ----------------------------------------------------------- |
147
+ | [production-build.md](production-build.md) | Build pipeline, output structure, frontend/backend split |
148
+ | [serving-modes.md](serving-modes.md) | Self-hosted, CDN, BaaS (fetch handler), CLI flags |
149
+ | [fetch-handler.md](fetch-handler.md) | Fetch handler, ArtifactStore interface, BaaS custom stores |
150
+ | [invalidation.md](invalidation.md) | Rebuild, renderer server, cleanup |
151
+ | [source-editing.md](source-editing.md) | Enable + use the source-editing inspector (AIditor, DL#219) |
@@ -106,9 +106,30 @@ interface ArtifactStore {
106
106
  loadModule(modulePath: string, local?: boolean): Promise<any>;
107
107
  getAssetPath(relativePath: string): string;
108
108
  getBuildDir(): string;
109
+
110
+ // Instances — the manifest holds routes only; the store owns the instance index
111
+ getInstance(
112
+ route: RouteEntry,
113
+ params: Record<string, string>,
114
+ ): Promise<InstanceEntry | undefined>;
115
+ listInstances(route: RouteEntry, options?: { fresh?: boolean }): AsyncIterable<InstanceRef>;
116
+
117
+ // Optional — implement to enable stale refresh and render-on-miss (omit for a read-only store)
118
+ writeCacheData?(
119
+ route: RouteEntry,
120
+ params: Record<string, string>,
121
+ entry: CacheEntry,
122
+ ): Promise<void>;
123
+ removeInstance?(route: RouteEntry, params: Record<string, string>): Promise<void>;
124
+ readCacheDataFresh?(relativePath: string): Promise<CacheEntry>;
109
125
  }
110
126
  ```
111
127
 
128
+ - `readCacheData` must throw `CacheNotFoundError` (exported from `/serve`) when the instance has no data.
129
+ - `getInstance` returns `undefined` only for a real "does not exist" — other errors must throw (they are not a 404).
130
+ - `writeCacheData` upserts the data at the path derived by `deriveCachePath(route.pattern, params)` and keeps the index consistent (the item itself is the index entry). Race handling should be idempotent (adopt a concurrent winner), not a lock.
131
+ - `listInstances` may serve a TTL-cached listing (`SwrCache`, default 10 minutes); `fresh: true` re-reads. Page through the backend's own page-size limit.
132
+
112
133
  `loadModule` handles all module loading — server elements, page components, headless components. The `local` flag indicates whether the path is relative to the build directory (`true`) or an npm package (`false`). For filesystem deployments, local modules resolve from `basePath` and npm modules use bare `import()`. BaaS implementations resolve all modules from their pre-bundled registry, ignoring the `local` flag.
113
134
 
114
135
  For serve-only imports (no build-time dependencies), use `@jay-framework/production-server/serve`.
@@ -29,7 +29,7 @@ jay-stack rebuild --route /products/[slug] --params '{"slug":"blue-widget"}'
29
29
 
30
30
  ### How it works
31
31
 
32
- 1. Reads the route manifest to find affected routes/instances
32
+ 1. Reads the route manifest to find affected routes, and the artifact store's instance listing for their instances
33
33
  2. Re-runs slow render with fresh data
34
34
  3. Re-compiles server element and client bundle
35
35
  4. Updates the instance entry in the manifest
@@ -69,9 +69,4 @@ The manifest tracks which contracts each route uses. When a webhook says "produc
69
69
 
70
70
  ## Build Output After Rebuild
71
71
 
72
- Rebuilt instances write new files to both `frontend/` and `backend/`:
73
-
74
- - `backend/pre-rendered/` — updated jay-html, cache, server element
75
- - `frontend/pages/` — updated client bundle, CSS
76
-
77
- Old files are not deleted immediately — they're tracked in `cleanup-manifest.json`. This avoids breaking in-flight requests that still reference old bundles.
72
+ A rebuilt instance overwrites its `backend/pre-rendered/{route}/page[_hash].cache.json` in place (atomically, at the same derived path), so no manifest update and no orphaned files are involved. Route-level bundles in `frontend/` are not rebuilt by an instance rebuild.
@@ -34,7 +34,7 @@ build/v{n}/
34
34
  │ └── logo.png
35
35
  │
36
36
  ├── backend/ # Server-only artifacts (→ container)
37
- │ ├── route-manifest.json # All routes, instances, action registry
37
+ │ ├── route-manifest.json # Routes (no instances), action registry
38
38
  │ ├── build-metadata.json # Version, timestamp, instance count
39
39
  │ ├── server/ # Compiled server code
40
40
  │ │ ├── init.js
@@ -63,11 +63,16 @@ The build is **environment-agnostic**. The same output serves any deployment mod
63
63
 
64
64
  `backend/route-manifest.json` contains:
65
65
 
66
- - **routes** — pattern, segments, server module path, instances with params
67
- - **instances** — `preRenderedPath` and `serverElementPath` (relative to `backend/`), `clientBundlePath` and `clientCssPath` (relative to `frontend/`)
66
+ - **routes** — pattern, segments, server module path, route-level `serverElementPath`, `routeClientBundlePath` and `routeCssPath`. The manifest holds **routes only** — it does not list instances.
68
67
  - **actions** — server module paths, action names
69
68
  - **sharedManifest** — maps package names to hashed filenames in `frontend/shared/`
70
69
 
70
+ ## Instances
71
+
72
+ An instance (one set of route params) is identified by its data file, `backend/pre-rendered/{route}/page[_{hash}].cache.json`. The path is derived from the route pattern and the params (`_` + first 8 hex of the md5 of the sorted-params JSON), and the file carries `route`, `params` and the freshness fields (`renderedAt`, `ttl`, `grace`) itself — it is its own index entry.
73
+
74
+ The `ArtifactStore` lists a route's instances (sitemap, llms.txt, rebuild) from those files, so new instances created after the build — by [render-on-miss](serving-modes.md#fresh-data-and-new-items) or a rebuild — appear without touching the manifest.
75
+
71
76
  ## Project Structure Requirements
72
77
 
73
78
  For production builds to work correctly:
@@ -116,3 +116,28 @@ jay-stack serve --role renderer --port 4001
116
116
  ```
117
117
 
118
118
  The renderer server listens for data change webhooks and rebuilds affected page instances. The main server picks up the updated artifacts automatically (it re-reads the manifest when `build-metadata.json` changes).
119
+
120
+ ## Fresh Data and New Items
121
+
122
+ The main server keeps slow-render data fresh and serves items that did not exist at build time. Each server instance does this independently against its own artifact store (a shared store, such as Wix's data collection, is refreshed once for everyone).
123
+
124
+ **Stale-while-revalidate.** When slow render returns `ttl` / `grace` (seconds, see the developer `render-results.md` guide), the server classifies each instance as `fresh`, `stale` or `expired`:
125
+
126
+ | State | Served | Headers |
127
+ | --------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------- |
128
+ | `fresh` | Stored data; `max-age` / `s-maxage` clamped to the remaining TTL | `jay-freshness: fresh, <age>ms` |
129
+ | `stale` | Stored data immediately, refresh runs in the background | `Cache-Control: no-store`, `jay-freshness: stale, <age>ms` |
130
+ | `expired` | Waits up to `JAY_STALE_WAIT_MS` (2000) for the refresh, else the stored data | as `stale`, or `fresh` if the refresh finished |
131
+
132
+ Old data is never cached by CDNs (`no-store`) so a refresh becomes visible at once; validators (`ETag` gets `-<renderedAt>` appended, `Last-Modified`) are kept so crawlers and browsers can still revalidate cheaply.
133
+
134
+ **Render on miss.** A URL that matches a route but has no instance (e.g. a product published after the build) is slow-rendered on the first request, stored, and served. If the slow render answers `notFound()` the request is a real 404 (remembered in memory for 10 minutes); if it cannot render right now the server answers `503` with `Retry-After: 2`.
135
+
136
+ **Limits.** Concurrent requests for the same instance share one slow render. At most `JAY_SLOW_RENDER_CONCURRENCY` (default 5) slow renders run at once across refreshes and misses; there is no queue — a refresh that finds all slots busy serves the old data, a miss answers 503. A failed refresh backs off before retrying.
137
+
138
+ | Variable | Default | Meaning |
139
+ | ----------------------------- | ------- | ------------------------------------------------------ |
140
+ | `JAY_SLOW_RENDER_CONCURRENCY` | `5` | Max concurrent slow renders (refresh + render-on-miss) |
141
+ | `JAY_STALE_WAIT_MS` | `2000` | Max wait for the refresh of an `expired` instance |
142
+ | `JAY_MISS_TIMEOUT_MS` | `8000` | Max wait of a render-on-miss before 503 |
143
+ | `JAY_FRESHNESS_HEADER` | on | Set to `off` to omit the `jay-freshness` header |
@@ -0,0 +1,89 @@
1
+ # Source Editing — DevOps / AIditor Integration Guide
2
+
3
+ **Audience:** AIditor (or a similar visual editor) and the devops integrating it. How to turn on Jay's
4
+ source-editing inspector and use it to map a rendered pixel back to its `.jay-html` source (DL#219).
5
+
6
+ **This is dev-only.** The inspector is served only by the dev server, and only when enabled. It never
7
+ affects a production build.
8
+
9
+ ---
10
+
11
+ ## 1. Enable it
12
+
13
+ In the project's `.jay` file:
14
+
15
+ ```yaml
16
+ devServer:
17
+ sourceEditing: true
18
+ ```
19
+
20
+ When on, the dev server injects `jay-dev-*` markers into rendered pages (`jay-dev-src` line ranges on every
21
+ element, `jay-dev-item` on `forEach` roots, `jay-dev-region*` on region roots). Off by default — the markers
22
+ change rendered output, so enable only for source-editing sessions, never in production.
23
+
24
+ ## 2. Open a page in embed mode
25
+
26
+ Load the page in an `<iframe>` with the `_jay_embed` query param:
27
+
28
+ ```
29
+ http://localhost:<port>/<route>?_jay_embed
30
+ ```
31
+
32
+ Embed mode registers the inspector's `message` listener (shared with the freeze protocol). The page posts
33
+ `jay:route` to the parent on load.
34
+
35
+ ## 3. Ask "what is this?" — the postMessage protocol
36
+
37
+ Send a point or a rectangle (iframe-relative CSS pixels); receive the resolved source location.
38
+
39
+ ```
40
+ // parent → iframe
41
+ { type: 'jay:inspectPoint', requestId, x, y }
42
+ { type: 'jay:inspectRect', requestId, x, y, width, height }
43
+
44
+ // iframe → parent
45
+ { type: 'jay:inspectResult', requestId,
46
+ file, // project-relative page jay-html path (where the lines live)
47
+ route, routePattern, // derive route params from these
48
+ node: { // the element actually hit / chosen; null if nothing marked
49
+ lines: { from, to }, // 1-based line range in `file`
50
+ tag, // e.g. 'li', 'span'
51
+ html, // one-level HTML of the element (open tag + authored attrs + inline text;
52
+ // grandchildren elided) — the single content field
53
+ rect: { x, y, width, height }, // on-screen box, for overlaying a highlight
54
+ contentFromBinding?: true, // set when the element is generated from a bound value (html-string)
55
+ } | null,
56
+ stack: [ // enclosing data scopes, innermost → outermost (plain wrappers omitted)
57
+ { kind: 'forEach', lines, itemId }, // itemId = runtime trackBy record id
58
+ { kind: 'region', lines, name, props? }, // name = contract; props = static primitive props
59
+ ] }
60
+ ```
61
+
62
+ **`inspectRect`** (drag selection) uses the rect rule: among marked elements the box encloses (overlap over
63
+ the element's own area ≥ 0.8), the **largest** is chosen; if none, it falls back to the box center.
64
+
65
+ ## 4. Decide how to edit (the decision rule)
66
+
67
+ - **Static content** (no `contentFromBinding`, not inside a region): the text/attribute is authored in the
68
+ jay-html — edit `file` at `node.lines`.
69
+ - **Bound content** (`contentFromBinding`, or inside a `region`/`forEach` frame): the value comes from data,
70
+ not the markup. Read the binding at `node.lines` (e.g. `{post.content}`), then resolve its source:
71
+ - `stack` region frame → the `name` is the contract; map it via `agent-kit/plugins-index.yaml` to the
72
+ plugin's **editing guide** (see the plugin **source-editing** guide).
73
+ - `forEach` frame `itemId` identifies the record; route params (`route`/`routePattern`) identify the page
74
+ instance.
75
+ - Editing a bound value means editing its source (e.g. a markdown `.md` file, or a plugin's data via its
76
+ `devOnly` action), not the rendered markup.
77
+
78
+ The inspector returns **locators, not resolved bindings** — it stays client-only, and the agent reads the
79
+ jay-html + agent-kit to resolve. (The dev server does not resolve contracts; see DL#219 §2.)
80
+
81
+ ## 5. Try it
82
+
83
+ The smoke-test example ships a live harness: run `yarn dev` in `examples/jay-stack/smoke-test` and open
84
+ `http://localhost:3300/what-is-this.html` — hover/drag over any page and watch the `jay:inspectResult`.
85
+
86
+ ---
87
+
88
+ See also: the developer **configuration** guide (the `.jay` `sourceEditing` field) and the plugin
89
+ **source-editing** guide (making a plugin's contracts editable).
@@ -34,30 +34,31 @@ The plugin participates in four CLI commands, each running different hooks:
34
34
 
35
35
  ## Guides
36
36
 
37
- | File | Topic |
38
- | ------------------------------------------------------ | ----------------------------------------------------------------------- |
39
- | [Contract Authoring Guide](../contracts/GUIDE.md) | Writing contracts: syntax, page/component/linked contracts, examples |
40
- | [contracts-guide.md](contracts-guide.md) | Plugin-specific contract concerns |
41
- | [plugin-structure.md](plugin-structure.md) | plugin.yaml, package layout, exports |
42
- | [component-structure.md](component-structure.md) | makeJayStackComponent, builder API, three-phase rendering |
43
- | [design-system-guide.md](design-system-guide.md) | Shipping a template designers flatten; upgrades via `jay-stack sync` |
44
- | [component-state.md](component-state.md) | createSignal, createMemo, createEffect, createDerivedArray, createEvent |
45
- | [component-refs.md](component-refs.md) | Refs, collection refs, element types |
46
- | [component-data.md](component-data.md) | Immutable data, JSON Patch, createPatchableSignal |
47
- | [component-context.md](component-context.md) | Context hooks: provide, reactive, global |
48
- | [render-results.md](render-results.md) | phaseOutput, RenderPipeline, errors, redirects |
49
- | [actions-guide.md](actions-guide.md) | makeJayAction, makeJayQuery, .jay-action files |
50
- | [webhooks-guide.md](webhooks-guide.md) | makeWebhook, data change invalidation, renderer server |
51
- | [services-guide.md](services-guide.md) | createJayService, makeJayInit |
52
- | [plugin-routes.md](plugin-routes.md) | Plugin-provided pages: routes, jay-html templates, page components |
53
- | [seo-guide.md](seo-guide.md) | SEO head tags: title, meta, OG, canonical via phaseOutput |
54
- | [commands-guide.md](commands-guide.md) | makeCliCommand, .jay-command files, CONSOLE_CONTEXT, jay-stack run |
55
- | [validation.md](validation.md) | jay-stack validate-plugin, writing custom jay-html validators |
56
- | [setup-guide.md](setup-guide.md) | Setup handlers, agent-kit handlers, references generation |
57
- | [add-menu-guide.md](add-menu-guide.md) | AIditor add-menu items: schema, interaction, browse, presentation |
58
- | [aiditor-settings-guide.md](aiditor-settings-guide.md) | AIditor Project settings tabs: template, materialization, devOnly route |
59
- | [dev-server-service.md](dev-server-service.md) | Dev server service API: routes, params, freeze management |
60
- | `../references/<plugin>/` | Plugin reference data |
37
+ | File | Topic |
38
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------- |
39
+ | [Contract Authoring Guide](../contracts/GUIDE.md) | Writing contracts: syntax, page/component/linked contracts, examples |
40
+ | [contracts-guide.md](contracts-guide.md) | Plugin-specific contract concerns; outcomes as variants, text as defaults |
41
+ | [plugin-structure.md](plugin-structure.md) | plugin.yaml, package layout, exports |
42
+ | [component-structure.md](component-structure.md) | makeJayStackComponent, builder API, three-phase rendering |
43
+ | [design-system-guide.md](design-system-guide.md) | Shipping a template designers flatten; upgrades via `jay-stack sync` |
44
+ | [component-state.md](component-state.md) | createSignal, createMemo, createEffect, createDerivedArray, createEvent |
45
+ | [component-refs.md](component-refs.md) | Refs, collection refs, element types |
46
+ | [component-data.md](component-data.md) | Immutable data, JSON Patch, createPatchableSignal |
47
+ | [component-context.md](component-context.md) | Context hooks: provide, reactive, global |
48
+ | [render-results.md](render-results.md) | phaseOutput, RenderPipeline, errors, redirects |
49
+ | [actions-guide.md](actions-guide.md) | makeJayAction, makeJayQuery, .jay-action files |
50
+ | [webhooks-guide.md](webhooks-guide.md) | makeWebhook, data change invalidation, renderer server |
51
+ | [services-guide.md](services-guide.md) | createJayService, makeJayInit |
52
+ | [plugin-routes.md](plugin-routes.md) | Plugin-provided pages: routes, jay-html templates, page components |
53
+ | [seo-guide.md](seo-guide.md) | SEO head tags: title, meta, OG, canonical via phaseOutput |
54
+ | [commands-guide.md](commands-guide.md) | makeCliCommand, .jay-command files, CONSOLE_CONTEXT, jay-stack run |
55
+ | [validation.md](validation.md) | jay-stack validate-plugin, writing custom jay-html validators |
56
+ | [setup-guide.md](setup-guide.md) | Setup handlers, agent-kit handlers, references generation |
57
+ | [add-menu-guide.md](add-menu-guide.md) | AIditor add-menu items: schema, interaction, browse, presentation |
58
+ | [aiditor-settings-guide.md](aiditor-settings-guide.md) | AIditor Project settings tabs: template, materialization, devOnly route |
59
+ | [source-editing.md](source-editing.md) | Make a plugin's contracts editable: editingGuide/readonly, coverage (DL#219) |
60
+ | [dev-server-service.md](dev-server-service.md) | Dev server service API: routes, params, freeze management |
61
+ | `../references/<plugin>/` | Plugin reference data |
61
62
 
62
63
  ## Key Principles
63
64
 
@@ -49,6 +49,7 @@ makeJayAction('name')
49
49
  .withMethod('PUT') // Override HTTP method (default: POST for actions)
50
50
  .withCaching({ maxAge: 60 }) // Enable caching (queries only)
51
51
  .withFiles() // Accept file uploads (multipart/form-data)
52
+ .withRequest() // Receive the caller's cookies as argument 2 (see below)
52
53
  .withHandler(async (input, svc1, svc2) => {
53
54
  // Define handler
54
55
  return result;
@@ -133,6 +134,62 @@ refs.uploadBtn.onClick(async () => {
133
134
  });
134
135
  ```
135
136
 
137
+ ## .withRequest() — Reading the Caller's Cookies
138
+
139
+ Opt in with `.withRequest()` and the handler receives an `ActionRequest` as its **second** argument, before the services:
140
+
141
+ ```typescript
142
+ import { makeJayAction, ActionError } from '@jay-framework/fullstack-component';
143
+
144
+ export const updateAccount = makeJayAction('account.update')
145
+ .withServices(ACCOUNT_SERVICE)
146
+ .withRequest()
147
+ .withHandler(async (input: { name: string }, request, account) => {
148
+ const session = request.cookies.session; // server-trusted, includes HttpOnly cookies
149
+ if (!session) throw new ActionError('UNAUTHORIZED', 'Not signed in');
150
+ return account.update(session, input.name);
151
+ });
152
+ ```
153
+
154
+ Without `.withRequest()`, the handler is `(input, ...services)` as before. The client call is always `action(input)`.
155
+
156
+ Rules:
157
+
158
+ - **Identity comes from `request.cookies`, never from `input`.** Input is client-controlled and spoofable.
159
+ - **No `.withCaching()`.** A cookie-reading handler is per-user. Combining both throws when the action is defined. Every response of a `.withRequest()` action is sent with `Cache-Control: private, no-store`.
160
+ - **A GET query (`makeJayQuery`) must not mutate.** Only non-GET actions are protected by the same-origin check.
161
+ - **Non-GET calls must come from the same origin.** A cross-site browser request (by `Sec-Fetch-Site`, or `Origin` when that header is absent) is rejected with 403 `CROSS_ORIGIN_REQUEST` before the handler runs. Jay's client always calls same-origin. Requests with neither header (server-to-server, curl) are allowed.
162
+ - **Direct server-side calls get no cookies.** Calling a `.withRequest()` action directly (from another handler, a render phase, or the CLI) passes `{ cookies: {} }`. To act for the same caller, put the logic in a shared function and pass `request` to it explicitly.
163
+
164
+ ### Setting cookies (login, refresh, logout)
165
+
166
+ Page JS cannot write an HttpOnly credential, so credential cookies are set by an action:
167
+
168
+ ```typescript
169
+ export const login = makeJayAction('account.login')
170
+ .withServices(AUTH_SERVICE)
171
+ .withRequest()
172
+ .withHandler(async (input: { email: string; password: string }, request, auth) => {
173
+ const token = await auth.signIn(input.email, input.password);
174
+ request.setCookie('session', token, { maxAge: 60 * 60 * 24 * 30 });
175
+ return { ok: true };
176
+ });
177
+
178
+ export const logout = makeJayAction('account.logout')
179
+ .withRequest()
180
+ .withHandler(async (input: {}, request) => {
181
+ request.clearCookie('session'); // same path/domain as when it was set
182
+ return { ok: true };
183
+ });
184
+ ```
185
+
186
+ - **Defaults:** `httpOnly: true`, `sameSite: 'Lax'`, `path: '/'`, and `secure` when the request is https (including `x-forwarded-proto: https`). Options: `httpOnly`, `secure`, `sameSite` (`'Strict' | 'Lax' | 'None'`; `'None'` forces `secure`), `path`, `domain`, `maxAge` (seconds), `expires`.
187
+ - **Cookies are sent only on success.** If the handler throws (including `ActionError`), cookies it set are discarded. To clear a cookie on failure, return a success value that says so.
188
+ - **`clearCookie` must use the same `path` and `domain`** the cookie was set with, or the browser keeps the original.
189
+ - Setting the same name twice keeps the last write. Cookie names must be RFC 6265 tokens.
190
+ - **Streams cannot set cookies.** `makeJayStream(...).withRequest()` gives a read-only `StreamActionRequest` (`request.cookies` only), because headers are sent before the generator runs.
191
+ - **Direct calls cannot set cookies.** There is no HTTP response; the cookie is discarded with a warning.
192
+
136
193
  ## ActionError
137
194
 
138
195
  Throw typed errors from action handlers:
@@ -122,6 +122,48 @@ For dynamic/materialized contracts linking to static contracts in a plugin packa
122
122
 
123
123
  `trackBy` must reference a `data` tag with `string` or `number` type within the same sub-contract.
124
124
 
125
+ ## Outcomes and User-Visible Text
126
+
127
+ The page owns every word the visitor reads. A plugin reports **what happened**; the page decides **how it reads** (wording, language, layout). So any outcome a page may want to render differently — an error, a failure kind, a success, an empty or unavailable state — must be a **variant**, never only a string.
128
+
129
+ - **One kind of outcome** → a `boolean` variant (`hasError`, `isSubmitted`).
130
+ - **Several kinds** → an `enum` variant with one member per kind the page may word differently, plus a neutral member (`NONE`, `IDLE`) for "nothing to report".
131
+ - **Text is optional default copy.** A `data` string next to the variant (`errorMessage`, `statusMessage`) is fine as an English default. Never make it the only signal: a page must not have to test for a non-empty string, or match its text, to know what happened.
132
+ - **Keep codes stable and locale-neutral.** Name members by meaning (`NETWORK`, `PERMISSION`), not by message. **Appending** a member is safe; inserting, renaming, removing, or reordering one is a breaking change.
133
+
134
+ ```yaml
135
+ # ✅ Outcome as variants; text is a default the page may ignore
136
+ - {
137
+ tag: submitState,
138
+ type: variant,
139
+ dataType: 'enum (IDLE | SUBMITTING | SUCCEEDED | FAILED)',
140
+ phase: fast+interactive,
141
+ }
142
+ - {
143
+ tag: submitFailure,
144
+ type: variant,
145
+ dataType: 'enum (NONE | VALIDATION | REJECTED | NETWORK)',
146
+ phase: fast+interactive,
147
+ }
148
+ - {
149
+ tag: statusMessage,
150
+ type: data,
151
+ dataType: string,
152
+ phase: fast+interactive,
153
+ description: English default text,
154
+ }
155
+
156
+ # ❌ Outcome only as text — the page can't localize or redesign it without parsing English
157
+ - { tag: statusMessage, type: data, dataType: string, phase: fast+interactive }
158
+ ```
159
+
160
+ ```html
161
+ <!-- The page writes its own copy, in its own language -->
162
+ <p if="form.submitFailure === NETWORK">Problème de connexion, réessayez.</p>
163
+ ```
164
+
165
+ Emit enum values from the enum the contract compiles to (or a mirror of it in the same order); the runtime value is the member's index. Document each member's meaning in the plugin's agent-kit.
166
+
125
167
  ## Async Data
126
168
 
127
169
  Wrap any tag in `Promise<T>` with `async: true`:
@@ -31,6 +31,25 @@ return phaseOutput(
31
31
 
32
32
  Use this when the component renders per-user data that must not be cached by CDN or browser. Can be combined with `headTags` in the same options object.
33
33
 
34
+ ### Freshness — `ttl` and `grace` (slow phase only)
35
+
36
+ A slow render runs at build time, so its data ages. Return `ttl` (and optionally `grace`) — both in **seconds** — to tell the production server when the data goes stale:
37
+
38
+ ```typescript
39
+ return phaseOutput(
40
+ { name: product.name, price: product.price },
41
+ { productId: product.id },
42
+ { ttl: 300, grace: 6 * 60 * 60 }, // fresh for 5 minutes, usable for 6 hours
43
+ );
44
+ ```
45
+
46
+ - **`ttl`** — the data is `fresh` for `ttl` seconds after it was rendered. After that it is `stale`: the server serves it immediately (`Cache-Control: no-store`) and re-runs the slow render in the background. No `ttl` → the data never goes stale (today's behavior). Minimum effective `ttl` is 10 seconds.
47
+ - **`grace`** — absolute age after which the data is `expired`: the server waits (up to `JAY_STALE_WAIT_MS`, default 2000) for the refresh before answering. Default `max(12h, ttl)`.
48
+ - Responses carry `jay-freshness: fresh | stale | expired, <age>ms`.
49
+ - A page with several slow parts (page, keyed headless components, headless instances) gets the **minimum positive** `ttl` and the minimum positive `grace` across all of them; `0` / `null` are ignored.
50
+ - With `RenderPipeline`, return `ttl` / `grace` from the `toPhaseOutput` callback next to `viewState` and `carryForward`.
51
+ - Pick a `ttl` that matches how quickly the underlying data changes, not how fast you want pages to be: every `ttl` expiry costs one slow render per instance.
52
+
34
53
  ### Cookies (fast phase only)
35
54
 
36
55
  The fast phase receives `props.cookies` — a `Record<string, string>` parsed from the HTTP `Cookie` header: