@akanjs/devkit 3.2.0-rc.2 → 3.2.0-rc.5

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +78 -4
  2. package/akanApp/akanApp.host.test.ts +51 -0
  3. package/akanApp/akanApp.host.ts +3 -3
  4. package/akanContext.ts +2 -3
  5. package/akanMcpContract.ts +1 -1
  6. package/applicationBuildRunner.test.ts +67 -0
  7. package/applicationBuildRunner.ts +1 -0
  8. package/codeAgent/skills/akan-debug/SKILL.md +6 -2
  9. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  10. package/commandDecorators/argMeta.ts +4 -0
  11. package/commandDecorators/command.ts +51 -33
  12. package/commandDecorators/commandDecorators.test.ts +74 -9
  13. package/frontendBuild/bundleDefine.ts +2 -0
  14. package/frontendBuild/clientBuildTypes.ts +0 -1
  15. package/frontendBuild/clientEntryDiscovery.test.ts +30 -0
  16. package/frontendBuild/nativeCore.test.ts +148 -0
  17. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  18. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  19. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  20. package/frontendBuild/vendorImportScanner.ts +25 -10
  21. package/incrementalBuilder/incrementalBuilder.host.test.ts +5 -1
  22. package/incrementalBuilder/incrementalBuilder.host.ts +2 -1
  23. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  24. package/{constantFieldNames.test.ts → moduleFieldNames.test.ts} +24 -2
  25. package/moduleFieldNames.ts +25 -0
  26. package/package.json +3 -3
  27. package/preview/PreviewView.ts +32 -11
  28. package/preview/previewView.test.ts +76 -9
  29. package/workflow/index.ts +1 -0
  30. package/workflow/plainName.ts +28 -0
  31. package/workflow/plan.ts +1 -7
  32. package/workspaceGraph.test.ts +84 -0
  33. package/workspaceGraph.ts +40 -5
  34. package/constantFieldNames.ts +0 -18
package/CHANGELOG.md CHANGED
@@ -34,6 +34,17 @@
34
34
  - **`pkgs/akanjs/ARCHITECTURE.md`** pairs a hand-kept walk through the processes and a request with a generated part between markers. `akan sync-architecture` regenerates that part, and a devkit test fails when it is stale, when a channel is gone, when two facets load values from each other, when a message lacks a recognised sender or handler outside a short list whose other end lies beyond the framework source, or when the hand-kept part names a class the source no longer has.
35
35
  - **`AkanIpcMessage` no longer lists `backend-ready`.** The gateway sends it to the dev host, which reads it as a `BuilderMessage`, where it stays; no gateway or child ever handled it.
36
36
 
37
+ - 4d61170: The Akan Graph follows an app's adaptor wiring further. `applyAdaptor(LlmAdaptorRole, ChatCompletionsLlm)` in an app's or lib's `option.ts` draws a `binds` edge from the role, `adaptor::LlmAdaptorRole`, to the implementation it binds, labelled with the app or lib and located at the call, so a method that calls a plugged role reaches the class that answers it. `get_graph` scoped to an app keeps that app's own bindings, and a lib's binding of a role the app does not bind itself; scoped to a lib or a module it keeps every binding of a role it reaches. A Change Map carries every binding of a role it shows, changed or not, so the binding an app runs stays in view; a binding the app removed no longer hides the lib binding it now runs; and Studio's Change Map for one app keeps only the bindings that app runs. An app service holds the fields its module's lib services plug (`...user.services`), so `this.notifier.getMemo()` in an app method links to the `Notifier` a lib's `MemoService` plugs, where it linked to the `memo` model; a service whose own builder declares the field (`notifier: use<…>()`) inherits nothing. A plug imported from a package outside the workspace, `plug(Stripe)` from `stripe-sdk`, or through a lib's re-export of it (`export { Stripe }`, `export { default as Stripe }`, or `export * from "stripe-sdk"` where the lib declares no `Stripe` itself), is `adaptor:stripe-sdk:Stripe` with the package in `detail`, where it linked to a same-name adaptor the app declares or to an unlocated `adaptor:libs/util:Stripe`. Two adaptors with the same class name in one app or lib are two nodes, each with its own location, digest and `plugs`, and a plug reaches the one its import names. A job's `detail` lists only the options its kind honours: `once` on an `initialize` job, `unlocked` on an `interval` job (`Bun.cron` never overlaps a cron job, so `lock: false` changes nothing there); `cron("0 * * * *" as const)` reads `0 * * * *`.
38
+
39
+ Ids and details that change for a saved graph or a Change Map base:
40
+
41
+ - While two adaptors of one owner share a class name, each is keyed by its file, `adaptor:apps/demo:srvkit/clockB.ts#Clock`; an adaptor whose name no other adaptor of its owner shares keeps `adaptor:<owner>:<Name>`. The Change Map treats the two forms of one declaration as one node, so a second same-name adaptor shows as one added node, not a removal of the first.
42
+ - A plug from a non-workspace package is `adaptor:<package>:<Name>` with the package as `detail`, where it was `adaptor::<Name>` or a workspace adaptor of that name.
43
+ - A job's `detail` drops `once` and `unlocked` where the runtime ignores them, and `as const` from a cron expression.
44
+
45
+ - **`binds`** joins `AkanGraphEdgeKind` in `akanjs/common`.
46
+ - **`FileFacts.adaptorBindings`** in `@akanjs/language`: each `applyAdaptor(role, adaptor)` call a file makes; `AkanIndex.setFile` also reads non-test files whose text mentions `applyAdaptor`. **`ServiceClassFact.fields`**: the names a service's builder declares.
47
+
37
48
  - c723713: The Akan Graph keys a guard function by the file that declares it, locates more of a workspace's guards, and stops counting things that are not guards.
38
49
 
39
50
  A guard function a route or layout imports is keyed by its file, as one the page declares already was: `guard:libs/shared:webkit/signedIn.ts#signedIn`. A `canEdit` in `webkit/` and one in `common/` are two nodes, each at its own declaration, and an edit to either marks only that one changed in the Change Map. A guard called through a namespace import (`auth.signedIn()`), or imported under a name a file re-exports (`export { signedIn as hasSession } from "./auth"`), reaches its declaration. A function the guard declares, or a parameter it binds, shadows an import of the same name only inside its own scope, so `[1].forEach((signedIn) => …)` no longer hides a real `signedIn()` beside it. A guard declared after another declarator (`export const ready = true, signedIn = …`) is found, and an overloaded guard's digest covers every signature. Only a file whose text declares or re-exports the name is parsed, so a guard that calls `usePage()` from the generated client no longer parses every page that names it.
@@ -126,7 +137,7 @@
126
137
  **Behavior change:** where the shell exports a value the workspace `.env` also sets, such as `AKAN_PUBLIC_ENV`, `AKAN_PUBLIC_REPO_NAME` or `AKAN_PUBLIC_SERVE_DOMAIN`, `akan` now takes the shell's. Unset it, or match the `.env`, to build what you built before.
127
138
 
128
139
  - e65172d: The CLI refreshes a cloud sign-in once less than an hour, or half the token's lifetime, is left, whichever is shorter. Before, a token that lived an hour or less read as due the moment it arrived, so every command rotated the refresh token and then called the cloud unsigned. Any unexpired token is now sent with the request, except the refresh call, which carries none. `akan login` signs in again when a session is due for refresh and has no refresh token left.
