@jay-framework/jay-stack-cli 0.25.5 → 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:
@@ -125,6 +125,16 @@ A cloud platform that boots the dev server but neither installs the CLI nor inje
125
125
  working site with a non-functional `/aiditor` — the single most common surprise. Install both, or
126
126
  document that visual editing is unavailable there.
127
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
+
128
138
  ### Health / shutdown (containers)
129
139
 
130
140
  Run with `--test-mode` to expose `GET /_jay/health` (readiness probe) and `POST /_jay/shutdown` (graceful
@@ -132,9 +142,10 @@ stop) — the sanctioned hooks for container healthchecks and lifecycle, instead
132
142
 
133
143
  ## Guides
134
144
 
135
- | File | Topic |
136
- | ------------------------------------------ | ---------------------------------------------------------- |
137
- | [production-build.md](production-build.md) | Build pipeline, output structure, frontend/backend split |
138
- | [serving-modes.md](serving-modes.md) | Self-hosted, CDN, BaaS (fetch handler), CLI flags |
139
- | [fetch-handler.md](fetch-handler.md) | Fetch handler, ArtifactStore interface, BaaS custom stores |
140
- | [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:
@@ -0,0 +1,70 @@
1
+ # Source Editing — Plugin Contributor Guide
2
+
3
+ **Audience:** Plugin authors (and AI agents) making a plugin's contracts **editable at the source** (DL#219).
4
+
5
+ **Related:** [actions-guide.md](actions-guide.md) (`devOnly` actions), [plugin-routes.md](plugin-routes.md)
6
+ (`devOnly` routes), and the devops **source-editing** guide (the inspector protocol the AIditor uses).
7
+
8
+ ---
9
+
10
+ ## Why
11
+
12
+ When a page binds content to your contract — e.g. `<h1>{productPage.productName}</h1>` — the source-editing
13
+ inspector resolves that pixel to the contract, but it can't know **how** your data is edited: which record,
14
+ what the write path is, or where an editor UI lives. Only your plugin knows. So each contract declares its
15
+ **editing story**, and `jay-stack validate` warns when one is missing.
16
+
17
+ ## Declare the editing story (required for coverage)
18
+
19
+ In `plugin.yaml`, each contract declares **one** of:
20
+
21
+ ```yaml
22
+ contracts:
23
+ - name: product-page
24
+ contract: ./product-page.jay-contract
25
+ component: productPage
26
+ editingGuide: ./editing/product-page.md # how an agent edits this contract's bound data
27
+ - name: stock-badge
28
+ contract: ./stock-badge.jay-contract
29
+ component: stockBadge
30
+ readonly: true # this contract's data isn't editable in place
31
+ ```
32
+
33
+ - **`editingGuide`** — relative path to a markdown guide shipped by the plugin.
34
+ - **`readonly: true`** — the contract's data is derived/not editable in place (e.g. a computed badge).
35
+
36
+ `jay-stack validate` emits a `source-editing-coverage` warning for any contract that declares neither (and
37
+ checks the `editingGuide` file exists).
38
+
39
+ ## What the editing guide contains
40
+
41
+ The guide is the **single entry point** an agent follows to edit content bound to your contract. It should tell
42
+ the agent what to do with the identity the inspector provides:
43
+
44
+ - The inspector's `stack` gives the **record identity** — a `forEach` frame's `itemId` (trackBy), a `region`
45
+ frame's static `props`, plus the page's route params (`route`/`routePattern`).
46
+ - From that, instruct the agent to either:
47
+ - **Write the source** — call a `devOnly` action that persists the change (see actions-guide.md), or
48
+ - **Open an editor** — call a `devOnly` action that returns a URL and open it, or
49
+ - offer **several options** and let the agent choose.
50
+
51
+ Action names are ordinary (nothing is reserved). Example mapping for a stores plugin:
52
+
53
+ | Contract | Record key (from inspect result) | Guide instructs |
54
+ | ---------------- | -------------------------------- | ----------------------------------------- |
55
+ | `product-page` | `slug` (route param) | `getEditUrl(slug)` → fetch slug→productId |
56
+ | `product-search` | `productId` (`forEach` `itemId`) | `getEditUrl(productId)` |
57
+
58
+ ## Discovery
59
+
60
+ `plugin.yaml` is the source of truth: an agent that resolves a region to your contract reads `editingGuide`
61
+ from the plugin's `plugin.yaml` and then reads the guide file. Keep the guide concise and imperative — it is
62
+ read by an agent at edit time. (Surfacing `editingGuide` into `agent-kit/plugins-index.yaml` alongside the
63
+ contract entry, so it's one hop from the index, is a planned enhancement.)
64
+
65
+ ## Checklist
66
+
67
+ 1. Add `editingGuide:` (or `readonly: true`) to each contract in `plugin.yaml`.
68
+ 2. Write the guide at that path — the decision rule above: identity → write action / URL action.
69
+ 3. Expose the write/URL path as a `devOnly` action (actions-guide.md).
70
+ 4. Run `jay-stack validate` — resolve any `source-editing-coverage` warnings.
@@ -251,3 +251,39 @@ export const validate: JayHtmlValidatorFn = (ctx) => {
251
251
  return findings;
252
252
  };
253
253
  ```
254
+
255
+ ## Project-Level Validators (DL#232)
256
+
257
+ A per-file validator sees one jay-html file at a time. A **project-level validator** runs **once per
258
+ project** — for concerns that are not a single file: a config file, a `.well-known` file, a policy
259
+ file, cross-file consistency. Declare them under `projectValidators` (same `./tools` handler rules as
260
+ `validators`):
261
+
262
+ ```yaml
263
+ projectValidators:
264
+ - name: seo-site
265
+ handler: validateProject # export name from the ./tools entry
266
+ description: robots.txt, site identity, and ARD checks
267
+ ```
268
+
269
+ The handler receives only `{ projectRoot }` (and the plugin's own `settings`) — **not** parsed config
270
+ or parsed jay-html. A validator that needs `.jay` config reads it itself via `projectRoot`:
271
+
272
+ ```typescript
273
+ import type { ProjectValidatorFn, ProjectValidationFinding } from '@jay-framework/compiler-shared';
274
+
275
+ export const validateProject: ProjectValidatorFn = async (ctx) => {
276
+ const findings: ProjectValidationFinding[] = [];
277
+ // ctx.projectRoot — absolute project root path
278
+ // ctx.settings — the plugin's own settings (optional)
279
+ return findings;
280
+ };
281
+ ```
282
+
283
+ Each `ProjectValidationFinding` has `severity` (`'error'` | `'warning'`), `file` (project-relative,
284
+ or `.jay`), `message`, and optional `suggestion`. Project validators run **after** the per-file pass
285
+ and are **skipped under `--compile-only`**. Findings surface with `source: plugin:<plugin>/<name>`.
286
+
287
+ Built-in example: `@jay-framework/seo-validator` ships a `seo-site` project validator (robots.txt,
288
+ `site.baseUrl`, `site.name`, and ARD `/.well-known/ard.json`) — these moved out of core `validate`
289
+ into the plugin via this hook (DL#232), so a project only gets them when it installs seo-validator.