@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,978 @@
1
+ // ── Decorator metadata registries ─────────────────────────────────────────────
2
+ //
3
+ // Standard TC39 decorators, keyed by the class prototype; the `get*` readers walk the
4
+ // prototype chain so subclasses inherit.
5
+ //
6
+ // METHOD / GETTER decorators (@computed, @expose on a method, @renderless, @on) register
7
+ // via `addInitializer`, which Bun compiles correctly.
8
+ //
9
+ // FIELD decorators (@expose on a field, @locked, @reactive, @modelable, @transient,
10
+ // @validate, @url, @session) CANNOT defer to a field initializer or to `addInitializer`:
11
+ // Bun 1.3.x cross-wires those across every class defined in one file (a class runs another
12
+ // class's initializers, and a name captured in the closure resolves to the LAST class in
13
+ // the file). The only reliable signal is the decorator BODY, which runs synchronously with
14
+ // the correct `context.name`. So field decorators capture {name, apply} into a module
15
+ // buffer at definition time; the buffer is drained lazily the first time a `get*` reader is
16
+ // called for a class — we construct a probe instance to learn that class's own field names
17
+ // and match the buffered entries by name (see `_drainFields`).
18
+
19
+ import type { RuleBuilder, FieldRule } from "@zerotal/validator";
20
+ import type { LooseEventName } from "./events.ts";
21
+
22
+ /**
23
+ * A `@validate` rule: a callback that receives the validator's chain API and returns the built
24
+ * field rule. This is the value passed to the {@link validate} decorator and the shape of each
25
+ * entry in {@link ValidationRules}.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const nameRule: ValidateBuilder = (rule) => rule.required().min(2).max(50);
30
+ * ```
31
+ *
32
+ * @category Validation
33
+ */
34
+ export type ValidateBuilder = (rule: RuleBuilder) => FieldRule;
35
+
36
+ const _computedProtos = new WeakMap<object, Set<string>>();
37
+ const _transientProtos = new WeakMap<object, Set<string>>();
38
+ const _exposedProps = new WeakMap<object, Set<string>>();
39
+ const _exposedMethods = new WeakMap<object, Set<string>>();
40
+ const _lockedProps = new WeakMap<object, Set<string>>();
41
+ const _renderlessMethods = new WeakMap<object, Set<string>>();
42
+ const _validateRules = new WeakMap<object, Map<string, ValidateBuilder>>();
43
+ const _onListeners = new WeakMap<object, Map<string, string>>();
44
+ const _urlProps = new WeakMap<object, Map<string, UrlOptions>>();
45
+ /**
46
+ * prop -> where its value comes from:
47
+ * undefined — the property's own name is the segment
48
+ * string — that segment
49
+ * class — whichever segment resolved to an instance of it
50
+ */
51
+ const _routeParamProps = new WeakMap<object, Map<string, ParamSource>>();
52
+ const _sessionProps = new WeakMap<object, Map<string, SessionOptions>>();
53
+ const _reactiveProps = new WeakMap<object, Set<string>>();
54
+ const _modelableProps = new WeakMap<object, Set<string>>();
55
+ const _taskMethods = new WeakMap<object, Set<string>>();
56
+ const _presenceProps = new WeakMap<object, Map<string, PresenceChannel>>();
57
+ const _sharedProps = new WeakMap<object, Map<string, SharedChannel>>();
58
+
59
+ function _register(map: WeakMap<object, Set<string>>, proto: object, key: string): void {
60
+ let s = map.get(proto);
61
+ if (!s) {
62
+ s = new Set();
63
+ map.set(proto, s);
64
+ }
65
+ s.add(key);
66
+ }
67
+
68
+ function _registerMap<V>(
69
+ map: WeakMap<object, Map<string, V>>,
70
+ proto: object,
71
+ key: string,
72
+ value: V,
73
+ ): void {
74
+ let m = map.get(proto);
75
+ if (!m) {
76
+ m = new Map();
77
+ map.set(proto, m);
78
+ }
79
+ m.set(key, value);
80
+ }
81
+
82
+ function _collect(map: WeakMap<object, Set<string>>, startProto: object): Set<string> {
83
+ const result = new Set<string>();
84
+ let proto: object | null = startProto;
85
+ while (proto && proto !== Object.prototype) {
86
+ const s = map.get(proto);
87
+ if (s) for (const k of s) result.add(k);
88
+ proto = Object.getPrototypeOf(proto) as object | null;
89
+ }
90
+ return result;
91
+ }
92
+
93
+ function _collectMap<V>(map: WeakMap<object, Map<string, V>>, startProto: object): Map<string, V> {
94
+ const result = new Map<string, V>();
95
+ let proto: object | null = startProto;
96
+ while (proto && proto !== Object.prototype) {
97
+ const m = map.get(proto);
98
+ if (m) for (const [k, v] of m) if (!result.has(k)) result.set(k, v);
99
+ proto = Object.getPrototypeOf(proto) as object | null;
100
+ }
101
+ return result;
102
+ }
103
+
104
+ /** Register against the instance's own prototype (from an initializer/addInitializer). */
105
+ function _registerOn(map: WeakMap<object, Set<string>>, self: unknown, key: string): void {
106
+ _register(map, Object.getPrototypeOf(self as object) as object, key);
107
+ }
108
+
109
+ // ── Field-decorator buffer (Bun standard-decorator workaround) ────────────────
110
+
111
+ interface PendingField {
112
+ name: string;
113
+ apply: (proto: object) => void;
114
+ }
115
+ const _pendingFields: PendingField[] = [];
116
+ const _drainedProtos = new WeakSet<object>();
117
+
118
+ /** Enqueue a field registration captured (with the correct name) in the decorator body. */
119
+ function _enqueueField(name: string, apply: (proto: object) => void): void {
120
+ _pendingFields.push({ name, apply });
121
+ }
122
+
123
+ /**
124
+ * Drain the buffered field registrations belonging to `ctor` onto its prototype. Called
125
+ * lazily by the readers. We construct a throwaway instance to discover the class's own field
126
+ * names IN DECLARATION ORDER (`Object.keys`), then claim the longest contiguous run of buffered
127
+ * entries whose names map to STRICTLY INCREASING positions in that order. A class's field
128
+ * decorators run consecutively at definition time (including a subclass's mixin-supplied fields)
129
+ * and in declaration order, so its block is exactly such a run.
130
+ *
131
+ * The strictly-increasing constraint is what makes this robust when two classes share field
132
+ * names AND their blocks are adjacent in the buffer (e.g. AddTransactionModal and TransferModal
133
+ * both declare `accountId`/`open`/`fAmount`). A plain "longest run ⊆ own" would greedily extend
134
+ * one class's run into the neighbour's leading shared fields; but those shared names map to
135
+ * EARLIER positions in this class's declaration order, so the increasing check stops the run at
136
+ * the true block boundary. Keeps multiple decorators on one field (e.g. `@expose @url page`)
137
+ * together (same name → same index, but they're adjacent duplicates — handled by allowing equal
138
+ * only when it's the identical consecutive name).
139
+ */
140
+ function _drainFields(ctor: unknown): void {
141
+ if (typeof ctor !== "function") return;
142
+ const proto = (ctor as { prototype?: object }).prototype;
143
+ if (!proto || _drainedProtos.has(proto)) return;
144
+ _drainedProtos.add(proto); // mark first to stay re-entrancy-safe during the probe
145
+ if (_pendingFields.length === 0) return;
146
+
147
+ let ownIndex: Map<string, number>;
148
+ try {
149
+ const keys = Object.keys(new (ctor as new () => object)());
150
+ ownIndex = new Map(keys.map((k, i) => [k, i]));
151
+ } catch {
152
+ return; // not constructible without args — nothing we can match
153
+ }
154
+
155
+ // Longest contiguous run whose names map to strictly-increasing declaration-order indices
156
+ // (equal allowed only for an identical repeated name — multiple decorators on one field).
157
+ let bestStart = -1;
158
+ let bestLen = 0;
159
+ for (let i = 0; i < _pendingFields.length; i++) {
160
+ if (!ownIndex.has(_pendingFields[i]!.name)) continue;
161
+ let last = -1;
162
+ let lastName = "";
163
+ let j = i;
164
+ while (j < _pendingFields.length) {
165
+ const name = _pendingFields[j]!.name;
166
+ const idx = ownIndex.get(name);
167
+ if (idx === undefined) break;
168
+ if (idx < last || (idx === last && name !== lastName)) break;
169
+ last = idx;
170
+ lastName = name;
171
+ j++;
172
+ }
173
+ if (j - i > bestLen) {
174
+ bestLen = j - i;
175
+ bestStart = i;
176
+ }
177
+ }
178
+ if (bestStart === -1) return;
179
+ const claimed = _pendingFields.splice(bestStart, bestLen);
180
+ for (const e of claimed) e.apply(proto);
181
+ }
182
+
183
+ /** Drain via an instance or a prototype before reading. */
184
+ function _ensureDrained(target: object): void {
185
+ // target may be an instance (use its constructor) or a prototype (use .constructor).
186
+ const ctor = (target as { constructor?: unknown }).constructor;
187
+ if (typeof ctor !== "function") {
188
+ _drainFields(ctor);
189
+ return;
190
+ }
191
+ // Drain base-most ancestor → derived, so fields DECLARED on a base class register
192
+ // on the *base* prototype (shared by every subclass) instead of on whichever
193
+ // subclass is constructed first. Without this, sibling subclasses of a decorated
194
+ // base (e.g. one generated page class per admin resource) never inherit the base's
195
+ // @url/@expose/@locked props, so their URL/state binding silently breaks.
196
+ const chain: unknown[] = [];
197
+ for (
198
+ let c: unknown = ctor;
199
+ typeof c === "function" && (c as { prototype?: object }).prototype;
200
+ c = Object.getPrototypeOf(c)
201
+ ) {
202
+ chain.unshift(c);
203
+ }
204
+ for (const cls of chain) _drainFields(cls);
205
+ }
206
+
207
+ // Field decorators capture their name+registration in the BODY (correct name) and enqueue
208
+ // it; they do NOT transform the field, so the field's own initializer (default value) runs
209
+ // unchanged. Registration happens later, on first read, via `_drainFields`.
210
+ function _fieldDecorator(
211
+ register: (proto: object, name: string) => void,
212
+ ): (value: unknown, context: ClassFieldDecoratorContext) => void {
213
+ return (_value, context) => {
214
+ const name = String(context.name);
215
+ _assertField(name, context);
216
+ _enqueueField(name, (proto) => register(proto, name));
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Reject a field-style decorator applied to a getter/setter/accessor. A `@computed`
222
+ * getter (and any accessor) has no writable snapshot storage, so `@locked`/`@expose`/…
223
+ * on it would serialize a value the framework then tries to write back on update —
224
+ * clobbering the getter. TypeScript already flags this (the decorator's context type
225
+ * excludes getters), but Bun runs code without typechecking, so guard at runtime too.
226
+ */
227
+ function _assertField(name: string, context: { kind: string }): void {
228
+ if (context.kind !== "field") {
229
+ throw new Error(
230
+ `[Flow] "${name}" — @locked/@url/@session/@transient/@reactive/@modelable can only ` +
231
+ `decorate a field, not a ${context.kind}. A getter isn't stored in the snapshot; ` +
232
+ `use @computed for a derived value (it renders as static server text).`,
233
+ );
234
+ }
235
+ }
236
+
237
+ export function getComputedKeys(instance: object): Set<string> {
238
+ return _collect(_computedProtos, Object.getPrototypeOf(instance) as object);
239
+ }
240
+ export function getTransientKeys(instance: object): Set<string> {
241
+ _ensureDrained(instance);
242
+ return _collect(_transientProtos, Object.getPrototypeOf(instance) as object);
243
+ }
244
+ export function getExposedProps(instance: object): Set<string> {
245
+ _ensureDrained(instance);
246
+ return _collect(_exposedProps, Object.getPrototypeOf(instance) as object);
247
+ }
248
+ export function getLockedProps(instance: object): Set<string> {
249
+ _ensureDrained(instance);
250
+ return _collect(_lockedProps, Object.getPrototypeOf(instance) as object);
251
+ }
252
+ export function getExposedMethods(PageClass: { prototype: object }): Set<string> {
253
+ return _collect(_exposedMethods, PageClass.prototype as object);
254
+ }
255
+ /** `@task` method names — streaming, cancellable long-running actions. */
256
+ export function getTaskMethods(PageClass: { prototype: object }): Set<string> {
257
+ return _collect(_taskMethods, PageClass.prototype as object);
258
+ }
259
+ export function getRenderlessMethods(PageClass: { prototype: object }): Set<string> {
260
+ return _collect(_renderlessMethods, PageClass.prototype as object);
261
+ }
262
+ export function getValidateRules(instance: object): Map<string, ValidateBuilder> {
263
+ _ensureDrained(instance);
264
+ return _collectMap(_validateRules, Object.getPrototypeOf(instance) as object);
265
+ }
266
+ export function getListeners(PageClass: { prototype: object }): Map<string, string> {
267
+ return _collectMap(_onListeners, PageClass.prototype as object);
268
+ }
269
+ export function getUrlProps(instance: object): Map<string, UrlOptions> {
270
+ _ensureDrained(instance);
271
+ return _collectMap(_urlProps, Object.getPrototypeOf(instance) as object);
272
+ }
273
+ export function getSessionProps(instance: object): Map<string, SessionOptions> {
274
+ _ensureDrained(instance);
275
+ return _collectMap(_sessionProps, Object.getPrototypeOf(instance) as object);
276
+ }
277
+
278
+ /**
279
+ * The session key a `@session` field reads and writes.
280
+ *
281
+ * Plain by default — `@session userId` is the session's own `userId`, the same value any
282
+ * controller or other component sees. `scoped: true` namespaces it to the component instead,
283
+ * for state that belongs to this one page and should not collide with anything else.
284
+ */
285
+ export function sessionKeyFor(prop: string, opts: SessionOptions, componentName: string): string {
286
+ const key = opts.key ?? prop;
287
+ return opts.scoped ? `flow:${componentName}:${key}` : key;
288
+ }
289
+ /** Reactive-prop names. Accepts a prototype directly (e.g. ChildClass.prototype). */
290
+ export function getReactiveProps(proto: object): Set<string> {
291
+ _ensureDrained(proto);
292
+ return _collect(_reactiveProps, proto);
293
+ }
294
+ /** Modelable-prop names. Accepts a prototype directly (e.g. ChildClass.prototype). */
295
+ export function getModelableProps(proto: object): Set<string> {
296
+ _ensureDrained(proto);
297
+ return _collect(_modelableProps, proto);
298
+ }
299
+ /** `@presence` props → their channel resolver, for the instance's class. */
300
+ export function getPresenceProps(instance: object): Map<string, PresenceChannel> {
301
+ _ensureDrained(instance);
302
+ return _collectMap(_presenceProps, Object.getPrototypeOf(instance) as object);
303
+ }
304
+ /** `@shared` props → their channel resolver, for the instance's class. */
305
+ export function getSharedProps(instance: object): Map<string, SharedChannel> {
306
+ _ensureDrained(instance);
307
+ return _collectMap(_sharedProps, Object.getPrototypeOf(instance) as object);
308
+ }
309
+
310
+ // ── @computed ─────────────────────────────────────────────────────────────────
311
+
312
+ /** Wrap a getter so it memoizes its result while a render pass is active. */
313
+ function _memoizeGetter(orig: () => unknown, name: string): () => unknown {
314
+ return function (this: Record<string, unknown>): unknown {
315
+ if (!this["__flowComputing"]) return orig.call(this); // only cache during render
316
+ let cache = this["__flowComputed"] as Map<string, unknown> | undefined;
317
+ if (!cache) {
318
+ cache = new Map();
319
+ this["__flowComputed"] = cache;
320
+ }
321
+ if (cache.has(name)) return cache.get(name);
322
+ const v = orig.call(this);
323
+ cache.set(name, v);
324
+ return v;
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Marks a getter as computed — a value derived from other state, excluded from the snapshot.
330
+ *
331
+ * @remarks
332
+ * A computed getter is not serialized and is not client-writable; it renders as static
333
+ * server-produced text. The result is memoized for the duration of a single render pass
334
+ * (Livewire-style per-request caching): an expensive getter read several times in one template
335
+ * runs once. The cache is scoped to the active render (see `_renderFlowPage`), so reads
336
+ * outside rendering always recompute and never go stale. Apply only to a getter — a field-style
337
+ * decorator on it is rejected; conversely `@computed` on a non-getter is a TypeScript error.
338
+ *
339
+ * @example
340
+ * ```tsx
341
+ * class Cart extends Component {
342
+ * @expose items: LineItem[] = [];
343
+ *
344
+ * @computed get total(): number {
345
+ * return this.items.reduce((sum, i) => sum + i.price * i.qty, 0);
346
+ * }
347
+ * }
348
+ * ```
349
+ *
350
+ * @category Reactivity
351
+ */
352
+ export function computed<This, Return>(
353
+ getter: (this: This) => Return,
354
+ context: ClassGetterDecoratorContext<This, Return>,
355
+ ): (this: This) => Return {
356
+ const name = String(context.name);
357
+
358
+ context.addInitializer(function (this: This) {
359
+ _registerOn(_computedProtos, this, name);
360
+ });
361
+ return _memoizeGetter(getter, name) as (this: This) => Return;
362
+ }
363
+
364
+ // ── @transient ────────────────────────────────────────────────────────────────
365
+
366
+ /**
367
+ * Marks a field as transient — excluded from the snapshot entirely.
368
+ *
369
+ * @remarks
370
+ * Unlike `@computed` (which is derived and re-runs each render), a transient field is ordinary
371
+ * server-side state that simply never crosses the wire and is not persisted between requests:
372
+ * it holds its default value at the start of each render and is invisible to the client. Use it
373
+ * for server-only scratch state (caches, handles, injected services) that must not be serialized.
374
+ * Applies only to a field; a getter/accessor is rejected at runtime.
375
+ *
376
+ * @example
377
+ * ```tsx
378
+ * class Report extends Component {
379
+ * // Server-only handle — never serialized to the client.
380
+ * @transient private db!: DatabaseConnection;
381
+ * }
382
+ * ```
383
+ *
384
+ * @category Exposure & state
385
+ */
386
+ export const transient = _fieldDecorator((proto, name) => _register(_transientProtos, proto, name));
387
+
388
+ // ── @expose ───────────────────────────────────────────────────────────────────
389
+
390
+ type ExposeContext = ClassFieldDecoratorContext | ClassMethodDecoratorContext;
391
+
392
+ /**
393
+ * The opt-in network boundary: makes a member reachable from the client and crosses the wire.
394
+ *
395
+ * @remarks
396
+ * On a **field**, the property is included in the snapshot and is mutable by the client (e.g. via
397
+ * `flow:model`), so it round-trips both directions. On a **method**, the method becomes callable
398
+ * from the browser over the WebSocket as an action. Nothing not marked (directly or via a
399
+ * decorator that implies it, such as `@locked`, `@task`, `@on`, `@reactive`, `@modelable`,
400
+ * `@presence`, `@shared`) is exposed. Rejected on a getter/accessor — use `@computed` for a
401
+ * derived value, or an `@expose` field for client-writable state.
402
+ *
403
+ * @example
404
+ * ```tsx
405
+ * class Counter extends Component {
406
+ * @expose count = 0;
407
+ *
408
+ * @expose increment() {
409
+ * this.count++;
410
+ * }
411
+ * }
412
+ * ```
413
+ *
414
+ * @category Exposure & state
415
+ */
416
+ export function expose(_value: unknown, context: ExposeContext): void {
417
+ const name = String(context.name);
418
+ const kind = (context as { kind: string }).kind;
419
+ if (kind !== "field" && kind !== "method") {
420
+ throw new Error(
421
+ `[Flow] @expose can only decorate a field or a method, not a ${kind} ("${name}"). ` +
422
+ `A getter isn't stored in the snapshot; use @computed for a derived value ` +
423
+ `(it renders as static server text), or an @expose field for client-writable state.`,
424
+ );
425
+ }
426
+ if (context.kind === "field") {
427
+ // Field: capture in the body and enqueue (see _fieldDecorator / _drainFields).
428
+ _enqueueField(name, (proto) => _register(_exposedProps, proto, name));
429
+ return;
430
+ }
431
+ // Method: addInitializer is available (and reliable) on method contexts.
432
+ context.addInitializer(function (this: unknown) {
433
+ _registerOn(_exposedMethods, this, name);
434
+ });
435
+ }
436
+
437
+ // ── @locked ───────────────────────────────────────────────────────────────────
438
+
439
+ /**
440
+ * Marks a field as exposed but read-only client-side: it is included in the snapshot (the client
441
+ * can read/render it) but the client cannot mutate it via `flow:model`.
442
+ *
443
+ * @remarks
444
+ * `@locked` implies `@expose` (it registers the prop as exposed) while additionally flagging it
445
+ * as locked, so client-originated writes are rejected. Use it for server-authoritative state the
446
+ * UI displays but must not change directly — the value only moves via server actions. Applies to
447
+ * a field only.
448
+ *
449
+ * @example
450
+ * ```tsx
451
+ * class Profile extends Component {
452
+ * // Client renders it, but cannot bind it with flow:model.
453
+ * @locked role = "member";
454
+ * }
455
+ * ```
456
+ *
457
+ * @category Exposure & state
458
+ */
459
+ export const locked = _fieldDecorator((proto, name) => {
460
+ _register(_exposedProps, proto, name);
461
+ _register(_lockedProps, proto, name);
462
+ });
463
+
464
+ // ── @presence ─────────────────────────────────────────────────────────────────
465
+ // Binds a property to a broadcast presence channel: the framework keeps it filled
466
+ // with the live member list (who's here), updating on join/leave. The channel is
467
+ // resolved per-instance — a static string, or a `(self) => string` for dynamic rooms.
468
+ // The prop is server-controlled (locked): in the snapshot, not client-writable.
469
+
470
+ /**
471
+ * A presence channel identifier: either a static channel name, or a resolver called with the
472
+ * component instance to compute a per-instance name (for dynamic rooms).
473
+ *
474
+ * @category Realtime
475
+ */
476
+ export type PresenceChannel = string | ((self: Record<string, any>) => string);
477
+
478
+ /**
479
+ * Binds a field to a broadcast presence channel — the framework keeps it filled with the live
480
+ * member list (who's here), updating on join/leave.
481
+ *
482
+ * @remarks
483
+ * The channel is resolved per-instance: pass a static string, or a `(self) => string` resolver
484
+ * for dynamic rooms. The bound prop is server-controlled — like `@locked`, it is included in the
485
+ * snapshot but is not client-writable. Applies to a field only.
486
+ *
487
+ * @param channel - The channel name, or a resolver over the component instance.
488
+ * @returns A field decorator that registers the presence binding.
489
+ *
490
+ * @example
491
+ * ```tsx
492
+ * class RoomHeader extends Component {
493
+ * @presence((self) => `room.${self.roomId}`)
494
+ * members: PresenceMember[] = [];
495
+ *
496
+ * @locked roomId = "";
497
+ * }
498
+ * ```
499
+ *
500
+ * @category Realtime
501
+ */
502
+ export function presence(
503
+ channel: PresenceChannel,
504
+ ): (value: unknown, context: ClassFieldDecoratorContext) => void {
505
+ return _fieldDecorator((proto, name) => {
506
+ _registerMap(_presenceProps, proto, name, channel);
507
+ // Serialized + read-only client-side, like @locked.
508
+ _register(_exposedProps, proto, name);
509
+ _register(_lockedProps, proto, name);
510
+ });
511
+ }
512
+
513
+ /** Resolve a `@presence` channel for a component instance (adds no prefix). */
514
+ export function resolvePresenceChannel(channel: PresenceChannel, instance: object): string {
515
+ return typeof channel === "function" ? channel(instance as Record<string, unknown>) : channel;
516
+ }
517
+
518
+ // ── @shared ─────────────────────────────────────────────────────────────────
519
+ // Binds a property to convergent, server-authoritative shared state on a broadcast
520
+ // channel: mutating it in an action writes to a per-channel room store and broadcasts
521
+ // to the channel, so every other subscriber's component re-reads and converges. The
522
+ // channel resolves per-instance (a static string, or `(self) => string` for dynamic
523
+ // rooms), the same shape as @presence — so one "room" channel can carry both who's-here
524
+ // (@presence) and shared state (@shared). Serialized + read-only client-side (@locked):
525
+ // clients render it but can only change it through @expose actions (last-write-wins).
526
+
527
+ /**
528
+ * A shared-state channel identifier: either a static channel name, or a resolver called with the
529
+ * component instance to compute a per-instance name (for dynamic rooms).
530
+ *
531
+ * @category Realtime
532
+ */
533
+ export type SharedChannel = string | ((self: Record<string, any>) => string);
534
+
535
+ /**
536
+ * Binds a field to convergent, server-authoritative shared state on a broadcast channel.
537
+ *
538
+ * @remarks
539
+ * Mutating the field inside an action writes to a per-channel room store and broadcasts to the
540
+ * channel, so every other subscriber's component re-reads and converges (last-write-wins). The
541
+ * channel resolves per-instance (a static string, or `(self) => string` for dynamic rooms) — the
542
+ * same shape as {@link presence}, so one "room" channel can carry both who's-here (`@presence`)
543
+ * and shared state (`@shared`). Serialized and read-only client-side (like `@locked`): clients
544
+ * render it but can only change it through `@expose` actions. Applies to a field only.
545
+ *
546
+ * @param channel - The channel name, or a resolver over the component instance.
547
+ * @returns A field decorator that registers the shared-state binding.
548
+ *
549
+ * @example
550
+ * ```tsx
551
+ * class Whiteboard extends Component {
552
+ * @shared((self) => `board.${self.boardId}`)
553
+ * strokes: Stroke[] = [];
554
+ *
555
+ * @locked boardId = "";
556
+ *
557
+ * @expose addStroke(stroke: Stroke) {
558
+ * this.strokes = [...this.strokes, stroke]; // broadcasts to every subscriber
559
+ * }
560
+ * }
561
+ * ```
562
+ *
563
+ * @category Realtime
564
+ */
565
+ export function shared(
566
+ channel: SharedChannel,
567
+ ): (value: unknown, context: ClassFieldDecoratorContext) => void {
568
+ return _fieldDecorator((proto, name) => {
569
+ _registerMap(_sharedProps, proto, name, channel);
570
+ // Serialized + read-only client-side, like @locked — writes go through actions.
571
+ _register(_exposedProps, proto, name);
572
+ _register(_lockedProps, proto, name);
573
+ });
574
+ }
575
+
576
+ /** Resolve a `@shared` channel for a component instance (adds no prefix). */
577
+ export function resolveSharedChannel(channel: SharedChannel, instance: object): string {
578
+ return typeof channel === "function" ? channel(instance as Record<string, unknown>) : channel;
579
+ }
580
+
581
+ // ── @reactive ───────────────────────────────────────────────────────────────
582
+ // A child-component prop the PARENT can push new values into on every re-render.
583
+ // Included in the snapshot and client-writable (so the framework can deliver
584
+ // parent updates), and re-emitted by the parent so the child re-renders when the
585
+ // bound value changes. Mirrors Livewire's #[Reactive].
586
+
587
+ /**
588
+ * Marks a child-component prop as reactive — the parent re-pushes new values into it on every
589
+ * re-render.
590
+ *
591
+ * @remarks
592
+ * The prop is included in the snapshot and is client-writable (so the framework can deliver the
593
+ * parent's updates), and the parent re-emits it so the child re-renders when the bound value
594
+ * changes. This is a one-way parent→child binding; use `@modelable` for two-way. Mirrors
595
+ * Livewire's `#[Reactive]`. Applies to a field only.
596
+ *
597
+ * @example
598
+ * ```tsx
599
+ * class PriceTag extends Component {
600
+ * // Re-pushed by the parent whenever the parent's bound value changes.
601
+ * @reactive amount = 0;
602
+ * }
603
+ * ```
604
+ *
605
+ * @category Reactivity
606
+ */
607
+ export const reactive = _fieldDecorator((proto, name) => {
608
+ _register(_exposedProps, proto, name);
609
+ _register(_reactiveProps, proto, name);
610
+ });
611
+
612
+ // ── @modelable ────────────────────────────────────────────────────────────────
613
+ // A reactive prop that ALSO syncs back to the parent: `value={this.x}` on a child
614
+ // component binds the parent's `x` to the child's modelable prop both ways. Mirrors
615
+ // Livewire's #[Modelable]. A modelable prop is implicitly reactive.
616
+
617
+ /**
618
+ * Marks a child-component prop as two-way bound to the parent (parent↔child).
619
+ *
620
+ * @remarks
621
+ * A modelable prop is reactive (the parent pushes new values in on re-render) AND syncs back:
622
+ * `value={this.x}` on a child component binds the parent's `x` to the child's modelable prop in
623
+ * both directions. A modelable prop is implicitly reactive (it registers as both). Mirrors
624
+ * Livewire's `#[Modelable]`. Applies to a field only.
625
+ *
626
+ * @example
627
+ * ```tsx
628
+ * class TextField extends Component {
629
+ * // Parent binds with `value={this.query}`; edits here flow back to the parent.
630
+ * @modelable value = "";
631
+ * }
632
+ * ```
633
+ *
634
+ * @category Reactivity
635
+ */
636
+ export const modelable = _fieldDecorator((proto, name) => {
637
+ _register(_exposedProps, proto, name);
638
+ _register(_reactiveProps, proto, name);
639
+ _register(_modelableProps, proto, name);
640
+ });
641
+
642
+ // ── @renderless ───────────────────────────────────────────────────────────────
643
+
644
+ /**
645
+ * Marks an action method as renderless — the action runs on the server but the follow-up
646
+ * re-render is skipped.
647
+ *
648
+ * @remarks
649
+ * Unlike `@task`, `@renderless` does NOT imply `@expose` — it only registers the method as
650
+ * renderless, so it must be combined with `@expose` (or another decorator that exposes it) to be
651
+ * callable from the client. Use it for side-effect-only actions whose result the UI does not need
652
+ * to reflect (logging, fire-and-forget writes), avoiding the cost of a re-render and patch.
653
+ * Applies to a method only.
654
+ *
655
+ * @example
656
+ * ```tsx
657
+ * class Analytics extends Component {
658
+ * @expose @renderless track(event: string) {
659
+ * this.logger.record(event); // no DOM update needed
660
+ * }
661
+ * }
662
+ * ```
663
+ *
664
+ * @category Actions
665
+ */
666
+ export function renderless(_fn: unknown, context: ClassMethodDecoratorContext): void {
667
+ context.addInitializer(function (this: unknown) {
668
+ _registerOn(_renderlessMethods, this, String(context.name));
669
+ });
670
+ }
671
+
672
+ // ── @task ───────────────────────────────────────────────────────────────────────
673
+ // A long-running, cancellable action whose incremental field writes stream to the client.
674
+ // While a @task runs, the framework flushes throttled partial patches — so `this.answer +=
675
+ // token` appears live — keeps the triggering control in its loading state for the whole
676
+ // duration, and exposes `this.signal` (an AbortSignal) that `this.cancel()` on the client
677
+ // trips for cooperative cancellation. Auto-@expose (callable from the browser).
678
+
679
+ /**
680
+ * Marks an async method as a streaming, cancellable task. Implicitly `@expose` (callable from the
681
+ * browser).
682
+ *
683
+ * @remarks
684
+ * While a `@task` runs, the framework flushes throttled partial patches — so incremental field
685
+ * writes such as `this.answer += token` appear live in the client — keeps the triggering control
686
+ * in its loading state for the whole duration, and exposes `this.signal` (an `AbortSignal`) that
687
+ * `this.cancel()` on the client trips for cooperative cancellation. Because it auto-exposes, no
688
+ * separate `@expose` is needed. Applies to a (typically async) method.
689
+ *
690
+ * @example
691
+ * ```tsx
692
+ * class Chat extends Component {
693
+ * @expose answer = "";
694
+ *
695
+ * @task async ask(prompt: string) {
696
+ * for await (const token of streamCompletion(prompt, this.signal)) {
697
+ * this.answer += token; // each write streams to the client
698
+ * }
699
+ * }
700
+ * }
701
+ * ```
702
+ *
703
+ * @category Actions
704
+ */
705
+ export function task(_fn: unknown, context: ClassMethodDecoratorContext): void {
706
+ context.addInitializer(function (this: unknown) {
707
+ const proto = Object.getPrototypeOf(this as object) as object;
708
+ const name = String(context.name);
709
+ _register(_exposedMethods, proto, name);
710
+ _register(_taskMethods, proto, name);
711
+ });
712
+ }
713
+
714
+ // ── @validate ─────────────────────────────────────────────────────────────────
715
+
716
+ /**
717
+ * Attaches a validation rule to a field using the framework validator's fluent chain.
718
+ *
719
+ * @remarks
720
+ * The rule is read by `this.validate()` (called with no argument) and by real-time validation on
721
+ * client update. `@validate` only registers the rule — it does not expose the field, so pair it
722
+ * with `@expose` for client-editable inputs. Applies to a field only. The decorator-attached
723
+ * rules mirror the object form accepted by `this.validate({ ... })` (see {@link ValidationRules}).
724
+ *
725
+ * @param build - A {@link ValidateBuilder} that builds the field rule from the chain API.
726
+ * @returns A field decorator that registers the rule.
727
+ *
728
+ * @example
729
+ * ```tsx
730
+ * class SignupForm extends Component {
731
+ * @expose @validate((rule) => rule.required().min(2).max(50)) name = "";
732
+ * @expose @validate((rule) => rule.required().email()) email = "";
733
+ * @expose @validate((rule) => rule.required().min(8).confirmed()) password = "";
734
+ * }
735
+ * ```
736
+ *
737
+ * @category Validation
738
+ */
739
+ export function validate(
740
+ build: ValidateBuilder,
741
+ ): (value: unknown, context: ClassFieldDecoratorContext) => void {
742
+ return _fieldDecorator((proto, name) => _registerMap(_validateRules, proto, name, build));
743
+ }
744
+
745
+ // ── @on ───────────────────────────────────────────────────────────────────────
746
+
747
+ /**
748
+ * Binds a method as a cross-component (realtime) event listener. Auto-exposes the method.
749
+ *
750
+ * @remarks
751
+ * When the named event is dispatched (via `dispatch` / `dispatchTo` / `dispatchSelf`, or an
752
+ * `echo:…` broadcast), the decorated method is invoked with the event payload. The event name
753
+ * autocompletes to the known {@link FlowEvents} contract while still accepting any string (for
754
+ * `echo:…` broadcasts and gradually-typed events). Annotate the handler's parameter with
755
+ * `EventPayload<"name">` to type the payload against the same contract. Because it auto-exposes,
756
+ * no separate `@expose` is needed. Applies to a method.
757
+ *
758
+ * @param eventName - The event to listen for; a known {@link FlowEvents} key or any string.
759
+ * @returns A method decorator that registers the listener.
760
+ *
761
+ * @example
762
+ * ```tsx
763
+ * class Feed extends Component {
764
+ * @expose posts: Post[] = [];
765
+ *
766
+ * @on("post-created")
767
+ * onPostCreated(payload: EventPayload<"post-created">) {
768
+ * this.posts = [payload, ...this.posts];
769
+ * }
770
+ * }
771
+ * ```
772
+ *
773
+ * @category Realtime
774
+ */
775
+ export function on(
776
+ eventName: LooseEventName,
777
+ ): (fn: unknown, context: ClassMethodDecoratorContext) => void {
778
+ return (_fn: unknown, context: ClassMethodDecoratorContext): void => {
779
+ context.addInitializer(function (this: unknown) {
780
+ const proto = Object.getPrototypeOf(this as object) as object;
781
+ const name = String(context.name);
782
+ _register(_exposedMethods, proto, name);
783
+ _registerMap(_onListeners, proto, eventName, name);
784
+ });
785
+ };
786
+ }
787
+
788
+ // ── @url ──────────────────────────────────────────────────────────────────────
789
+
790
+ /**
791
+ * Options for the {@link url} decorator, controlling how a property maps onto the browser URL
792
+ * query string.
793
+ *
794
+ * @category Persistence
795
+ */
796
+ export interface UrlOptions {
797
+ /** Override the URL query parameter name. Defaults to the property name. */
798
+ as?: string;
799
+ /** Push a new history entry or replace the current one. Defaults to `'replace'`. */
800
+ history?: "push" | "replace";
801
+ }
802
+
803
+ type UrlDecorator = (value: unknown, context: ClassFieldDecoratorContext) => void;
804
+
805
+ /**
806
+ * Syncs a field to the browser URL query string, so its value is reflected in and restored from
807
+ * the URL.
808
+ *
809
+ * @remarks
810
+ * Usable bare as `@url` (no parens) or configured as `@url({ as: 'page', history: 'push' })` — see
811
+ * {@link UrlOptions}. By default the query parameter takes the property's name and updates
812
+ * `replace` the current history entry.
813
+ *
814
+ * A URL-synced field is client-visible by definition, so `@url` **implies `@expose`** — no need
815
+ * to write both. Add `@locked` for a value the client reads but the server owns. Applies to a
816
+ * field.
817
+ *
818
+ * @param opts - Optional {@link UrlOptions} when called with parentheses.
819
+ * @returns When configured, a field decorator; when used bare, nothing (applied directly).
820
+ *
821
+ * @example
822
+ * ```tsx
823
+ * class ProductList extends Component {
824
+ * @url page = 1;
825
+ * @url({ as: "q", history: "push" }) search = "";
826
+ * }
827
+ * ```
828
+ *
829
+ * @category Persistence
830
+ */
831
+ export function url(value: unknown, context: ClassFieldDecoratorContext): void;
832
+ export function url(opts?: UrlOptions): UrlDecorator;
833
+ export function url(
834
+ optsOrValue?: UrlOptions | unknown,
835
+ context?: ClassFieldDecoratorContext,
836
+ ): UrlDecorator | void {
837
+ if (context !== undefined) {
838
+ return _urlDecorator({})(optsOrValue, context); // bare @url
839
+ }
840
+ const opts = (optsOrValue ?? {}) as UrlOptions;
841
+ return _urlDecorator(opts);
842
+ }
843
+
844
+ function _urlDecorator(opts: UrlOptions): UrlDecorator {
845
+ return _fieldDecorator((proto, name) => {
846
+ _registerMap(_urlProps, proto, name, opts);
847
+ // A URL-synced field is client-visible by definition — its value is in the address bar,
848
+ // and keeping it in sync means the client holds it. So @url implies @expose; add @locked
849
+ // alongside it for a value the client reads but may not write.
850
+ _register(_exposedProps, proto, name);
851
+ });
852
+ }
853
+
854
+ // ── @param ────────────────────────────────────────────────────────────────────
855
+
856
+ type ParamDecorator = (value: unknown, context: ClassFieldDecoratorContext) => void;
857
+
858
+ /** A model class used as a `@param` token — matched against the resolved segment values. */
859
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- any model constructor shape
860
+ export type ParamModel = abstract new (...args: any[]) => unknown;
861
+
862
+ /** Where a `@param` field reads from: a segment name, a model class, or its own name. */
863
+ export type ParamSource = string | ParamModel | undefined;
864
+
865
+ /** Fields bound to a route segment, with the source each one reads. */
866
+ export function getRouteParamProps(instance: object): Map<string, ParamSource> {
867
+ _ensureDrained(instance);
868
+ return _collectMap(_routeParamProps, Object.getPrototypeOf(instance) as object);
869
+ }
870
+
871
+ /**
872
+ * Fills a field from the matched route segment before `onMount()` runs — including a
873
+ * [route-model binding](/docs/routing#route-model-binding), so a page on `/posts/:post`
874
+ * receives the loaded `Post` without querying for it.
875
+ *
876
+ * @remarks
877
+ * Usable bare as `@param` (the field's own name is the segment), as `@param('slug')` to read a
878
+ * differently-named segment, or as `@param(Post)` to take whichever segment resolved to an
879
+ * instance of that model — so the field never has to track what a route called it. One model
880
+ * claims one segment name, so the token form is unambiguous for anything binding implicitly;
881
+ * a route that hand-binds a second segment to the same model yields the leftmost.
882
+ *
883
+ * Seeding happens **once, on the initial GET** — the only request that carries the URL's
884
+ * segments. On subsequent WebSocket actions the value is restored from the snapshot instead
885
+ * (a model through Flow's model synth), so pair it with `@locked` and the record is never
886
+ * re-queried.
887
+ *
888
+ * `@param` registers the binding only; combine it with `@locked` for server-owned data, or
889
+ * `@expose` if the client may also write it. Applies to a field.
890
+ *
891
+ * @param name - The route segment to read; defaults to the property's own name.
892
+ * @returns When configured, a field decorator; when used bare, nothing (applied directly).
893
+ *
894
+ * @example
895
+ * ```tsx
896
+ * class PostDetailPage extends Component {
897
+ * @locked @param post!: Post; // /posts/:post -> the bound Post
898
+ * @locked @param(Post) article!: Post; // whichever segment resolved to a Post
899
+ * @locked @param("tab") activeTab = ""; // /posts/:post/:tab -> the raw segment
900
+ * }
901
+ * ```
902
+ *
903
+ * @category Persistence
904
+ */
905
+ export function param(value: unknown, context: ClassFieldDecoratorContext): void;
906
+ export function param(source?: string | ParamModel): ParamDecorator;
907
+ export function param(
908
+ sourceOrValue?: string | ParamModel | unknown,
909
+ context?: ClassFieldDecoratorContext,
910
+ ): ParamDecorator | void {
911
+ if (context !== undefined) {
912
+ return _paramDecorator(undefined)(sourceOrValue, context); // bare @param
913
+ }
914
+ return _paramDecorator(sourceOrValue as ParamSource);
915
+ }
916
+
917
+ function _paramDecorator(source: ParamSource): ParamDecorator {
918
+ return _fieldDecorator((proto, prop) => _registerMap(_routeParamProps, proto, prop, source));
919
+ }
920
+
921
+ // ── @session ──────────────────────────────────────────────────────────────────
922
+
923
+ /** Options for {@link session}. */
924
+ export interface SessionOptions {
925
+ /** Session key to read and write. Defaults to the property's own name. */
926
+ key?: string;
927
+ /**
928
+ * Namespace the key to this component (`flow:<Component>:<key>`), so the value belongs to
929
+ * this page alone and cannot collide with another component's field of the same name.
930
+ * Default: `false` — the key is the session's own.
931
+ */
932
+ scoped?: boolean;
933
+ }
934
+
935
+ type SessionDecorator = (value: unknown, context: ClassFieldDecoratorContext) => void;
936
+
937
+ /**
938
+ * Binds a field to the server-side session, so its value survives across page loads.
939
+ *
940
+ * @remarks
941
+ * Requires `SessionMiddleware`. The field reads and writes the session's own key of the same
942
+ * name, so `@session userId` is the same `userId` a controller sees — pass `{ key }` to read a
943
+ * differently-named one, or `{ scoped: true }` to namespace it to this component instead.
944
+ *
945
+ * The value stays **server-side**: `@session` alone puts nothing in the snapshot, so the
946
+ * browser never sees it and cannot write it. Add `@locked` for a value the client should read.
947
+ * Applies to a field only.
948
+ *
949
+ * @param opts - Optional {@link SessionOptions} when called with parentheses.
950
+ * @returns When configured, a field decorator; when used bare, nothing (applied directly).
951
+ *
952
+ * @example
953
+ * ```tsx
954
+ * class Preferences extends Component {
955
+ * @session userId = ""; // the session's `userId`
956
+ * @session({ key: "s" }) whatever = 0; // the session's `s`
957
+ * @session({ scoped: true }) draft = ""; // flow:Preferences:draft
958
+ * @locked @session theme = "light"; // readable by the client, server-owned
959
+ * }
960
+ * ```
961
+ *
962
+ * @category Persistence
963
+ */
964
+ export function session(value: unknown, context: ClassFieldDecoratorContext): void;
965
+ export function session(opts?: SessionOptions): SessionDecorator;
966
+ export function session(
967
+ optsOrValue?: SessionOptions | unknown,
968
+ context?: ClassFieldDecoratorContext,
969
+ ): SessionDecorator | void {
970
+ if (context !== undefined) {
971
+ return _sessionDecorator({})(optsOrValue, context); // bare @session
972
+ }
973
+ return _sessionDecorator((optsOrValue ?? {}) as SessionOptions);
974
+ }
975
+
976
+ function _sessionDecorator(opts: SessionOptions): SessionDecorator {
977
+ return _fieldDecorator((proto, name) => _registerMap(_sessionProps, proto, name, opts));
978
+ }