129
- - c3b5b3c: A web page no longer loads `@akanjs/native/core`. `isNativeApp()` and the other host checks read the platform the shell writes before the bundle runs, and a native shell still starts core with the page. On the minimal app this takes 15 KB raw (5.6 KB brotli) off the vendor floor and 20 KB raw (6.6 KB brotli) off every route entry. `akan build` also resolves the `production` export condition for the browser, SSR-client, CSR and MCP App view bundles, so a package such as Lexical ships its production build alone. On the minimal app the Agent editor chunk drops from 91 KB to 63 KB brotli. `akan start` is unchanged.
140
+ - c3b5b3c: The framework no longer puts `@akanjs/native/core` on a web page. `isNativeApp()` and the other host checks read the platform the shell writes before the bundle runs, and a native shell still starts core with the page. A file that imports `akanjs/client/native` statically still brings core to every page that reaches it. On the minimal app this takes 15 KB raw (5.6 KB brotli) off the vendor floor and 20 KB raw (6.6 KB brotli) off every route entry. `akan build` also resolves the `production` export condition for the browser, SSR-client, CSR and MCP App view bundles, so a package such as Lexical ships its production build alone. On the minimal app the Agent editor chunk drops from 91 KB to 63 KB brotli. `akan start` is unchanged.
130
141
  - 6d5b544: A database service now has `create<Model>Many(rows)`. It creates the rows on the same path as `create<Model>`: `_preCreate` and `_postCreate`, the document's save and create hooks, and the live and pubsub events each row's commit fires. Every row's `_preCreate` and document pre hooks run first, so a check there that queries the table does not see the batch's own rows. The rows are then written in one transaction, joining the caller's when one is open, with one multi-row INSERT per 500 rows instead of one statement per row; if the database refuses one row, none is written. Every row's `_postCreate` runs once the transaction commits, as `create<Model>`'s runs once its row is stored, so a `_postCreate` that throws leaves the rows written. A service that already declares a method of that name, itself or in a lib service it extends, keeps its own at runtime, but its signature no longer typechecks against the generated one: rename it.
131
142
 
132
143
  A store can list actions in `static silent = ["refreshToken"] satisfies (keyof UserStore)[]`. When a silent action fails, it shows no toast and logs at verbose instead of error. It still rejects with the original error, so a background write such as a token refresh is awaited by its caller, which handles the failure. Generated actions can be listed, and a store built on a lib store keeps the lib's silent actions. A name in `static silent` that is not an action of the store is reported when the store registers.
@@ -154,7 +165,7 @@
154
165
  - **`@akanjs/devkit`**: `applyTemplate` takes `dryRun` to list the files a template would write without writing them, and `WorkspaceExecutor.commit` takes an `identity` to commit under.
155
166
 
156
167
  - 1870fcd: `akan verify` adds a `fix:` line to a module file's two-parent import that names the layer barrel to import from instead, such as `import from "@apps/<app>/common"`. The lint message itself now points at the layer barrel too.
157
- - 76ab8d4: `akan doctor` exits with code 1 when its report says `"status": "failed"`, in text or JSON, with or without `--strict`, and for `--ios`, so the `agent:doctor` script (`akan doctor --strict --format json`) or a CI step fails on it. Before, it always exited 0. The full report still prints on stdout. A warning never changes the exit code: `--strict` still only decides which conventions count as errors, today a missing module abstract. Doctor's `dictionary-label-missing` now reads a constant's fields from its syntax, so a field that appears only in a comment or a string no longer asks for a label. A new `--sample` workspace no longer warns about `task`'s `priority`, `tags`, `assignee` and `completedAt` or `workHistory`'s `actor` and `previousStatus`, which its constants suggest only in comments.
168
+ - 76ab8d4: `akan doctor` exits with code 1 when its report says `"status": "failed"`, in text or JSON, with or without `--strict`, and for `--ios`, so the `agent:doctor` script (`akan doctor --strict --format json`) or a CI step fails on it. Before, it always exited 0. The full report still prints on stdout. A warning never changes the exit code: `--strict` still only decides which conventions count as errors, today a missing module abstract. Doctor's `dictionary-label-missing` now reads both a constant's fields and its dictionary's labels from their syntax, ignoring comments and strings. A field that appears only in a comment no longer asks for a label: a new `--sample` workspace no longer warns about `task`'s `priority`, `tags`, `assignee` and `completedAt` or `workHistory`'s `actor` and `previousStatus`, which its constants suggest only in comments. A label that exists only in a comment, or a `name:` inside a string, no longer counts, so a field whose label is commented out now warns.
158
169
  - 245d318: A font declared on a root layout now gets its metric-adjusted fallback faces: `@font-face` rules named `"<font> fallback"` that draw a local system font with `size-adjust`, `ascent-override` and `descent-override` matched to the web font. The variable `--font-<name>` already named that family, but the build never wrote it, because fontaine's `readMetrics` takes a URL and returned nothing for the file path it was given. While a web font loaded (`font-display: swap`), text drew in the browser's default font, usually a serif, and moved when the web font arrived. The metrics are read from the source font, not the subset, so a subset without Latin letters (`subsets: ["ks-x-1001"]`, or only `subsetText`) still sizes the fallback correctly. A font whose metrics cannot be read keeps its face and gets no fallback, as before. Set `adjustFontFallback: false` on a font to keep the old output. Cached font output from `akan start` is regenerated once.
159
170
  - 6f6cc2a: A fresh clone of a workspace now builds and starts after `bun install` alone. The env files that hold secrets stay gitignored, so a clone has none of them: before, `akan typecheck`, `akan build` and `akan verify` failed with `Cannot find module './env.server.local'`, and with no `.env` the CLI picked the `debug` environment, so `akan build` stopped asking for `AKAN_PUBLIC_REPO_NAME` and `AKAN_PUBLIC_SERVE_DOMAIN`.
160
171
 
@@ -192,7 +203,8 @@
192
203
  - **A narrowed build fails when route code imports a name its facet does not export**, and names the missing names. Before, the page threw a `SyntaxError` in the browser.
193
204
 
194
205
  - 4561152: The skill examples now use one neutral domain, books from a public read-only API: a `BookApi` adapter in `srvkit/`, a `lib/_openlibrary/` service with a `book` query, `fetch.book(id)` and `/books/[id]` pages. This covers the `akan-service` external-API recipe, the `akan-page` API summary, the `akanApi` import block, the `config` rule's `localePrefix` example and the README's as-needed path. The recipes teach the same steps as before.
195
- - 53eb6f1: A command whose app-or-lib target is left out no longer stops at a prompt error when stdin is not a terminal, as in a coding agent's shell or CI. In a workspace with a single app, lib or package, `akan sync`, `akan lint`, `akan test` and `akan create-module <name>` run on it and say so on stderr, so a new workspace's `bun run sync`, `bun run lint` and `bun run test` work there. Otherwise every target command (app, lib, package, module) exits at once with the valid targets and the line to rerun, such as `akan sync <app-or-lib>`. A library, package or module is still never picked unasked, since `akan remove-library` and its kin would delete the only one. A terminal is asked as before.
206
+ - 53eb6f1: A command whose app-or-lib target is left out no longer stops at a prompt error when stdin is not a terminal, as in a coding agent's shell or CI. In a workspace with a single app, lib or package, `akan sync`, `akan lint`, `akan test` and `akan create-module <name>` run on it and say so on stderr, so a new workspace's `bun run sync`, `bun run lint` and `bun run test` work there. Otherwise every target command (app, lib, package, module) exits at once with the valid targets and the line to rerun, such as `akan sync <app-or-lib>`. A terminal is asked as before.
207
+ - 44149b1: Without a terminal, a command that deletes nothing now takes the workspace's only library, package or module when none is named, as it already did for the only app or lib, and names it on stderr: `No lib named, so this runs on 'util', the only one in this workspace: akan sync-library util`. This covers `sync-library`, `sync-package`, `build-package`, `verify-dist-package`, `create-view`, `create-unit`, `create-template` and `create-crud-page`. Before, each refused and asked for the target to be named. The remove commands still never take one they were not given. A terminal is still asked, even when there is only one choice.
196
208
  - b24fef3: An app that draws no Akan UI component now builds and runs without the UI's optional peers. Before, `akan build` failed with `Could not resolve: "react-icons/ai"` or `"react-spring"` in an app that imported nothing from `akanjs/ui`: the System UI every root layout mounts drew react-icons glyphs and reached the model, list and dialog components through the `Load` and `Button` modules, and the CSR build, on by default, bundled the whole `akanjs/ui` barrel.
