@zerotal/flow 1.0.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/package.json +84 -0
  5. package/src/Component.ts +1498 -0
  6. package/src/Form.ts +189 -0
  7. package/src/Layout.ts +47 -0
  8. package/src/augment.ts +94 -0
  9. package/src/client/FlowClientError.ts +12 -0
  10. package/src/client/FlowComponent.ts +96 -0
  11. package/src/client/alpinejs-morph.d.ts +5 -0
  12. package/src/client/bridge.ts +3463 -0
  13. package/src/client/confirm.ts +338 -0
  14. package/src/client/cspAdapter.ts +79 -0
  15. package/src/client/cspEvaluator.ts +494 -0
  16. package/src/client/errorOverlay.ts +124 -0
  17. package/src/client/headless.ts +948 -0
  18. package/src/client/index.csp.ts +47 -0
  19. package/src/client/index.ts +56 -0
  20. package/src/client/optimistic.ts +52 -0
  21. package/src/client/timeline.ts +160 -0
  22. package/src/client/url.ts +51 -0
  23. package/src/commands/MakeFlowCommand.ts +290 -0
  24. package/src/compiler/cache.ts +101 -0
  25. package/src/compiler/directives.ts +107 -0
  26. package/src/compiler/helpers.ts +26 -0
  27. package/src/compiler/index.ts +172 -0
  28. package/src/compiler/transform.ts +2070 -0
  29. package/src/components.ts +1465 -0
  30. package/src/config.ts +52 -0
  31. package/src/decorators.ts +978 -0
  32. package/src/dehydrate.ts +333 -0
  33. package/src/durable.ts +243 -0
  34. package/src/events.ts +118 -0
  35. package/src/frameworkEvents.ts +40 -0
  36. package/src/headless.ts +1147 -0
  37. package/src/index.ts +267 -0
  38. package/src/jsx-dev-runtime.ts +14 -0
  39. package/src/jsx-runtime.ts +1272 -0
  40. package/src/mixins.ts +199 -0
  41. package/src/observability.ts +167 -0
  42. package/src/pagination.ts +300 -0
  43. package/src/presence.ts +81 -0
  44. package/src/provider/FlowProvider.ts +1401 -0
  45. package/src/registry.ts +118 -0
  46. package/src/renderContext.ts +55 -0
  47. package/src/router.ts +401 -0
  48. package/src/shared.ts +226 -0
  49. package/src/store.ts +116 -0
  50. package/src/synths/CarbonIntervalSynth.ts +22 -0
  51. package/src/synths/CarbonSynth.ts +20 -0
  52. package/src/synths/CollectionSynth.ts +62 -0
  53. package/src/synths/DateSynth.ts +24 -0
  54. package/src/synths/ModelSynth.ts +78 -0
  55. package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
  56. package/src/synths/index.ts +148 -0
  57. package/src/testing.ts +361 -0
  58. package/src/types.ts +315 -0
  59. package/src/uploads/FileUploads.ts +94 -0
  60. package/src/uploads/TemporaryUploadedFile.ts +274 -0
  61. package/src/uploads/sign.ts +18 -0
  62. package/src/urlSafety.ts +0 -0
  63. package/src/utils.ts +216 -0
  64. package/src/validation.ts +55 -0
