@akanjs/cli 3.0.0-alpha.9 → 3.0.0-alpha.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.build-stamp +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/{abstractCompactor-e5yq60qz.js → abstractCompactor-cefxzggj.js} +3 -5
- package/{agent.command-f0gqkfgz.js → agent.command-r93wmq7m.js} +11 -13
- package/{application.command-x903m2qq.js → application.command-5nqfqgdn.js} +62 -38
- package/{applicationBuildRunner-ha3y8ew8.js → applicationBuildRunner-b21m07fs.js} +23 -20
- package/{applicationReleasePackager-gg8ccbeh.js → applicationReleasePackager-3wh58n2m.js} +8 -9
- package/buildBatch.proc.js +12 -14
- package/capacitorApp-gzcx6txb.js +12 -0
- package/{cloud.command-zs6kyf83.js → cloud.command-g0pk0k60.js} +15 -18
- package/commandManifest.json +1 -1
- package/{context.command-fmepgjzg.js → context.command-qd8kqrnh.js} +19 -23
- package/dependencyScanner-shs82ynp.js +8 -0
- package/devTui-2xf0meaq.js +552 -0
- package/devTuiView-7jy8f30g.js +9 -0
- package/getDirname-dr3jz1xg.js +7 -0
- package/{guideline.command-n6sw0a0c.js → guideline.command-f38x8jhw.js} +16 -18
- package/guidelines/agentRule/agentRule.generate.json +31 -0
- package/guidelines/agentRule/agentRule.instruction.md +533 -0
- package/guidelines/conventions/conventions.instruction.md +406 -197
- package/guidelines/cssRule/cssRule.instruction.md +7 -0
- package/guidelines/framework/framework.generate.json +3 -2
- package/guidelines/framework/framework.instruction.md +1 -9
- package/guidelines/mcpRule/mcpRule.generate.json +25 -0
- package/guidelines/mcpRule/mcpRule.instruction.md +217 -0
- package/guidelines/modelStore/modelStore.instruction.md +12 -0
- package/guidelines/modelTemplate/modelTemplate.instruction.md +5 -0
- package/guidelines/queryRule/queryRule.generate.json +31 -0
- package/guidelines/queryRule/queryRule.instruction.md +143 -0
- package/guidelines/runtimeRule/runtimeRule.generate.json +25 -0
- package/guidelines/runtimeRule/runtimeRule.instruction.md +213 -0
- package/guidelines/ssrRule/ssrRule.instruction.md +49 -5
- package/guidelines/transportRule/transportRule.generate.json +31 -0
- package/guidelines/transportRule/transportRule.instruction.md +60 -0
- package/guidelines/workspaceOnboarding/workspaceOnboarding.instruction.md +51 -760
- package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +33 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +402 -0
- package/incrementalBuilder.proc.js +26 -25
- package/{index-774gcx29.js → index-0emb9eat.js} +41 -34
- package/index-3d9y066c.js +76 -0
- package/{index-3vta1tqa.js → index-3vvbv974.js} +32 -32
- package/{index-76rn3g2c.js → index-47pj3aee.js} +4 -4
- package/{index-y4vkyhvv.js → index-5xdqxbn9.js} +14 -14
- package/{index-1577bej2.js → index-6wwzs1bc.js} +2 -2
- package/{index-wnp7hwq7.js → index-92zztqy1.js} +7 -7
- package/{index-jhpa13zj.js → index-9bwhxrqf.js} +57 -57
- package/{index-bjpxzr6s.js → index-9wtbw98m.js} +6 -3
- package/{index-fgc8r6dj.js → index-a5vmm6x4.js} +3 -1
- package/{index-xys926f2.js → index-by5gpp8x.js} +4 -4
- package/{index-kdh76j26.js → index-cezy7yb3.js} +100 -32
- package/{index-62955kn5.js → index-d61rcfa2.js} +758 -127
- package/{index-82jrh4hj.js → index-deekbtkj.js} +13 -13
- package/{index-rxkpk7n4.js → index-e82dyeqy.js} +4 -4
- package/{index-qzdjd4ct.js → index-h759vjfa.js} +18 -18
- package/{index-j37qq1f2.js → index-h7yw2z9n.js} +7 -7
- package/{index-qb61h0k7.js → index-jydtyzva.js} +1098 -1360
- package/{index-46tjzh6s.js → index-kcxmv48c.js} +29 -29
- package/{index-4m0bphac.js → index-kfnr6c19.js} +31 -30
- package/{index-xmmzf150.js → index-mpmyqm1y.js} +1 -1
- package/{index-xjd3rqzj.js → index-n68jx79w.js} +6 -15
- package/{index-0cj2zxbm.js → index-n85fer99.js} +12 -11
- package/{index-779ske1y.js → index-pgsswppj.js} +27 -27
- package/index-pvb4vdwr.js +159 -0
- package/{index-67546d0j.js → index-q0y2jq9j.js} +14 -2
- package/{index-z8ptfkzv.js → index-q8sn827n.js} +969 -240
- package/{index-5sn5w2mt.js → index-r50qen0z.js} +94 -61
- package/{index-eyyf2149.js → index-rntcdss1.js} +36 -37
- package/{index-gcv8ncfh.js → index-sjak5v5b.js} +16 -12
- package/{index-j9cxndc0.js → index-w8kmx1b6.js} +20 -11
- package/{index-jf2ya7r2.js → index-y74k5anj.js} +8 -8
- package/{index-d43t8af2.js → index-zkvpaggp.js} +58 -25
- package/index.js +22 -25
- package/{library.command-cv9gdbmg.js → library.command-em103wtn.js} +11 -10
- package/{localRegistry.command-d8fg36zc.js → localRegistry.command-gz8apn4y.js} +20 -23
- package/{module.command-qyr1s9cn.js → module.command-451pztkc.js} +13 -16
- package/{package.command-wsjx0k47.js → package.command-rfhyf38t.js} +8 -10
- package/package.json +5 -7
- package/{page.command-rdzg65rm.js → page.command-kt19da5g.js} +7 -9
- package/{primitive.command-89g38b53.js → primitive.command-pgy69ere.js} +13 -16
- package/{quality.command-0cs2z92d.js → quality.command-gpe2vqb5.js} +226 -105
- package/recipeScanner-fpagtgc1.js +11 -0
- package/{repair.command-6fphxfwj.js → repair.command-2sbrz187.js} +12 -14
- package/{routeSourceValidator-e4jmbfcx.js → routeSourceValidator-ze6x894c.js} +0 -2
- package/{scalar.command-xfer69s6.js → scalar.command-jcf4e5sv.js} +11 -14
- package/subspace.command-kqzqtk0p.js +755 -0
- package/templates/app/env/env.client.debug.ts.template +1 -5
- package/templates/app/env/env.client.develop.ts.template +1 -5
- package/templates/app/env/env.client.local.ts.template +1 -5
- package/templates/app/env/env.client.main.ts.template +1 -5
- package/templates/app/env/env.client.testing.ts.template +1 -5
- package/templates/app/env/env.client.type.ts +2 -6
- package/templates/app/env/env.server.debug.ts.template +1 -5
- package/templates/app/env/env.server.develop.ts.template +1 -5
- package/templates/app/env/env.server.local.ts.template +1 -5
- package/templates/app/env/env.server.main.ts.template +1 -5
- package/templates/app/main.ts +3 -1
- package/templates/appSample/lib/task/task.signal.ts +11 -6
- package/templates/appSample/srvkit/AuthGuard.ts +9 -5
- package/templates/appSample/srvkit/SessionInternalArg.ts +4 -3
- package/templates/crudPages/[__model__Id]/edit/page.tsx +5 -5
- package/templates/crudPages/[__model__Id]/page.tsx +10 -13
- package/templates/crudPages/new/page.tsx +5 -5
- package/templates/crudPages/page.tsx +9 -9
- package/templates/crudSinglePage/page.tsx +11 -7
- package/templates/env/_env.server.type.ts +7 -5
- package/templates/lib/db.ts +1 -1
- package/templates/lib/dict.ts +1 -1
- package/templates/lib/sig.ts +1 -1
- package/templates/lib/srv.ts +6 -2
- package/templates/module/__model__.dictionary.ts +4 -1
- package/templates/module/__model__.signal.ts +2 -3
- package/templates/module/__model__.store.ts +1 -2
- package/templates/workspaceRoot/.gitignore.template +0 -1
- package/templates/workspaceRoot/.husky/pre-commit.template +16 -0
- package/templates/workspaceRoot/.vscode/settings.json.template +1 -1
- package/templates/workspaceRoot/AGENTS.md.template +4 -0
- package/templates/workspaceRoot/CLAUDE.md.template +19 -0
- package/templates/workspaceRoot/biome.json.template +2 -1
- package/templates/workspaceRoot/docs/AI-DEVELOPMENT.md.template +10 -14
- package/templates/workspaceRoot/docs/GENERATED.md.template +12 -10
- package/templates/workspaceRoot/package.json.template +2 -4
- package/typeChecker-3xgrzc2v.js +7 -0
- package/typecheck.proc.js +4 -5
- package/{workflow.command-2ygfqt4f.js → workflow.command-m4z25a2p.js} +22 -25
- package/{workspace.command-88m0zs64.js → workspace.command-dqgj9zbk.js} +96 -81
- package/capacitorApp-9xswcgx7.js +0 -61
- package/dependencyScanner-f6grrep3.js +0 -9
- package/getDirname-m9s0cmhq.js +0 -8
- package/index-mxvakhsm.js +0 -66
- package/index-r24hmh0q.js +0 -4
- package/recipeScanner-tghkzrrt.js +0 -12
- package/typeChecker-kravn7ns.js +0 -8
- /package/{index-0wae5ebk.js → index-meh558jy.js} +0 -0
- /package/{index-ss469dec.js → index-nz8prx9g.js} +0 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Runtime Rule — Serving, Processes, Logging, Image, Assets
|
|
2
|
+
|
|
3
|
+
How an Akan app is built, what it serves, how many processes it runs, where its logs go, and what ends up in
|
|
4
|
+
its image. Everything here is declared in `akan.config.ts` or narrowed by an env at boot; none of it is reached
|
|
5
|
+
from domain code. `conventions` carries the invariants — this is the full contract behind them.
|
|
6
|
+
|
|
7
|
+
## Web Surfaces — Building And Serving Without SSR/CSR
|
|
8
|
+
|
|
9
|
+
An app serves three things: the API, the SSR/RSC web renderer, and the CSR single-file bundle. The API is always
|
|
10
|
+
on; the other two are declared in `akan.config.ts` as **`web: true | false | { csr: boolean }`** and can be
|
|
11
|
+
narrowed again per deployment.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const config: AppConfig = { web: { csr: false } }; // web without the mobile bundle
|
|
15
|
+
const config: AppConfig = { web: false }; // api only
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **`web: { csr: false }`** drops the CSR build phase and the `/__csr` + `?csr=true` routes. The CSR bundle is
|
|
19
|
+
what the Capacitor mobile build ships, so a web-only deployment never needs it — and an app that declares a
|
|
20
|
+
`mobile` section is refused, because `akan build-ios` copies `dist/apps/<app>/csr/<target>.html` into the
|
|
21
|
+
native project.
|
|
22
|
+
- **`web: false`** is an API-only build: no base artifact, no pages or client bundles, no RSC worker
|
|
23
|
+
entrypoint, and no `public/` in the image (the web router's catch-all is its only reader). Nothing under
|
|
24
|
+
`page/` is served, including routes a lib contributed through `syncPageLibs`.
|
|
25
|
+
- **There is no CSR-without-SSR option, by type.** The CSR bundle inlines the stylesheet the SSR base artifact
|
|
26
|
+
compiles, so it would ship an unstyled app — the object form therefore carries only `csr`, and SSR goes off
|
|
27
|
+
only through the whole-surface `false`.
|
|
28
|
+
- **At runtime, `AKAN_SSR` and `AKAN_CSR` narrow further and never widen** — `false` or `0` turns one off, and
|
|
29
|
+
`AKAN_SSR=false` takes CSR with it for the same reason the option has no such pair. A surface the build left
|
|
30
|
+
out cannot be switched back on, and the boot log names what the process ended up serving. The generated
|
|
31
|
+
Dockerfile writes the build's own answer as the image default.
|
|
32
|
+
- **A build with no web artifact boots the API instead of crashing.** `WebRouter.create` returns `null` when
|
|
33
|
+
`.akan/artifact/base-artifact.json` is absent — an api-only build, or a workspace with no `page/` at all.
|
|
34
|
+
- `akan start` ignores `web` and keeps the whole dev surface: the incremental builder is also the file watcher,
|
|
35
|
+
so switching it off would take server-code HMR with it. It warns once when the config and the dev server
|
|
36
|
+
disagree.
|
|
37
|
+
- The saving is mostly the RSC worker, which is a **separate process per web-serving replica**. Measured on
|
|
38
|
+
`apps/akan` at boot plus one render: 350MB across 3 processes with SSR on, 120MB across 2 with
|
|
39
|
+
`AKAN_SSR=false`, and the built image goes 86MB → 6.2MB when the artifacts are left out of it too.
|
|
40
|
+
- **The generated image installs `ca-certificates` and `tzdata` and nothing else.** It used to carry the whole
|
|
41
|
+
Chromium runtime, ffmpeg, `build-essential`, `python3` and `redis` in every app's image whether or not the app
|
|
42
|
+
reached for any of them. An app that needs one declares it in `docker.preRuns` / `docker.postRuns`, which are
|
|
43
|
+
emitted around the `bun install` — that is the migration for a `puppeteer` or `ffmpeg` app.
|
|
44
|
+
|
|
45
|
+
## Route Prefixes — Where The API Is Mounted
|
|
46
|
+
|
|
47
|
+
Signal endpoints hang off `/api` and the websocket upgrade off `/api/ws`. Both move, and both have to move in
|
|
48
|
+
three places at once: the route table the replica builds, the gateway's own upgrade check, and the `fetchClient`
|
|
49
|
+
in every browser tab. That last one is the constraint the design is built around.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// apps/<app>/main.ts — the deployment's answer
|
|
53
|
+
await new AkanApp({ prefix: "/backend", websocketPrefix: "/socket" }).start();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// apps/<app>/akan.config.ts — the build's answer, for the bundles a server never gets to correct
|
|
58
|
+
const config: AppConfig = { api: { prefix: "/backend", websocketPrefix: "/socket" } };
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **Three sources, narrowest first.** `globalThis.__AKAN_PREFIX__`, then `AKAN_API_PREFIX` / `AKAN_WS_PREFIX`,
|
|
62
|
+
then `AKAN_PUBLIC_API_PREFIX` / `AKAN_PUBLIC_WS_PREFIX`, then `/api` and `/ws`. Read them through
|
|
63
|
+
`getApiPrefix()` / `getWsPrefix()` from `akanjs/base`; never write either literal again.
|
|
64
|
+
- **`AKAN_API_PREFIX` is deliberately outside the `AKAN_PUBLIC_*` namespace.** Only that namespace is inlined
|
|
65
|
+
into client bundles at build time, so a name inside it could never act as a runtime override — and only a name
|
|
66
|
+
inside it can reach a prebuilt bundle at all. The two jobs need two names.
|
|
67
|
+
- **`new AkanApp({ prefix })` reaches the browser through the page, not through a prop.** `FetchClient` fixes its
|
|
68
|
+
origin when the module graph initializes, before any component renders, so a React prop — a `System.Provider`
|
|
69
|
+
value, anything out of `implicitRootLayout` — is always too late. The value rides the SSR classic bootstrap
|
|
70
|
+
script instead, the one thing guaranteed to run ahead of every module script, and is omitted when it matches
|
|
71
|
+
what the bundle already assumes. `akan.config.ts` is what a Capacitor bundle or a statically served CSR shell
|
|
72
|
+
follows, because neither is rendered by a server that could tell it otherwise.
|
|
73
|
+
- **A prefix is a path segment.** A blank value or a bare `/` is refused: `/` would be mounted ahead of the SSR
|
|
74
|
+
catch-all and swallow every page route. A prefix whose first segment is a declared basePath is refused at
|
|
75
|
+
`init()` for the same reason in the other direction.
|
|
76
|
+
- **The prefix is fixed before `init()`.** `setPrefix` / `setWebsocketPrefix` throw once the server has started,
|
|
77
|
+
because `init()` is where the route table and the devtools routes are built.
|
|
78
|
+
- `robots.txt`, the locale redirect's API bypass and the OpenAPI `servers` URL all derive from it. The blob
|
|
79
|
+
storage default (`/api/localFile/getBlob`) does **not**: those URLs are stored in rows, so moving the prefix
|
|
80
|
+
does not move what was already written.
|
|
81
|
+
|
|
82
|
+
## The Process Model — Gateway And Solo
|
|
83
|
+
|
|
84
|
+
A container runs one process per replica, a gateway in front of them when there is more than one, and one RSC
|
|
85
|
+
worker per web-serving replica.
|
|
86
|
+
|
|
87
|
+
- **One traffic replica runs in the container's only process.** `AKAN_REPLICA=0,0,1` — the default, and what
|
|
88
|
+
every environment in `infra/app/values` sets — means there is nothing to balance and nothing to fan pubsub out
|
|
89
|
+
to, so `AkanApp` starts that replica in-process rather than spawning it. Measured on `apps/akan`: 28MB less RSS
|
|
90
|
+
and twice the requests per second, because every request used to cross a unix-socket proxy hop. Declare two or
|
|
91
|
+
more and the gateway is back, spawning and proxying them.
|
|
92
|
+
- **`AKAN_SOLO=false` forces the gateway** for a single replica. Like `AKAN_SSR`, the env only narrows — it
|
|
93
|
+
cannot fold a real gateway's replicas into one process. Passing `replica` to `new AkanApp(...)` also keeps the
|
|
94
|
+
gateway: code that states a topology is asking for the thing that serves it.
|
|
95
|
+
- **`akan start` always runs the gateway**, whatever the replica count. It is also the dev host's builder relay,
|
|
96
|
+
its crash page, and what holds the port across a child restart.
|
|
97
|
+
- **A batch-only replica (`0,1,0`) keeps the gateway too**, because a batch server never listens and the gateway
|
|
98
|
+
is then the only thing bound to answer `/_akan/app/health`.
|
|
99
|
+
- **The RSC worker is never folded in.** It runs under `--conditions react-server`, which resolves the same
|
|
100
|
+
module graph differently, so it cannot share a process with the server that renders client components.
|
|
101
|
+
- **A solo process answers `/_akan/app/health`, `/_akan/app/metrics` and `/_akan/bench/ping` itself**, in the
|
|
102
|
+
gateway's own shape — a `children` array with one entry — so a probe reads one contract either way. It owns the
|
|
103
|
+
rotating log file the gateway would otherwise write, in the same `runtime/logs` directory.
|
|
104
|
+
- **Nothing supervises a solo process but the orchestrator**, since the gateway's crash-restart-with-backoff went
|
|
105
|
+
with it. `infra/app/templates/app.yaml` carries the liveness, readiness and startup probes that replace it.
|
|
106
|
+
- **`main.ts` imports `AkanApp` from `akanjs/server/akanApp`, not the barrel.** The barrel re-exports
|
|
107
|
+
`AkanServer`, whose graph the gateway never runs; through it the process evaluated 35MB of SSR renderer and
|
|
108
|
+
SQLite driver to spawn children and relay bytes. Keep entrypoint imports at the leaf.
|
|
109
|
+
|
|
110
|
+
## Logging — Records, Request Context And Live Tail
|
|
111
|
+
|
|
112
|
+
Every `Logger` call builds a `LogRecord` before any text exists: `at`, `level`, OTel `sev`, `name`, `message`,
|
|
113
|
+
`pid`, `replicaIdx`, `role` (`gateway` / `all` / `batch` / `rsc-worker`) and — inside a call — `traceId`,
|
|
114
|
+
`endpoint` (`mutation:signScContract`, `internal:cleanupJob`, `page:/org/[orgId]`) and `origin` (`http`,
|
|
115
|
+
`websocket`, `mcp`, `internal`, `page`). The console line is rendered from the record and is byte-identical to
|
|
116
|
+
what it was; a sink reads `entry.record` and renders only if it touches `entry.message`.
|
|
117
|
+
|
|
118
|
+
The level ladder is `trace verbose debug info warn error`. `log` was a seventh tier *below* `info` that
|
|
119
|
+
`AKAN_PUBLIC_LOG_LEVEL=info` silently dropped; the method is kept and emits at `info`, so
|
|
120
|
+
`no-deprecated-log-level.grit` refuses a call to it — write `.info()`. `AKAN_PUBLIC_LOG_LEVEL=log` still boots,
|
|
121
|
+
normalized to `info` with one warning.
|
|
122
|
+
|
|
123
|
+
- **Request context is on by default, production included.** `SignalContext.try` / `SignalContext.run` own the
|
|
124
|
+
`AsyncLocalStorage` scope for the whole call, including the 500 log, so every line of one request shares a
|
|
125
|
+
`traceId`. Internal triggers get `internal:<key>`, the RSC worker gets `page:<route>`, MCP calls are the
|
|
126
|
+
endpoint's own type with `origin: "mcp"`. `AKAN_LOG_CONTEXT=0` is the escape hatch; `AKAN_TRACE=1` is a
|
|
127
|
+
different switch that adds span and query aggregation. Measured cost: ~25ns per call.
|
|
128
|
+
- **Not everything carries context, by design.** The primitive query fast path (an unauthenticated GET of a
|
|
129
|
+
primitive with no args, guards or middlewares) skips it; the schedule adaptor's own `started/finished/error`
|
|
130
|
+
lines wrap the traced handler from outside; gateway-internal lines have none. `akan logs --endpoint` says so.
|
|
131
|
+
- **`Logger.addSink(sink, { minLevel })` — give a sink its floor.** A sink with none follows `AKAN_LOG_FILE_LEVEL`
|
|
132
|
+
(default `trace`), which is why a registered sink used to make every `verbose` call render (1,294ns against an
|
|
133
|
+
11ns reject). The IPC forwarder and any sink that never reads the text pass a floor and cost a record literal.
|
|
134
|
+
- **The hub lives with whoever owns the surface**: the gateway when there is one, the replica itself under solo
|
|
135
|
+
(`AKAN_REPLICA=0,0,1`), the same rule as the rotating log file and `/_akan/app/*`. It keeps a ring
|
|
136
|
+
(`AKAN_LOG_BUFFER` records / `AKAN_LOG_BUFFER_MB`, default 2000 / 4), suppresses a line repeating more than 20
|
|
137
|
+
times a second into one counted line, and serves `<runtimeDir>/akan-control.sock` (`0600`, NDJSON). A child
|
|
138
|
+
forwards records only while a subscriber wants that level (`log.level` IPC), so an unwatched process sends
|
|
139
|
+
nothing; `AKAN_LOG_STREAM=1` keeps it on. The RSC worker forwards to its replica the same way.
|
|
140
|
+
- **`akan logs <app>`** is the client: `--level --grep --endpoint --trace --child --role --origin --since`
|
|
141
|
+
AND together, comma lists OR, `*` is the only glob; `--replay N` first, `--follow false` for history only,
|
|
142
|
+
`--json` for NDJSON, `--runtime-dir` for a built app. Inside `akan console`, `.tail level=warn grep=payment`,
|
|
143
|
+
`.tail off`, `.trace <id>` — the console is its own `listen: false` process, so these attach to the running
|
|
144
|
+
server's socket rather than reading their own logs.
|
|
145
|
+
- **The generated Dockerfile sets `AKAN_LOG_TO_FILE=0`.** A container's writable layer is ephemeral and nothing
|
|
146
|
+
collects a file from it; stdout is the collection path. A deployment that wants the files back sets
|
|
147
|
+
`AKAN_LOG_TO_FILE=1`.
|
|
148
|
+
- **Never log per delivered record in anything that delivers records.** The control socket and the hub do not,
|
|
149
|
+
and a subscriber asking for everything is the test.
|
|
150
|
+
|
|
151
|
+
## The Generated Image — `docker` In `akan.config.ts`
|
|
152
|
+
|
|
153
|
+
**`docker` is `string | { image, preRuns, postRuns, command }`** — a whole Dockerfile, or the parts Akan
|
|
154
|
+
assembles one from. There is no `content` field; the string *is* the content.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const config: AppConfig = {
|
|
158
|
+
docker: { preRuns: ["apt-get update && apt-get install -y --no-install-recommends ffmpeg"] },
|
|
159
|
+
};
|
|
160
|
+
const config: AppConfig = { docker: "FROM oven/bun:1-slim\n…" }; // verbatim, nothing merged in
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- `image` and each run entry take `string | { amd64?, arm64? }`; the object form compiles to a
|
|
164
|
+
`RUN if [ "$TARGETARCH" = "<arch>" ]` guard, so a multi-arch build runs it on one leg only. `preRuns` land
|
|
165
|
+
before `bun install --production` (where a native dependency's build tools have to be), `postRuns` after it
|
|
166
|
+
and before the app files are copied.
|
|
167
|
+
- **A lib declares the steps its own runtime needs**, and every app that mounts it inherits them:
|
|
168
|
+
`libs/<lib>/akan.config.ts` takes `docker: { preRuns, postRuns }` and nothing else — the base image and the
|
|
169
|
+
command belong to the app. Lib steps are emitted before the app's own, and an identical step declared twice
|
|
170
|
+
becomes one layer.
|
|
171
|
+
- **The string form takes no contributions.** A Dockerfile handed over whole is used exactly as written, so a
|
|
172
|
+
lib's `preRuns` are dropped rather than spliced into a file Akan does not own. An app that needs both writes
|
|
173
|
+
the parts instead.
|
|
174
|
+
- Like `externalLibs`, lib steps are collected from **every lib in the workspace**, not just this app's
|
|
175
|
+
dependency closure — narrowing that set needs the dependency scan, and this config is re-read on every file
|
|
176
|
+
change in dev. Keep a lib's steps to what its runtime genuinely requires.
|
|
177
|
+
- `AkanAppConfig.docker` is the resolved declaration; `AkanAppConfig.dockerfile` is the text `akan build` writes
|
|
178
|
+
to `dist/apps/<app>/Dockerfile`.
|
|
179
|
+
|
|
180
|
+
## Shipped Assets — `assets` In `akan.config.ts`
|
|
181
|
+
|
|
182
|
+
`akan build` copies the whole `public/` tree into `dist`, lib assets dereferenced, and that copy is the image.
|
|
183
|
+
**`assets: { pruneFonts, keepFonts }`** trims the fonts out of it that nothing reads. **Source trees are never
|
|
184
|
+
touched** — an app's and a lib's own `public/` keep every file, because one shared font folder is picked over
|
|
185
|
+
differently by every app that mounts it and by other repos.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const config: LibConfig = { assets: { keepFonts: ["fonts/Assistant-*.woff2"] } }; // libs/<lib>/akan.config.ts
|
|
189
|
+
const config: AppConfig = { assets: { pruneFonts: false } }; // emergency valve, not the normal escape hatch
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
- **A font with `optimize` on is a build input, not a runtime asset.** `FontOptimizer` subsets it into
|
|
193
|
+
`.akan/artifact/fonts` and the emitted CSS points at `/_akan/fonts/*`, which `WebRouter` serves from the
|
|
194
|
+
artifact — so the source in `public/` is never opened again. That is the bulk of what this drops, and it
|
|
195
|
+
applies to the fonts the app *does* use, not only the ones it does not.
|
|
196
|
+
- **The keep-set is derived per app, per build; there is no list of fonts to drop.** An exclude list would rot
|
|
197
|
+
the moment another app or repo started using one of them, and nobody would know to update it. A font survives
|
|
198
|
+
because ① its font declares `optimize: false`, so `FontCss.getRuntimeCss` emits the raw src; ② something
|
|
199
|
+
references its filename — anything under `public/`, the compiled stylesheet, a client/server bundle, the CSR
|
|
200
|
+
shell; or ③ a `keepFonts` glob names it.
|
|
201
|
+
- **`keepFonts` belongs to the `akan.config.ts` that owns the font**, written against that lib's or app's own
|
|
202
|
+
`public/` (`"fonts/X.woff2"`, resolved to `libs/<lib>/fonts/X.woff2`). A lib shared across repos carries the
|
|
203
|
+
reason its own CSS needs a font, instead of every mounting app rediscovering it in production.
|
|
204
|
+
- **A reference from a build bundle is ignored for an already-subset font.** Every route file's `fonts`
|
|
205
|
+
declaration is inlined into the pages bundle, the client chunks and the CSR shell, so a declared source is in
|
|
206
|
+
all three whether or not anything loads it. `public/` and the compiled CSS are read as authoritative.
|
|
207
|
+
- **A generated manifest that lists every public asset keeps every font it names** — a service-worker precache
|
|
208
|
+
file is the usual one. That is a real reference, not a false positive, so the build reports it at `info`:
|
|
209
|
+
read the `[font-prune] kept …` line before concluding the prune does nothing.
|
|
210
|
+
- Matching is by filename, including the percent-encoded spelling, so a `url()` survives however it is written.
|
|
211
|
+
- The phase is `assets`, between `csr` and `compress`. An api-only build (`web: false`) copies no `public/` and
|
|
212
|
+
the phase finds nothing to do.
|
|
213
|
+
|
|
@@ -71,18 +71,62 @@ Never collapse this into one client file with a mode `useState` and every panel
|
|
|
71
71
|
|
|
72
72
|
**③ Sync state instead of fetching it.** A server component cannot hold state, so render the initial data on the
|
|
73
73
|
server and hand it across as a serializable object. The route calls `fetch.initXInY(...)` / `fetch.viewX(...)` and
|
|
74
|
-
passes
|
|
75
|
-
fetches on mount renders an empty shell, hydrates, then round-trips for data the server
|
|
74
|
+
passes `xInitInY` / `xView` into a `Zone`; `Load.Units` / `Load.View` hydrate the store from it. A
|
|
75
|
+
`useEffect(…, [])` that fetches on mount renders an empty shell, hydrates, then round-trips for data the server
|
|
76
|
+
already had.
|
|
76
77
|
|
|
77
78
|
**④ Push the boundary down to the leaf that needs it.** A store-reading `Zone` should hold zero markup and delegate
|
|
78
79
|
to a server `View` — `User.Zone.Self` is one line, `st.use.self()` into `<User.View.General user={self} />`, so the
|
|
79
80
|
whole detail surface renders server-side wherever a route uses the `View` directly.
|
|
80
81
|
|
|
81
|
-
**⑤ Hand
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
**⑤ Hand each promise across, not the awaited value.** `fetch.init<Model><Suffix>`, `fetch.view<Model>` and
|
|
83
|
+
`fetch.edit<Model>` are awaitable *and* destructurable, so a route can split one call into the promises its
|
|
84
|
+
sections actually need:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
export default async function Page({ params: { orgId } }: PageProps) {
|
|
88
|
+
const { taskInitInOrg, taskListInOrg } = fetch.initTaskInOrg(orgId);
|
|
89
|
+
const { orgView } = fetch.viewOrg(orgId);
|
|
90
|
+
return (
|
|
91
|
+
<>
|
|
92
|
+
<Org.Zone.Header view={orgView} />
|
|
93
|
+
<Task.Zone.Card init={taskInitInOrg} />
|
|
94
|
+
<Load.Stream of={taskListInOrg} fallback={<Loading.Skeleton active />}>
|
|
95
|
+
{(taskList) => taskList.map((task) => <Task.Unit.Row key={task.id} task={task} />)}
|
|
96
|
+
</Load.Stream>
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Both queries leave immediately — splitting the result never serializes them — and each section renders behind its
|
|
103
|
+
own boundary as its own data lands, so one slow query no longer holds the page. `x<Slice>List` does not wait for
|
|
104
|
+
the aggregate the way `x<Slice>Init` must.
|
|
105
|
+
|
|
106
|
+
**`await` is still the right call for what the page needs immediately.** It returns the shape the helper always
|
|
107
|
+
gave and keeps that markup in the shell, which is what SEO snapshots, prerendering and pre-hydration E2E read; a
|
|
108
|
+
boundary moves its subtree out of the shell and into the stream. Independent awaited fetches still go through one
|
|
84
109
|
`Promise.all`.
|
|
85
110
|
|
|
111
|
+
Three rules the shape enforces:
|
|
112
|
+
|
|
113
|
+
- **`x<Slice>List` and `x<Slice>Insight` are server-only.** They hold hydrated model instances, which React Flight
|
|
114
|
+
refuses as props to a client component. Consume them in a server component; `x<Slice>Init` (plain data) is the
|
|
115
|
+
one that crosses into a `Zone`.
|
|
116
|
+
- **Never hand the whole handle to a component** — pass the field. `<Zone init={xInitInY} />`, not
|
|
117
|
+
`<Zone init={fetch.initXInY(id)} />`.
|
|
118
|
+
- **Rendering the list on the server costs one extra hydration.** `Load.Stream` builds the model instances on the
|
|
119
|
+
server and `Load.Units` builds them again after hydration, so a large list is still better off going through
|
|
120
|
+
`x<Slice>Init` → `Zone` alone. Reach for `Load.Stream` when the markup is small and static enough that having
|
|
121
|
+
it in the HTML is worth more than the second pass.
|
|
122
|
+
|
|
123
|
+
**A failure now surfaces where it happened.** One `Promise.all` in the page body reports nothing about which of
|
|
124
|
+
eight queries threw; per-section boundaries put each rejection at its own boundary.
|
|
125
|
+
|
|
126
|
+
**Keep the auth gate in the shell.** A redirect thrown after the shell has flushed can only be delivered as a
|
|
127
|
+
soft-redirect script, so `getSelf({ unauthorize: "/signin" })` stays in `_layout.tsx` or the page body — never
|
|
128
|
+
inside a streamed section.
|
|
129
|
+
|
|
86
130
|
**⑥ Use named `ReactNode` slots, not just `children`.** `Layout.Navbar` accepts `title`, `back`, `left`, `right`,
|
|
87
131
|
and `children`, so a client shell composes server-rendered content in five places instead of absorbing it.
|
|
88
132
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Transport Rule",
|
|
3
|
+
"description": "How a call reaches an Akan signal — guards across HTTP and websocket, socket identity and cleanup, binary pubsub frames, and mutation HTTP verbs",
|
|
4
|
+
"group": "core",
|
|
5
|
+
"codegenPriority": 4,
|
|
6
|
+
"deprecated": false,
|
|
7
|
+
"scans": [
|
|
8
|
+
{
|
|
9
|
+
"type": "example",
|
|
10
|
+
"description": "Guard classes and their scope markers",
|
|
11
|
+
"path": "{apps,libs}/*/srvkit/guards.ts",
|
|
12
|
+
"sample": 2
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"type": "example",
|
|
16
|
+
"description": "Signal endpoints declaring guards, pubsub, and message handlers",
|
|
17
|
+
"path": "{apps,libs}/*/lib/*/*.signal.ts",
|
|
18
|
+
"sample": 4
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"update": {
|
|
22
|
+
"filePath": "./transportRule.instruction.md",
|
|
23
|
+
"contents": ["guards across transports", "signal body types", "binary pubsub", "mutation HTTP verbs"],
|
|
24
|
+
"rules": [
|
|
25
|
+
"Use current Akan APIs only",
|
|
26
|
+
"State the rule and the reason it exists, never restate a type signature",
|
|
27
|
+
"Say what leaks or breaks when a rule is ignored",
|
|
28
|
+
"Keep it workspace-neutral — no app or repo names"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Transport Rule — Guards, Sockets, Binary Frames, HTTP Verbs
|
|
2
|
+
|
|
3
|
+
How a call reaches a signal: what a guard may read on either transport, how a websocket carries its credential
|
|
4
|
+
and its per-connection state, when a pubsub payload leaves JSON for a binary frame, and which HTTP verb a
|
|
5
|
+
mutation is mounted under. `conventions` carries the invariants — this is the full contract behind them.
|
|
6
|
+
|
|
7
|
+
## Guards And Transports
|
|
8
|
+
|
|
9
|
+
- Guards run on both HTTP and websocket calls. Read the caller with `context.get<T>("account")` (`pkgs/akanjs/signal/signalContext.ts`) instead of branching on `getHttpContext()` / `getWebSocketContext()`.
|
|
10
|
+
- Slice-level `guards` only reach the generated query/mutation endpoints. A `pubsub`/`message` endpoint is unguarded unless it declares its own `guards` in its signal option.
|
|
11
|
+
- A pubsub room is authorized once, at subscribe. When a socket's credential changes the framework re-runs each room's guards and unsubscribes the ones that now fail (`SignalResolver.revalidateWsRooms`), so guards must stay side-effect free and safe to re-run.
|
|
12
|
+
- A websocket carries its credential in the handshake snapshot on `ws.data` (`AppWsData`); clients that hold the token in memory send it with `fetch.setJwt(...)`, which forwards an auth frame over the socket.
|
|
13
|
+
- **Never read the caller's IP off the socket or the request peer — take `.with(Ip)`.** Whenever a federation
|
|
14
|
+
gateway is in front, `ws.remoteAddress` and the child's own peer are the *gateway* (`127.0.0.1`) for every
|
|
15
|
+
caller and for the whole life of every socket. `Ip` reads what a proxy recorded (`x-real-ip`, then the first
|
|
16
|
+
`x-forwarded-for` entry) and falls back to the peer only when nothing proxied the call — which is the answer
|
|
17
|
+
for a solo process behind an ingress that sets neither; `context.getClientIp()` is the same answer inside a
|
|
18
|
+
guard or middleware. It arrives unwrapped from
|
|
19
|
+
its `::ffff:` IPv4-mapped form, so it can address a `udp4` socket as well as identify a caller, and it is
|
|
20
|
+
`null` rather than a placeholder when genuinely unknown — a loopback-looking address for an unknown caller
|
|
21
|
+
is the failure this replaced. The gateway also forwards the client port (`x-forwarded-port`, read with
|
|
22
|
+
`context.getClientPort()`) along with host and protocol.
|
|
23
|
+
- **Every socket carries a `socketId`, and only the framework mints one.** `AppWsData` assigns it at the handshake, and a `message` / `pubsub` handler reads it off the `Ws` internal arg — `.with(Ws)` hands `{ ws, socketId, subscribe, on, off }`. Never mint your own from `ws.data`: the room bookkeeping keys on the framework's id, and a second one fails to match it silently. It identifies a **connection**, not a caller — a reconnect gets a new id and the federation gateway's own socket is a different one — so per-user state keys on the account, never on this.
|
|
24
|
+
|
|
25
|
+
- **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it.**
|
|
26
|
+
From a `pubsub` subscribe it belongs to that room — `unsubscribe` runs when the client leaves it or a credential
|
|
27
|
+
change revokes it, `disconnect` when the socket closes while still subscribed — so a room already left runs
|
|
28
|
+
neither again, and cleanup that must happen either way registers for both. From a `message` handler it belongs to
|
|
29
|
+
the socket and runs at close. A handler that throws is logged and never blocks the rest of the teardown.
|
|
30
|
+
|
|
31
|
+
## Binary Pubsub
|
|
32
|
+
|
|
33
|
+
- **`pubsub(Binary)` sends its payload in a websocket binary frame**, skipping the JSON `{ type: "pub" }`
|
|
34
|
+
envelope and the base64 a JSON wire would need. The client's subscribe callback receives a `Uint8Array`.
|
|
35
|
+
Nothing else is declared: the return type is the whole switch, and text and binary frames coexist on one
|
|
36
|
+
socket, so every JSON endpoint is untouched. The optimization applies only when the **whole** return is
|
|
37
|
+
`Binary` — `[Binary]`, or a `Binary` inside a model, falls back to base64.
|
|
38
|
+
- **A declared `Binary` room coalesces under backpressure**: while a socket cannot keep up the room keeps only
|
|
39
|
+
its newest frame and drops the rest, which is what a telemetry or video stream wants. Declare
|
|
40
|
+
`pubsub(Binary, { backpressure: "queue" })` when the frames are a sequence a subscriber must see in full, such
|
|
41
|
+
as deltas against a base it already holds; the send buffer then grows with the slowest subscriber. Coalescing
|
|
42
|
+
is keyed by the endpoint, not carried with the frame, so this process, an IPC deliver and a Redis fan-out all
|
|
43
|
+
reach the same answer. Coalesced frames are counted in `pubsubCoalesceCount` on the replica's metrics report.
|
|
44
|
+
- **A `pubsub(Any)` that happens to carry bytes is framed too, and warns once.** `Any` passes a `Buffer` through
|
|
45
|
+
untouched, so the transport sees bytes and sends them whole rather than as a number array — an undeclared
|
|
46
|
+
publisher is fixed rather than left corrupt. It is still not the contract: it queues rather than coalescing
|
|
47
|
+
(nobody declared lossy delivery for that room), and a Redis fan-out still JSON-stringifies it. Declare
|
|
48
|
+
`Binary`.
|
|
49
|
+
- The federation gateway relays a binary frame unchanged, and Bun IPC carries bytes as bytes, so the only hop
|
|
50
|
+
that needed teaching was the socket itself. A cross-server (Redis) fan-out encodes it as protobuf `bytes`.
|
|
51
|
+
|
|
52
|
+
## Mutation HTTP Verb
|
|
53
|
+
|
|
54
|
+
- A `mutation` is `POST`. `{ method: "PATCH" | "PUT" | "DELETE" }` moves it, and one path may carry several verbs
|
|
55
|
+
— a `query` GET and a `mutation` POST on the same custom `path` are mounted side by side. Two endpoints claiming
|
|
56
|
+
the same path **and** verb fail the boot rather than silently shadowing one another.
|
|
57
|
+
- Reach for it only when a foreign wire protocol forces the verb (a client you cannot change that sends
|
|
58
|
+
`PATCH /rest/v1/<table>`). Akan's own `fetch.*` client, the OpenAPI document, and the API explorer all follow
|
|
59
|
+
whatever is declared, so nothing needs restating per caller.
|
|
60
|
+
|