197
209
 
198
210
  **Upgrade note.** A workspace can drop `react-spring`, `@react-spring/web`, `@use-gesture/react`, `react-refresh`, `scheduler` and `tailwind-scrollbar` from its `package.json`, unless its own code imports them. `akanjs` now depends on `@react-spring/web`, `@use-gesture/react`, `react-refresh`, `scheduler` and `tailwind-scrollbar` itself, because the System UI, the dev runtime and `akanjs/ui/styles.css` always use them, and nothing uses `react-spring` any more. New workspaces no longer list them. A workspace whose own code keeps `react-spring` or `@react-spring/web` must move it to major 10: an older one installs a second copy under `akanjs`, and a spring value the app makes no longer typechecks on an `animated.*` element from `akanjs/ui`.
@@ -204,12 +216,18 @@
204
216
  - **The CSR build and its MCP App views** rewrite `akanjs/ui` imports to the components a file names, as the SSR route builds already did, so a page that imports `Link` no longer bundles every component.
205
217
  - **Animations use `@react-spring/web`** instead of the `react-spring` umbrella package, which also installed the native, three.js, konva and zdog renderers.
206
218
 
219
+ - b516497: `akan create-package --name` takes `name` or `@scope/name`, each part lowercase letters, digits, `-`, `.` and `_` starting with a letter or digit, and refuses anything else before it touches a file: `"../x" is no package name; use name or @scope/name, each of lowercase letters, digits, -, . and _, starting with a letter or digit`. Before, `--name ../x` wrote `x/` beside `pkgs/` and added it to the root `tsconfig.json`. `akan create-ui`, `add-field` and `add-enum-field` refuse a `--module` that is not a module name with a `primitive-input-invalid` diagnostic in their report, as they already reported a missing one. Before, `create-ui --module ../x` wrote its component into the app root, and `add-field --module ../x` looked for the constant and dictionary outside the module folder. `plainNameProblem` returns the message `assertPlainName` throws, and `assertPackageName` sits beside it in `@akanjs/devkit/workflow/plainName`.
220
+ - 58b7329: `akan create-scalar`, `remove-scalar`, `create-module`, `create-service`, `create-application`, `create-library`, `install-library` and `create-workspace --app` refuse a name that is not one plain path segment before they touch a file, such as `"../.." is no scalar name; use letters, digits, - and _, starting with a letter`. Before, the name went into the path unchecked: `akan remove-scalar ../.. <app>` deleted `apps/<app>` and exited 0, and the create commands wrote their files outside `lib/`, `apps/` or `libs/`. A scalar, module or service name is checked as a workflow's `module` input already was, an app or lib name as its `app` input; both checks now live in `@akanjs/devkit/workflow/plainName`, with `assertPlainName`.
221
+ - 4e0a38e: A page preview starts a new browser when the one before it died. Bun refuses to start a new browser until a later event-loop turn has reaped the dead one. `akan preview` opens its pages back to back when the app's log socket does not answer. In that case, if Chrome died during one page, every page after it failed at once with `Failed to spawn Chrome (set BUN_CHROME_PATH, …)`. Now a preview whose browser fails to start for any reason other than running out of `startTimeoutMs` waits 100 ms and tries the start once more. A browser that cannot start at all still fails in about 100 ms, now with its own error, such as `Chrome process closed the pipe`.
222
+ - b4b8f85: `akan preview` and the code agent's page preview give a browser that died or was closed the full `startTimeoutMs` (120 s by default) for its next start. Before, after one page had run out of the start bound, a browser that then died while another page waited for it stayed marked as slow to start. The next page started a fresh browser within only its own `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
207
223
  - 686e0b8: `akan preview` and the code agent's page preview bound a page only once the browser is up. Before, a process's first page shared its `timeoutMs` with starting the browser. On a cold machine where Chrome took longer than that to start, the first page failed as `Timed out loading …; the dev server may be rebuilding or stuck.`
208
224
 
