akanjs 2.4.1-rc.7 → 2.4.2-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +326 -0
- package/client/csrTypes.ts +7 -0
- package/client/frameConfig.ts +6 -1
- package/client/rscNavigation.ts +9 -0
- package/common/index.ts +5 -0
- package/common/websocketAuth.ts +24 -0
- package/fetch/client/fetchClient.ts +1 -0
- package/fetch/client/wsClient.ts +28 -1
- package/index.ts +6 -0
- package/package.json +1 -1
- package/server/akanServer.ts +5 -4
- package/server/resolver/signal.resolver.ts +23 -0
- package/server/routing/apiRouter.ts +11 -3
- package/server/routing/appWsData.ts +50 -0
- package/server/rscClient.tsx +9 -0
- package/service/predefinedAdaptor/database.adaptor.ts +44 -3
- package/signal/signalContext.ts +37 -12
- package/signal/types.ts +2 -1
- package/types/client/csrTypes.d.ts +7 -0
- package/types/client/rscNavigation.d.ts +8 -0
- package/types/common/index.d.ts +1 -0
- package/types/common/websocketAuth.d.ts +19 -0
- package/types/fetch/client/wsClient.d.ts +6 -0
- package/types/index.d.ts +6 -0
- package/types/server/resolver/signal.resolver.d.ts +6 -0
- package/types/server/routing/apiRouter.d.ts +3 -4
- package/types/server/routing/appWsData.d.ts +24 -0
- package/types/server/rscClient.d.ts +1 -0
- package/types/signal/signalContext.d.ts +7 -0
- package/types/signal/types.d.ts +5 -1
- package/ui/Model/EditModal.tsx +24 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,331 @@
|
|
|
1
1
|
# akanjs
|
|
2
2
|
|
|
3
|
+
## 2.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 473be34: fix(devkit): restart the backend for server code saved while the builder was away
|
|
8
|
+
|
|
9
|
+
Nothing watches the source tree between a builder leaving and its replacement being ready: the idle
|
|
10
|
+
suspend stops its own watcher before the wake's boot build, a recycle or crash takes the builder's
|
|
11
|
+
watcher with it, and a fresh builder primes its mtime index from whatever it finds on disk — so a save
|
|
12
|
+
that lands in that window is _baseline_ to it and produces no event anywhere. The client half of such a
|
|
13
|
+
save is rescued by the boot build reading the new file; the backend half was not. A `.service.ts` saved
|
|
14
|
+
in that window left the server running the code it replaced, with nothing on screen to say so.
|
|
15
|
+
|
|
16
|
+
The dev host now stamps `(mtime, size)` for every file in the backend import graph when the builder goes
|
|
17
|
+
away — at suspend, at a recycle request, at a crash exit — and compares them when a builder is ready
|
|
18
|
+
again, restarting the backend for anything that moved. Comparing stamps rather than waiting for events
|
|
19
|
+
also covers the watcher dropping one, which Bun's recursive `fs.watch` does.
|
|
20
|
+
|
|
21
|
+
A config change while suspended is exempt, because it replaces the backend along with the builder on its
|
|
22
|
+
own. Where the backend graph scan has never succeeded — path-role fallback rules — there is nothing to
|
|
23
|
+
stamp, and the host says so rather than staying quiet about it.
|
|
24
|
+
|
|
25
|
+
- f5bfa27: perf(devkit): stop parsing every source file to find barrel imports
|
|
26
|
+
|
|
27
|
+
`rewriteBarrelImports` ran a full TypeScript parse of every file it was given, to find import
|
|
28
|
+
statements it then discarded for all but the barrel ones. It runs on every source file of every dev
|
|
29
|
+
rebuild, which made it the single most expensive thing in one.
|
|
30
|
+
|
|
31
|
+
- **63% of files import no barrel at all.** A static import cannot name a specifier without that
|
|
32
|
+
specifier appearing literally in the source, so a substring test skips them before the parser is
|
|
33
|
+
involved — 4ms for 1189 files. The bundler plugin already did this privately; it now lives in
|
|
34
|
+
`rewriteBarrelImports` so the CSS and client-entry walks get it too.
|
|
35
|
+
- **`setParentNodes: true` was paid for no reader.** Nothing reads `node.parent`; every position comes
|
|
36
|
+
from `getStart(sourceFile)`, which takes the file explicitly.
|
|
37
|
+
|
|
38
|
+
Measured across 1189 files: 299ms and 161MB of RSS become 88ms and 5MB. End to end on `apps/akan`,
|
|
39
|
+
`CssCompiler.discoverCssAndSources` drops from 262ms to 190ms and the client-entry discovery walk from
|
|
40
|
+
242ms to 176ms, retaining 107MB instead of 128MB. Verified output-identical on 1535 files: same import
|
|
41
|
+
statements from the parser, and no file the pre-filter skips would have been rewritten.
|
|
42
|
+
|
|
43
|
+
`CssCompiler` also memoises import resolution for the life of one rebuild, where a miss cost up to 13
|
|
44
|
+
sequential `exists()` calls repeated per importer (a further 216ms → 190ms).
|
|
45
|
+
|
|
46
|
+
- 473be34: fix(devkit): bound the `ps` fallback that reads another process's memory
|
|
47
|
+
|
|
48
|
+
Where there is no `/proc` (macOS), the dev host reads a process's RSS by shelling out to `ps`, with no
|
|
49
|
+
timeout. An absent `ps` was already handled — it answers `null`, which callers read as "no new
|
|
50
|
+
information" — but a stuck one was not, and its only caller awaits it at the end of a 20s settle before
|
|
51
|
+
committing a builder recycle. A hang there meant the recycle silently never happened.
|
|
52
|
+
|
|
53
|
+
Now spawned directly with a 2s kill timer, which is the same treatment the dev-stability harness already
|
|
54
|
+
needed after `ps` hung under load.
|
|
55
|
+
|
|
56
|
+
- 068158b: feat(devkit): bound dev-server memory and stop losing watch events
|
|
57
|
+
|
|
58
|
+
Two dev-server problems that compounded each other.
|
|
59
|
+
|
|
60
|
+
- The builder grew without bound because `Bun.build` retains native bundler arenas that
|
|
61
|
+
`Bun.gc(true)` never reclaims. It is now recycled once its RSS passes a ceiling derived
|
|
62
|
+
from the container's cgroup limit, draining in flight work first.
|
|
63
|
+
- Bun's recursive `fs.watch` reports roughly one path per coalescing window and discards
|
|
64
|
+
the rest, so concurrent saves went unbuilt. Changes are now resolved against a
|
|
65
|
+
`SourceMtimeIndex` baseline and events only decide _when_ to look.
|
|
66
|
+
|
|
67
|
+
- 46a1a4a: fix(devkit): flush all builder ipc through BuilderChannel and isolate the boot build
|
|
68
|
+
|
|
69
|
+
`BuilderReply` only covered request responses, so recycle `process.exit` could still
|
|
70
|
+
drop unflushed events like `css-updated` and leave the backend on a stale bundle. The
|
|
71
|
+
boot `SsrBaseArtifactBuilder` also stayed in the long-lived watcher and retained most of
|
|
72
|
+
its idle RSS.
|
|
73
|
+
|
|
74
|
+
- Replace `BuilderReply` with `BuilderChannel` (`send` / `emit` / `drain`) so every
|
|
75
|
+
builder→host message awaits ipc flush before recycle exit.
|
|
76
|
+
- Run the boot base artifact build in the disposable `buildBatch` worker (`needs: ["base"]`)
|
|
77
|
+
and keep only the serializable artifact/fonts in the watcher.
|
|
78
|
+
- Split the idle resource-budget assertion so host+backend stays tight while the builder
|
|
79
|
+
can swing within its RSS recycle ceiling.
|
|
80
|
+
|
|
81
|
+
- 90c6597: fix(devkit): answer in-flight builder requests on recycle and flush replies before exit
|
|
82
|
+
|
|
83
|
+
Builder RSS recycle (and unexpected exits) left mid-flight `build-route` /
|
|
84
|
+
`build-csr` promises hanging: the host never tracked correlation ids, and even a
|
|
85
|
+
clean drain could lose a large `build-route-res` when `process.exit` truncated an
|
|
86
|
+
unflushed ipc write past the pipe buffer.
|
|
87
|
+
|
|
88
|
+
- Track in-flight ids on the host and fail them with a reloadable error when the
|
|
89
|
+
builder recycles, crashes, or is stopped.
|
|
90
|
+
- Send replies through `BuilderReply`, which awaits the ipc flush (with a timeout)
|
|
91
|
+
so recycle drain means "answered", not "truncated".
|
|
92
|
+
|
|
93
|
+
- aca901d: fix(devkit): keep builder replies matched to the backend that asked for them
|
|
94
|
+
|
|
95
|
+
`BuilderRpc` numbers its requests from 1 in each backend _process_, while the builder it
|
|
96
|
+
talks to outlives the backend. After a restart the two generations collided on id 1: the
|
|
97
|
+
builder answered the departed backend's request, the dev host relayed it, and the new
|
|
98
|
+
backend settled its own id 1 with another route's manifest delta — a page rendered against
|
|
99
|
+
client modules that were never built for it. The answer it was actually waiting for then
|
|
100
|
+
arrived to an empty pending map and was dropped, discarding the correct build too.
|
|
101
|
+
|
|
102
|
+
`BuilderRequestRouter` renumbers ids host-side, so neither the backend nor the builder
|
|
103
|
+
learns anything changed and a reply whose generation is gone is discarded rather than
|
|
104
|
+
misdelivered.
|
|
105
|
+
|
|
106
|
+
- 068158b: fix(cli): make `bun run akan <cmd>` concurrency-safe
|
|
107
|
+
|
|
108
|
+
`bun run akan` rebuilds the CLI into a shared `dist/` before every command, so two
|
|
109
|
+
commands started at once could read a half-written bundle.
|
|
110
|
+
|
|
111
|
+
- cb895b7: fix(devkit): find created directories on a filesystem with a coarse mtime clock
|
|
112
|
+
|
|
113
|
+
`SourceMtimeIndex` finds new files by noticing their directory's mtime moved, which Linux
|
|
114
|
+
stamps from a coarse clock: 400 back-to-back `mkdir`s left the parent's mtime unmoved 319
|
|
115
|
+
times on overlayfs and 324 times on ext4, smallest observable step 1ms. macOS APFS
|
|
116
|
+
(0.042ms) missed none, which is why this only ever showed up on Linux.
|
|
117
|
+
|
|
118
|
+
A directory mutated in the same millisecond as the recorded value — but after the walk that
|
|
119
|
+
recorded it — therefore left no trace, and because its files were never tracked, later edits
|
|
120
|
+
to them went unreported for the life of the process. Directories whose mtime was still fresh
|
|
121
|
+
when it was read are now re-walked on the next scan (`dirSettleMs`, 20ms default), and
|
|
122
|
+
`HmrWatcher` schedules one more scan while any remain unsettled.
|
|
123
|
+
|
|
124
|
+
- f8a9bc5: perf(devkit): cache tailwind candidate tokens across builds
|
|
125
|
+
|
|
126
|
+
The CSS rebuild read the full text of every source file on every save. Phase 2 moved css
|
|
127
|
+
compilation into a per-generation batch worker, so an in-memory cache is discarded before
|
|
128
|
+
the next save can use it — the cache goes to disk instead, the same way font subsetting
|
|
129
|
+
does, which also survives a builder recycle and a dev-host restart.
|
|
130
|
+
|
|
131
|
+
Measured on `apps/akan` (556 sources, 26800 candidates): the candidate scan drops from
|
|
132
|
+
58-60ms to 13-19ms. Note that this is a smaller share of the rebuild than expected — the
|
|
133
|
+
full CSS rebuild is ~380ms, so the scan was never the dominant cost. Nothing is written
|
|
134
|
+
when nothing was re-read.
|
|
135
|
+
|
|
136
|
+
- d973712: perf(cli): keep heavy dependencies out of the long-lived dev processes
|
|
137
|
+
|
|
138
|
+
`akan start` holds the CLI entry and the builder watcher for the whole dev session, so an
|
|
139
|
+
eagerly imported dependency is resident for the whole session too.
|
|
140
|
+
|
|
141
|
+
- The dev host reached `@inquirer/prompts` (~24MB) because `runCommands` shares a module
|
|
142
|
+
with the interactive argument fallbacks. Those now load the prompt stack on first use,
|
|
143
|
+
which for `akan start` is never.
|
|
144
|
+
- The builder watcher reached `tailwindcss` and `@tailwindcss/node` (~40MB) through the
|
|
145
|
+
`frontendBuild` barrel, which re-exports `cssCompiler` and `ssrBaseArtifactBuilder`. That
|
|
146
|
+
has been dead weight since css compilation moved into the batch worker; the watcher now
|
|
147
|
+
imports by module path.
|
|
148
|
+
|
|
149
|
+
`entryModuleGraph.test.ts` guards both by walking each built entry's chunk closure. The
|
|
150
|
+
previous check grepped the entry file alone, which cannot see a dependency reached through
|
|
151
|
+
a shared chunk and so reported both of these as absent.
|
|
152
|
+
|
|
153
|
+
- 068158b: fix(devkit): pin the dev port and bound the waits that could hang forever
|
|
154
|
+
|
|
155
|
+
- `AKAN_DEV_PORT` pins the dev port. It used to derive from an app's index in the `apps/`
|
|
156
|
+
listing, so adding an app moved a running dev server's port at its next restart.
|
|
157
|
+
- `BuilderRpc` created request promises with no timeout, and nothing else answers a lost
|
|
158
|
+
request. Since the builder is recycled routinely, a page request that landed mid
|
|
159
|
+
route-build left the SSR promise pending forever with nothing to retry. Now bounded by
|
|
160
|
+
`AKAN_BUILDER_RPC_TIMEOUT_MS` (120s default) with a message naming the likely cause.
|
|
161
|
+
|
|
162
|
+
- cc3dd40: feat: expose local-dev metadata endpoints for devtools visualization
|
|
163
|
+
|
|
164
|
+
Add four JSON endpoints, registered only when `AKAN_PUBLIC_ENV=local` (override with `AKAN_DEVTOOLS`),
|
|
165
|
+
that describe the running system for an external developer-tools UI:
|
|
166
|
+
|
|
167
|
+
- `GET /_akan/constant` — every model's Input/Object/Full/Light/Insight view, scalars, enums, filter
|
|
168
|
+
query/sort, and derived relation edges.
|
|
169
|
+
- `GET /_akan/signal` — declared and framework-generated endpoints, slices, internals, and a flattened
|
|
170
|
+
route table with fully resolved HTTP/WS paths.
|
|
171
|
+
- `GET /_akan/dictionary` — the merged i18n tree, module kinds, and flattened dotted keys (`?lang=` narrows it).
|
|
172
|
+
- `GET /_akan/deps` — the DI graph: services, adaptors, signals, uses, middleware, env, roles, and the
|
|
173
|
+
topological init stages.
|
|
174
|
+
|
|
175
|
+
They live in `AkanServer.#createBuiltinRoutes()` next to `/openapi.json`, so they stay off the `/api` prefix
|
|
176
|
+
and never enter the `serializedSignal` payload shipped to clients. Outside `local` the routes are not
|
|
177
|
+
registered at all and fall through to the SSR catch-all.
|
|
178
|
+
|
|
179
|
+
Supporting changes:
|
|
180
|
+
|
|
181
|
+
- `DictionaryRegistry` collects each `makeTrans` root, which was previously closure-private and unreachable
|
|
182
|
+
from the server.
|
|
183
|
+
- `DiLifecycle` gains a read-only `modules` accessor and retains disabled-module reasons that were only logged.
|
|
184
|
+
- `SignalResolver.getScheduleSkipReason` is now public so the reported schedule placement cannot drift from
|
|
185
|
+
the scheduler's own rules.
|
|
186
|
+
|
|
187
|
+
Secrets discipline: secret constant fields report name and type but no `default`/`example`, `env` carries
|
|
188
|
+
values for `AKAN_PUBLIC_*` only and every other key by name alone, and `uses` are reported as key plus class
|
|
189
|
+
name — never the instance. `env` inject keys are extracted by scanning the factory source, never by running it.
|
|
190
|
+
|
|
191
|
+
- 8a2b795: perf(devkit): stop retaining source text in client-entry discovery, and expire its misses
|
|
192
|
+
|
|
193
|
+
`GraphClientEntryDiscovery` is created once per builder process, so its caches live for the
|
|
194
|
+
whole dev session in the watcher.
|
|
195
|
+
|
|
196
|
+
- It kept the full text of every file the walk had touched plus a barrel-rewritten copy of
|
|
197
|
+
each, when the walk only ever asks two things of a file — is it a client entry, and what
|
|
198
|
+
does it import — both of which were already cached separately under the same key. Those
|
|
199
|
+
three caches collapse into one holding just the derived facts: measured on `apps/akan`,
|
|
200
|
+
retention after a full walk drops from 135-142MB to 125-127MB.
|
|
201
|
+
- `invalidate()` never cleared the file-existence and resolution caches. They are keyed by
|
|
202
|
+
extension-less path and by `dir\0specifier`, neither of which maps back to a file that was
|
|
203
|
+
just created, so a negative recorded before a module existed was permanent: adding a new
|
|
204
|
+
module and importing it left the import unresolved until the next config change or builder
|
|
205
|
+
recycle. Negative answers are now dropped on any invalidate; positive ones are keyed by a
|
|
206
|
+
real path and are left alone.
|
|
207
|
+
|
|
208
|
+
- 068158b: perf(devkit): cache font subsetting across dev-server boots
|
|
209
|
+
|
|
210
|
+
`FontOptimizer.optimize()` re-subset every font file on each builder boot even though it
|
|
211
|
+
already computed a config hash and wrote hashed outputs. It now skips the
|
|
212
|
+
`fonteditor-core` / `subset-font` work when the expected outputs are present, which also
|
|
213
|
+
keeps those two packages out of the common path entirely.
|
|
214
|
+
|
|
215
|
+
- 473be34: fix(devkit): hold page requests during the recycle drain, not only after the builder exits
|
|
216
|
+
|
|
217
|
+
A builder asked to recycle drains first — it stays alive finishing its queued work and refuses
|
|
218
|
+
everything new — and throughout that window the dev host still reported it as `ready`. So a route or CSR
|
|
219
|
+
request that arrived during the drain was sent, refused by the departing builder, and relayed to the
|
|
220
|
+
backend as a failure: the same dev error page the request-holding fix was written to remove, in the half
|
|
221
|
+
of the window it never covered. The hold was unreachable there, because it only runs when the send
|
|
222
|
+
itself fails.
|
|
223
|
+
|
|
224
|
+
The builder host now reports `recycling` for the drain, which `send()` refuses on and which the hold
|
|
225
|
+
decision treats like a restart. `ready` — the field `onExit` reads to tell a planned exit from a builder
|
|
226
|
+
that never came up — is deliberately unchanged.
|
|
227
|
+
|
|
228
|
+
- e5fde3b: fix(devkit): hold page requests while the builder restarts instead of failing them
|
|
229
|
+
|
|
230
|
+
A route or CSR request that arrived while the builder was recycling or restarting was answered
|
|
231
|
+
immediately with `builder is restarting; reload after the builder is ready`. Nothing retried, so the
|
|
232
|
+
browser tab showed an error for a builder that was seconds from being back — and the builder is recycled
|
|
233
|
+
routinely, whenever its RSS passes the ceiling.
|
|
234
|
+
|
|
235
|
+
Those requests are now held and replayed when the builder reports ready, which is what the idle-suspend
|
|
236
|
+
path already did for exactly this reason. `BuilderRpc`'s own timeout still bounds the wait, the queue is
|
|
237
|
+
capped so a builder that never returns cannot grow it, and anything still held when the builder is
|
|
238
|
+
stopped for good is failed rather than left silent.
|
|
239
|
+
|
|
240
|
+
- 473be34: fix(devkit): say when a build worker was killed rather than crashed
|
|
241
|
+
|
|
242
|
+
The disposable build worker holds the largest transient in the dev tree (~548MB on a mid-size app, over
|
|
243
|
+
1GB on a large one), so on a small sandbox it is the process the kernel reaches for first. A worker the
|
|
244
|
+
OOM killer takes exits with code `null` and `SIGKILL`, and the build was reported as `build worker exited
|
|
245
|
+
with code null before reporting a result` — indistinguishable from an ordinary crash, though the two have
|
|
246
|
+
opposite fixes: find the build error, or raise the memory limit.
|
|
247
|
+
|
|
248
|
+
The failure path is unchanged and still safe (that generation goes red, the last-good artifact keeps
|
|
249
|
+
serving); the message now names the signal, and calls out `SIGKILL` as most often the OOM killer.
|
|
250
|
+
|
|
251
|
+
- f28466f: fix(server): stop closing the database out from under its own schema setup
|
|
252
|
+
|
|
253
|
+
`getStore()` returns a store synchronously while `ensure()` goes on creating tables and indexes, so a
|
|
254
|
+
shutdown could close the connection mid-setup. The rejection was unhandled — `void store.ensure()` —
|
|
255
|
+
and surfaced as `RangeError: Cannot use a closed database` blamed on whatever ran next, which read as a
|
|
256
|
+
flaky test rather than a race at shutdown. Reproduced at 3 failures in 8 runs of the akanjs suite, 0 in
|
|
257
|
+
10 after the fix.
|
|
258
|
+
|
|
259
|
+
All three SQL adaptors (bun:sqlite, libsql, Postgres) now track those setups and let them finish before
|
|
260
|
+
closing. Every statement `ensure()` runs is `IF NOT EXISTS`, so one cut short is simply redone next boot.
|
|
261
|
+
|
|
262
|
+
- 51851fa: perf: cut idle/dev-save memory with phase-1 quick wins
|
|
263
|
+
|
|
264
|
+
Apply the phase-1 resource plan without architecture changes:
|
|
265
|
+
|
|
266
|
+
- Self-arming CSR rebuild — skip the dead CSR artifact until `/__csr` or `?csr=true` first needs it
|
|
267
|
+
(keeps mobile live-reload working once armed).
|
|
268
|
+
- Bound RSC worker reload accumulation with threshold/RSS recycle instead of retaining every pages
|
|
269
|
+
bundle generation.
|
|
270
|
+
- Split `@akanjs/devkit` into subpath exports and move route/overrides AST validation out of the
|
|
271
|
+
resident `executors` graph so `typescript` is not pulled into long-lived start processes.
|
|
272
|
+
- Lazy-load CLI command modules via a command manifest so unused command graphs stay cold.
|
|
273
|
+
|
|
274
|
+
Also await async endpoint guards (including in parallel) so `canPass` promises are honored.
|
|
275
|
+
|
|
276
|
+
- 1c3436f: perf: bound builder memory with RSS recycle and disposable batch workers
|
|
277
|
+
|
|
278
|
+
Apply the phase-2 bounded-builder plan so Bun.build retention no longer grows without
|
|
279
|
+
bound across a long `akan start` session:
|
|
280
|
+
|
|
281
|
+
- Report builder RSS after each work item and recycle the builder process when it crosses
|
|
282
|
+
a ceiling (`AKAN_BUILDER_MAX_RSS_MB`, else cgroup × 0.35, else 1200 MB), only when no
|
|
283
|
+
build is in flight and the generation is green.
|
|
284
|
+
- Extract shared `memoryLimit` helpers (also used by the RSC worker) and announce recovered
|
|
285
|
+
pages/css state after a recycle-triggered boot so a live backend picks up the new
|
|
286
|
+
`base-artifact.json`.
|
|
287
|
+
- Move pages/css/csr `Bun.build` work into a disposable `buildBatch` worker that exits per
|
|
288
|
+
generation (optional `AKAN_BUILD_WORKER_REUSE_COUNT`), keeping the watcher process thin.
|
|
289
|
+
|
|
290
|
+
- 128e9a3: fix(devkit): survive a container image with no `ps`
|
|
291
|
+
|
|
292
|
+
`DevStabilityHarness` shells out to `ps` to find leftover dev processes, and slim images such as
|
|
293
|
+
`oven/bun` ship without procps, so the spawn threw. It now returns the same `null` it already
|
|
294
|
+
returns for a `ps` that does not answer in time — "could not look", not "nothing is running".
|
|
295
|
+
Fixture liveness never depended on it (`process.kill(pid, 0)`), so sweeping still works.
|
|
296
|
+
|
|
297
|
+
- a5d4a8a: fix: register and correctly invoke `internal(... { process })` queue workers
|
|
298
|
+
|
|
299
|
+
`process` internals accepted jobs but never ran them. Three defects:
|
|
300
|
+
|
|
301
|
+
- `buildInternal.process` was the only scheduled factory that did not default `enabled: true`, so
|
|
302
|
+
`SignalResolver.resolveSchedule` skipped it and no worker was ever registered. Placement is now governed by
|
|
303
|
+
`serverMode`/`operationMode` alone, matching the existing `serverMode: "all"` default.
|
|
304
|
+
- Registered workers were called with the `AkanJob` rather than the declared `msg` arguments. The job payload is
|
|
305
|
+
now spread onto the declared args and deserialized against their declared types, so the `exec` signature
|
|
306
|
+
`(...msgArgs, job)` holds at runtime.
|
|
307
|
+
- `BullQueue` scoped its worker to queue `<prefix>:<key>` while enqueueing onto queue `<prefix>`, so cluster mode
|
|
308
|
+
never consumed jobs. Producer and consumer now share one queue per process key.
|
|
309
|
+
|
|
310
|
+
`resolveSchedule` also logs when a `process` internal gets no worker on the current server, since the producer is
|
|
311
|
+
installed regardless of placement.
|
|
312
|
+
|
|
313
|
+
- 473be34: fix(devkit): stop turning the builder's memory ceiling off on the first page load
|
|
314
|
+
|
|
315
|
+
The dev host stops enforcing the builder's RSS ceiling when recycling evidently cannot meet it. The
|
|
316
|
+
evidence it used was "two over-ceiling reports within 30s of a recycle" — but a builder reports after
|
|
317
|
+
every build, and one page load builds a route per navigation. On a container-derived ceiling (a 1.2GB
|
|
318
|
+
sandbox gives the builder ~420MB, against ~247MB per route build) the first page load after a recycle
|
|
319
|
+
switched the ceiling off for the rest of the session, leaving the builder unbounded on exactly the
|
|
320
|
+
deployment shape the ceiling exists for.
|
|
321
|
+
|
|
322
|
+
It now measures what it always claimed to: when a replacement builder becomes ready, before it has built
|
|
323
|
+
anything on demand, the host reads its RSS from the OS. That is the floor every future replacement lands
|
|
324
|
+
on, so a floor already over the ceiling means recycling cannot help — and only that stops enforcement,
|
|
325
|
+
with the message naming `AKAN_BUILDER_MAX_RSS_MB`. Otherwise the ceiling stands and the existing 30s
|
|
326
|
+
minimum interval bounds what it costs, now with a one-off warning when the builder keeps crossing back
|
|
327
|
+
inside that interval.
|
|
328
|
+
|
|
3
329
|
## 2.4.0
|
|
4
330
|
|
|
5
331
|
### Minor Changes
|
package/client/csrTypes.ts
CHANGED
|
@@ -50,6 +50,13 @@ export interface PageConfig {
|
|
|
50
50
|
rscPatchHeadSafe?: boolean;
|
|
51
51
|
topSafeAreaColor?: string;
|
|
52
52
|
bottomSafeAreaColor?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Keeps the route out of `akan build`. The route still serves under `akan start`, but nothing about it
|
|
55
|
+
* reaches production: no bundle, no manifest entry, no URL. On a `_layout`, every route under that
|
|
56
|
+
* directory is excluded with it. Must be written as a literal `true`/`false` — the build reads it from
|
|
57
|
+
* the source without evaluating the module.
|
|
58
|
+
*/
|
|
59
|
+
devOnly?: boolean;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export interface CsrState {
|
package/client/frameConfig.ts
CHANGED
|
@@ -24,6 +24,8 @@ const pageConfigKeys = new Set<keyof PageConfig>([
|
|
|
24
24
|
"topSafeAreaColor",
|
|
25
25
|
"bottomSafeAreaColor",
|
|
26
26
|
]);
|
|
27
|
+
|
|
28
|
+
const buildPageConfigKeys = new Set<keyof PageConfig>(["devOnly"]);
|
|
27
29
|
const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
|
|
28
30
|
const ssrRenderModes = new Set<SsrRenderMode>(["stream", "block"]);
|
|
29
31
|
const DEFAULT_BOOLEAN_INSET = 48;
|
|
@@ -38,10 +40,13 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
|
|
|
38
40
|
if (!isRecord(config)) throw new Error(`[route-convention] pageConfig in ${routeKey} must be an object.`);
|
|
39
41
|
const pageConfig = config as PageConfig;
|
|
40
42
|
for (const key of Object.keys(pageConfig)) {
|
|
41
|
-
if (!pageConfigKeys.has(key as keyof PageConfig)) {
|
|
43
|
+
if (!pageConfigKeys.has(key as keyof PageConfig) && !buildPageConfigKeys.has(key as keyof PageConfig)) {
|
|
42
44
|
throw new Error(`[route-convention] unsupported pageConfig option "${key}" in ${routeKey}`);
|
|
43
45
|
}
|
|
44
46
|
}
|
|
47
|
+
if (pageConfig.devOnly !== undefined && typeof pageConfig.devOnly !== "boolean") {
|
|
48
|
+
throw new Error(`[route-convention] pageConfig.devOnly in ${routeKey} must be a boolean.`);
|
|
49
|
+
}
|
|
45
50
|
if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
|
|
46
51
|
throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
|
|
47
52
|
}
|
package/client/rscNavigation.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
declare global {
|
|
2
2
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
3
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
3
4
|
var __AKAN_RSC_NAVIGATE__:
|
|
4
5
|
| ((href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => Promise<void>)
|
|
5
6
|
| undefined;
|
|
@@ -9,11 +10,19 @@ export const clearRscNavigationCache = () => {
|
|
|
9
10
|
globalThis.__AKAN_RSC_CLEAR_CACHE__?.();
|
|
10
11
|
};
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* True when the page tree currently on screen was replayed from the RSC navigation cache instead of
|
|
15
|
+
* fetched from the server. Data hydrated out of such a payload can be arbitrarily old, so anything
|
|
16
|
+
* that must show current values should refetch.
|
|
17
|
+
*/
|
|
18
|
+
export const isRscNavigationFromCache = () => globalThis.__AKAN_RSC_IS_FROM_CACHE__?.() ?? false;
|
|
19
|
+
|
|
12
20
|
export const navigateRsc = (href: string, options?: { replace?: boolean; scrollToTop?: boolean }) => {
|
|
13
21
|
return globalThis.__AKAN_RSC_NAVIGATE__?.(href, options);
|
|
14
22
|
};
|
|
15
23
|
|
|
16
24
|
export const useRscNavigation = () => ({
|
|
17
25
|
clearCache: clearRscNavigationCache,
|
|
26
|
+
isFromCache: isRscNavigationFromCache,
|
|
18
27
|
navigate: navigateRsc,
|
|
19
28
|
});
|
package/common/index.ts
CHANGED
|
@@ -54,3 +54,8 @@ export { sleep } from "./sleep";
|
|
|
54
54
|
export { splitVersion } from "./splitVersion";
|
|
55
55
|
export { getBasePathFromPathname, parseBasePaths } from "./subRoute";
|
|
56
56
|
export type * from "./types";
|
|
57
|
+
export {
|
|
58
|
+
type WebsocketAuthAckData,
|
|
59
|
+
type WebsocketAuthRequest,
|
|
60
|
+
websocketAuthContract,
|
|
61
|
+
} from "./websocketAuth";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface WebsocketAuthRequest {
|
|
2
|
+
key: string;
|
|
3
|
+
data: [string | null];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface WebsocketAuthAckData {
|
|
7
|
+
type: "auth";
|
|
8
|
+
revokedRooms: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Framework-owned websocket auth contract shared by the client and the server dispatcher.
|
|
13
|
+
* The credential frame carries the raw bearer token; verifying it stays in userland middleware,
|
|
14
|
+
* so the server only swaps the credential snapshot held on the socket.
|
|
15
|
+
*/
|
|
16
|
+
export const websocketAuthContract = {
|
|
17
|
+
key: "__auth",
|
|
18
|
+
makeRequest: (jwt: string | null): WebsocketAuthRequest => ({ key: "__auth", data: [jwt] }),
|
|
19
|
+
makeAck: (revokedRooms: string[]): WebsocketAuthAckData => ({ type: "auth", revokedRooms }),
|
|
20
|
+
readJwt: (data: unknown): string | null => {
|
|
21
|
+
const jwt = Array.isArray(data) ? data[0] : null;
|
|
22
|
+
return typeof jwt === "string" && jwt.length > 0 ? jwt : null;
|
|
23
|
+
},
|
|
24
|
+
} as const;
|
|
@@ -197,6 +197,7 @@ export class FetchClient {
|
|
|
197
197
|
}
|
|
198
198
|
setJwt(jwt: string | null) {
|
|
199
199
|
this.jwt = jwt;
|
|
200
|
+
this.ws.setJwt(jwt);
|
|
200
201
|
}
|
|
201
202
|
#makeAuthHeaders(option?: FetchPolicy): Record<string, string> {
|
|
202
203
|
if (option?.token) return { Authorization: `Bearer ${option.token}` };
|
package/fetch/client/wsClient.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { Logger } from "akanjs/common";
|
|
1
|
+
import { Logger, websocketAuthContract } from "akanjs/common";
|
|
2
2
|
import type {
|
|
3
|
+
WebsocketAuthAck,
|
|
3
4
|
WebsocketMessageData,
|
|
4
5
|
WebsocketPublishData,
|
|
5
6
|
WebsocketReqData,
|
|
@@ -39,6 +40,7 @@ export class WsClient {
|
|
|
39
40
|
#roomSubscribeMap = new Map<string, SubscribeOption>();
|
|
40
41
|
#listenerMap = new Map<string, Set<Listener>>();
|
|
41
42
|
#destroyed = false;
|
|
43
|
+
#jwt: string | null = null;
|
|
42
44
|
connected = false;
|
|
43
45
|
|
|
44
46
|
constructor(
|
|
@@ -52,6 +54,21 @@ export class WsClient {
|
|
|
52
54
|
this.ErrorCls = ErrorCls;
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The handshake only carries a same-origin cookie, so clients that hold the token in memory
|
|
59
|
+
* (native, cross-origin) authenticate with this frame instead. Signing out sends `null`, which
|
|
60
|
+
* drops the handshake cookie server-side and revokes the rooms it had authorized.
|
|
61
|
+
*/
|
|
62
|
+
setJwt(jwt: string | null) {
|
|
63
|
+
if (this.#jwt === jwt) return;
|
|
64
|
+
this.#jwt = jwt;
|
|
65
|
+
if (this.#ws?.readyState === WebSocket.OPEN) this.#sendAuth();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#sendAuth() {
|
|
69
|
+
this.#ws?.send(JSON.stringify(websocketAuthContract.makeRequest(this.#jwt)));
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
connect() {
|
|
56
73
|
if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) return;
|
|
57
74
|
this.logger.debug(`Connecting to ${this.url}`);
|
|
@@ -68,6 +85,8 @@ export class WsClient {
|
|
|
68
85
|
this.#reconnectAttempts = 0;
|
|
69
86
|
this.connected = true;
|
|
70
87
|
this.logger.debug(`WebSocket connected`);
|
|
88
|
+
|
|
89
|
+
if (this.#jwt) this.#sendAuth();
|
|
71
90
|
this.#roomSubscribeMap.forEach((option) => {
|
|
72
91
|
const data: WebsocketReqData = { key: option.key, data: option.data, subscribe: true };
|
|
73
92
|
this.#ws?.send(JSON.stringify(data));
|
|
@@ -97,6 +116,14 @@ export class WsClient {
|
|
|
97
116
|
this.#handlePubsub(publishData.roomId, publishData.data);
|
|
98
117
|
break;
|
|
99
118
|
}
|
|
119
|
+
case "auth": {
|
|
120
|
+
const ack = parsed as WebsocketAuthAck;
|
|
121
|
+
for (const roomId of ack.revokedRooms) {
|
|
122
|
+
this.#roomSubscribeMap.delete(roomId);
|
|
123
|
+
this.logger.warn(`Websocket room ${roomId} is no longer authorized`);
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
100
127
|
default:
|
|
101
128
|
this.logger.warn(`Unknown WebSocket message type: ${type} ${JSON.stringify(parsed)}`);
|
|
102
129
|
break;
|
package/index.ts
CHANGED
|
@@ -163,6 +163,12 @@ export interface AppConfigResult {
|
|
|
163
163
|
docker: DockerConfig;
|
|
164
164
|
defaultDatabaseMode: DatabaseMode;
|
|
165
165
|
routes?: AkanRouteConfig[];
|
|
166
|
+
/**
|
|
167
|
+
* Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
|
|
168
|
+
* dependency that ships a `page` folder, an array takes exactly the libs listed, `false` (the default)
|
|
169
|
+
* syncs nothing and removes what a previous sync created.
|
|
170
|
+
*/
|
|
171
|
+
syncPageLibs?: string[] | boolean;
|
|
166
172
|
externalLibs: string[];
|
|
167
173
|
barrelImports: string[];
|
|
168
174
|
optimizeImports: string[];
|
package/package.json
CHANGED
package/server/akanServer.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
|
23
23
|
import { WebProxyRunner } from "./proxy";
|
|
24
24
|
import { SignalResolver } from "./resolver";
|
|
25
25
|
import { ApiRouter } from "./routing/apiRouter";
|
|
26
|
+
import type { AppWsData } from "./routing/appWsData";
|
|
26
27
|
import type { HttpRoutes, SignalRoutes, WebsocketRoutes } from "./types";
|
|
27
28
|
import type { WebRouter } from "./webRouter";
|
|
28
29
|
|
|
@@ -65,8 +66,8 @@ export interface AkanServerConsoleInfo {
|
|
|
65
66
|
export class AkanServer {
|
|
66
67
|
status: "stopped" | "initializing" | "initialized" | "starting" | "running" | "stopping" = "stopped";
|
|
67
68
|
|
|
68
|
-
#server: Bun.Server<
|
|
69
|
-
#wsServer: Bun.Server<
|
|
69
|
+
#server: Bun.Server<AppWsData | HmrWsData> | null = null;
|
|
70
|
+
#wsServer: Bun.Server<AppWsData | HmrWsData> | null = null;
|
|
70
71
|
#prepared: AkanAppPrepared | null = null;
|
|
71
72
|
readonly logger: Logger;
|
|
72
73
|
readonly name: string;
|
|
@@ -241,7 +242,7 @@ export class AkanServer {
|
|
|
241
242
|
}),
|
|
242
243
|
|
|
243
244
|
data: {},
|
|
244
|
-
} as Bun.WebSocketHandler<
|
|
245
|
+
} as Bun.WebSocketHandler<AppWsData | HmrWsData>;
|
|
245
246
|
|
|
246
247
|
this.#server = Bun.serve({
|
|
247
248
|
idleTimeout: 0,
|
|
@@ -270,7 +271,7 @@ export class AkanServer {
|
|
|
270
271
|
builtinRoutes,
|
|
271
272
|
routeOptions,
|
|
272
273
|
renderEnvRoutes,
|
|
273
|
-
upgradeAppWs: (req: Request, data:
|
|
274
|
+
upgradeAppWs: (req: Request, data: AppWsData) => this.#wsServer?.upgrade(req, { data }) ?? false,
|
|
274
275
|
webProxyRunner,
|
|
275
276
|
}),
|
|
276
277
|
websocket: websocketHandlers,
|
|
@@ -452,6 +452,29 @@ export class SignalResolver {
|
|
|
452
452
|
return Boolean(req.headers.get("authorization") || req.headers.get("cookie")?.includes("jwt="));
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Re-checks the guards of every room this socket is subscribed to and drops the ones that no
|
|
457
|
+
* longer pass. Called when the socket's credential changes: a pubsub room is authorized once at
|
|
458
|
+
* subscribe time, so without this a signed-out socket would keep receiving its old rooms.
|
|
459
|
+
*/
|
|
460
|
+
static async revalidateWsRooms(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<string[]> {
|
|
461
|
+
const roomCtxMap = SignalResolver.#liveWsPubsubRoomCtx.get(ws);
|
|
462
|
+
if (!roomCtxMap?.size) return [];
|
|
463
|
+
const websocket = SignalResolver.#getWebsocket(registry);
|
|
464
|
+
const revokedRooms: string[] = [];
|
|
465
|
+
for (const [roomId, roomCtx] of [...roomCtxMap]) {
|
|
466
|
+
if (await roomCtx.authorize()) continue;
|
|
467
|
+
ws.unsubscribe(roomId);
|
|
468
|
+
await Promise.all([...roomCtx.getWebSocketContext().onUnsubscribe.values()].map((handler) => handler()));
|
|
469
|
+
roomCtxMap.delete(roomId);
|
|
470
|
+
websocket.leaveRoom(ws, roomId);
|
|
471
|
+
revokedRooms.push(roomId);
|
|
472
|
+
SignalResolver.logger.verbose(`WebSocket lost access to room ${roomId}; unsubscribed`);
|
|
473
|
+
}
|
|
474
|
+
if (roomCtxMap.size === 0) SignalResolver.#liveWsPubsubRoomCtx.delete(ws);
|
|
475
|
+
return revokedRooms;
|
|
476
|
+
}
|
|
477
|
+
|
|
455
478
|
static async handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry) {
|
|
456
479
|
await SignalResolver.#getWebsocket(registry).registerSocket(ws);
|
|
457
480
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { dayjs } from "akanjs/base";
|
|
2
|
-
import type
|
|
2
|
+
import { type Logger, websocketAuthContract } from "akanjs/common";
|
|
3
3
|
import type { InjectRegistry } from "akanjs/service";
|
|
4
4
|
import { Exception, type WebsocketReqData } from "akanjs/signal";
|
|
5
5
|
import type { HmrWsData, HmrWsHub } from "../hmr/wsHub";
|
|
6
6
|
import { copyBunRequestFields, type WebProxyRunner } from "../proxy";
|
|
7
7
|
import { SignalResolver } from "../resolver";
|
|
8
8
|
import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types";
|
|
9
|
+
import { AppWsData } from "./appWsData";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Minimal render-state view the HMR WS hello message needs.
|
|
@@ -30,7 +31,7 @@ export interface ApiRouteInputs {
|
|
|
30
31
|
routeOptions?: Record<string, SignalRouteOptions>;
|
|
31
32
|
renderEnvRoutes: HttpRoutes;
|
|
32
33
|
/** Upgrades the incoming request into an app-signal WebSocket. */
|
|
33
|
-
upgradeAppWs: (req: Request, data:
|
|
34
|
+
upgradeAppWs: (req: Request, data: AppWsData) => boolean;
|
|
34
35
|
webProxyRunner?: WebProxyRunner | null;
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -85,7 +86,7 @@ export class ApiRouter {
|
|
|
85
86
|
const endpointPaths = new Set([...endpointEntries.map(([path]) => path), ...builtinEntries.map(([path]) => path)]);
|
|
86
87
|
const routeTable = {
|
|
87
88
|
[`${prefix}${websocketPrefix}` as "/api/ws"]: (req) => {
|
|
88
|
-
const upgraded = upgradeAppWs(req,
|
|
89
|
+
const upgraded = upgradeAppWs(req, AppWsData.fromRequest(req));
|
|
89
90
|
if (upgraded) return;
|
|
90
91
|
return new Response("Failed to upgrade to WebSocket", { status: 500 });
|
|
91
92
|
},
|
|
@@ -138,6 +139,13 @@ export class ApiRouter {
|
|
|
138
139
|
if (typeof message === "string") {
|
|
139
140
|
const msg = JSON.parse(message) as WebsocketReqData;
|
|
140
141
|
if (!msg.key) throw new Error("Message key is required");
|
|
142
|
+
if (msg.key === websocketAuthContract.key) {
|
|
143
|
+
|
|
144
|
+
AppWsData.applyCredential(AppWsData.of(ws), websocketAuthContract.readJwt(msg.data));
|
|
145
|
+
const revokedRooms = await SignalResolver.revalidateWsRooms(ws, registry);
|
|
146
|
+
ws.send(JSON.stringify(websocketAuthContract.makeAck(revokedRooms)));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
141
149
|
const wsRoute = wsRoutes[msg.key];
|
|
142
150
|
if (!wsRoute) throw new Error(`WebSocket route "${msg.key}" is not registered`);
|
|
143
151
|
const eventType =
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "user-agent"] as const;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Credential snapshot taken at the websocket handshake and carried on `ws.data` for the life of the
|
|
5
|
+
* socket, so auth middleware and guards can read the caller the same way they read an HTTP request.
|
|
6
|
+
* Only the credential headers are copied — retaining the whole `Request` would pin it for as long
|
|
7
|
+
* as the socket stays open.
|
|
8
|
+
*/
|
|
9
|
+
export class AppWsData {
|
|
10
|
+
static fromRequest(req: Request): AppWsData {
|
|
11
|
+
const headers = new Headers();
|
|
12
|
+
for (const key of CREDENTIAL_HEADERS) {
|
|
13
|
+
const value = req.headers.get(key);
|
|
14
|
+
if (value) headers.set(key, value);
|
|
15
|
+
}
|
|
16
|
+
return new AppWsData(headers);
|
|
17
|
+
}
|
|
18
|
+
static of(ws: Bun.ServerWebSocket<unknown>): AppWsData {
|
|
19
|
+
return ws.data as AppWsData;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Swaps the credential the socket authenticates with. Callers must run this synchronously on the
|
|
23
|
+
* auth frame: frames arrive in order, so a subscribe sent right after the credential must not be
|
|
24
|
+
* able to observe the previous one.
|
|
25
|
+
*/
|
|
26
|
+
static applyCredential(data: AppWsData, jwt: string | null) {
|
|
27
|
+
if (jwt) data.headers.set("authorization", `Bearer ${jwt}`);
|
|
28
|
+
else {
|
|
29
|
+
data.headers.delete("authorization");
|
|
30
|
+
data.cookies.delete("jwt");
|
|
31
|
+
const cookie = [...data.cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
32
|
+
if (cookie) data.headers.set("cookie", cookie);
|
|
33
|
+
else data.headers.delete("cookie");
|
|
34
|
+
}
|
|
35
|
+
data.account = undefined;
|
|
36
|
+
data.resolvedAuthorization = undefined;
|
|
37
|
+
}
|
|
38
|
+
createdAt: number;
|
|
39
|
+
headers: Headers;
|
|
40
|
+
cookies: Bun.CookieMap;
|
|
41
|
+
account?: unknown;
|
|
42
|
+
/** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
|
|
43
|
+
resolvedAuthorization?: string;
|
|
44
|
+
socketId?: string;
|
|
45
|
+
constructor(headers: Headers) {
|
|
46
|
+
this.createdAt = Date.now();
|
|
47
|
+
this.headers = headers;
|
|
48
|
+
this.cookies = new Bun.CookieMap(headers.get("cookie") ?? "");
|
|
49
|
+
}
|
|
50
|
+
}
|
package/server/rscClient.tsx
CHANGED
|
@@ -47,6 +47,7 @@ declare global {
|
|
|
47
47
|
| undefined;
|
|
48
48
|
var __AKAN_RSC_REFRESH__: ((options?: { buildId?: number }) => Promise<void>) | undefined;
|
|
49
49
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
50
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
50
51
|
var __AKAN_DEV_SYNC_NAVIGATION__: ((href: string, kind: "push" | "replace" | "back" | "pop") => void) | undefined;
|
|
51
52
|
var __AKAN_DEV_SYNC_NAVIGATION_APPLYING__: boolean | undefined;
|
|
52
53
|
var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
|
|
@@ -299,8 +300,11 @@ let currentRouterState: AkanRouterStateV1 | null = initialRouterState;
|
|
|
299
300
|
let currentSegmentTree: RscSegmentCacheNode | null = createAkanSegmentCacheTree(initialNode);
|
|
300
301
|
let currentFullNode: RscCacheNode = initialNode;
|
|
301
302
|
let currentCommitKind: "full" | "patch" = "full";
|
|
303
|
+
let currentCommitFromCache = false;
|
|
302
304
|
let navigationSeq = 0;
|
|
303
305
|
|
|
306
|
+
globalThis.__AKAN_RSC_IS_FROM_CACHE__ = () => currentCommitFromCache;
|
|
307
|
+
|
|
304
308
|
function rememberCommittedRouteState(node: RscCacheNode): void {
|
|
305
309
|
rscPatchCache.clear();
|
|
306
310
|
if (!node.routerState) return;
|
|
@@ -378,6 +382,7 @@ function Root(): ReactNode {
|
|
|
378
382
|
maxEntries: MAX_RSC_CACHE_ENTRIES,
|
|
379
383
|
startTransition,
|
|
380
384
|
commitThenable: (node) => {
|
|
385
|
+
currentCommitFromCache = false;
|
|
381
386
|
resetAkanSegmentOutletPatches();
|
|
382
387
|
setThenable(node.thenable);
|
|
383
388
|
},
|
|
@@ -397,6 +402,7 @@ function Root(): ReactNode {
|
|
|
397
402
|
const scrollToTop = options.scrollToTop ?? true;
|
|
398
403
|
try {
|
|
399
404
|
let nextNode = rscCache.get(target);
|
|
405
|
+
const servedFromCache = !!nextNode;
|
|
400
406
|
if (!nextNode) {
|
|
401
407
|
const cachedPatch = rscPatchCache.get(target);
|
|
402
408
|
if (cachedPatch) {
|
|
@@ -425,6 +431,7 @@ function Root(): ReactNode {
|
|
|
425
431
|
bumpScrollToTop: () => setScrollToTopTick((tick) => tick + 1),
|
|
426
432
|
})
|
|
427
433
|
) {
|
|
434
|
+
currentCommitFromCache = true;
|
|
428
435
|
rememberPatchedRouteState(patchResult.tree, patchResult.patchedNode);
|
|
429
436
|
rememberRscPatchCacheNode(rscPatchCache, cachedPatch, MAX_RSC_CACHE_ENTRIES);
|
|
430
437
|
return;
|
|
@@ -449,6 +456,7 @@ function Root(): ReactNode {
|
|
|
449
456
|
bumpScrollToTop: () => setScrollToTopTick((tick) => tick + 1),
|
|
450
457
|
})
|
|
451
458
|
) {
|
|
459
|
+
currentCommitFromCache = false;
|
|
452
460
|
rememberPatchedRouteState(fetched.tree, fetched.patchedNode);
|
|
453
461
|
const patchCacheNode = createRscPatchNavigationCacheNode({
|
|
454
462
|
href: target,
|
|
@@ -493,6 +501,7 @@ function Root(): ReactNode {
|
|
|
493
501
|
maxEntries: MAX_RSC_CACHE_ENTRIES,
|
|
494
502
|
startTransition,
|
|
495
503
|
commitThenable: (node) => {
|
|
504
|
+
currentCommitFromCache = servedFromCache;
|
|
496
505
|
resetAkanSegmentOutletPatches();
|
|
497
506
|
setThenable(node.thenable);
|
|
498
507
|
},
|
|
@@ -1586,6 +1586,41 @@ export class SqlDocumentStore {
|
|
|
1586
1586
|
throw new Error(`Invalid database identifier: ${refName}`);
|
|
1587
1587
|
}
|
|
1588
1588
|
}
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* The schema setups `getStore` starts but nobody awaits.
|
|
1592
|
+
*
|
|
1593
|
+
* `getStore` returns a store synchronously while `ensure()` goes on creating tables and indexes, so
|
|
1594
|
+
* the connection could be closed out from under one — a server shutting down, or a test tearing its
|
|
1595
|
+
* fixture down. That surfaced as an unhandled `Cannot use a closed database` blamed on whatever ran
|
|
1596
|
+
* next, which made it look like a flaky test rather than a race at shutdown.
|
|
1597
|
+
*
|
|
1598
|
+
* Every statement `ensure()` runs is `IF NOT EXISTS`, so one cut short is simply redone on the next
|
|
1599
|
+
* boot; a failure *before* the close is still a real problem and still surfaces.
|
|
1600
|
+
*/
|
|
1601
|
+
class PendingStoreEnsures {
|
|
1602
|
+
readonly #pending = new Set<Promise<void>>();
|
|
1603
|
+
#closed = false;
|
|
1604
|
+
|
|
1605
|
+
track(ensure: Promise<void>): void {
|
|
1606
|
+
const tracked = ensure
|
|
1607
|
+
.catch((error: unknown) => {
|
|
1608
|
+
if (this.#closed) return;
|
|
1609
|
+
throw error;
|
|
1610
|
+
})
|
|
1611
|
+
.finally(() => {
|
|
1612
|
+
this.#pending.delete(tracked);
|
|
1613
|
+
});
|
|
1614
|
+
this.#pending.add(tracked);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/** Let them finish against a live connection. Call before closing it. */
|
|
1618
|
+
async settle(): Promise<void> {
|
|
1619
|
+
this.#closed = true;
|
|
1620
|
+
await Promise.allSettled([...this.#pending]);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1589
1624
|
export class SqliteDatabase
|
|
1590
1625
|
extends adapt("sqliteDatabase", ({ env }) => ({
|
|
1591
1626
|
config: env((env: SqliteEnv) => {
|
|
@@ -1617,6 +1652,7 @@ export class SqliteDatabase
|
|
|
1617
1652
|
#client!: BunSqliteClient;
|
|
1618
1653
|
#stores = new Map<string, SqlDocumentStore>();
|
|
1619
1654
|
#transaction = new AsyncLocalStorage<TransactionContext>();
|
|
1655
|
+
#ensures = new PendingStoreEnsures();
|
|
1620
1656
|
|
|
1621
1657
|
override async onInit() {
|
|
1622
1658
|
await mkdir(path.dirname(this.config.filePath), { recursive: true });
|
|
@@ -1634,6 +1670,7 @@ export class SqliteDatabase
|
|
|
1634
1670
|
}
|
|
1635
1671
|
|
|
1636
1672
|
override async onDestroy() {
|
|
1673
|
+
await this.#ensures.settle();
|
|
1637
1674
|
this.#db?.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1638
1675
|
await this.#client?.close();
|
|
1639
1676
|
}
|
|
@@ -1647,7 +1684,7 @@ export class SqliteDatabase
|
|
|
1647
1684
|
if (existing) return existing;
|
|
1648
1685
|
const store = new SqlDocumentStore(this, constant, database, schema as DocumentSchema);
|
|
1649
1686
|
this.#stores.set(database.refName, store);
|
|
1650
|
-
|
|
1687
|
+
this.#ensures.track(store.ensure());
|
|
1651
1688
|
return store;
|
|
1652
1689
|
}
|
|
1653
1690
|
|
|
@@ -1724,6 +1761,7 @@ export class LibsqlDatabase
|
|
|
1724
1761
|
#client!: LibsqlAkanClient;
|
|
1725
1762
|
#stores = new Map<string, SqlDocumentStore>();
|
|
1726
1763
|
#transaction = new AsyncLocalStorage<TransactionContext>();
|
|
1764
|
+
#ensures = new PendingStoreEnsures();
|
|
1727
1765
|
|
|
1728
1766
|
override async onInit() {
|
|
1729
1767
|
const url = this.config.url ?? "file:local.db";
|
|
@@ -1736,6 +1774,7 @@ export class LibsqlDatabase
|
|
|
1736
1774
|
}
|
|
1737
1775
|
|
|
1738
1776
|
override async onDestroy() {
|
|
1777
|
+
await this.#ensures.settle();
|
|
1739
1778
|
await this.#client?.close();
|
|
1740
1779
|
}
|
|
1741
1780
|
|
|
@@ -1748,7 +1787,7 @@ export class LibsqlDatabase
|
|
|
1748
1787
|
if (existing) return existing;
|
|
1749
1788
|
const store = new SqlDocumentStore(this, constant, database, schema as DocumentSchema);
|
|
1750
1789
|
this.#stores.set(database.refName, store);
|
|
1751
|
-
|
|
1790
|
+
this.#ensures.track(store.ensure());
|
|
1752
1791
|
return store;
|
|
1753
1792
|
}
|
|
1754
1793
|
|
|
@@ -1808,6 +1847,7 @@ export class PostgresDatabase
|
|
|
1808
1847
|
#client!: PostgresAkanClient;
|
|
1809
1848
|
#stores = new Map<string, SqlDocumentStore>();
|
|
1810
1849
|
#transaction = new AsyncLocalStorage<TransactionContext>();
|
|
1850
|
+
#ensures = new PendingStoreEnsures();
|
|
1811
1851
|
|
|
1812
1852
|
override async onInit() {
|
|
1813
1853
|
const { default: postgres } = await import("postgres");
|
|
@@ -1827,6 +1867,7 @@ export class PostgresDatabase
|
|
|
1827
1867
|
}
|
|
1828
1868
|
|
|
1829
1869
|
override async onDestroy() {
|
|
1870
|
+
await this.#ensures.settle();
|
|
1830
1871
|
await this.#client?.close();
|
|
1831
1872
|
}
|
|
1832
1873
|
|
|
@@ -1839,7 +1880,7 @@ export class PostgresDatabase
|
|
|
1839
1880
|
if (existing) return existing;
|
|
1840
1881
|
const store = new SqlDocumentStore(this, constant, database, schema as DocumentSchema, new PostgresDialect());
|
|
1841
1882
|
this.#stores.set(database.refName, store);
|
|
1842
|
-
|
|
1883
|
+
this.#ensures.track(store.ensure());
|
|
1843
1884
|
return store;
|
|
1844
1885
|
}
|
|
1845
1886
|
|
package/signal/signalContext.ts
CHANGED
|
@@ -125,6 +125,38 @@ export class SignalContext<
|
|
|
125
125
|
}),
|
|
126
126
|
);
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Re-checks this context's guards outside of a request, for a websocket room that is already
|
|
130
|
+
* subscribed. Only global middlewares run: they carry the account resolution this depends on,
|
|
131
|
+
* while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
|
|
132
|
+
*/
|
|
133
|
+
async authorize(): Promise<boolean> {
|
|
134
|
+
try {
|
|
135
|
+
await this.#withMiddleware(async () => await this.#checkGuards(), { endpointMiddlewares: false })();
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
#withMiddleware(
|
|
142
|
+
coreExec: () => Promise<unknown>,
|
|
143
|
+
{ endpointMiddlewares = true }: { endpointMiddlewares?: boolean } = {},
|
|
144
|
+
): () => Promise<unknown> {
|
|
145
|
+
const middlewares = [
|
|
146
|
+
...this.#middleware.values(),
|
|
147
|
+
...(endpointMiddlewares ? (this.endpointInfo.signalOption.middlewares ?? []) : []),
|
|
148
|
+
];
|
|
149
|
+
if (middlewares.length === 0) return coreExec;
|
|
150
|
+
let next = coreExec;
|
|
151
|
+
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
152
|
+
const MiddlewareCls = middlewares[i];
|
|
153
|
+
if (!MiddlewareCls) continue;
|
|
154
|
+
const middleware = new MiddlewareCls();
|
|
155
|
+
const currentNext = next;
|
|
156
|
+
next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
|
|
157
|
+
}
|
|
158
|
+
return next;
|
|
159
|
+
}
|
|
128
160
|
async exec() {
|
|
129
161
|
if (!this.trace) return await this.#exec();
|
|
130
162
|
return await runWithTrace(this.trace, async () => {
|
|
@@ -137,7 +169,6 @@ export class SignalContext<
|
|
|
137
169
|
}
|
|
138
170
|
async #exec() {
|
|
139
171
|
if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
|
|
140
|
-
const endpointMiddlewares = this.endpointInfo.signalOption.middlewares ?? [];
|
|
141
172
|
const coreExec = async () => {
|
|
142
173
|
if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
|
|
143
174
|
if (this.trace) await traceSpan("guards", () => this.#checkGuards());
|
|
@@ -158,17 +189,7 @@ export class SignalContext<
|
|
|
158
189
|
async () => await this.endpointInfo.execFn?.call(this.adaptor, ...this.args, ...this.internalArgs),
|
|
159
190
|
);
|
|
160
191
|
};
|
|
161
|
-
|
|
162
|
-
if (this.#middleware.size > 0 || endpointMiddlewares.length > 0) {
|
|
163
|
-
const middlewares = [...this.#middleware.values(), ...endpointMiddlewares];
|
|
164
|
-
for (let i = middlewares.length - 1; i >= 0; i--) {
|
|
165
|
-
const MiddlewareCls = middlewares[i];
|
|
166
|
-
if (!MiddlewareCls) continue;
|
|
167
|
-
const middleware = new MiddlewareCls();
|
|
168
|
-
const currentNext = next;
|
|
169
|
-
next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
192
|
+
const next = this.#withMiddleware(coreExec);
|
|
172
193
|
const result = this.trace ? await traceSpan("execChain", () => next()) : await next();
|
|
173
194
|
if (this.endpointInfo.type === "pubsub") return;
|
|
174
195
|
if (result instanceof Response) return result;
|
|
@@ -340,6 +361,10 @@ export class SignalContext<
|
|
|
340
361
|
if (this.transport !== "websocket") throw new Error("Transport is not websocket");
|
|
341
362
|
return this.ctx as WebSocketExecutionContext<Appended>;
|
|
342
363
|
}
|
|
364
|
+
get<T = unknown>(key: string): T | null {
|
|
365
|
+
if (this.transport === "http") return this.getHttpContext<{ [key: string]: T }>().req[key] ?? null;
|
|
366
|
+
return this.getWebSocketContext<{ [key: string]: T }>().ws.data[key] ?? null;
|
|
367
|
+
}
|
|
343
368
|
getRoomId(key: string) {
|
|
344
369
|
if (this.transport !== "websocket") throw new Error("Transport is not websocket");
|
|
345
370
|
else if (this.endpointInfo.type !== "pubsub") throw new Error("Endpoint is not pubsub");
|
package/signal/types.ts
CHANGED
|
@@ -140,4 +140,5 @@ export type WebsocketReqData = { key: string; data: unknown[]; subscribe?: boole
|
|
|
140
140
|
export type WebsocketMessageData = { type: "msg"; key: string; data: object | object[] };
|
|
141
141
|
export type WebsocketSubscribeAck = { type: "sub"; roomId: string; subscribe: boolean };
|
|
142
142
|
export type WebsocketPublishData = { type: "pub"; roomId: string; data: object | object[] };
|
|
143
|
-
export type
|
|
143
|
+
export type WebsocketAuthAck = { type: "auth"; revokedRooms: string[] };
|
|
144
|
+
export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData | WebsocketAuthAck;
|
|
@@ -43,6 +43,13 @@ export interface PageConfig {
|
|
|
43
43
|
rscPatchHeadSafe?: boolean;
|
|
44
44
|
topSafeAreaColor?: string;
|
|
45
45
|
bottomSafeAreaColor?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Keeps the route out of `akan build`. The route still serves under `akan start`, but nothing about it
|
|
48
|
+
* reaches production: no bundle, no manifest entry, no URL. On a `_layout`, every route under that
|
|
49
|
+
* directory is excluded with it. Must be written as a literal `true`/`false` — the build reads it from
|
|
50
|
+
* the source without evaluating the module.
|
|
51
|
+
*/
|
|
52
|
+
devOnly?: boolean;
|
|
46
53
|
}
|
|
47
54
|
export interface CsrState {
|
|
48
55
|
transition: TransitionType;
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
declare global {
|
|
2
2
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
3
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
3
4
|
var __AKAN_RSC_NAVIGATE__: ((href: string, options?: {
|
|
4
5
|
replace?: boolean;
|
|
5
6
|
scrollToTop?: boolean;
|
|
6
7
|
}) => Promise<void>) | undefined;
|
|
7
8
|
}
|
|
8
9
|
export declare const clearRscNavigationCache: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* True when the page tree currently on screen was replayed from the RSC navigation cache instead of
|
|
12
|
+
* fetched from the server. Data hydrated out of such a payload can be arbitrarily old, so anything
|
|
13
|
+
* that must show current values should refetch.
|
|
14
|
+
*/
|
|
15
|
+
export declare const isRscNavigationFromCache: () => boolean;
|
|
9
16
|
export declare const navigateRsc: (href: string, options?: {
|
|
10
17
|
replace?: boolean;
|
|
11
18
|
scrollToTop?: boolean;
|
|
12
19
|
}) => Promise<void> | undefined;
|
|
13
20
|
export declare const useRscNavigation: () => {
|
|
14
21
|
clearCache: () => void;
|
|
22
|
+
isFromCache: () => boolean;
|
|
15
23
|
navigate: (href: string, options?: {
|
|
16
24
|
replace?: boolean;
|
|
17
25
|
scrollToTop?: boolean;
|
package/types/common/index.d.ts
CHANGED
|
@@ -27,3 +27,4 @@ export { sleep } from "./sleep.d.ts";
|
|
|
27
27
|
export { splitVersion } from "./splitVersion.d.ts";
|
|
28
28
|
export { getBasePathFromPathname, parseBasePaths } from "./subRoute.d.ts";
|
|
29
29
|
export type * from "./types.d.ts";
|
|
30
|
+
export { type WebsocketAuthAckData, type WebsocketAuthRequest, websocketAuthContract, } from "./websocketAuth.d.ts";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface WebsocketAuthRequest {
|
|
2
|
+
key: string;
|
|
3
|
+
data: [string | null];
|
|
4
|
+
}
|
|
5
|
+
export interface WebsocketAuthAckData {
|
|
6
|
+
type: "auth";
|
|
7
|
+
revokedRooms: string[];
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Framework-owned websocket auth contract shared by the client and the server dispatcher.
|
|
11
|
+
* The credential frame carries the raw bearer token; verifying it stays in userland middleware,
|
|
12
|
+
* so the server only swaps the credential snapshot held on the socket.
|
|
13
|
+
*/
|
|
14
|
+
export declare const websocketAuthContract: {
|
|
15
|
+
readonly key: "__auth";
|
|
16
|
+
readonly makeRequest: (jwt: string | null) => WebsocketAuthRequest;
|
|
17
|
+
readonly makeAck: (revokedRooms: string[]) => WebsocketAuthAckData;
|
|
18
|
+
readonly readJwt: (data: unknown) => string | null;
|
|
19
|
+
};
|
|
@@ -15,6 +15,12 @@ export declare class WsClient {
|
|
|
15
15
|
connected: boolean;
|
|
16
16
|
constructor(url: string, ErrorCls?: ErrorConstructor | undefined);
|
|
17
17
|
setErrorConstructor(ErrorCls?: ErrorConstructor): void;
|
|
18
|
+
/**
|
|
19
|
+
* The handshake only carries a same-origin cookie, so clients that hold the token in memory
|
|
20
|
+
* (native, cross-origin) authenticate with this frame instead. Signing out sends `null`, which
|
|
21
|
+
* drops the handshake cookie server-side and revokes the rooms it had authorized.
|
|
22
|
+
*/
|
|
23
|
+
setJwt(jwt: string | null): void;
|
|
18
24
|
connect(): void;
|
|
19
25
|
destroy(): void;
|
|
20
26
|
on<Data = unknown>(key: string, callback: (data: Data) => void): this;
|
package/types/index.d.ts
CHANGED
|
@@ -161,6 +161,12 @@ export interface AppConfigResult {
|
|
|
161
161
|
docker: DockerConfig;
|
|
162
162
|
defaultDatabaseMode: DatabaseMode;
|
|
163
163
|
routes?: AkanRouteConfig[];
|
|
164
|
+
/**
|
|
165
|
+
* Mounts `libs/<lib>/page` into this app under `page/(libs)/(<lib>)` on sync. `true` takes every lib
|
|
166
|
+
* dependency that ships a `page` folder, an array takes exactly the libs listed, `false` (the default)
|
|
167
|
+
* syncs nothing and removes what a previous sync created.
|
|
168
|
+
*/
|
|
169
|
+
syncPageLibs?: string[] | boolean;
|
|
164
170
|
externalLibs: string[];
|
|
165
171
|
barrelImports: string[];
|
|
166
172
|
optimizeImports: string[];
|
|
@@ -30,6 +30,12 @@ export declare class SignalResolver {
|
|
|
30
30
|
live: LiveRegistry;
|
|
31
31
|
middleware: Map<string, MiddlewareCls>;
|
|
32
32
|
}): SignalRoutes;
|
|
33
|
+
/**
|
|
34
|
+
* Re-checks the guards of every room this socket is subscribed to and drops the ones that no
|
|
35
|
+
* longer pass. Called when the socket's credential changes: a pubsub room is authorized once at
|
|
36
|
+
* subscribe time, so without this a signed-out socket would keep receiving its old rooms.
|
|
37
|
+
*/
|
|
38
|
+
static revalidateWsRooms(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<string[]>;
|
|
33
39
|
static handleWsOpen(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<void>;
|
|
34
40
|
static handleWsClose(ws: Bun.ServerWebSocket<any>, registry: InjectRegistry): Promise<void>;
|
|
35
41
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Logger } from "akanjs/common";
|
|
2
2
|
import type { InjectRegistry } from "akanjs/service";
|
|
3
3
|
import type { HmrWsHub } from "../hmr/wsHub.d.ts";
|
|
4
4
|
import { type WebProxyRunner } from "../proxy.d.ts";
|
|
5
5
|
import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types.d.ts";
|
|
6
|
+
import { AppWsData } from "./appWsData.d.ts";
|
|
6
7
|
/**
|
|
7
8
|
* Minimal render-state view the HMR WS hello message needs.
|
|
8
9
|
* `LazyHmrController` exposes this shape via `state.buildId` / `state.cssAssets`,
|
|
@@ -27,9 +28,7 @@ export interface ApiRouteInputs {
|
|
|
27
28
|
routeOptions?: Record<string, SignalRouteOptions>;
|
|
28
29
|
renderEnvRoutes: HttpRoutes;
|
|
29
30
|
/** Upgrades the incoming request into an app-signal WebSocket. */
|
|
30
|
-
upgradeAppWs: (req: Request, data:
|
|
31
|
-
createdAt: number;
|
|
32
|
-
}) => boolean;
|
|
31
|
+
upgradeAppWs: (req: Request, data: AppWsData) => boolean;
|
|
33
32
|
webProxyRunner?: WebProxyRunner | null;
|
|
34
33
|
}
|
|
35
34
|
export interface WebsocketHandlersInputs {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential snapshot taken at the websocket handshake and carried on `ws.data` for the life of the
|
|
3
|
+
* socket, so auth middleware and guards can read the caller the same way they read an HTTP request.
|
|
4
|
+
* Only the credential headers are copied — retaining the whole `Request` would pin it for as long
|
|
5
|
+
* as the socket stays open.
|
|
6
|
+
*/
|
|
7
|
+
export declare class AppWsData {
|
|
8
|
+
static fromRequest(req: Request): AppWsData;
|
|
9
|
+
static of(ws: Bun.ServerWebSocket<unknown>): AppWsData;
|
|
10
|
+
/**
|
|
11
|
+
* Swaps the credential the socket authenticates with. Callers must run this synchronously on the
|
|
12
|
+
* auth frame: frames arrive in order, so a subscribe sent right after the credential must not be
|
|
13
|
+
* able to observe the previous one.
|
|
14
|
+
*/
|
|
15
|
+
static applyCredential(data: AppWsData, jwt: string | null): void;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
headers: Headers;
|
|
18
|
+
cookies: Bun.CookieMap;
|
|
19
|
+
account?: unknown;
|
|
20
|
+
/** The `authorization` value `account` was resolved from, so each frame need not re-verify it. */
|
|
21
|
+
resolvedAuthorization?: string;
|
|
22
|
+
socketId?: string;
|
|
23
|
+
constructor(headers: Headers);
|
|
24
|
+
}
|
|
@@ -13,6 +13,7 @@ declare global {
|
|
|
13
13
|
buildId?: number;
|
|
14
14
|
}) => Promise<void>) | undefined;
|
|
15
15
|
var __AKAN_RSC_CLEAR_CACHE__: (() => void) | undefined;
|
|
16
|
+
var __AKAN_RSC_IS_FROM_CACHE__: (() => boolean) | undefined;
|
|
16
17
|
var __AKAN_DEV_SYNC_NAVIGATION__: ((href: string, kind: "push" | "replace" | "back" | "pop") => void) | undefined;
|
|
17
18
|
var __AKAN_DEV_SYNC_NAVIGATION_APPLYING__: boolean | undefined;
|
|
18
19
|
var __AKAN_GET_SYNC_ROUTE_HREF__: ((href: string) => string) | undefined;
|
|
@@ -32,6 +32,12 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
|
|
|
32
32
|
getAdaptor<T extends Adaptor>(adaptorCls: AdaptorCls<T>): T;
|
|
33
33
|
getService<T>(refName: string): T;
|
|
34
34
|
init(): Promise<this>;
|
|
35
|
+
/**
|
|
36
|
+
* Re-checks this context's guards outside of a request, for a websocket room that is already
|
|
37
|
+
* subscribed. Only global middlewares run: they carry the account resolution this depends on,
|
|
38
|
+
* while endpoint middlewares (cache/timeout/retry) would observe a call that never executes.
|
|
39
|
+
*/
|
|
40
|
+
authorize(): Promise<boolean>;
|
|
35
41
|
exec(): Promise<Response | undefined>;
|
|
36
42
|
static try(endpoint: Adaptor, endpointInfo: EndpointInfo, key: string, fn: () => Promise<Response | undefined>): Promise<Response | undefined>;
|
|
37
43
|
static resolveReturn(value: unknown, { signalContext, returnRef, arrDepth, registry, live, }: {
|
|
@@ -47,6 +53,7 @@ export declare class SignalContext<Ctx extends HttpExecutionContext | WebSocketE
|
|
|
47
53
|
}): Promise<unknown>;
|
|
48
54
|
getHttpContext<Appended = unknown>(): HttpExecutionContext<Appended>;
|
|
49
55
|
getWebSocketContext<Appended = unknown>(): WebSocketExecutionContext<Appended>;
|
|
56
|
+
get<T = unknown>(key: string): T | null;
|
|
50
57
|
getRoomId(key: string): string;
|
|
51
58
|
getEnv(): Env;
|
|
52
59
|
getArg<T = unknown>(argName: string): T | undefined;
|
package/types/signal/types.d.ts
CHANGED
|
@@ -147,5 +147,9 @@ export type WebsocketPublishData = {
|
|
|
147
147
|
roomId: string;
|
|
148
148
|
data: object | object[];
|
|
149
149
|
};
|
|
150
|
-
export type
|
|
150
|
+
export type WebsocketAuthAck = {
|
|
151
|
+
type: "auth";
|
|
152
|
+
revokedRooms: string[];
|
|
153
|
+
};
|
|
154
|
+
export type WebsocketResData = WebsocketMessageData | WebsocketSubscribeAck | WebsocketPublishData | WebsocketAuthAck;
|
|
151
155
|
export {};
|
package/ui/Model/EditModal.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { clsx, router, usePage } from "akanjs/client";
|
|
2
|
+
import { clsx, isRscNavigationFromCache, router, usePage } from "akanjs/client";
|
|
3
3
|
import { capitalize, deepObjectify, lowerlize } from "akanjs/common";
|
|
4
4
|
import { ConstantRegistry, immerify } from "akanjs/constant";
|
|
5
5
|
import type { ClientEdit, ServerEdit, SliceMeta } from "akanjs/fetch";
|
|
@@ -11,6 +11,8 @@ import { AiOutlinePlus, AiOutlineSave } from "react-icons/ai";
|
|
|
11
11
|
import { Button } from "../Button";
|
|
12
12
|
import { Modal } from "../Modal";
|
|
13
13
|
|
|
14
|
+
const EDIT_PAYLOAD_MAX_AGE_MS = 60_000;
|
|
15
|
+
|
|
14
16
|
interface EditModelProps<Full> {
|
|
15
17
|
/** Rendering mode for the edit shell. */
|
|
16
18
|
type?: "modal" | "form" | "empty";
|
|
@@ -148,6 +150,7 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
148
150
|
setModelModal: `set${ModelName}Modal`,
|
|
149
151
|
modelLoading: `${modelName}Loading`,
|
|
150
152
|
modelViewAt: `${modelName}ViewAt`,
|
|
153
|
+
editModel: `edit${ModelName}`,
|
|
151
154
|
newModel: `new${ModelName}`,
|
|
152
155
|
crystalizeModel: `crystalize${ModelName}`,
|
|
153
156
|
modelObj: `${modelName}Obj`,
|
|
@@ -159,10 +162,19 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
159
162
|
(state: unknown) => (state as { [key: string]: { id: string | null } })[names.modelForm].id,
|
|
160
163
|
);
|
|
161
164
|
const modelFormLoading = storeUse[names.modelFormLoading]() as string | boolean;
|
|
165
|
+
const modalId = id ?? ((modelEdit as any)?.[names.modelObj] as Full | undefined)?.id ?? undefined;
|
|
162
166
|
const isModalOpen =
|
|
163
167
|
modelModal === (modal ?? "edit") &&
|
|
164
|
-
(modelFormLoading === false || modelFormLoading ===
|
|
165
|
-
((!modelFormId && !
|
|
168
|
+
(modelFormLoading === false || modelFormLoading === modalId) &&
|
|
169
|
+
((!modelFormId && !modalId) || modalId === modelFormId);
|
|
170
|
+
const isEditPayloadStale = useCallback((viewAt?: Date | null) => {
|
|
171
|
+
if (isRscNavigationFromCache()) return true;
|
|
172
|
+
return (
|
|
173
|
+
viewAt instanceof Date &&
|
|
174
|
+
!Number.isNaN(viewAt.getTime()) &&
|
|
175
|
+
Date.now() - viewAt.getTime() > EDIT_PAYLOAD_MAX_AGE_MS
|
|
176
|
+
);
|
|
177
|
+
}, []);
|
|
166
178
|
useEffect(() => {
|
|
167
179
|
if (!modelEdit) return;
|
|
168
180
|
const refName = (modelEdit as ServerEdit<string, Full>).refName;
|
|
@@ -170,15 +182,21 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
170
182
|
const cnst = ConstantRegistry.getDatabase(modelName);
|
|
171
183
|
const modelRef = cnst.full;
|
|
172
184
|
if (editType === "edit") {
|
|
173
|
-
const
|
|
185
|
+
const modelObj = (modelEdit as any)[names.modelObj] as Full;
|
|
186
|
+
const viewAt = (modelEdit as any)[names.modelViewAt] as Date;
|
|
187
|
+
const crystal = new modelRef().set(modelObj) as unknown as Full;
|
|
174
188
|
st.set({
|
|
175
189
|
[names.model]: crystal,
|
|
176
190
|
[names.modelLoading]: false,
|
|
177
191
|
[names.modelForm]: immerify(modelRef, crystal),
|
|
178
192
|
[names.modelFormLoading]: false,
|
|
179
193
|
[names.modelModal]: modal ?? "edit",
|
|
180
|
-
[names.modelViewAt]:
|
|
194
|
+
[names.modelViewAt]: viewAt,
|
|
181
195
|
});
|
|
196
|
+
if (isEditPayloadStale(viewAt))
|
|
197
|
+
void storeDo[names.editModel](modelObj.id, { modal }).catch(() => {
|
|
198
|
+
st.set({ [names.modelFormLoading]: false });
|
|
199
|
+
});
|
|
182
200
|
} else {
|
|
183
201
|
|
|
184
202
|
const crystal = new modelRef().set(modelEdit as Full) as unknown as Full;
|
|
@@ -186,7 +204,7 @@ export default function EditModal<Full extends { id: string }>({
|
|
|
186
204
|
}
|
|
187
205
|
return () => {
|
|
188
206
|
};
|
|
189
|
-
}, [modelEdit]);
|
|
207
|
+
}, [modelEdit, isEditPayloadStale]);
|
|
190
208
|
|
|
191
209
|
const handleCancel = useCallback(() => {
|
|
192
210
|
const modelForm = (st.get() as any)[names.modelForm] as Full;
|