@@ -0,0 +1,1401 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import {
3
+ ServiceProvider,
4
+ type Application,
5
+ Router,
6
+ HttpContext,
7
+ RequestContext,
8
+ Pipeline,
9
+ registerFileRouteResolver,
10
+ FrameworkEvents,
11
+ config,
12
+ } from "@zerotal/core";
13
+ import {
14
+ WebSocketConnected,
15
+ WebSocketDisconnected,
16
+ FlowActionHandled,
17
+ } from "../frameworkEvents.ts";
18
+ import { installFlowObservability } from "../observability.ts";
19
+ import { DEFAULT_PERSISTENT_MIDDLEWARE } from "../config.ts";
20
+ import type { AppEnvironment, Container, MiddlewareClass, NextFn } from "@zerotal/core";
21
+ import type { WebSocketHandlers } from "@zerotal/core";
22
+ import type { CallFrame, Snapshot } from "../types.ts";
23
+ import { getPage, getAllowedMethods, getRenderlessMethods, BUILTIN_ACTIONS } from "../registry.ts";
24
+ import {
25
+ hydrate,
26
+ dehydrate,
27
+ encodeSnapshotDelta,
28
+ FlowIntegrityError,
29
+ FlowSnapshotOwnershipError,
30
+ warnIfLarge,
31
+ type SnapshotDelta,
32
+ } from "../dehydrate.ts";
33
+ import { _renderFlowPage } from "../jsx-runtime.ts";
34
+ import { populatePresence } from "../presence.ts";
35
+ import { populateShared, snapshotSharedValues, commitShared } from "../shared.ts";
36
+ import { persistDurable } from "../durable.ts";
37
+ import type { Component } from "../Component.ts";
38
+ import { flowRoute, registerFlowFileRoute, _persistSessionProps } from "../router.ts";
39
+ import type { PageClassWithMeta } from "../registry.ts";
40
+ import { getTaskMethods } from "../decorators.ts";
41
+ import { ValidationError } from "../validation.ts";
42
+ import { makeSignedRef } from "../uploads/TemporaryUploadedFile.ts";
43
+ import { isAllowedOrigin } from "@zerotal/core/http";
44
+
45
+ // ── In-memory runtime.js bundle (built once at boot) ─────────────────────────
46
+
47
+ /** True when this process is the `serve --dev-worker` (dev fast refresh gates on it). */
48
+ const _isDevWorker = Bun.argv.includes("--dev-worker");
49
+
50
+ // ── Running @task registry (for cancellation) ─────────────────────────────────
51
+ // componentId → the AbortController of its currently-running @task, plus the connection
52
+ // that started it. A `$cancel` frame (sent out-of-band by the client, bypassing the
53
+ // per-component send queue) aborts it; the task observes `this.signal` / `this.cancelled`
54
+ // and stops cooperatively.
55
+ //
56
+ // The owning socket is recorded because `$cancel` runs before component lookup, before
57
+ // hydrate() — the only HMAC check — and before rate limiting, and this map is
58
+ // process-global keyed only by component id. Without the owner check, any connected user
59
+ // could kill any other user's running task given its 32-bit id, which leaks in screenshots
60
+ // and bug reports.
61
+ interface RunningTask {
62
+ controller: AbortController;
63
+ owner: FlowBunWs;
64
+ }
65
+ const _runningTasks = new Map<string, RunningTask>();
66
+
67
+ /** How often (ms) a running @task flushes a throttled streaming patch of its changed state. */
68
+ const TASK_FLUSH_MS = 80;
69
+
70
+ /** Dev-only error detail carried on the error frame so the client can render the error overlay. */
71
+ interface DevErrorInfo {
72
+ name: string;
73
+ message: string;
74
+ stack?: string | undefined;
75
+ action: string;
76
+ }
77
+
78
+ /** Extract overlay-ready detail from a thrown value. @internal (exported for tests). */
79
+ export function _devErrorInfo(error: unknown, action: string): DevErrorInfo {
80
+ const e = error instanceof Error ? error : new Error(String(error));
81
+ return { name: e.name || "Error", message: e.message, stack: e.stack, action };
82
+ }
83
+
84
+ let _runtimeBundle: string | null = null;
85
+
86
+ /**
87
+ * CSP-safe mode flag (no 'unsafe-eval'). Set `flow.cspSafe` in `config/flow.ts`,
88
+ * or the ZT_FLOW_CSP_SAFE / APP_CSP_SAFE env flag when there is no config file.
89
+ */
90
+ function _isCspSafe(): boolean {
91
+ const fromEnv = Bun.env["ZT_FLOW_CSP_SAFE"] ?? Bun.env["APP_CSP_SAFE"];
92
+ return config.safe("flow.cspSafe", fromEnv === "true" || fromEnv === "1");
93
+ }
94
+
95
+ /** @internal — read by router.ts to build <script> tags. */
96
+ export function _runtimeJs(): string | null {
97
+ return _runtimeBundle;
98
+ }
99
+
100
+ // ── Rate limiter for HMAC failures (Livewire pattern) ─────────────────────────
101
+
102
+ const _checksumFailures = new Map<string, { count: number; since: number }>();
103
+ const CHECKSUM_MAX = 10;
104
+ const CHECKSUM_WINDOW_MS = 600_000; // 10 minutes
105
+
106
+ // Periodic sweep so the map doesn't grow unbounded under IP rotation.
107
+ setInterval(() => {
108
+ const now = Date.now();
109
+ for (const [ip, entry] of _checksumFailures) {
110
+ if (now - entry.since > CHECKSUM_WINDOW_MS) _checksumFailures.delete(ip);
111
+ }
112
+ }, CHECKSUM_WINDOW_MS).unref?.();
113
+
114
+ /** Returns false when this IP has exceeded the failure threshold. */
115
+ function _isChecksumRateLimited(ip: string): boolean {
116
+ const entry = _checksumFailures.get(ip);
117
+ if (!entry) return false;
118
+ if (Date.now() - entry.since > CHECKSUM_WINDOW_MS) {
119
+ _checksumFailures.delete(ip);
120
+ return false;
121
+ }
122
+ return entry.count >= CHECKSUM_MAX;
123
+ }
124
+
125
+ /** Called only when a checksum actually fails — not on every request. */
126
+ function _recordChecksumFailure(ip: string): void {
127
+ const now = Date.now();
128
+ const entry = _checksumFailures.get(ip);
129
+ if (!entry || now - entry.since > CHECKSUM_WINDOW_MS) {
130
+ _checksumFailures.set(ip, { count: 1, since: now });
131
+ } else {
132
+ entry.count++;
133
+ }
134
+ }
135
+
136
+ // ── Session relay store ───────────────────────────────────────────────────────
137
+ // When a Flow action mutates the session (login/logout) and then redirects,
138
+ // the session cookie cannot be set directly over WebSocket. Instead we store
139
+ // the serialised Set-Cookie header under a short-lived one-time token and send
140
+ // the token to the client. The client fetches /__flow/session-relay?t=<token>
141
+ // (a regular HTTP request), the browser receives the Set-Cookie header and
142
+ // stores the cookie, then the redirect to the intended page proceeds.
143
+ //
144
+ // Tokens expire after 30 s; a sweep runs every 60 s to prune stale entries.
145
+
146
+ interface _RelayEntry {
147
+ setCookie: string;
148
+ expiresAt: number;
149
+ }
150
+ const _sessionRelayStore = new Map<string, _RelayEntry>();
151
+ setInterval(() => {
152
+ const now = Date.now();
153
+ for (const [k, v] of _sessionRelayStore) if (v.expiresAt < now) _sessionRelayStore.delete(k);
154
+ }, 60_000).unref?.();
155
+
156
+ // ── Temporary upload garbage collection ───────────────────────────────────────
157
+ // Files uploaded to /__flow/upload land on the temp disk under flow-tmp/. We track
158
+ // each one and delete it after a TTL so abandoned uploads don't accumulate. (Promoting a
159
+ // file to permanent storage via TemporaryUploadedFile.store() removes the temp copy itself.)
160
+
161
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; // 25 MB ceiling (apps can validate stricter)
162
+ const TEMP_UPLOAD_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
163
+ const _tempUploads = new Map<string, number>(); // tmpPath → expiresAt
164
+ setInterval(() => {
165
+ const now = Date.now();
166
+ for (const [path, exp] of _tempUploads) {
167
+ if (exp >= now) continue;
168
+ _tempUploads.delete(path);
169
+ void (async () => {
170
+ try {
171
+ const { Storage } = (await import("@zerotal/core/storage")) as {
172
+ Storage: { disk(): { delete(p: string): Promise<void> } };
173
+ };
174
+ await Storage.disk().delete(path);
175
+ } catch {
176
+ /* best-effort sweep */
177
+ }
178
+ })();
179
+ }
180
+ }, 30 * 60_000).unref?.();
181
+
182
+ // ── WS message handler ────────────────────────────────────────────────────────
183
+
184
+ type FlowBunWs = {
185
+ data: {
186
+ flowUrl?: string;
187
+ remoteIp?: string;
188
+ /** Origin of the upgrade request — used to build the synthetic request URL. */
189
+ origin?: string;
190
+ /** Headers captured at WS upgrade (cookies, authorization, …) — replayed on every frame. */
191
+ headers?: Headers;
192
+ /** Per-connection id assigned on open — keys the connected-clients registry. */
193
+ connId?: string;
194
+ /**
195
+ * HTML suppression cache: componentId → Bun.hash of the last `html` sent on this
196
+ * connection. When the next render hashes the same, `html` is omitted from the patch
197
+ * (the client keeps its DOM). Per-connection, so it is GC'd on disconnect, and WS
198
+ * stickiness guarantees a component's actions all reach this same map.
199
+ */
200
+ flowHtmlHashes?: Map<string, string>;
201
+ };
202
+ send(msg: string): void;
203
+ };
204
+
205
+ /** Dependencies handed to the WS message handler by FlowProvider.onBooting(). */
206
+ type FlowWsDeps = {
207
+ container: Container;
208
+ globalMiddleware: () => MiddlewareClass[];
209
+ };
210
+
211
+ async function _handleFlowMessage(ws: FlowBunWs, raw: string, deps: FlowWsDeps): Promise<void> {
212
+ let frame: CallFrame;
213
+ try {
214
+ frame = JSON.parse(raw) as CallFrame;
215
+ } catch {
216
+ return;
217
+ }
218
+
219
+ if (frame.type !== "call") return;
220
+
221
+ // `$cancel` is handled out-of-band, before hydrate/dispatch: the client sends it while a
222
+ // @task is mid-flight (the per-component queue is still blocked on that task), so it can't
223
+ // go through the normal action path. Abort the running task's controller and return; the
224
+ // task observes the abort and finishes, sending its own final patch.
225
+ if (frame.method === "$cancel") {
226
+ const running = _runningTasks.get(frame.component);
227
+ // Only the connection that started the task may cancel it. Component ids are short and
228
+ // routinely visible, so ownership has to come from the socket, not from the frame.
229
+ if (running && running.owner === ws) running.controller.abort();
230
+ return;
231
+ }
232
+
233
+ const ip = ws.data.remoteIp ?? "unknown";
234
+ const entry = getPage(frame.snapshot.memo.name);
235
+
236
+ if (!entry) {
237
+ ws.send(
238
+ JSON.stringify({ type: "error", component: frame.component, message: "Unknown component" }),
239
+ );
240
+ return;
241
+ }
242
+
243
+ if (_isChecksumRateLimited(ip)) {
244
+ ws.send(
245
+ JSON.stringify({
246
+ type: "error",
247
+ component: frame.component,
248
+ message: "Too many invalid requests",
249
+ }),
250
+ );
251
+ return;
252
+ }
253
+
254
+ // The snapshot is hydrated inside the pipeline below rather than here.
255
+ //
256
+ // Hydration asserts that the snapshot belongs to whoever is asking, and that
257
+ // check reads the authenticated user off the request context — which only the
258
+ // Session and Auth middleware can put there, and they have not run yet. Doing
259
+ // it here compares a snapshot issued to a signed-in user against a context
260
+ // with no user at all, so every action on an authenticated page is rejected
261
+ // as somebody else's.
262
+
263
+ // ── Persistent middleware (Livewire-style) ──────────────────────────────────
264
+ const pagePath = frame.snapshot.memo.path;
265
+ const middleware = [
266
+ ...deps.globalMiddleware().filter(_isPersistentMiddleware),
267
+ ...Router.middlewareFor("GET", pagePath),
268
+ ];
269
+
270
+ const request = new Request(`${ws.data.origin ?? "http://localhost"}${pagePath}`, {
271
+ headers: ws.data.headers ?? new Headers(),
272
+ });
273
+
274
+ await deps.container.runScoped(async (scoped) => {
275
+ const ctx = new HttpContext(request, scoped);
276
+
277
+ await RequestContext.run(ctx, async () => {
278
+ let passed = false;
279
+ let redirectUrl: string | undefined;
280
+
281
+ const DispatchTerminal = class {
282
+ async handle(c: HttpContext, next: NextFn): Promise<Response | void> {
283
+ passed = true;
284
+
285
+ // Auth has run by now, so the ownership check has a subject to compare
286
+ // against.
287
+ let page: Component;
288
+ try {
289
+ page = await hydrate(frame.snapshot, entry.PageClass as unknown as new () => Component);
290
+ } catch (e) {
291
+ if (e instanceof FlowIntegrityError) {
292
+ _recordChecksumFailure(ip); // only count actual tampered/invalid snapshots
293
+ ws.send(
294
+ JSON.stringify({
295
+ type: "error",
296
+ component: frame.component,
297
+ message: "Invalid snapshot",
298
+ }),
299
+ );
300
+ if (!c.response) c.response = new Response(null, { status: 400 });
301
+ return next();
302
+ }
303
+ if (e instanceof FlowSnapshotOwnershipError) {
304
+ // Not tampering — the session changed under a page that is still
305
+ // open, which is what signing out in another tab looks like. The
306
+ // page's state belongs to who you were, so reloading is the only
307
+ // honest recovery, and the client is told to do exactly that.
308
+ ws.send(
309
+ JSON.stringify({
310
+ type: "error",
311
+ component: frame.component,
312
+ message: "Your session changed — reload the page.",
313
+ reload: true,
314
+ }),
315
+ );
316
+ if (!c.response) c.response = new Response(null, { status: 403 });
317
+ return next();
318
+ }
319
+ throw e;
320
+ }
321
+
322
+ redirectUrl = await _dispatchFrame(ws, frame, page, entry, c);
323
+ if (!c.response) c.response = new Response(null, { status: 204 });
324
+ return next();
325
+ }
326
+ };
327
+
328
+ const _startedAt = performance.now();
329
+ try {
330
+ const pipeline = Pipeline.send<HttpContext>(ctx).through([
331
+ ...middleware,
332
+ DispatchTerminal as unknown as MiddlewareClass,
333
+ ]);
334
+ // The *container* resolves pipes; the scoped resolver is already on the
335
+ // HttpContext above and serves per-request bindings. They are not
336
+ // interchangeable — a ScopedResolver has no `bound()`/`makeSync()`, so
337
+ // passing it here breaks the moment a page's route middleware needs
338
+ // resolving rather than plain construction.
339
+ pipeline.via(deps.container);
340
+ await pipeline.thenReturn();
341
+ } catch (e) {
342
+ _touchConnection(ws, ctx);
343
+ FrameworkEvents.emit(
344
+ new FlowActionHandled(
345
+ frame.component,
346
+ frame.method,
347
+ performance.now() - _startedAt,
348
+ false,
349
+ ip,
350
+ ctx,
351
+ ),
352
+ );
353
+ ws.send(
354
+ JSON.stringify({
355
+ type: "error",
356
+ component: frame.snapshot.memo.id,
357
+ message: e instanceof Error ? e.message : "Request blocked by middleware",
358
+ }),
359
+ );
360
+ return;
361
+ }
362
+
363
+ // Action executed — announce it with the request-scoped context (user/ip/queries).
364
+ if (passed) {
365
+ _touchConnection(ws, ctx);
366
+ FrameworkEvents.emit(
367
+ new FlowActionHandled(
368
+ frame.component,
369
+ frame.method,
370
+ performance.now() - _startedAt,
371
+ true,
372
+ ip,
373
+ ctx,
374
+ ),
375
+ );
376
+ }
377
+
378
+ if (!passed) {
379
+ const location = ctx.response?.headers.get("Location");
380
+ if (location) {
381
+ ws.send(JSON.stringify({ type: "redirect", url: location }));
382
+ } else {
383
+ ws.send(
384
+ JSON.stringify({
385
+ type: "error",
386
+ component: frame.snapshot.memo.id,
387
+ message: `Request blocked by middleware (${ctx.response?.status ?? 403})`,
388
+ }),
389
+ );
390
+ }
391
+ return;
392
+ }
393
+
394
+ // Run the response finalizers the HTTP path runs. SessionMiddleware saves
395
+ // the session from one of these, so without this a WS action that mutates
396
+ // the session — signing in, above all — writes no `Set-Cookie` and the
397
+ // change is silently lost the moment the browser navigates.
398
+ if (ctx.response) {
399
+ for (const finalize of ctx._responseFinalizers) {
400
+ try {
401
+ await finalize(ctx.response);
402
+ } catch (error) {
403
+ console.error("[Zerotal] response finalizer failed:", error);
404
+ }
405
+ }
406
+ }
407
+
408
+ const setCookieHeader = ctx.response?.headers.get("Set-Cookie");
409
+
410
+ if (redirectUrl) {
411
+ if (setCookieHeader) {
412
+ const token = crypto.randomUUID();
413
+ _sessionRelayStore.set(token, {
414
+ setCookie: setCookieHeader,
415
+ expiresAt: Date.now() + 30_000,
416
+ });
417
+ ws.send(JSON.stringify({ type: "redirect", url: redirectUrl, sessionToken: token }));
418
+ } else {
419
+ ws.send(JSON.stringify({ type: "redirect", url: redirectUrl }));
420
+ }
421
+ return;
422
+ }
423
+
424
+ // Non-redirect action: if session was mutated, relay the updated cookie to
425
+ // the browser (WebSocket frames cannot carry Set-Cookie directly).
426
+ if (setCookieHeader) {
427
+ const token = crypto.randomUUID();
428
+ _sessionRelayStore.set(token, {
429
+ setCookie: setCookieHeader,
430
+ expiresAt: Date.now() + 30_000,
431
+ });
432
+ ws.send(JSON.stringify({ type: "session", token }));
433
+ }
434
+ });
435
+ });
436
+ }
437
+
438
+ // ── Patch send (delta snapshot + HTML suppression) ────────────────────────────
439
+
440
+ interface DispatchEffects {
441
+ scripts: string[];
442
+ errors: Record<string, string[]>;
443
+ title: string | null;
444
+ }
445
+
446
+ /** Effects carried by a mid-`@task` streaming patch — none; flashes/events flush at the end. */
447
+ const _EMPTY_EFFECTS: DispatchEffects = { scripts: [], errors: {}, title: null };
448
+
449
+ /**
450
+ * Send a "patch" frame, delta-encoding the snapshot and suppressing `html` when the
451
+ * re-render is byte-identical to the last one on this connection. `prev` is the client's
452
+ * own snapshot (`frame.snapshot`), so the delta reconstructs to exactly what was signed.
453
+ * `html` is undefined for @renderless patches (snapshot-only).
454
+ */
455
+ function _sendPatch(
456
+ ws: FlowBunWs,
457
+ componentId: string,
458
+ html: string | undefined,
459
+ next: Snapshot,
460
+ prev: Snapshot,
461
+ effects: DispatchEffects,
462
+ actionError = false,
463
+ partial = false,
464
+ ): void {
465
+ const delta = encodeSnapshotDelta(prev, next);
466
+
467
+ let outHtml = html;
468
+ if (html !== undefined) {
469
+ const hashes = (ws.data.flowHtmlHashes ??= new Map<string, string>());
470
+ const h = Bun.hash(html).toString();
471
+ if (hashes.get(componentId) === h) {
472
+ outHtml = undefined; // unchanged since last patch → let the client keep its DOM
473
+ } else {
474
+ hashes.set(componentId, h);
475
+ }
476
+ }
477
+
478
+ ws.send(
479
+ JSON.stringify({
480
+ type: "patch",
481
+ component: componentId,
482
+ ...(outHtml !== undefined ? { html: outHtml } : {}),
483
+ memo: delta.memo,
484
+ checksum: delta.checksum,
485
+ dataDelta: delta.dataDelta,
486
+ dataRemoved: delta.dataRemoved,
487
+ scripts: effects.scripts.length > 0 ? effects.scripts : undefined,
488
+ errors: Object.keys(effects.errors).length > 0 ? effects.errors : undefined,
489
+ title: effects.title ?? undefined,
490
+ ...(actionError ? { actionError: true } : {}),
491
+ ...(partial ? { partial: true } : {}),
492
+ }),
493
+ );
494
+
495
+ _logTransport(next, delta, html, outHtml);
496
+ }
497
+
498
+ /** Opt-in dev instrumentation (`ZT_FLOW_TRANSPORT_LOG=1`): show delta vs full payload sizes. */
499
+ function _logTransport(
500
+ next: Snapshot,
501
+ delta: SnapshotDelta,
502
+ html: string | undefined,
503
+ sentHtml: string | undefined,
504
+ ): void {
505
+ if (!Bun.env["ZT_FLOW_TRANSPORT_LOG"]) return;
506
+ const full = Buffer.byteLength(JSON.stringify(next), "utf8");
507
+ const sent = Buffer.byteLength(
508
+ JSON.stringify({ m: delta.memo, c: delta.checksum, d: delta.dataDelta, r: delta.dataRemoved }),
509
+ "utf8",
510
+ );
511
+ const htmlBytes = html ? Buffer.byteLength(html, "utf8") : 0;
512
+ const htmlNote =
513
+ html === undefined
514
+ ? "no html"
515
+ : sentHtml === undefined
516
+ ? `html suppressed (−${htmlBytes}B)`
517
+ : `html ${htmlBytes}B`;
518
+ const changed = Object.keys(delta.dataDelta);
519
+ console.log(
520
+ `[Flow] patch ${next.memo.name}: snapshot ${sent}B (full ${full}B, ` +
521
+ `${changed.length} field${changed.length === 1 ? "" : "s"}: ${changed.join(",") || "—"}), ${htmlNote}`,
522
+ );
523
+ }
524
+
525
+ // ── Frame dispatch (inside middleware pipeline) ───────────────────────────────
526
+
527
+ /**
528
+ * Execute the action described by `frame`, re-render the page, and push WS
529
+ * messages for flashes, patches, events, downloads, and scripts.
530
+ *
531
+ * @returns The redirect URL if the action called this.redirect(), otherwise undefined.
532
+ */
533
+ async function _dispatchFrame(
534
+ ws: FlowBunWs,
535
+ frame: CallFrame,
536
+ page: Component,
537
+ entry: NonNullable<ReturnType<typeof getPage>>,
538
+ ctx: HttpContext,
539
+ ): Promise<string | undefined> {
540
+ page._isHydrated = true;
541
+ // Use the HMAC-signed id from the snapshot — frame.component is unsigned.
542
+ page._flowId = frame.snapshot.memo.id;
543
+ page._flowPath = frame.snapshot.memo.path;
544
+ page._prevChildIds = frame.snapshot.memo.children ?? [];
545
+ page._childIds = [];
546
+
547
+ page._streamSender = (ref, content, replace) => {
548
+ ws.send(JSON.stringify({ type: "stream", ref, content, replace }));
549
+ };
550
+
551
+ // The route HttpContext handed to lifecycle hooks. On WebSocket round-trips the request URL is
552
+ // the stored route pattern, so model bindings aren't re-resolved here — bound models persist
553
+ // on the component via synths. We still pass the context for consistency with the initial GET
554
+ // (and controllers), so a hook signature like `onMount(ctx)` remains type-safe even when
555
+ // `ctx.params` is empty.
556
+
557
+ // Lifecycle: boot() runs on every request; hydrate() runs only on these
558
+ // subsequent (WebSocket) requests, after state has been restored from the snapshot.
559
+ await page.onBoot(ctx);
560
+ await page.onHydrate();
561
+
562
+ // Refill @shared props from the room store (server-authoritative read-latest) so the
563
+ // action operates on the converged value, and snapshot them to detect what it changes.
564
+ // No-op unless the component has @shared props.
565
+ populateShared(page);
566
+ const sharedBefore = snapshotSharedValues(page);
567
+
568
+ // True when the action (or a rejected client write) routed to onError — drives the
569
+ // client's `showOnError` failed-state directive (optimistic UI). Validation errors are
570
+ // NOT failures in this sense; they populate the error bag instead.
571
+ let actionErrored = false;
572
+
573
+ // Dev-only: the first unexpected error thrown during this dispatch, captured for the client
574
+ // error overlay. Only populated (and sent) under the dev worker — production leaks no stack.
575
+ // A ref object (not a bare `let`) so the capture closure's assignment is visible to the reader.
576
+ const devErrorRef: { current: DevErrorInfo | null } = { current: null };
577
+ const captureDevError = (error: unknown): void => {
578
+ if (!_isDevWorker || devErrorRef.current) return;
579
+ // Skip intended HTTP errors (auth/404/validation carry a numeric status) — the overlay is for
580
+ // unexpected bugs, not for the framework's own control-flow errors.
581
+ if (typeof (error as { status?: unknown } | null)?.status === "number") return;
582
+ devErrorRef.current = _devErrorInfo(error, frame.method);
583
+ };
584
+
585
+ // Apply pending flow:model updates from the client through _applyClientUpdate so
586
+ // the @expose / @locked allowlist is enforced (internal _* fields can't be
587
+ // clobbered) AND the updating()/updated() hooks fire around each write. An
588
+ // onUpdating() hook may throw to reject the update — routed to onError().
589
+ for (const [key, value] of Object.entries(frame.updates ?? {})) {
590
+ try {
591
+ await page._applyClientUpdate(key, value);
592
+ } catch (error) {
593
+ if (!(error instanceof ValidationError)) {
594
+ actionErrored = true;
595
+ captureDevError(error);
596
+ try {
597
+ await page.onError(error instanceof Error ? error : new Error(String(error)));
598
+ } catch {
599
+ /* keep going */
600
+ }
601
+ }
602
+ }
603
+ }
604
+
605
+ // Guard against malformed frames (non-array args → fn.apply would throw).
606
+ if (!Array.isArray(frame.args)) frame.args = [];
607
+
608
+ // If refresh was requested, re-run onMount
609
+ if (page._shouldRefresh) {
610
+ page._shouldRefresh = false;
611
+ await page.onMount(ctx);
612
+ }
613
+
614
+ const method = frame.method;
615
+ let skipRender = false;
616
+ const className = entry.PageClass.name;
617
+ const compId = frame.snapshot.memo.id;
618
+
619
+ // Delta patches chain against the last snapshot the client received. A running @task advances
620
+ // this base as it streams throttled partial patches; for every other action it stays the
621
+ // client's original snapshot, so their behaviour is unchanged.
622
+ let deltaBase: Snapshot = frame.snapshot;
623
+
624
+ if (BUILTIN_ACTIONS.has(method)) {
625
+ if (method === "$set") {
626
+ const [key, value] = frame.args as [string, unknown];
627
+ // A rejected live/blur write (onUpdating throws) must surface the failed state and
628
+ // revert, not crash the dispatch — mirror the deferred-updates loop above.
629
+ try {
630
+ await page._applyClientUpdate(key, value);
631
+ } catch (error) {
632
+ if (!(error instanceof ValidationError)) {
633
+ actionErrored = true;
634
+ captureDevError(error);
635
+ try {
636
+ await page.onError(error instanceof Error ? error : new Error(String(error)));
637
+ } catch {
638
+ /* keep going */
639
+ }
640
+ }
641
+ }
642
+ } else if (method === "$refresh") {
643
+ await page.onMount(ctx);
644
+ } else if (method === "$mount") {
645
+ // Lazy/defer: run onMount() for a component that was initially rendered
646
+ // as a placeholder. The full render happens below.
647
+ await page.onMount(ctx);
648
+ } else if (method === "$rerender") {
649
+ // Dev fast refresh: no-op. hydrate() + onHydrate() already ran above, so the
650
+ // render below reflects the newly-compiled code while preserving snapshot state
651
+ // (onMount is intentionally NOT re-run).
652
+ } else if (method === "$presence") {
653
+ // A presence join/leave occurred — refill the @presence member list(s), then render.
654
+ await populatePresence(page);
655
+ } else if (method === "$shared") {
656
+ // The channel signalled a shared-state change — the read-latest at the top of this
657
+ // dispatch already refilled the @shared prop(s) from the store; just re-render.
658
+ }
659
+ } else {
660
+ const allowed = getAllowedMethods(entry.PageClass as typeof Component);
661
+ if (!allowed.has(method)) {
662
+ ws.send(
663
+ JSON.stringify({
664
+ type: "error",
665
+ component: frame.snapshot.memo.id,
666
+ message: `Method "${method}" is not allowed`,
667
+ }),
668
+ );
669
+ return;
670
+ }
671
+
672
+ const renderlessMethods = getRenderlessMethods(entry.PageClass as typeof Component);
673
+ skipRender = renderlessMethods.has(method);
674
+
675
+ // @task: while the action runs, stream throttled partial patches of its changing state
676
+ // (so `this.answer += token` appears live) and wire an AbortSignal the client trips via
677
+ // this.cancel(). A @renderless task doesn't stream (no re-render) but still gets cancel.
678
+ const isTask = getTaskMethods(entry.PageClass as typeof Component).has(method);
679
+ let taskController: AbortController | null = null;
680
+ let flushTimer: ReturnType<typeof setInterval> | null = null;
681
+ let flushing = false;
682
+ let taskDone = false;
683
+
684
+ const flushPartial = (): void => {
685
+ if (flushing || taskDone || skipRender) return;
686
+ flushing = true;
687
+ try {
688
+ // Field-level streaming: serialize the component's current state WITHOUT re-rendering,
689
+ // and send only the changed snapshot fields (no html). Reactive bindings of those fields
690
+ // — `text={this.x}` / `x-text` / `:attr` — update the DOM instantly off the client store;
691
+ // the final (full) patch re-renders once to reconcile any static template positions. This
692
+ // is why a streamed field should be bound reactively for a live view.
693
+ //
694
+ // No render ran, so preserve the client's child ids in the memo (dehydrate reads them),
695
+ // and restore the dispatch's own child-id bookkeeping afterward.
696
+ const savedChildIds = page._childIds;
697
+ page._childIds = [...page._prevChildIds];
698
+ const snap = dehydrate(page, frame.snapshot.memo);
699
+ page._childIds = savedChildIds;
700
+ // Skip when no serialized field changed since the last patch on this connection.
701
+ if (JSON.stringify(snap.data) === JSON.stringify(deltaBase.data)) return;
702
+ _sendPatch(ws, compId, undefined, snap, deltaBase, _EMPTY_EFFECTS, false, true);
703
+ deltaBase = snap;
704
+ } catch {
705
+ /* a mid-stream serialization error is non-fatal — the final patch reports the outcome */
706
+ } finally {
707
+ flushing = false;
708
+ }
709
+ };
710
+
711
+ if (isTask) {
712
+ taskController = new AbortController();
713
+ _runningTasks.set(compId, { controller: taskController, owner: ws });
714
+ page._taskSignal = taskController.signal;
715
+ flushTimer = setInterval(flushPartial, TASK_FLUSH_MS);
716
+ }
717
+
718
+ try {
719
+ const fn = (page as unknown as Record<string, unknown>)[method] as (
720
+ ...a: unknown[]
721
+ ) => unknown;
722
+ await fn.apply(page, frame.args ?? []);
723
+ } catch (error) {
724
+ if (error instanceof ValidationError) {
725
+ // Validation errors are NOT bugs — store them and continue to re-render.
726
+ // The client will show field-level error messages via $errors.
727
+ // _errors is already set on the page by validate().
728
+ } else {
729
+ actionErrored = true;
730
+ captureDevError(error);
731
+ try {
732
+ await page.onError(error instanceof Error ? error : new Error(String(error)));
733
+ } catch {
734
+ // onError itself threw — still try to send a response
735
+ }
736
+ }
737
+ } finally {
738
+ if (isTask) {
739
+ taskDone = true;
740
+ if (flushTimer) clearInterval(flushTimer);
741
+ _runningTasks.delete(compId);
742
+ page._taskSignal = null;
743
+ }
744
+ }
745
+ }
746
+
747
+ await page.onUpdate();
748
+
749
+ // Write back any @shared prop the action changed and broadcast it to the channel so other
750
+ // subscribers converge (via a $shared re-read). No-op unless a @shared value changed.
751
+ await commitShared(page, sharedBefore);
752
+
753
+ // Persist @session props after every action.
754
+ _persistSessionProps(page, className, ctx);
755
+
756
+ const effects = page._drainEffects();
757
+
758
+ if (effects.redirectUrl) {
759
+ return effects.redirectUrl;
760
+ }
761
+
762
+ // Use the HMAC-signed component id for all outbound frames.
763
+ const canonicalId = compId;
764
+
765
+ // Flush flashes — forward the full payload (title/position/duration/icon/…).
766
+ // `component` lets a toast's action/onClose invoke a @expose method on this
767
+ // component over the bridge.
768
+ for (const f of effects.flashes) {
769
+ ws.send(JSON.stringify({ type: "flash", component: canonicalId, ...f }));
770
+ }
771
+
772
+ // Dispatch cross-component events (to/self targeting mirrors Livewire's dispatch()->to()/->self()).
773
+ for (const event of effects.events) {
774
+ ws.send(
775
+ JSON.stringify({
776
+ type: "event",
777
+ name: event.name,
778
+ data: event.data,
779
+ to: event.to,
780
+ self: event.self ? canonicalId : undefined,
781
+ }),
782
+ );
783
+ }
784
+
785
+ // File downloads
786
+ for (const dl of effects.downloads) {
787
+ ws.send(
788
+ JSON.stringify({
789
+ type: "download",
790
+ filename: dl.filename,
791
+ content: dl.content,
792
+ mime: dl.mime,
793
+ }),
794
+ );
795
+ }
796
+
797
+ // Dev-only: an unexpected error was thrown during this action — send its detail (message +
798
+ // stack + action) so the client renders a full-screen error overlay. Gated to the dev worker
799
+ // in captureDevError, so production sends nothing here (no stack leak). The patch still follows,
800
+ // so the component reconciles and `showOnError` fires as usual.
801
+ if (devErrorRef.current) {
802
+ const de = devErrorRef.current;
803
+ ws.send(
804
+ JSON.stringify({
805
+ type: "error",
806
+ component: canonicalId,
807
+ message: de.message,
808
+ name: de.name,
809
+ stack: de.stack,
810
+ action: de.action,
811
+ }),
812
+ );
813
+ }
814
+
815
+ // @renderless: send snapshot-only patch (no html)
816
+ if (skipRender) {
817
+ page._childIds = [...page._prevChildIds];
818
+ await page.onDehydrate();
819
+ const snapshot = dehydrate(page, frame.snapshot.memo);
820
+ warnIfLarge(snapshot, frame.snapshot.memo.name);
821
+ await persistDurable(page, ctx, snapshot);
822
+ _sendPatch(ws, canonicalId, undefined, snapshot, deltaBase, effects, actionErrored);
823
+ return undefined;
824
+ }
825
+
826
+ // Re-render first (collects nested child ids), then dehydrate.
827
+ const innerHtml = await _renderFlowPage(page, () => page.render());
828
+ await page.onDehydrate();
829
+ const snapshot = dehydrate(page, frame.snapshot.memo);
830
+ warnIfLarge(snapshot, frame.snapshot.memo.name);
831
+ await persistDurable(page, ctx, snapshot);
832
+
833
+ const compName = frame.snapshot.memo.name;
834
+ const html = `<div data-flow-root x-data="{}" data-flow-id="${canonicalId}" data-flow-name="${compName}">${innerHtml}</div>`;
835
+ _sendPatch(ws, canonicalId, html, snapshot, deltaBase, effects, actionErrored);
836
+ return undefined;
837
+ }
838
+
839
+ // ── Persistent middleware registry ────────────────────────────────────────────
840
+
841
+ function _isPersistentMiddleware(mw: MiddlewareClass): boolean {
842
+ for (const entry of FlowProvider.persistentMiddleware) {
843
+ if (typeof entry === "string") {
844
+ let c: unknown = mw;
845
+ while (typeof c === "function") {
846
+ if ((c as { name?: string }).name === entry) return true;
847
+ c = Object.getPrototypeOf(c);
848
+ }
849
+ } else if (mw === entry || Object.prototype.isPrototypeOf.call(entry, mw)) {
850
+ return true;
851
+ }
852
+ }
853
+ return false;
854
+ }
855
+
856
+ // ── FlowProvider ─────────────────────────────────────────────────────────────
857
+
858
+ /**
859
+ * The service provider that wires Flow (reactive SSR over WebSocket) into a Zerotal app.
860
+ *
861
+ * It registers the `Router.flow()` route macro and the file-route resolver (so `flow` page
862
+ * Components become routes), and in web/worker/test envs stands up the runtime: builds and
863
+ * serves the client bundle at `/__flow/runtime.js`, mounts the WebSocket action handler at
864
+ * `/__flow/ws` (plus the `/__flow/http` fallback, `/__flow/upload`, and the session-relay
865
+ * endpoint), and AOT-compiles registered `render()` methods. In `console`/`repl` envs it boots
866
+ * only far enough to enumerate routes and register the `make:flow` generator. Add it to your
867
+ * app's provider list; it is a dependency of the admin and monitor panels.
868
+ *
869
+ * @example
870
+ * ```ts
871
+ * // bootstrap/providers.ts
872
+ * import { FlowProvider } from "@zerotal/flow";
873
+ *
874
+ * const providers = [
875
+ * // …session/auth providers first…
876
+ * FlowProvider,
877
+ * ];
878
+ * export default providers;
879
+ * ```
880
+ *
881
+ * @category Provider
882
+ */
883
+ export class FlowProvider extends ServiceProvider {
884
+ // `console`/`repl` are included so that route-enumerating CLI commands (e.g.
885
+ // `route:list`) boot this provider and its onRegister() runs — that registers
886
+ // the file-route resolver which turns `flow` page Components into routes.
887
+ // Without it those pages never appear in any CLI route listing. The heavy
888
+ // web-server setup in onBooting/onBooted/onStarting (client bundle, WebSocket
889
+ // handlers, AOT compile, dev build hooks) is skipped in those envs via
890
+ // _isWebRuntime() below — CLI commands need only the route registrations.
891
+ static override environments: AppEnvironment[] = ["web", "worker", "test", "console", "repl"];
892
+
893
+ private _disposeObservability: (() => void) | undefined = undefined;
894
+
895
+ override async onStopping(): Promise<void> {
896
+ this._disposeObservability?.();
897
+ this._disposeObservability = undefined;
898
+ }
899
+
900
+ /** True only in the long-lived envs that actually serve HTTP/WS and render pages.
901
+ * `console`/`repl` boot the provider just to enumerate routes, so they skip the
902
+ * web-only lifecycle work below. */
903
+ private _isWebRuntime(): boolean {
904
+ const env = this.app.environment;
905
+ return env !== "console" && env !== "repl";
906
+ }
907
+
908
+ /**
909
+ * Global middleware re-applied on every WebSocket update (Livewire-style
910
+ * persistent middleware). Matched against the app's global pipeline by
911
+ * class reference or class name. Route middleware always re-runs and does
912
+ * not need to be listed here.
913
+ */
914
+ static persistentMiddleware: (string | MiddlewareClass)[] = [...DEFAULT_PERSISTENT_MIDDLEWARE];
915
+
916
+ /**
917
+ * Add global middleware to the persistent list — middleware re-run on every WebSocket
918
+ * action, matched by class reference or class name.
919
+ *
920
+ * @param middleware - Middleware classes or their names to mark persistent.
921
+ * @category Provider
922
+ *
923
+ * @example
924
+ * ```ts
925
+ * // In a ServiceProvider's register/boot:
926
+ * FlowProvider.persistMiddleware(TenantMiddleware, 'LocaleMiddleware');
927
+ * ```
928
+ */
929
+ static persistMiddleware(...middleware: (string | MiddlewareClass)[]): void {
930
+ FlowProvider.persistentMiddleware.push(...middleware);
931
+ }
932
+
933
+ /**
934
+ * Seed the persistent list from `config('flow.persistentMiddleware')` when the app
935
+ * ships a `config/flow.ts`. Anything already added via `persistMiddleware()` is kept
936
+ * on top, so a provider that registered before this one is never dropped.
937
+ */
938
+ private _applyPersistentMiddlewareConfig(): void {
939
+ const configured = config.safe<(string | MiddlewareClass)[]>(
940
+ "flow.persistentMiddleware",
941
+ DEFAULT_PERSISTENT_MIDDLEWARE,
942
+ );
943
+ const added = FlowProvider.persistentMiddleware.filter(
944
+ (mw) => !DEFAULT_PERSISTENT_MIDDLEWARE.includes(mw) && !configured.includes(mw),
945
+ );
946
+ FlowProvider.persistentMiddleware = [...configured, ...added];
947
+ }
948
+
949
+ override onRegister(): void {
950
+ this._applyPersistentMiddlewareConfig();
951
+ Router.macro("flow", flowRoute);
952
+
953
+ registerFileRouteResolver(({ urlPath, module, middleware, name, filePath }) => {
954
+ const PageClass = _findPageExport(module);
955
+ if (!PageClass) return false;
956
+ // Store source file path so the AOT compiler can read it at boot.
957
+ (PageClass as unknown as Record<string, unknown>).__sourceFile = filePath;
958
+ // `urlPath` already includes the active group prefix (scanFileRoutes prepends it), so
959
+ // register at the absolute path — using Router.get here would apply the prefix twice.
960
+ registerFlowFileRoute(urlPath, PageClass, middleware, name);
961
+ return true;
962
+ });
963
+ }
964
+
965
+ override async onBooting(): Promise<void> {
966
+ // CLI envs (console/repl) only need the route registrations from onRegister();
967
+ // skip the HTTP/WS server wiring below.
968
+ if (!this._isWebRuntime()) return;
969
+
970
+ // CSP-safe mode (no 'unsafe-eval'): select the CSP client entry, which swaps
971
+ // Alpine's evaluator for the eval-free interpreter. Toggled by env.
972
+ const cspSafe = _isCspSafe();
973
+
974
+ // Build the client-side runtime bundle
975
+ try {
976
+ const entryPath = cspSafe ? "../client/index.csp.ts" : "../client/index.ts";
977
+ const entry = fileURLToPath(new URL(entryPath, import.meta.url));
978
+ const result = await Bun.build({
979
+ entrypoints: [entry],
980
+ target: "browser",
981
+ minify: Bun.env["APP_ENV"] === "production",
982
+ format: "esm",
983
+ });
984
+
985
+ if (result.success && result.outputs.length > 0) {
986
+ _runtimeBundle = await result.outputs[0]!.text();
987
+ } else {
988
+ console.warn("[Flow] Client bundle build failed:", result.logs);
989
+ }
990
+ } catch (e) {
991
+ console.warn("[Flow] Could not build client bundle:", e);
992
+ }
993
+
994
+ // Serve the client-side runtime bundle
995
+ Router.get(
996
+ "/__flow/runtime.js",
997
+ class FlowRuntimeHandler {
998
+ handle(http: HttpContext): void {
999
+ if (!_runtimeBundle) {
1000
+ http.response = new Response("// Flow runtime unavailable", {
1001
+ status: 503,
1002
+ headers: { "Content-Type": "text/javascript; charset=utf-8" },
1003
+ });
1004
+ return;
1005
+ }
1006
+ http.response = new Response(_runtimeBundle, {
1007
+ status: 200,
1008
+ headers: {
1009
+ "Content-Type": "text/javascript; charset=utf-8",
1010
+ "Cache-Control": "no-store",
1011
+ },
1012
+ });
1013
+ }
1014
+ },
1015
+ "handle",
1016
+ );
1017
+
1018
+ // One-time HTTP endpoint: client fetches this to apply a session cookie
1019
+ // that was mutated by a WS action (WebSocket frames cannot carry Set-Cookie).
1020
+ //
1021
+ // Registered as a RAW route (no middleware pipeline) so that SessionMiddleware
1022
+ // does not run and overwrite the relay's Set-Cookie with a fresh empty session.
1023
+ Router.raw("GET", "/__flow/session-relay", (req: Request): Response => {
1024
+ const token = new URL(req.url).searchParams.get("t") ?? "";
1025
+ const entry = _sessionRelayStore.get(token);
1026
+ _sessionRelayStore.delete(token); // one-time use regardless of validity
1027
+ if (!entry || entry.expiresAt < Date.now()) {
1028
+ return new Response(null, { status: 204 });
1029
+ }
1030
+ return new Response(null, {
1031
+ status: 204,
1032
+ headers: { "Set-Cookie": entry.setCookie },
1033
+ });
1034
+ });
1035
+
1036
+ // Authenticated file-upload endpoint. Bytes can't ride the WebSocket (JSON-only), so the
1037
+ // client POSTs multipart here; we store to the temp disk and return a signed reference the
1038
+ // client then $sets onto the bound component property. Goes through the normal middleware
1039
+ // pipeline (Session/Auth), so ctx.user is populated.
1040
+ Router.post(
1041
+ "/__flow/upload",
1042
+ class FlowUploadHandler {
1043
+ async handle(http: HttpContext): Promise<void> {
1044
+ const user = (http as unknown as { user?: unknown }).user;
1045
+ if (!user) {
1046
+ http.response = Response.json({ error: "Unauthenticated." }, { status: 401 });
1047
+ return;
1048
+ }
1049
+ let form: FormData;
1050
+ try {
1051
+ form = await http.request.formData();
1052
+ } catch {
1053
+ http.response = Response.json({ error: "Invalid upload." }, { status: 400 });
1054
+ return;
1055
+ }
1056
+ const entry = form.get("file");
1057
+ if (!(entry instanceof File) || !entry.name || entry.size === 0) {
1058
+ http.response = Response.json({ error: "No file provided." }, { status: 422 });
1059
+ return;
1060
+ }
1061
+ if (entry.size > MAX_UPLOAD_BYTES) {
1062
+ http.response = Response.json(
1063
+ { error: "File exceeds the size limit." },
1064
+ { status: 422 },
1065
+ );
1066
+ return;
1067
+ }
1068
+ const ext = (entry.name.split(".").pop() ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
1069
+ const tmpPath = `flow-tmp/${crypto.randomUUID()}${ext ? "." + ext : ""}`;
1070
+ const bytes = new Uint8Array(await entry.arrayBuffer());
1071
+ try {
1072
+ const { Storage } = (await import("@zerotal/core/storage")) as {
1073
+ Storage: {
1074
+ disk(): {
1075
+ put(p: string, d: Uint8Array, o?: { contentType?: string }): Promise<void>;
1076
+ };
1077
+ };
1078
+ };
1079
+ await Storage.disk().put(tmpPath, bytes, {
1080
+ contentType: entry.type || "application/octet-stream",
1081
+ });
1082
+ } catch {
1083
+ http.response = Response.json({ error: "Storage is not configured." }, { status: 500 });
1084
+ return;
1085
+ }
1086
+ _tempUploads.set(tmpPath, Date.now() + TEMP_UPLOAD_TTL_MS);
1087
+ http.response = Response.json(
1088
+ makeSignedRef({
1089
+ tmpPath,
1090
+ originalName: entry.name,
1091
+ mime: entry.type || "application/octet-stream",
1092
+ size: entry.size,
1093
+ }),
1094
+ );
1095
+ }
1096
+ },
1097
+ "handle",
1098
+ );
1099
+
1100
+ // Register WebSocket handlers
1101
+ const app = this.app as unknown as Application;
1102
+ const flowDeps: FlowWsDeps = {
1103
+ container: this.app.container,
1104
+ globalMiddleware: () => app.globalMiddleware as unknown as MiddlewareClass[],
1105
+ };
1106
+ (this.app as unknown as Application).withWebSocket(
1107
+ _makeFlowWsHandlers(flowDeps),
1108
+ (req: Request, server?: unknown) => ({
1109
+ flowUrl: new URL(req.url).pathname,
1110
+ // Prefer the actual socket IP (not spoofable); fall back to
1111
+ // x-forwarded-for only when behind a trusted reverse proxy.
1112
+ remoteIp:
1113
+ (
1114
+ server as { requestIP?: (r: Request) => { address: string } | null } | undefined
1115
+ )?.requestIP?.(req)?.address ??
1116
+ req.headers.get("x-forwarded-for") ??
1117
+ "unknown",
1118
+ origin: new URL(req.url).origin,
1119
+ headers: req.headers,
1120
+ }),
1121
+ "/__flow/ws", // path so this coexists with broadcasting's /app/ws (multiplexed by path)
1122
+ );
1123
+
1124
+ // HTTP fallback: the exact same action pipeline over a plain POST, for networks (strict
1125
+ // corporate proxies) that block WebSocket upgrades. The client switches to this after a few
1126
+ // failed WS handshakes. We run `_handleFlowMessage` against a "collecting" fake socket whose
1127
+ // `send` gathers the frames it would push, then return them as a JSON array for the client to
1128
+ // apply. A raw route (no HTTP middleware pipeline) so the action's own persistent-middleware
1129
+ // replay handles auth/session exactly as it does over the socket. Streaming @tasks degrade to a
1130
+ // single batched response.
1131
+ Router.raw("POST", "/__flow/http", async (req: Request): Promise<Response> => {
1132
+ // Raw routes are stored outside the middleware pipeline, so nothing here is covered by
1133
+ // CsrfMiddleware — and a cross-origin fetch with `credentials: "include"` and the default
1134
+ // `text/plain` content type is a CORS-*simple* request, so there is no preflight to stop
1135
+ // it either. Without this check any site the user visits could drive every @expose action
1136
+ // their session permits. Mirrors the guard on the WebSocket upgrade.
1137
+ if (!isAllowedOrigin(req, app._allowedOrigins())) {
1138
+ return new Response("Forbidden origin.", { status: 403 });
1139
+ }
1140
+ const body = await req.text();
1141
+ const frames: unknown[] = [];
1142
+ const fakeWs = {
1143
+ data: {
1144
+ remoteIp: req.headers.get("x-forwarded-for") ?? "unknown",
1145
+ origin: new URL(req.url).origin,
1146
+ headers: req.headers,
1147
+ },
1148
+ send(msg: string): void {
1149
+ try {
1150
+ frames.push(JSON.parse(msg));
1151
+ } catch {
1152
+ /* non-JSON frame — skip */
1153
+ }
1154
+ },
1155
+ } as unknown as FlowBunWs;
1156
+
1157
+ await _handleFlowMessage(fakeWs, body, flowDeps);
1158
+
1159
+ return new Response(JSON.stringify(frames), {
1160
+ status: 200,
1161
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
1162
+ });
1163
+ });
1164
+ }
1165
+
1166
+ /**
1167
+ * AOT-compile and validate registered Component render() methods.
1168
+ *
1169
+ * Must run in onStarting() — after Application.boot() — so that
1170
+ * _loadFileRoutes() has already set __sourceFile on each page class.
1171
+ * Running earlier (onBooting/onBooted) causes all pages to be skipped
1172
+ * because __sourceFile is undefined at that point.
1173
+ */
1174
+ override async onStarting(): Promise<void> {
1175
+ // No page rendering happens in CLI envs, so skip the AOT compile pass.
1176
+ if (!this._isWebRuntime()) return;
1177
+ try {
1178
+ const appRoot = process.cwd();
1179
+ const { compileRegisteredPages } = await import("../compiler/index.ts");
1180
+ await compileRegisteredPages(appRoot, _isCspSafe());
1181
+ } catch (e) {
1182
+ if (e instanceof Error && e.name === "FlowValidationError") throw e;
1183
+ // In CSP-safe mode there is no eval-based runtime fallback, so a compile
1184
+ // failure must surface rather than silently degrade.
1185
+ if (_isCspSafe()) throw e;
1186
+ console.warn("[Flow] AOT compiler error (falling back to runtime):", e);
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * Register dev build hooks so DevOrchestrator rebuilds CSS and JS bundles
1192
+ * when files in `resources/css/` or `resources/js/` change during `serve --dev`.
1193
+ *
1194
+ * Both hooks are no-ops when the corresponding entry point does not exist,
1195
+ * so apps without Tailwind or a JS bundle are unaffected.
1196
+ *
1197
+ * resources/css/app.css → public/css/app.css (Tailwind v4)
1198
+ * resources/js/app.js → public/js/app.js (browser ESM bundle)
1199
+ */
1200
+ override async onBooted(): Promise<void> {
1201
+ // Forward Flow's realtime events to whatever observers are installed.
1202
+ this._disposeObservability = installFlowObservability(this.app);
1203
+
1204
+ // Register CLI generators when running in console mode (the `commands`
1205
+ // runner is only bound then). Done before the web-runtime early-return
1206
+ // below, since that path is exactly the CLI case.
1207
+ const runner = this.app.container.tryMake("commands") as
1208
+ { registerLazy(name: string, thunk: () => Promise<unknown>): void } | undefined;
1209
+ if (runner) {
1210
+ runner.registerLazy("make:flow", () =>
1211
+ import("../commands/MakeFlowCommand.ts").then((m) => m.MakeFlowCommand),
1212
+ );
1213
+ }
1214
+
1215
+ // Dev asset build hooks are a `serve --dev` concern — irrelevant to CLI envs.
1216
+ if (!this._isWebRuntime()) return;
1217
+
1218
+ const cwd = process.cwd();
1219
+ const cssEntry = `${cwd}/resources/css/app.css`;
1220
+ const cssOutdir = `${cwd}/public/css`;
1221
+ const jsEntry = `${cwd}/resources/js/app.js`;
1222
+ const jsOutdir = `${cwd}/public/js`;
1223
+
1224
+ const [cssExists, jsExists] = await Promise.all([
1225
+ Bun.file(cssEntry).exists(),
1226
+ Bun.file(jsEntry).exists(),
1227
+ ]);
1228
+
1229
+ if (!cssExists && !jsExists) return;
1230
+
1231
+ const { registerDevBuildHook, buildCssBundle, buildJsBundle } =
1232
+ await import("@zerotal/core/dev");
1233
+
1234
+ // Combined hook — DevOrchestrator calls this on any frontend file change.
1235
+ // Registered under Flow's own name so it sits alongside, rather than on
1236
+ // top of, the build belonging to another view layer: an Inertia app that
1237
+ // installs @zerotal/monitor gets Flow through `dependsOn`, and both
1238
+ // bundles have to keep rebuilding.
1239
+ registerDevBuildHook("flow", async () => {
1240
+ const results = await Promise.all([
1241
+ cssExists
1242
+ ? buildCssBundle(cssEntry, cssOutdir, false)
1243
+ : Promise.resolve({ success: true, logs: [] as unknown[] }),
1244
+ jsExists
1245
+ ? buildJsBundle(jsEntry, jsOutdir, false)
1246
+ : Promise.resolve({ success: true, logs: [] as unknown[] }),
1247
+ ]);
1248
+ const failed = results.flatMap((r) => (r.success ? [] : (r.logs ?? [])));
1249
+ return { success: failed.length === 0, logs: failed };
1250
+ });
1251
+
1252
+ // Build once at startup so assets are ready before the first request.
1253
+ const [cssResult, jsResult] = await Promise.all([
1254
+ cssExists
1255
+ ? buildCssBundle(cssEntry, cssOutdir, false)
1256
+ : Promise.resolve({ success: true, logs: [] as unknown[] }),
1257
+ jsExists
1258
+ ? buildJsBundle(jsEntry, jsOutdir, false)
1259
+ : Promise.resolve({ success: true, logs: [] as unknown[] }),
1260
+ ]);
1261
+
1262
+ if (!cssResult.success) console.warn("[Flow] CSS build failed at startup:", ...cssResult.logs);
1263
+ if (!jsResult.success) console.warn("[Flow] JS build failed at startup:", ...jsResult.logs);
1264
+ }
1265
+
1266
+ /**
1267
+ * Register a custom synthesizer (serialization handler for a property type) before the
1268
+ * app boots.
1269
+ * @param synth - The synthesizer to register.
1270
+ * @category Provider
1271
+ */
1272
+ static registerSynth(synth: import("../synths/index.ts").Synth): void {
1273
+ import("../synths/index.ts").then((m) => m.registerSynth(synth));
1274
+ }
1275
+ }
1276
+
1277
+ // ── File-route Component detection ─────────────────────────────────────────────────
1278
+
1279
+ function _findPageExport(module: Record<string, unknown>): PageClassWithMeta | null {
1280
+ const candidates = [module["default"], ...Object.values(module)];
1281
+ for (const exp of candidates) {
1282
+ if (typeof exp !== "function") continue;
1283
+ // Use explicit brand set on Component — avoids false positives from third-party
1284
+ // classes that happen to have $set and render methods.
1285
+ if ((exp as { __isFlowPage?: boolean }).__isFlowPage === true) {
1286
+ return exp as unknown as PageClassWithMeta;
1287
+ }
1288
+ }
1289
+ return null;
1290
+ }
1291
+
1292
+ // ── WS handler factory ────────────────────────────────────────────────────────
1293
+
1294
+ // Live count of open Flow WebSocket connections — read by monitoring/ops.
1295
+ let _activeConnections = 0;
1296
+
1297
+ /**
1298
+ * Number of currently-open Flow (reactive SSR) WebSocket connections.
1299
+ * @returns The live count of open `/__flow/ws` connections.
1300
+ * @category Connections
1301
+ */
1302
+ export function flowActiveConnections(): number {
1303
+ return _activeConnections;
1304
+ }
1305
+
1306
+ /**
1307
+ * One currently-open Flow WebSocket client, enriched as actions identify the user.
1308
+ * @category Connections
1309
+ */
1310
+ export interface FlowConnection {
1311
+ id: string;
1312
+ ip: string;
1313
+ /** Authenticated user (email/id), once a re-run-middleware action reveals it. */
1314
+ user: string | null;
1315
+ connectedAt: number;
1316
+ lastActivityAt: number;
1317
+ actions: number;
1318
+ }
1319
+
1320
+ // Per-connection registry, keyed by the id stamped on `ws.data.connId` at open.
1321
+ const _connections = new Map<string, FlowConnection>();
1322
+
1323
+ /**
1324
+ * Snapshot of currently-connected Flow clients (most-recently-active first).
1325
+ * @returns An array of {@link FlowConnection}, sorted by `lastActivityAt` descending.
1326
+ * @category Connections
1327
+ */
1328
+ export function flowConnections(): FlowConnection[] {
1329
+ return [..._connections.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
1330
+ }
1331
+
1332
+ /** Best-effort user identity from a request-scoped HttpContext (set by re-run middleware). */
1333
+ function _connectionUser(ctx: object | undefined): string | null {
1334
+ const u = (ctx as { user?: unknown } | undefined)?.user;
1335
+ if (u == null) return null;
1336
+ if (typeof u === "string" || typeof u === "number") return String(u);
1337
+ if (typeof u === "object") {
1338
+ const o = u as Record<string, unknown>;
1339
+ const v = o.email ?? o.id ?? o.name;
1340
+ return v != null ? String(v) : null;
1341
+ }
1342
+ return null;
1343
+ }
1344
+
1345
+ /** Record activity on a connection: bump its action count and adopt the user once known. */
1346
+ function _touchConnection(ws: FlowBunWs, ctx: object | undefined): void {
1347
+ const id = ws.data.connId;
1348
+ if (!id) return;
1349
+ const conn = _connections.get(id);
1350
+ if (!conn) return;
1351
+ conn.lastActivityAt = Date.now();
1352
+ conn.actions++;
1353
+ const user = _connectionUser(ctx);
1354
+ if (user) conn.user = user;
1355
+ }
1356
+
1357
+ function _makeFlowWsHandlers(deps: FlowWsDeps): WebSocketHandlers {
1358
+ return {
1359
+ open(ws) {
1360
+ const w = ws as FlowBunWs;
1361
+ const url = w.data.flowUrl;
1362
+ if (url === "/__flow/ws") {
1363
+ _activeConnections++;
1364
+ const id = crypto.randomUUID();
1365
+ w.data.connId = id;
1366
+ const now = Date.now();
1367
+ _connections.set(id, {
1368
+ id,
1369
+ ip: w.data.remoteIp ?? "unknown",
1370
+ user: null,
1371
+ connectedAt: now,
1372
+ lastActivityAt: now,
1373
+ actions: 0,
1374
+ });
1375
+ FrameworkEvents.emit(new WebSocketConnected(url));
1376
+ // `dev` lets the client soft-refresh (re-render from the held snapshot) on a
1377
+ // reconnect after a dev restart, instead of a full reload that loses state.
1378
+ w.send(JSON.stringify({ type: "ready", dev: _isDevWorker }));
1379
+ }
1380
+ },
1381
+
1382
+ message(ws, raw) {
1383
+ const url = (ws as FlowBunWs).data.flowUrl;
1384
+ if (url !== "/__flow/ws") return;
1385
+
1386
+ const msg = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
1387
+ // The action round-trip is instrumented inside _handleFlowMessage, where the
1388
+ // request-scoped context (user, ip, queries) is available.
1389
+ void _handleFlowMessage(ws as FlowBunWs, msg, deps);
1390
+ },
1391
+
1392
+ close(ws) {
1393
+ const w = ws as FlowBunWs;
1394
+ if (w.data?.flowUrl === "/__flow/ws") {
1395
+ _activeConnections = Math.max(0, _activeConnections - 1);
1396
+ if (w.data.connId) _connections.delete(w.data.connId);
1397
+ FrameworkEvents.emit(new WebSocketDisconnected("/__flow/ws"));
1398
+ }
1399
+ },
1400
+ };
1401
+ }