209
225
  Starting the browser now has its own bound of 120 s, `startTimeoutMs` on `PreviewView.probe`. Running out of it reads `Timed out starting the <engine> browser after 120 s.`, where the engine is `chrome` or `webkit`. A browser that misses its bound may still come up late, so later pages in that process try again, but each waits only its own `timeoutMs` for the browser. Once the browser answers, the full bound applies again. So a browser that never starts holds the first page for 120 s, where before it held it for `timeoutMs` (60 s for `akan preview`, 30 s for the code agent's check).
210
226
 
211
- - eefd9cb: `akan preview <app> /_akan/studio` opens Studio instead of a 404. Preview added the default locale to every path, and the server routes `/_akan/*` without one, so `/en/_akan/studio` matched no page. A path under `/_akan`, `/__csr`, `/__rsc`, `/.well-known` or the app's API prefix, a file the app's `public/` holds, and the server's own `/favicon.ico`, `/robots.txt`, `sitemap.xml` and `manifest.webmanifest` now open as written: no locale, and no CSR boot query under `--csr`. Every other path, including one that already starts with a locale or ends in a dotted segment such as `/release/1.2.3`, is a page and gets the default locale as before. The `akan-debug` skill and the `akan preview` reference say so.
227
+ - eefd9cb: `akan preview <app> /_akan/studio` opens Studio instead of a 404. Preview added the default locale to every path, and the server routes `/_akan/*` without one, so `/en/_akan/studio` matched no page. A path under `/_akan`, `/__csr`, `/__rsc`, `/.well-known` or the app's API prefix, a file the app's `public/` holds, and the server's own `/favicon.ico`, `/robots.txt`, `/sitemap.xml` (at the root only, the one place the server answers it) and `manifest.webmanifest` now open as written: no locale, and no CSR boot query under `--csr`. Every other path, including one that already starts with a locale or ends in a dotted segment such as `/release/1.2.3`, is a page and gets the default locale as before. The `akan-debug` skill and the `akan preview` reference say so.
228
+ - 01ca04b: `akan preview` opens `/openapi.json` and the default `/mcp` as written, as the server mounts them outside `/:lang`; before, they got the default locale and opened a 404 page. A full URL (`http://localhost:<port>/oauth/token`) now opens exactly as written, with no locale and no CSR boot query, so an endpoint mounted outside the API prefix or a custom MCP path can be opened; before, a full URL was taken by its path and given the locale. A page `--changed` finds always gets the locale, even one at a server route's path such as `/mcp`. A screenshot's file name carries the page's query and a short hash of it, and a long path or query is cut with a hash, so `/_akan/studio?view=runtime` and `?view=signals` no longer overwrite one another and a long query no longer fails the page. The `akan-debug` skill, `akan preview --help` and the `akan preview` reference state the same rule.
212
229
  - b101fcf: `akan remove-application`, `remove-library`, `remove-package`, `remove-module` and `remove-scalar` no longer delete a target they picked themselves. Left out, or named but not in the workspace, the target is refused in a terminal or not, with the choices and the line to rerun, such as `` Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-application <app>` (choices: pokedex). `` Before, `akan remove-application` in a one-app workspace removed `apps/<app>` without asking, and without a terminal `akan remove-scalar <name>` removed the scalar from the only app or lib. A terminal is no longer offered a select for these commands. Other commands still take the only app, or the only app or lib without a terminal, as before. A command declares this with `destructive: true` on its target.
230
+ - 44149b1: `akan remove-module` now removes a service module named exactly, as in `akan remove-module pokedex:_billing` for `apps/pokedex/lib/_billing`. Before, no command could remove a service module: `remove-module` left out every `lib/_<name>` folder and refused one by name with `Invalid module name: _billing`. A service module left out or misnamed gets the same refusal as any target of a remove command, and its choices now list the service modules. `create-view`, `create-unit`, `create-template` and `create-crud-page` still take only a model module. A command opts in to service modules by taking `ModuleOrService` instead of `Module`.
213
231
  - 4fcf44f: A new `rename-key` workflow renames a slice key or a Filter query key everywhere it is spelled. That covers the key itself, every name derived from it (`initMemoInPublic`, `memoListInPublic`, `queryByName`) in the app and every lib, and its module dictionary entry. It is the same rename the editor extension's F2 runs, so agents get it through `plan_workflow` / `apply_workflow` and the CLI through `akan workflow plan rename-key --app <app> --module <module> --slice <key> --to <newKey>` (or `--query <key>`). The plan lists each file it will change. A use that could also be another model's is left alone, and `akan verify` then reports it.
214
232
  - 9a9e1fa: A server page from `akan build` now preloads its client modules from `<head>`, so a cold load stops waiting on chains of imports.
215
233
 
@@ -225,6 +243,27 @@
225
243
  - **Where it fails:** `akan build` fails on an overage or an unreadable file, and `--update-budget` rewrites an unreadable file. `akan verify` warns of each route over its entry and of an unreadable file, one line each, but never fails on them. A mobile or desktop build skips the step.
226
244
  - **Opt-in:** an app without the file gets the report and nothing fails. A route without an entry is reported, not failed. `akan sync` accepts `akan.budget.json` at an app root.
227
245
 
246
+ - 9055634: Partial RSC commit can now patch a real page, and it patches cleanly. It is still off by default behind `AKAN_PUBLIC_RSC_PARTIAL_COMMIT=1`. With the flag on:
247
+
248
+ - **Which pages render their head through `AkanRouteHead`.** Every page whose head is plain `title`, `meta` and `link` tags now renders it through a client component instead of plain head tags. That way any page can be the one a patch starts from. Patches still only land on pages that set `.config({ rscPatchHeadSafe: true })`.
249
+ - **A plain `.head()` is its own head snapshot.** A page no longer needs a hand-written snapshot. A head with anything else, such as a script or a component, keeps the full render.
250
+ - **React renders the head.** The old patch swapped `<head>` nodes behind React's back. That left a stale or broken head after the next full navigation or a back navigation.
251
+ - **A patch carries only the page's part of the head.** The layouts' part stays on the client, under the layouts the patch keeps. The page's part travels in the `X-Akan-Rsc-Patch-Head-Snapshot` header, which is now capped at 2 KB encoded, down from 12 KB. A larger page head renders whole, and the server logs that once per route.
252
+ - The cap leaves room under nginx's default 4 KB `proxy_buffer_size`, but it doesn't guarantee a fit: router state, trace headers and `Set-Cookie` share that buffer.
253
+ - The docs pages' part is about 1.1 KB, against about 5.7 KB for their whole head.
254
+ - **The layouts a patch keeps follow the new page.** `usePathCtx()` under an SSR page gives the patched page's path, pathname and query, so a menu's active item and a pager move with it.
255
+ - **A full commit after a patch goes straight to the new page's head, path and body.** It no longer shows the last full page for a frame first.
256
+ - **A patch falls back to a full render when the kept layouts would be wrong:**
257
+ - another route group's layout sits at the same URL prefix (`(guest)/account/login` to `(member)/account/settings`);
258
+ - the layouts' head would be different for the target, for example a layout `.head()` that reads a search arg. The router state carries a key of the layouts head its page rendered (`headKey`), and the server compares it with the target's. The key changes together with the rest of the router state, so a navigation started while the previous page is still committing sends that page's key;
259
+ - a route param differs (`/en` to `/ko`);
260
+ - a frame setting differs, such as `transition`;
261
+ - the page navigated from has no patchable head;
262
+ - its frame cannot be resolved.
263
+ - **A client built without the flag no longer asks for patches it cannot apply.** Before, a server running with the flag answered such a client with patches, and every sibling navigation made a second request. Such a client leaves the head key out of the router state it sends.
264
+ - **A refused head-safe patch runs each `.head()` once.** When a patch is refused after its head resolved, the full render reuses that head. Before, it ran every `.head()` a second time.
265
+ - **`@akanjs/devkit`: the SSR runtime manifest keeps every client export of the segment outlet module.** Before, it kept only one.
266
+
228
267
  - 27440aa: A production build now runs without Docker: `cd dist/apps/<app> && bun main.js` serves the app with no env set. Until now the env the built server needs (the app and repo names, the serve domain, locales, route prefixes, database modes and the rest) existed only as `ENV` lines in the generated Dockerfile. On a host without the image, such as a PaaS or a test harness, the server stopped at boot with `getEnv() cannot run at build time` unless a start script copied every value by hand.
229
268
 
230
269
  **Upgrade note.** Run `akan agent install` and commit what it writes: the `akan-runtime` skill, the `config` path rule and `runtimeRule` now say how to run a build without Docker.
@@ -280,6 +319,14 @@
280
319
  - **A public env value holding a private key is refused** when the native build records its `AKAN_PUBLIC_*` values, as names with `SECRET`, `TOKEN`, `PASSWORD`, `PRIVATE` or `CREDENTIAL` already were.
281
320
  - **akan-native only warns about `'unsafe-inline'` in `script-src`.** Inline styles under `style-src 'unsafe-inline'` no longer produce a warning; inline scripts are still left unhashed when `script-src` allows them.
282
321
 
322
+ - db5b284: An app using the `shared` and `util` libraries no longer loads `@akanjs/native/core` on its web pages. Their stores imported `akanjs/client/native` for `isNativeShell()` and for the push installation id. That put core and every native plugin wrapper on every page. Native SSO and `usePushNotification` now load the plugin they call on first use inside a shell. The `useCamera`, `useGeoLocation` and `usePurchase` hooks in `akanjs/webkit`, which `shared`'s `Field` and `User.Template` reach, import their plugin and the few core exports they use dynamically, when first called; inside a shell `useCamera` still reads the camera permission at mount. On the minimal app with `shared` merged into its store:
323
+
324
+ - a Link route drops from 148.7 KB raw (38.5 KB brotli) to 101.5 KB raw (23.3 KB brotli);
325
+ - a route drawing `Field` drops from 416.6 KB (113.8 KB brotli) to 370.2 KB (99.8 KB brotli);
326
+ - a route drawing `User.Template` drops from 526.8 KB (140.2 KB brotli) to 481.2 KB (126.3 KB brotli).
327
+
328
+ The runtime guideline and the `no-web-only-api-outside-webkit` message now name `akanjs/client` for `isNativeApp()`, and the native subpaths for push and haptics.
329
+
283
330
  - 5e08722: A coding agent now finds the imports and signatures a first app needs in its skills, instead of reading the akanjs sources.
284
331
 
285
332
  - **The `akan-service` and `akan-page` recipes** start each file with its import lines, and the `akan-service` rules say what `serverMode: "batch"` does: the module, endpoints included, mounts only in a `batch` or `all` process, so a page-facing service leaves it off.
@@ -308,6 +355,20 @@
308
355
  - A UI file that reads another UI file's export at its top level, through an import cycle, can now find it uninitialised and throw a TDZ error. Move that read into the component.
309
356
  - Generated module `index.ts` files only collect namespaces, so they are unaffected.
310
357
 
358
+ - 94d3673: Akan Studio lands on the Runtime view when `akan start`'s builder is stopped, not only when there is none or it never answers.
359
+
360
+ - **The dev host marks a reply no builder answered.** A builder request the dev host answers itself carries `kind: "unavailable"`: the builder is stopped, not started yet, failed to start, or exited or was stopped before answering. A failure the builder reports, such as a broken build, carries no kind and reads as before.
361
+ - **`BuilderRpc.isUnavailable(error)`** tells such a failure apart, as `BuilderRpc.isTimeout` does for a request that times out. A bare, empty or unknown `/_akan/studio` view then lands on Runtime, and a view asked for by name still answers 503 with the reason.
362
+ - **Mixed versions keep working.** A server reading a reply from an older dev host, with no kind, answers 503 as before, and an older server ignores the new field.
363
+
364
+ - 44149b1: A module named in a library that lacks it, such as `akan create-view util:nope`, is refused at once, as one named in an app is. Before, it fell through to the module picker in a terminal, and without one it failed as if no module had been named. Both are now refused, terminal or not, with the modules there are and the line to rerun: `` Unknown module 'util:nope': util has no such module, so rerun it as `akan create-view <sys:module>` (choices: pokedex:card, util:photo). `` An app's used to say only `Invalid module name: nope`.
365
+ - 0d7294c: Narrowed vendor entries hold up during a rolling deploy and on very large builds.
366
+
367
+ - **A route chunk that fails to load during a rolling deploy now loads its page as a document.** A document from the new build can get a page from a replica the deploy has not reached yet, and that page's chunks can import a name the document's narrowed vendor entries lack. Before, the page broke, and React's Flight client kept the failed load, so retrying in the tab failed the same way. Now, if a navigation to another page has not committed, the tab loads its target as a document, which brings a matching import map. If the page is already on screen (a refresh of it counts), the tab reloads it at its current address, fragment and any later query-only change included, and only when the error is a module link or resolve error and the browser is online. A network, ad-blocker or evaluation error leaves the page in place. Only chunks that the latest page navigation's response imports count. The document's own chunks, an overtaken navigation's and a replaced response's do not trigger it. The exception is a refresh of the same page that imports a chunk the document is still loading, which takes that chunk over. Each chunk starts at most one document load per tab, so a chunk that always fails shows its error instead of reloading in a loop. While one document load is under way, another failure does not start a second; the page's next navigation, or its return from the back-forward cache, ends that wait. This applies to `akan build` output only.
368
+ - **A document fallback to the address the tab already shows now reloads it.** A failed refresh, or a back or forward move to a URL with a `#fragment`, fell back with `location.replace` to that same URL, which only scrolls to the fragment and loads nothing. It now calls `location.reload()`.
369
+ - **`akan build` fails when the vendor scan cannot find a route's client entry**, and names the entry. Before, a build that wrote its route bundles somewhere the scan did not look narrowed every vendor entry to nothing. The build passed, and pages threw in the browser.
370
+ - **The vendor scan no longer overflows the stack on large minified chunks.** A function body of about 20,000 statements, which a minifier folds into one comma chain, used to fail `akan build` with `Maximum call stack size exceeded`.
371
+
311
372
  - bd4d45a: `akan verify` now fixes or explains three lint failures that were costly to resolve by hand.
312
373
 
313
374
  - **A daisyUI component class your own CSS defines is no longer refused.**
@@ -327,6 +388,7 @@
327
388
  - Updated dependencies [61fbd7d]
328
389
  - Updated dependencies [b3f85fa]
329
390
  - Updated dependencies [a707354]
391
+ - Updated dependencies [4d61170]
330
392
  - Updated dependencies [c723713]
331
393
  - Updated dependencies [0351ad4]
332
394
  - Updated dependencies [ba417c6]
@@ -344,11 +406,13 @@
344
406
  - Updated dependencies [1ebc57a]
345
407
  - Updated dependencies [c3b5b3c]
346
408
  - Updated dependencies [6d5b544]
409
+ - Updated dependencies [73f8830]
347
410
  - Updated dependencies [958f175]
348
411
  - Updated dependencies [9f30e93]
349
412
  - Updated dependencies [d9eaacc]
350
413
  - Updated dependencies [d9eaacc]
351
414
  - Updated dependencies [c334caf]
415
+ - Updated dependencies [170be15]
352
416
  - Updated dependencies [e143263]
353
417
  - Updated dependencies [e4f7876]
354
418
  - Updated dependencies [cb0fd5b]
@@ -362,11 +426,13 @@
362
426
  - Updated dependencies [4561152]
363
427
  - Updated dependencies [73e833b]
364
428
  - Updated dependencies [b24fef3]
429
+ - Updated dependencies [513e48d]
365
430
  - Updated dependencies [5cd1fc3]
366
431
  - Updated dependencies [f47cc82]
367
432
  - Updated dependencies [1f93507]
368
433
  - Updated dependencies [5d3cb3e]
369
434
  - Updated dependencies [9a9e1fa]
435
+ - Updated dependencies [9055634]
370
436
  - Updated dependencies [3537448]
371
437
  - Updated dependencies [6c3a303]
372
438
  - Updated dependencies [27440aa]
@@ -378,12 +444,20 @@
378
444
  - Updated dependencies [e3de9ef]
379
445
  - Updated dependencies [e3de9ef]
380
446
  - Updated dependencies [99fda20]
447
+ - Updated dependencies [db5b284]
381
448
  - Updated dependencies [5e08722]
382
449
  - Updated dependencies [df32c46]
383
450
  - Updated dependencies [5f809e8]
451
+ - Updated dependencies [76705dd]
384
452
  - Updated dependencies [206bb69]
453
+ - Updated dependencies [170be15]
454
+ - Updated dependencies [cfe50cb]
455
+ - Updated dependencies [94d3673]
456
+ - Updated dependencies [e4c7e5c]
385
457
  - Updated dependencies [6291fec]
386
458
  - Updated dependencies [0c480c6]
459
+ - Updated dependencies [e4c3009]
460
+ - Updated dependencies [0d7294c]
387
461
  - Updated dependencies [d9eaacc]
388
462
  - akanjs@3.2.0
389
463
  - @akanjs/language@3.2.0
@@ -143,3 +143,54 @@ describe("a gateway whose replica crash-loops", () => {
143
143
  ]);
144
144
  });
