@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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- 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
|
+
}
|