145
145
  });
146
+
147
+ describe("a builder request while no builder is running", () => {
148
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
149
+ const track = tempRoots();
150
+ const stoppedBuilder = {
151
+ status: "stopped",
152
+ patcherOff: false,
153
+ start: ({ onReady }: { onReady?: () => void }) => onReady?.(),
154
+ send: () => false,
155
+ stop: () => undefined,
156
+ } as unknown as IncrementalBuilderHost;
157
+
158
+ test("is answered at once and marked unavailable, so the backend can tell it from a failed build", async () => {
159
+ const { root, app } = track(await createTempApp("demo"));
160
+ const replyPath = path.join(root, "reply.json");
161
+ await writeText(
162
+ path.join(root, "apps/demo/main.ts"),
163
+ [
164
+ 'import fs from "node:fs";',
165
+ `const replyPath = ${JSON.stringify(replyPath)};`,
166
+ 'process.on("message", (message) => {',
167
+ ' if (message?.type !== "build-graph-res") return;',
168
+ ' fs.writeFileSync(replyPath + ".tmp", JSON.stringify(message));',
169
+ ' fs.renameSync(replyPath + ".tmp", replyPath);',
170
+ "});",
171
+ 'process.send?.({ type: "backend-ready", pid: process.pid });',
172
+ 'process.send?.({ type: "build-graph", id: 7 });',
173
+ "setInterval(() => undefined, 1_000);",
174
+ "",
175
+ ].join("\n"),
176
+ );
177
+ const create = IncrementalBuilderHost.create;
178
+ IncrementalBuilderHost.create = async () => stoppedBuilder;
179
+ const host = new AkanAppHost(app, { env: { ...process.env } as Record<string, string> });
180
+ try {
181
+ await host.start();
182
+ const deadline = performance.now() + 5_000;
183
+ while (!(await Bun.file(replyPath).exists()) && performance.now() < deadline) await Bun.sleep(25);
184
+ expect(await Bun.file(replyPath).json()).toEqual({
185
+ type: "build-graph-res",
186
+ id: 7,
187
+ ok: false,
188
+ error: "builder is stopped; reload after the builder is ready",
189
+ kind: "unavailable",
190
+ });
191
+ } finally {
192
+ await host.stop();
193
+ IncrementalBuilderHost.create = create;
194
+ }
195
+ });
196
+ });
@@ -922,11 +922,11 @@ export class AkanAppHost {
922
922
  }
923
923
  #failBuilderRequest(message: BuilderMessage, error: string): boolean {
924
924
  if (message.type === "build-route")
925
- this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error });
925
+ this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error, kind: "unavailable" });
926
926
  else if (message.type === "build-csr")
927
- this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error });
927
+ this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error, kind: "unavailable" });
928
928
  else if (message.type === "build-graph")
929
- this.#sendToBackend({ type: "build-graph-res", id: message.id, ok: false, error });
929
+ this.#sendToBackend({ type: "build-graph-res", id: message.id, ok: false, error, kind: "unavailable" });
930
930
  else return false;
931
931
  return true;
932
932
  }
package/akanContext.ts CHANGED
@@ -2,9 +2,9 @@ import { readdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
4
4
  import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
5
- import { constantFieldNames } from "./constantFieldNames";
6
5
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
7
6
  import { FileSys } from "./fileSys";
7
+ import { unlabeledFieldNames } from "./moduleFieldNames";
8
8
  import { collectRecipeSources, findInlineRecipeDuplicates, scanRecipes } from "./recipeScanner";
9
9
  import type { PackageJson } from "./types";
10
10
  import {
@@ -851,8 +851,7 @@ export class AkanContextAnalyzer {
851
851
  safeReadText(dictionaryPath),
852
852
  ]);
853
853
  if (constantContent && dictionaryContent) {
854
- for (const fieldName of constantFieldNames(constantContent)) {
855
- if (new RegExp(`\\b${fieldName}\\s*:`).test(dictionaryContent)) continue;
854
+ for (const fieldName of unlabeledFieldNames(constantContent, dictionaryContent)) {
856
855
  report(
857
856
  {
858
857
  severity: "warning",
@@ -324,7 +324,7 @@ export const readonlyMcpTools: McpToolDefinition[] = [
324
324
  {
325
325
  name: "get_graph",
326
326
  description:
327
- "Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with the guards they name (each guard keyed by the app or lib that declares it, with its scope and the comment that says what it checks), the service methods and queries each calls, the store actions that fetch them, and the components and routes that use them. A module's jobs (internal: interval, cron, timeout, initialize, destroy, process) read their schedule and server or operation modes and call service methods like endpoints do; each adaptor a service plugs (an adapt() class, or an akanjs role) is a node, a method that calls it links to it, and an adaptor links to the adaptors it plugs itself. Each page/ file is a route (its URL pattern after the locale) or a layout, layouts wrap what they contain, and a route or layout that runs .guard() reads `guard` and links each function its guard calls; a layout's guard gates every route it wraps. A graph too large to answer unscoped drops first the routes and layouts that neither changed nor use a change; app or module brings them back. Every node and edge carries its source location. Pass app and/or module to scope a large workspace. Pass since (a git revision such as main, or HEAD for uncommitted work) for the Change Map instead: what changed since HEAD forked from it, working tree included, with each node, field and edge marked added, removed or changed, the unchanged neighbours they call or are called by, and the changed files no node covers, each marked the same way.",
327
+ "Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with the guards they name (each guard keyed by the app or lib that declares it, with its scope and the comment that says what it checks), the service methods and queries each calls, the store actions that fetch them, and the components and routes that use them. A module's jobs (internal: interval, cron, timeout, initialize, destroy, process) read their schedule and server or operation modes and call service methods like endpoints do; each adaptor a service plugs (an adapt() class, or an akanjs role) is a node, a method that calls it links to it (an app service holds the fields its module's lib services plug, unless it declares the field itself), an adaptor links to the adaptors it plugs itself, a class from a package outside the workspace is adaptor:<package>:<Name>, and a role an app binds with applyAdaptor(Role, Impl) links to that implementation (binds, labelled with the app; a scope keeps its app's bindings). Each page/ file is a route (its URL pattern after the locale) or a layout, layouts wrap what they contain, and a route or layout that runs .guard() reads `guard` and links each function its guard calls; a layout's guard gates every route it wraps. A graph too large to answer unscoped drops first the routes and layouts that neither changed nor use a change; app or module brings them back. Every node and edge carries its source location. Pass app and/or module to scope a large workspace. Pass since (a git revision such as main, or HEAD for uncommitted work) for the Change Map instead: what changed since HEAD forked from it, working tree included, with each node, field and edge marked added, removed or changed, the unchanged neighbours they call or are called by, and the changed files no node covers, each marked the same way.",
328
328
  inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty, since: stringProperty } },
329
329
  },
330
330
  {
@@ -7,6 +7,7 @@ import { ProductionLockfile } from "./akanConfig/productionLockfile";
7
7
  import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
8
8
  import type { App } from "./commandDecorators";
9
9
  import { AppExecutor, Executor } from "./executors";
10
+ import { AllRoutesBuilder, type BuildAllRoutesResult, VendorNarrowing } from "./frontendBuild";
10
11
  import { bundleDefine } from "./frontendBuild/bundleDefine";
11
12
  import { createTempApp } from "./testHelpers";
12
13
  import { ProjectTypecheck } from "./typecheck/projectTypecheck";
@@ -307,6 +308,72 @@ console.info(new SampleService().name);
307
308
  }
308
309
  });
309
310
 
311
+ const buildSsrUntilVendors = async (routeChunkWritten: boolean) => {
312
+ const { root, workspace } = await createTempApp("portal");
313
+ const keys = ["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const;
314
+ const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
315
+ await Promise.all(["main.ts", "server.ts"].map((file) => writeFile(path.join(root, "apps", "portal", file), "")));
316
+ const app = AppExecutor.from(workspace, "portal");
317
+ app.prepareCommand = async () => ({ env: {} });
318
+ app.getPageKeys = async () => ["_index.tsx"];
319
+ const order: string[] = [];
320
+ let narrowed: Record<string, readonly string[]> = {};
321
+ const entryExports = VendorNarrowing.prototype.entryExports;
322
+ const bundle = spyOn(Bun, "build").mockImplementation(
323
+ async () => ({ success: true, outputs: [], logs: [] }) as unknown as Bun.BuildOutput,
324
+ );
325
+ const routes = spyOn(AllRoutesBuilder.prototype, "build").mockImplementation(async () => {
326
+ order.push("routes");
327
+ if (routeChunkWritten)
328
+ await Bun.write(
329
+ path.join(app.dist.cwdPath, ".akan/artifact/client/page-a1.js"),
330
+ 'import{st}from"akanjs/store";',
331
+ );
332
+ const clientManifest = { "page#default": { id: "/_akan/client/page-a1.js", chunks: [], name: "default" } };
333
+ return { manifest: { routeIds: ["/"], clientManifest } } as unknown as BuildAllRoutesResult;
334
+ });
335
+ const vendors = spyOn(VendorNarrowing.prototype, "entryExports").mockImplementation(function (
336
+ this: VendorNarrowing,
337
+ entries,
338
+ ) {
339
+ order.push("vendors");
340
+ narrowed = entryExports.call(this, entries);
341
+ throw new Error("stopped at the vendor build");
342
+ });
343
+ try {
344
+ const failure = await new ApplicationBuildRunner(app, { fast: true }).build().then(
345
+ () => "built",
346
+ (error: unknown) => (error instanceof Error ? error.message : String(error)),
347
+ );
348
+ return { failure, order, narrowed };
349
+ } finally {
350
+ bundle.mockRestore();
351
+ routes.mockRestore();
352
+ vendors.mockRestore();
353
+ for (const key of keys) {
354
+ if (shellEnv[key] === undefined) delete process.env[key];
355
+ else process.env[key] = shellEnv[key];
356
+ }
357
+ await rm(root, { recursive: true, force: true });
358
+ }
359
+ };
360
+
361
+ test("bundles the routes before the vendor entries, which export only the names the route chunks import", async () => {
362
+ const { failure, order, narrowed } = await buildSsrUntilVendors(true);
363
+
364
+ expect(failure).toBe("stopped at the vendor build");
365
+ expect(order).toEqual(["routes", "vendors"]);
366
+ expect(Object.entries(narrowed).find(([entry]) => entry.endsWith("akanjs-store.ts"))?.[1]).toEqual(["st"]);
367
+ });
368
+
369
+ test("stops before the vendor entries when a route's client entry is not where the scan reads", async () => {
370
+ const { failure, order } = await buildSsrUntilVendors(false);
371
+
372
+ expect(failure).toContain("no file under");
373
+ expect(failure).toContain("/_akan/client/page-a1.js");
374
+ expect(order).toEqual(["routes"]);
375
+ });
376
+
310
377
  test("akan typecheck names the script/ files nothing imports, and a build's typecheck leaves them out", async () => {
311
378
  const { root, app } = await createTempApp("portal");
312
379
  const run = spyOn(ProjectTypecheck.prototype, "run").mockImplementation(async () => "");
@@ -437,6 +437,7 @@ void run().catch((error) => {
437
437
  const vendorExports = await VendorImportScanner.exportsUsedIn(
438
438
  path.join(this.#app.dist.cwdPath, ".akan/artifact/client"),
439
439
  NARROWABLE_VENDOR_SPECIFIERS,
440
+ Object.values(allRoutes.manifest.clientManifest).map(({ id }) => id),
440
441
  );
441
442
  const base = await new SsrBaseArtifactBuilder(this.#app, "build", {
442
443
  serverEnv: this.#serverEnvPlugin(baseDevEnv.env, branches),
@@ -15,8 +15,12 @@ the server errors logged meanwhile, console errors and warnings, and layout smel
15
15
  stops it after; `--keep` leaves it up for the next preview. It never starts a second server beside a
16
16
  running one.
17
17
  - Paths are comma-separated (`akan preview <app> /board,/board/new`) and get the default locale when they lack
18
- one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off. A `/_akan` route such as Studio
19
- (`/_akan/studio?view=runtime`), a signal route or a file opens as written.
18
+ one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off. What the server answers outside a
19
+ locale opens as written: a `/_akan` route such as Studio (`/_akan/studio?view=runtime`), a route under the
20
+ API prefix, `/__csr`, `/__rsc`, `/.well-known`, `/openapi.json`, the default `/mcp`, `/favicon.ico`,
21
+ `/robots.txt`, `/sitemap.xml`, `manifest.webmanifest` and a file in the app's `public/`. A full URL
22
+ (`http://localhost:<port>/oauth/token`) opens exactly as written: use it for an endpoint mounted outside the
23
+ API prefix or a custom MCP path. A page `--changed` finds always gets the locale.
20
24
  `--changed` adds the pages your uncommitted page and layout edits reach. A dynamic route needs a concrete
21
25
  path (`/memo/1`); `akan context --app <app>` lists every route with its params and search names.
22
26
  - The viewport is a 390x844 phone; `--desktop` is 1280x800. `--csr` loads each page in the CSR boot the native
@@ -27,6 +27,8 @@ description: "Guard and tune endpoints on the wire: writing a Can<Verb><Model> g
27
27
  - `{ rateLimit: { calls, windowMs?, by? } }` per endpoint, `option.setRateLimit` per app, `plug(RateLimit)` for a
28
28
  business key.
29
29
  - A mutation repeated under one `Idempotency-Key` header runs once and replays its first answer.
30
+ - A browser send that changes nothing read back (typing status, a cursor move) passes `{ keepsReads: true }`, so it
31
+ neither ends in-flight query sharing nor drops prefetched pages.
30
32
  - `{ method: "PATCH" | "PUT" | "DELETE" }` moves a mutation off `POST` — only when a foreign protocol forces it.
31
33
  - `errors: { 409: ["ticket.error.locked"] }` names the errors an endpoint throws on purpose.
32
34
 
@@ -53,6 +53,7 @@ export interface ArgMeta<Context = CommandContext> {
53
53
  export interface InternalArgOption {
54
54
  nullable?: boolean;
55
55
  onlyApp?: boolean;
56
+ services?: boolean;
56
57
  }
57
58
  export interface InternalArgMeta {
58
59
  key: string;
@@ -112,5 +113,8 @@ export type Pkg = PkgExecutor;
112
113
  export const Module = createInternalArgToken<ModuleExecutor, "Module">("Module");
113
114
  export type Module = ModuleExecutor;
114
115
 
116
+ /** Module, or a service module (`lib/_<name>`) named as `<sys>:_<name>`. */
117
+ export const ModuleOrService = createInternalArgToken<ModuleExecutor, "Module">("Module", { services: true });
118
+
115
119
  export const Workspace = createInternalArgToken<WorkspaceExecutor, "Workspace">("Workspace");
116
120
  export type Workspace = WorkspaceExecutor;
@@ -9,7 +9,15 @@ import { Logger } from "akanjs/common";
9
9
  import chalk from "chalk";
10
10
  import { type Command, program } from "commander";
11
11
  import { AppSelectionMemory } from "../appSelectionMemory";
12
- import { AppExecutor, Executor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
12
+ import {
13
+ AppExecutor,
14
+ Executor,
15
+ LibExecutor,
16
+ ModuleExecutor,
17
+ PkgExecutor,
18
+ type SysExecutor,
19
+ WorkspaceExecutor,
20
+ } from "../executors";
13
21
  // Never the root barrel: it drags ink, ssh2 and the cloud stack into every command process.
14
22
  import { FileSys, getDirname } from "../fileSys";
15
23
  import type { PackageJson } from "../types";
@@ -311,6 +319,17 @@ export const getInternalArgumentValue = async (
311
319
  if (destructive) throw refusalOf(problemOf(kind), "this command deletes files and never picks its target", refusal);
312
320
  assertPromptable(problemOf(kind), refusal);
313
321
  };
322
+ const takeOnly = <Target>(kind: string, names: readonly string[], take: (name: string) => Target) => {
323
+ const [only] = names;
324
+ if (value || destructive || process.stdin.isTTY || names.length !== 1 || !only) return null;
325
+ const how = rerun ? `: ${rerun(only)}` : "";
326
+ Logger.rawLog(
327
+ `No ${kind} named, so this runs on '${only}', the only one in this workspace${how}`,
328
+ undefined,
329
+ "error",
330
+ );
331
+ return take(only);
332
+ };
314
333
  if (sysType === "sys" || sysType === "exec") {
315
334
  const pkgChoices = sysType === "exec" ? pkgNames : [];
316
335
  const execOf = (name: string) => {
@@ -326,17 +345,8 @@ export const getInternalArgumentValue = async (
326
345
  const [kind, placeholder] =
327
346
  sysType === "exec" ? ["app, lib or pkg", "<app-lib-or-pkg>"] : ["app or lib", "<app-or-lib>"];
328
347
  const choices = [...appNames, ...libNames, ...pkgChoices];
329
- const only = choices.length === 1 ? execOf(choices[0] ?? "") : null;
330
- //? Not for Lib, Pkg or Module, which name what a command acts on: `akan remove-library` would delete the only one.
331
- if (!value && !destructive && only && !process.stdin.isTTY) {
332
- const how = rerun ? `: ${rerun(only.name)}` : "";
333
- Logger.rawLog(
334
- `No ${kind} named, so this runs on '${only.name}', the only one in this workspace${how}`,
335
- undefined,
336
- "error",
337
- );
338
- return only;
339
- }
348
+ const only = takeOnly(kind, choices, execOf);
349
+ if (only) return only;
340
350
  assertAskable(kind, choices, placeholder);
341
351
  const name = await select<string>({
342
352
  message: sysType === "exec" ? `Select the App or Lib or Pkg name` : `Select the App or Lib name`,
@@ -353,40 +363,48 @@ export const getInternalArgumentValue = async (
353
363
  return AppExecutor.from(workspace, appName);
354
364
  } else if (sysType === "lib") {
355
365
  if (value && libNames.includes(value)) return LibExecutor.from(workspace, value);
366
+ const only = takeOnly("lib", libNames, (name) => LibExecutor.from(workspace, name));
367
+ if (only) return only;
356
368
  assertAskable("lib", libNames, "<lib>");
357
369
  const libName = await select<string>({ message: `Select the ${sysType} name`, choices: libNames });
358
370
  return LibExecutor.from(workspace, libName);
359
371
  } else if (sysType === "pkg") {
360
372
  const pkgs = await workspace.getPkgs();
361
373
  if (value && pkgs.includes(value)) return PkgExecutor.from(workspace, value);
374
+ const only = takeOnly("pkg", pkgs, (name) => PkgExecutor.from(workspace, name));
375
+ if (only) return only;
362
376
  assertAskable("pkg", pkgs, "<pkg>");
363
377
  const pkgName = await select<string>({ message: `Select the ${sysType} name`, choices: pkgs });
364
378
  return PkgExecutor.from(workspace, pkgName);
365
379
  } else if (sysType === "module") {
366
- if (value) {
367
- const [sysName, moduleName] = value.split(":");
368
- if (!sysName || !moduleName) throw new Error(`Invalid module name: ${value}`);
369
- if (appNames.includes(sysName)) {
370
- const app = AppExecutor.from(workspace, sysName);
371
- const modules = await app.getModules();
372
- if (modules.includes(moduleName)) return ModuleExecutor.from(app, moduleName);
373
- else throw new Error(`Invalid module name: ${moduleName}`);
374
- } else if (libNames.includes(sysName)) {
375
- const lib = LibExecutor.from(workspace, sysName);
376
- const modules = await lib.getModules();
377
- if (modules.includes(moduleName)) return ModuleExecutor.from(lib, moduleName);
378
- } else throw new Error(`Invalid system name: ${sysName}`);
379
- }
380
380
  const systems = [
381
381
  ...appNames.map((name) => AppExecutor.from(workspace, name)),
382
382
  ...libNames.map((name) => LibExecutor.from(workspace, name)),
383
383
  ];
384
- const choices =
385
- process.stdin.isTTY && !destructive
386
- ? []
387
- : (
388
- await Promise.all(systems.map(async (sys) => (await sys.getModules()).map((m) => `${sys.name}:${m}`)))
389
- ).flat();
384
+ const modulesOf = async (sys: SysExecutor) => [
385
+ ...(await sys.getModules()),
386
+ ...(argMeta.option?.services ? await sys.getServiceModules() : []),
387
+ ];
388
+ const listModules = async () =>
389
+ (await Promise.all(systems.map(async (sys) => (await modulesOf(sys)).map((m) => `${sys.name}:${m}`)))).flat();
390
+ if (value) {
391
+ const [sysName, moduleName] = value.split(":");
392
+ if (!sysName || !moduleName) throw new Error(`Invalid module name: ${value}`);
393
+ const sys = systems.find((each) => each.name === sysName);
394
+ if (!sys) throw new Error(`Invalid system name: ${sysName}`);
395
+ if ((await modulesOf(sys)).includes(moduleName)) return ModuleExecutor.from(sys, moduleName);
396
+ throw refusalOf(problemOf("module"), `${sysName} has no such module`, {
397
+ choices: await listModules(),
398
+ ...rerunRemedyOf(rerun, "<sys:module>"),
399
+ });
400
+ }
401
+ const choices = process.stdin.isTTY && !destructive ? [] : await listModules();
402
+ const only = takeOnly("module", choices, (label) => {
403
+ const [sysName, moduleName = ""] = label.split(":");
404
+ const sys = systems.find((each) => each.name === sysName);
405
+ return sys ? ModuleExecutor.from(sys, moduleName) : null;
406
+ });
407
+ if (only) return only;
390
408
  assertAskable("module", choices, "<sys:module>");
391
409
  const { type, name } = await select<{ type: "app" | "lib"; name: string }>({
392
410
  message: `select the App or Lib name`,
@@ -396,7 +414,7 @@ export const getInternalArgumentValue = async (
396
414
  ],
397
415
  });
398
416
  const executor = type === "app" ? AppExecutor.from(workspace, name) : LibExecutor.from(workspace, name);
399
- const modules = await executor.getModules();
417
+ const modules = await modulesOf(executor);
400
418
  const moduleName = await select<string>({
401
419
  message: `Select the module name`,
402
420
  choices: modules.map((name) => ({ name: `${executor.name}:${name}`, value: name })),