@zerotal/flow 1.8.0 → 1.9.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 CHANGED
@@ -11,6 +11,63 @@ change.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.9.0] — 2026-08-29
15
+
16
+ - **The component and flash types are documented.** Every built-in component exports the type of
17
+ its own props — `ModalProps`, `ForProps`, `VirtualizeProps` and eighteen more — and none was
18
+ named anywhere a reader would look, though wrapping a component is the usual reason to want
19
+ one. The flash API's shapes are documented alongside them: `FlashBuilder` and the eight types
20
+ around it, for a helper that composes a message rather than writing one inline.
21
+
22
+ ### Changed
23
+
24
+ - **INTERNAL: 35 exports are marked `@internal`.** The compiler's output helpers (`__esc`,
25
+ `__escAttr`, `jsLiteral`), the URL-safety internals, the wire-protocol frame types
26
+ (`PatchFrame`, `EventFrame`, `RedirectFrame`, …), the registration functions, the durable and
27
+ shared store accessors, and `FlowBrowser`'s Chrome discovery.
28
+
29
+ **Nothing is removed and nothing breaks.** The frame types are the clearest case: they
30
+ describe what crosses the socket, an app never constructs one, and naming them in the promised
31
+ surface implied a stability guarantee about a protocol that is free to change.
32
+
33
+ - **A bound field the model will not accept now says so.** `flow:model` on a column missing from
34
+ `fillable` was dropped in silence: the form submitted, nothing was written, nothing failed, and
35
+ there was no clue. The drop stays — the same path receives whatever a browser sends, so an
36
+ unknown key must not write a column and must not be able to 500 the page — but a developer's
37
+ typo produced exactly the same silence as a hostile payload. In development it now warns once
38
+ per class per field, naming the field, the current `fillable`, and both ways to fix it.
39
+
40
+ - **The client entries are held to the `@zerotal/orm` boundary.** Every orm import in this
41
+ package is `import type` — deliberately, because a root import once dragged
42
+ `await import("bun")` into a browser bundle and broke it at _resolution_ time, before
43
+ tree-shaking could drop it. One exception exists: `ModelSynth` imports a value. It is safe only
44
+ because no client entry reaches a synth, and that condition had been verified by hand, once,
45
+ with nothing holding it. A test now walks the import graph from each client entry and fails
46
+ naming the module that crossed.
47
+
48
+ - **The plain-form enhancement bundle is built on first request, not at boot.** Most apps never
49
+ serve a page that uses `<form data-enhance>`, so building it at boot spends time on an artefact
50
+ nobody asked for, in every process.
51
+
52
+ ### Added
53
+
54
+ - **`<form data-enhance>` — plain-form enhancement, no component required.** A page that is
55
+ just server-rendered HTML can now have a form that submits without the page flashing: it
56
+ posts through `fetch` and the matching form in the response replaces it in place, so a
57
+ validation error lands where the person is looking and nothing else on the page moves.
58
+
59
+ Delivered as its own dependency-free bundle at `/__flow/enhance.js`, because the whole point
60
+ is a page with no Flow component on it — such a page is never served `/__flow/runtime.js`,
61
+ and pulling Alpine and a WebSocket bridge onto it would be paying the framework's entire
62
+ client cost for a submit handler. Add it with `flowEnhanceTag()` in a non-Flow layout.
63
+
64
+ Every path degrades: a network failure re-submits natively rather than losing what was
65
+ typed, a redirect is followed and `pushState`d so the address bar agrees, and no JavaScript
66
+ at all is an ordinary form post — `data-enhance` is additive on a form that already works,
67
+ so there is no fallback path that can rot.
68
+
69
+ This completes ACTION 8, whose other two items turned out to be already shipped.
70
+
14
71
  ## [1.8.0] — 2026-08-24
15
72
 
16
73
  ### Added
package/api-surface.md CHANGED
@@ -174,9 +174,7 @@ class WebSocketDisconnected = {
174
174
  readonly url: string
175
175
  }
176
176
 
177
- const BLOCKED_URL = 'about:blank'
178
-
179
- const DEFAULT_PERSISTENT_MIDDLEWARE = (string | MiddlewareClass)[]
177
+ const FLOW_ENHANCE_PATH = '/__flow/enhance.js'
180
178
 
181
179
  const locked = (value: unknown, context: ClassFieldDecoratorContext) => void
182
180
 
@@ -186,8 +184,6 @@ const reactive = (value: unknown, context: ClassFieldDecoratorContext) => void
186
184
 
187
185
  const transient = (value: unknown, context: ClassFieldDecoratorContext) => void
188
186
 
189
- const URL_ATTRIBUTES = ReadonlySet<string>
190
-
191
187
  function Accordion = (props: AccordionProps) => HtmlNode
192
188
 
193
189
  function Alert = (props: AlertProps) => HtmlNode
@@ -230,26 +226,16 @@ function Flash = (props?: FlashProps) => HtmlNode
230
226
 
231
227
  function flow = <T>(target: T) => FlowChain<T>
232
228
 
233
- function flowActiveConnections = () => number
234
-
235
229
  function FlowConfig = (options?: Partial<FlowConfigShape>) => FlowConfigShape
236
230
 
237
- function flowConnections = () => FlowConnection[]
231
+ function flowEnhanceTag = () => string
238
232
 
239
233
  function For = <T>(props: ForProps<T>) => HtmlNode
240
234
 
241
- function getDurableStore = () => DurableStore
242
-
243
- function getSharedStore = () => SharedStore
244
-
245
235
  function Head = (props: { children?: unknown;}) => HtmlNode
246
236
 
247
237
  function InfiniteScroll = (props: InfiniteScrollProps) => HtmlNode
248
238
 
249
- function isSafeUrl = (url: string) => boolean
250
-
251
- function jsLiteral = (value: unknown) => string
252
-
253
239
  function Label = (props: LabelProps) => HtmlNode
254
240
 
255
241
  function Legend = (props: LegendProps) => HtmlNode
@@ -280,18 +266,10 @@ function presence = (channel: PresenceChannel) => (value: unknown, context: Clas
280
266
 
281
267
  function RadioGroup = (props: RadioGroupProps) => HtmlNode
282
268
 
283
- function registerComponent = (PageClass: typeof Component, path?: string) => void
284
-
285
269
  function registerFlowEvent = <K extends EventName>(name: K, guard: (payload: unknown) => payload is FlowEvents[K]) => void
286
270
 
287
- function registerForm = (FormClass: new () => Form) => void
288
-
289
- function registerSynth = (synth: Synth) => void
290
-
291
271
  function renderless = (_fn: unknown, context: ClassMethodDecoratorContext) => void
292
272
 
293
- function sanitizeUrl = (url: string) => string
294
-
295
273
  function SectionContent = (props: SectionContentProps) => HtmlNode
296
274
 
297
275
  function SectionOutlet = (props: SectionOutletProps) => HtmlNode
@@ -371,15 +349,6 @@ interface CalendarProps = {
371
349
  weekdayClass?: string
372
350
  }
373
351
 
374
- interface CallFrame = {
375
- args: unknown[]
376
- component: string
377
- method: string
378
- snapshot: Snapshot
379
- type: 'call'
380
- updates: Record<string, unknown>
381
- }
382
-
383
352
  interface CheckboxProps = {
384
353
  [key: string]: unknown
385
354
  bind: unknown
@@ -387,12 +356,6 @@ interface CheckboxProps = {
387
356
  class?: string
388
357
  }
389
358
 
390
- interface ChildMemo = {
391
- id: string
392
- key: string
393
- name: string
394
- }
395
-
396
359
  interface ComboboxOption = {
397
360
  label: string
398
361
  value: string | number
@@ -440,13 +403,6 @@ interface DisclosureProps = {
440
403
  trigger?: unknown
441
404
  }
442
405
 
443
- interface DownloadFrame = {
444
- content: string
445
- filename: string
446
- mime: string
447
- type: 'download'
448
- }
449
-
450
406
  interface DrawerProps = {
451
407
  [key: string]: unknown
452
408
  children?: unknown
@@ -469,12 +425,6 @@ interface DropdownProps = {
469
425
  trigger?: unknown
470
426
  }
471
427
 
472
- interface DurableStore = {
473
- delete: (key: string) => Promise<void> | void
474
- get: (key: string) => Promise<Snapshot | undefined> | Snapshot | undefined
475
- set: (key: string, value: Snapshot, ttlMs?: number) => Promise<void> | void
476
- }
477
-
478
428
  interface ErrorBoundaryProps = {
479
429
  [key: string]: unknown
480
430
  children?: unknown
@@ -484,15 +434,6 @@ interface ErrorBoundaryProps = {
484
434
  tag?: string
485
435
  }
486
436
 
487
- interface ErrorFrame = {
488
- action?: string
489
- component: string
490
- message: string
491
- name?: string
492
- stack?: string
493
- type: 'error'
494
- }
495
-
496
437
  interface ErrorProps = {
497
438
  [key: string]: unknown
498
439
  class?: string
@@ -505,20 +446,6 @@ interface ErrorsProps = {
505
446
  only?: string | string[]
506
447
  }
507
448
 
508
- interface ErrorsProxy = {
509
- [key: string]: ErrorField | ((...args: never[]) => unknown)
510
- add: { (field: string, message: string): void; (errors: Record<string, string | string[]>): void;}
511
- any: () => boolean
512
- clear: (field?: string) => void
513
- has: (field: string) => boolean
514
- }
515
-
516
- interface EventFrame = {
517
- data: Record<string, unknown>
518
- name: string
519
- type: 'event'
520
- }
521
-
522
449
  interface FieldProps = {
523
450
  [key: string]: unknown
524
451
  children?: unknown
@@ -583,20 +510,6 @@ interface FlashCallback = {
583
510
  method: string
584
511
  }
585
512
 
586
- interface FlashFrame = {
587
- actions?: FlashAction[]
588
- dismissible?: boolean
589
- duration?: number
590
- icon?: string | false
591
- level: 'success' | 'error' | 'warning' | 'info'
592
- message: string
593
- onClose?: FlashCallback
594
- position?: FlashPosition
595
- progressBar?: boolean
596
- title?: string
597
- type: 'flash'
598
- }
599
-
600
513
  interface FlashMessage = {
601
514
  actions?: FlashAction[]
602
515
  dismissible?: boolean
@@ -658,26 +571,6 @@ interface FlowConfigShape = {
658
571
  persistentMiddleware: (string | MiddlewareClass)[]
659
572
  }
660
573
 
661
- interface FlowConnection = {
662
- actions: number
663
- connectedAt: number
664
- id: string
665
- ip: string
666
- lastActivityAt: number
667
- user: string | null
668
- }
669
-
670
- interface FlowEffects = {
671
- downloads: { filename: string; content: string; mime: string;}[]
672
- errors: Record<string, string[]>
673
- events: { name: string; data: Record<string, unknown>; to?: string; self?: boolean;}[]
674
- flashes: FlashMessage[]
675
- redirectUrl: string | null
676
- scripts: string[]
677
- shouldRefresh: boolean
678
- title: string | null
679
- }
680
-
681
574
  interface FlowEvents = {}
682
575
 
683
576
  interface FlowStore = {}
@@ -757,16 +650,6 @@ interface ModalProps = {
757
650
  title?: unknown
758
651
  }
759
652
 
760
- interface PageClassWithMeta = {
761
- new (): Component
762
- head?: string
763
- interactive?: boolean
764
- layout?: new () => Layout
765
- models?: Record<string, typeof BaseModel>
766
- name: string
767
- title?: string | ((component: never) => string)
768
- }
769
-
770
653
  interface PagerProps = {
771
654
  [key: string]: unknown
772
655
  activeClass?: string
@@ -792,22 +675,6 @@ interface Paginator = {
792
675
  total: number
793
676
  }
794
677
 
795
- interface PatchFrame = {
796
- actionError?: boolean
797
- checksum?: string
798
- component: string
799
- dataDelta?: SnapshotData
800
- dataRemoved?: string[]
801
- errors?: Record<string, string[]>
802
- html?: string
803
- memo?: SnapshotMemo
804
- partial?: boolean
805
- scripts?: string[]
806
- snapshot?: Snapshot
807
- title?: string
808
- type: 'patch'
809
- }
810
-
811
678
  interface PopoverProps = {
812
679
  [key: string]: unknown
813
680
  buttonClass?: string
@@ -844,12 +711,6 @@ interface RedirectFlash = {
844
711
  withWarning: (message: string) => RedirectFlash
845
712
  }
846
713
 
847
- interface RedirectFrame = {
848
- sessionToken?: string
849
- type: 'redirect'
850
- url: string
851
- }
852
-
853
714
  interface SectionContentProps = {
854
715
  children?: unknown
855
716
  name: string
@@ -878,12 +739,6 @@ interface SessionOptions = {
878
739
  scoped?: boolean
879
740
  }
880
741
 
881
- interface SharedStore = {
882
- get: (key: string) => unknown | undefined
883
- has: (key: string) => boolean
884
- set: (key: string, value: unknown) => void
885
- }
886
-
887
742
  interface SkeletonProps = {
888
743
  [key: string]: unknown
889
744
  class?: string
@@ -913,19 +768,6 @@ interface Snapshot = {
913
768
  memo: SnapshotMemo
914
769
  }
915
770
 
916
- interface SnapshotMemo = {
917
- children: string[]
918
- iat?: number | undefined
919
- id: string
920
- listeners?: Record<string, string> | undefined
921
- name: string
922
- path: string
923
- presence?: { prop: string; channel: string;}[] | undefined
924
- shared?: { prop: string; channel: string;}[] | undefined
925
- slots?: Record<string, string> | undefined
926
- sub?: string | undefined
927
- }
928
-
929
771
  interface SwitchProps = {
930
772
  [key: string]: unknown
931
773
  bind: unknown
@@ -933,13 +775,6 @@ interface SwitchProps = {
933
775
  class?: string
934
776
  }
935
777
 
936
- interface Synth = {
937
- dehydrate: (value: T, meta: Record<string, unknown>) => unknown
938
- hydrate: (data: unknown, meta: Record<string, unknown>) => T | Promise<T>
939
- key: string
940
- match: (value: unknown) => value is T
941
- }
942
-
943
778
  interface TabItem = {
944
779
  content: unknown
945
780
  label: unknown
@@ -1048,10 +883,6 @@ type Mixin = (Base: TIn) => TOut
1048
883
 
1049
884
  type PresenceChannel = string | ((self: Record<string, any>) => string)
1050
885
 
1051
- type SharedChannel = string | ((self: Record<string, any>) => string)
1052
-
1053
- type SnapshotData = { [x: string]: SnapshotTuple;}
1054
-
1055
886
  type ValidationRules = { [x: string]: (rule: RuleBuilder) => FieldRule;}
1056
887
 
1057
888
  ## ./browser `(./src/browser/index.ts)`
@@ -1076,18 +907,9 @@ class FlowBrowser = {
1076
907
  waitUntil: (expression: string, label: string, timeoutMs?: number) => Promise<void>
1077
908
  }
1078
909
 
1079
- function findChrome = () => string | null
1080
-
1081
- interface OpenOptions = {
1082
- timeout?: number
1083
- waitForConnection?: boolean
1084
- }
1085
-
1086
910
  ## ./compiler-helpers `(./src/compiler/helpers.ts)`
1087
911
 
1088
- function __esc = (v: unknown) => string
1089
-
1090
- function __escAttr = (v: unknown) => string
912
+ _(no exports)_
1091
913
 
1092
914
  ## ./store `(./src/store.ts)`
1093
915
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -47,15 +47,15 @@
47
47
  "@alpinejs/morph": "^3.14.9",
48
48
  "@alpinejs/persist": "^3.14.9",
49
49
  "@alpinejs/resize": "^3.14.9",
50
- "@zerotal/client": "1.8.0",
51
- "@zerotal/core": "1.8.0",
52
- "@zerotal/orm": "1.8.0",
53
- "@zerotal/validator": "1.8.0",
50
+ "@zerotal/client": "1.9.0",
51
+ "@zerotal/core": "1.9.0",
52
+ "@zerotal/orm": "1.9.0",
53
+ "@zerotal/validator": "1.9.0",
54
54
  "alpinejs": "^3.14.9",
55
55
  "typescript": "^5.8.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@zerotal/broadcasting": "1.8.0",
58
+ "@zerotal/broadcasting": "1.9.0",
59
59
  "@types/alpinejs": "^3.13.10",
60
60
  "@types/alpinejs__anchor": "^3.13.1",
61
61
  "@types/alpinejs__collapse": "^3.13.4",
package/src/Component.ts CHANGED
@@ -68,6 +68,7 @@ export class ErrorField {
68
68
  // this.errors.has("field") → boolean
69
69
  // this.errors.field → ErrorField (for error={this.errors.field})
70
70
 
71
+ /** @internal */
71
72
  export interface ErrorsProxy {
72
73
  /** True when the given field has at least one error. */
73
74
  has(field: string): boolean;
@@ -265,6 +266,8 @@ function _autoChildKey(
265
266
  * The bag of side effects an action queued (flashes, a redirect, client scripts, events,
266
267
  * downloads, a title, and the current error bag). Drained by {@link Component._drainEffects}
267
268
  * and read by the WebSocket handler to build the response frames sent to the client.
269
+ *
270
+ * @internal
268
271
  */
269
272
  export interface FlowEffects {
270
273
  flashes: FlashMessage[];
package/src/Form.ts CHANGED
@@ -29,7 +29,11 @@ import type { FieldRule, Schema } from "@zerotal/validator";
29
29
  /** Registry of Form subclasses by name, used to reconstruct forms on hydration. */
30
30
  const _formClasses = new Map<string, new () => Form>();
31
31
 
32
- /** Register a Form subclass so it can be rebuilt from a snapshot. Called automatically. */
32
+ /**
33
+ * Register a Form subclass so it can be rebuilt from a snapshot. Called automatically.
34
+ *
35
+ * @internal
36
+ */
33
37
  export function registerForm(FormClass: new () => Form): void {
34
38
  _formClasses.set(FormClass.name, FormClass);
35
39
  }
@@ -29,6 +29,7 @@ import { CdpSession, findChrome, launchBrowser, type LaunchedBrowser } from "./c
29
29
  /** How long to wait for a condition driven by a server round-trip. */
30
30
  const DEFAULT_TIMEOUT_MS = 10_000;
31
31
 
32
+ /** @internal */
32
33
  export interface OpenOptions {
33
34
  /** Milliseconds to wait for the page to load and Flow to connect. */
34
35
  timeout?: number;
@@ -42,6 +42,8 @@ const CHROME_PATHS: Record<string, string[]> = {
42
42
  * Returning `null` rather than throwing lets a suite skip itself on a machine
43
43
  * without a browser instead of failing — a browser test that cannot run is not
44
44
  * the same as one that failed.
45
+ *
46
+ * @internal
45
47
  */
46
48
  export function findChrome(): string | null {
47
49
  const override = Bun.env["CHROME_PATH"];
@@ -0,0 +1,205 @@
1
+ // ── @zerotal/flow plain-form enhancement bundle ─────────────────────────────────
2
+ //
3
+ // The entry point for the second Bun.build() in FlowProvider.onBooting(). It
4
+ // produces /__flow/enhance.js.
5
+ //
6
+ // **Deliberately separate from the runtime bundle, and deliberately tiny.** The
7
+ // whole point of this feature is a page with *no Flow component on it* — an
8
+ // ordinary server-rendered form, on a route that is not `Router.flow`. Such a page
9
+ // is never served `/__flow/runtime.js`, so there is no script to hook into; and
10
+ // pulling Alpine and the WebSocket bridge onto a page with no component to drive
11
+ // would be paying the entire framework's client cost for a submit handler.
12
+ //
13
+ // **This file exports nothing, and must not.** It is loaded as a classic script,
14
+ // where a trailing `export` statement is a SyntaxError that discards the whole
15
+ // bundle — silently, because the forms then post natively and the page still
16
+ // works. The decisions worth testing live in `enhanceSupport.ts`, which Bun inlines
17
+ // back in at build time.
18
+ //
19
+ // ## The contract
20
+ //
21
+ // <form method="post" action="/subscribe" data-enhance>
22
+ //
23
+ // The form posts through `fetch`, and the matching form in the response replaces
24
+ // this one in place. Validation errors then appear where they belong and the rest
25
+ // of the page is untouched — no flash, no scroll loss, no re-render of a page that
26
+ // did not change.
27
+ //
28
+ // Everything about it degrades:
29
+ //
30
+ // - **No JavaScript is an ordinary post.** `data-enhance` is additive on a form
31
+ // that already has `method` and `action`, so the no-JS path is not a fallback
32
+ // that was written and might rot — it is the form itself, unmodified. If that
33
+ // is ever not free, the word "enhancement" is doing no work.
34
+ // - **A network failure re-submits natively.** The difference between a bad
35
+ // connection being slow and a bad connection losing what somebody typed.
36
+ // - **A redirect is followed**, its document swapped in and `pushState`d, so the
37
+ // address bar agrees with the page.
38
+
39
+ import { ATTR, BUSY_ATTR, matchIn, sameUrl, targetFor } from "./enhanceSupport.ts";
40
+
41
+ /**
42
+ * Move focus back to where it was before the swap.
43
+ *
44
+ * Replacing a subtree destroys the focused element, and a form that reports "this
45
+ * field is required" having just taken the cursor away from that field is worse
46
+ * than one that did not enhance at all. Matched by `name`, which survives a
47
+ * re-render in a way a DOM node cannot.
48
+ */
49
+ function restoreFocus(root: Element, name: string | null, start: number | null): void {
50
+ if (!name) return;
51
+ const next = root.querySelector<HTMLInputElement>(`[name="${CSS.escape(name)}"]`);
52
+ if (!next) return;
53
+ next.focus();
54
+ if (start === null) return;
55
+ try {
56
+ next.setSelectionRange(start, start);
57
+ } catch {
58
+ // Not a text-like control (checkbox, select, file) — focus alone is the job.
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Submit the form the way the browser would have, with the enhancement out of the
64
+ * way so this handler does not catch its own fallback.
65
+ */
66
+ function nativeSubmit(form: HTMLFormElement, submitter: HTMLElement | null): void {
67
+ form.removeAttribute(ATTR);
68
+ if (submitter && "click" in submitter) {
69
+ // Through the submitter, so a named button still contributes its value.
70
+ submitter.click();
71
+ return;
72
+ }
73
+ form.submit();
74
+ }
75
+
76
+ /**
77
+ * Handle one enhanced submission.
78
+ *
79
+ * Throws nothing: every failure path either falls back to a native submit or
80
+ * leaves the page exactly as it was.
81
+ */
82
+ async function submitEnhanced(form: HTMLFormElement, submitter: HTMLElement | null): Promise<void> {
83
+ const method = (form.getAttribute("method") || "get").toUpperCase();
84
+ const action = form.getAttribute("action") || window.location.href;
85
+
86
+ // `new FormData(form, submitter)` includes the button that was actually pressed,
87
+ // which is how `<button name="intent" value="delete">` tells the server which one.
88
+ const data = new FormData(form, submitter as HTMLButtonElement | null);
89
+
90
+ let url = action;
91
+ let body: FormData | null = data;
92
+ if (method === "GET") {
93
+ // A GET form carries its fields in the query string, not a body.
94
+ const query = new URLSearchParams();
95
+ for (const [key, value] of data.entries()) {
96
+ if (typeof value === "string") query.append(key, value);
97
+ }
98
+ const separator = action.includes("?") ? "&" : "?";
99
+ url = query.toString() ? `${action}${separator}${query}` : action;
100
+ body = null;
101
+ }
102
+
103
+ const target = targetFor(form, document, (selector) =>
104
+ console.warn(`[flow] data-enhance-target="${selector}" matched nothing; replacing the form.`),
105
+ );
106
+
107
+ const active = document.activeElement as HTMLInputElement | null;
108
+ const focusedName = active && form.contains(active) ? active.getAttribute("name") : null;
109
+ const caret = (() => {
110
+ try {
111
+ return active?.selectionStart ?? null;
112
+ } catch {
113
+ return null;
114
+ }
115
+ })();
116
+
117
+ form.setAttribute(BUSY_ATTR, "");
118
+
119
+ let response: Response;
120
+ let html: string;
121
+ try {
122
+ response = await fetch(url, {
123
+ method,
124
+ ...(body ? { body } : {}),
125
+ credentials: "same-origin",
126
+ redirect: "follow",
127
+ headers: { "X-Flow-Enhance": "1", Accept: "text/html" },
128
+ });
129
+ html = await response.text();
130
+ } catch {
131
+ // The network, not the server. Hand the submission back to the browser, which
132
+ // shows its own error and — crucially — has not lost the form data.
133
+ form.removeAttribute(BUSY_ATTR);
134
+ nativeSubmit(form, submitter);
135
+ return;
136
+ }
137
+
138
+ const parsed = new DOMParser().parseFromString(html, "text/html");
139
+
140
+ // `fetch` followed the redirect for us, so a changed URL means the server sent us
141
+ // somewhere. Swap the whole document and record the move, or the address bar
142
+ // keeps claiming we are on the page that did the posting.
143
+ if (response.redirected || !sameUrl(response.url, url, window.location.href)) {
144
+ document.documentElement.replaceWith(document.importNode(parsed.documentElement, true));
145
+ window.history.pushState({}, "", response.url);
146
+ window.dispatchEvent(new CustomEvent("flow:enhanced", { detail: { navigated: true } }));
147
+ return;
148
+ }
149
+
150
+ const replacement = matchIn(parsed, document, form, target);
151
+ if (!replacement) {
152
+ // The response has no counterpart for what we were going to replace — the page
153
+ // changed shape. Guessing would put arbitrary markup somewhere arbitrary; a
154
+ // full navigation is the honest answer.
155
+ window.location.href = response.url || url;
156
+ return;
157
+ }
158
+
159
+ const adopted = document.importNode(replacement, true);
160
+ target.replaceWith(adopted);
161
+ adopted.removeAttribute(BUSY_ATTR);
162
+ restoreFocus(adopted, focusedName, caret);
163
+ window.dispatchEvent(
164
+ new CustomEvent("flow:enhanced", { detail: { navigated: false, status: response.status } }),
165
+ );
166
+ }
167
+
168
+ /**
169
+ * One delegated listener on the document, for every enhanced form that exists now
170
+ * or is added later.
171
+ *
172
+ * Delegation rather than per-form binding because the swap above replaces forms
173
+ * with fresh nodes: anything bound directly would be discarded by the first
174
+ * successful submission, and the second one would post natively for no visible
175
+ * reason.
176
+ */
177
+ function install(): void {
178
+ document.addEventListener(
179
+ "submit",
180
+ (event) => {
181
+ const form = (event.target as HTMLElement | null)?.closest?.(`form[${ATTR}]`) as
182
+ HTMLFormElement | null | undefined;
183
+ if (!form) return;
184
+ // Let a default-prevented submit stay prevented; another handler owns it.
185
+ if (event.defaultPrevented) return;
186
+ // An explicit opt-out on a form that carries the attribute for styling.
187
+ if (form.getAttribute(ATTR) === "false") return;
188
+ // A submission already in flight: drop the second rather than racing two
189
+ // responses into the same target.
190
+ if (form.hasAttribute(BUSY_ATTR)) {
191
+ event.preventDefault();
192
+ return;
193
+ }
194
+
195
+ event.preventDefault();
196
+ void submitEnhanced(form, (event as SubmitEvent).submitter);
197
+ },
198
+ // Bubble phase: a component's own handler gets to preventDefault first.
199
+ false,
200
+ );
201
+ }
202
+
203
+ if (typeof document !== "undefined") {
204
+ install();
205
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The parts of the plain-form enhancement that are decisions rather than effects.
3
+ *
4
+ * Split out of `enhance.ts` for a reason worth recording. That file is served as a
5
+ * **classic script** — `<script src="/__flow/enhance.js" defer>` on pages that have
6
+ * no module loader and no framework on them. A bundle ending in an `export`
7
+ * statement is a `SyntaxError` in that position, and the browser discards the whole
8
+ * file. It does so *silently*: the forms fall back to native posts and the page
9
+ * still works, so the enhancement is simply absent on exactly the pages it was
10
+ * added for, with nothing in the console to say why.
11
+ *
12
+ * So the entry exports nothing, and everything worth asserting on lives here, where
13
+ * a test can import it. Bun inlines this back into the bundle at build time, so the
14
+ * split costs the browser nothing.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ /** Marks a form as enhanced. */
20
+ export const ATTR = "data-enhance";
21
+
22
+ /** Overrides what gets replaced: any CSS selector. */
23
+ export const TARGET_ATTR = "data-enhance-target";
24
+
25
+ /** Set on a form while its submission is in flight, for styling and re-entry guards. */
26
+ export const BUSY_ATTR = "data-enhance-busy";
27
+
28
+ /**
29
+ * Whether two URLs name the same place, resolved against the document.
30
+ *
31
+ * Both sides are resolved before comparing, because one of them is whatever the
32
+ * author wrote in `action` — usually relative — and the other is `response.url`,
33
+ * which `fetch` always reports absolute. Comparing them raw would call every
34
+ * ordinary submission a redirect.
35
+ */
36
+ export function sameUrl(a: string, b: string, baseHref: string): boolean {
37
+ if (!a || !b) return false;
38
+ try {
39
+ return new URL(a, baseHref).href === new URL(b, baseHref).href;
40
+ } catch {
41
+ return a === b;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Where a form's response should be written.
47
+ *
48
+ * The form itself by default. That is the useful default because it is what makes
49
+ * validation errors land where the person is looking without the page author
50
+ * naming anything — and because the form is the one element guaranteed to exist in
51
+ * the response, given the server just re-rendered it.
52
+ *
53
+ * @param onMissing - Called when `data-enhance-target` matches nothing.
54
+ */
55
+ export function targetFor(
56
+ form: HTMLFormElement,
57
+ root: ParentNode,
58
+ onMissing: (selector: string) => void,
59
+ ): Element {
60
+ const selector = form.getAttribute(TARGET_ATTR);
61
+ if (!selector) return form;
62
+ const found = root.querySelector(selector);
63
+ if (found) return found;
64
+ // A selector matching nothing is an authoring mistake. Swapping the form instead
65
+ // is both harmless and visible: the page still works and the console says why it
66
+ // did not do what was asked.
67
+ onMissing(selector);
68
+ return form;
69
+ }
70
+
71
+ /**
72
+ * Find, in a freshly parsed response document, the element corresponding to
73
+ * `target` in the live one.
74
+ *
75
+ * Identity is by `id` when there is one, then by the selector the author gave, then
76
+ * — for a bare `<form data-enhance>` — by position among the enhanced forms on the
77
+ * page. The positional fallback is what lets the simplest possible usage work with
78
+ * no attributes at all, and it is why `id` is tried first: two forms that change
79
+ * order between renders would otherwise swap content.
80
+ */
81
+ export function matchIn(
82
+ doc: Document,
83
+ live: ParentNode,
84
+ form: HTMLFormElement,
85
+ target: Element,
86
+ ): Element | null {
87
+ if (target.id) {
88
+ const byId = doc.getElementById(target.id);
89
+ if (byId) return byId;
90
+ }
91
+
92
+ const selector = form.getAttribute(TARGET_ATTR);
93
+ if (selector) return doc.querySelector(selector);
94
+
95
+ const forms = Array.from(live.querySelectorAll(`form[${ATTR}]`));
96
+ const index = forms.indexOf(form);
97
+ const candidates = doc.querySelectorAll(`form[${ATTR}]`);
98
+ return index >= 0 ? (candidates[index] ?? null) : (candidates[0] ?? null);
99
+ }
@@ -13,13 +13,21 @@
13
13
  * @packageDocumentation
14
14
  */
15
15
 
16
- /** Escape a value for safe injection as HTML text content. */
16
+ /**
17
+ * Escape a value for safe injection as HTML text content.
18
+ *
19
+ * @internal
20
+ */
17
21
  export function __esc(v: unknown): string {
18
22
  if (v == null || v === false || v === true) return "";
19
23
  return String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
24
  }
21
25
 
22
- /** Escape a value for safe injection inside an HTML attribute (double-quoted). */
26
+ /**
27
+ * Escape a value for safe injection inside an HTML attribute (double-quoted).
28
+ *
29
+ * @internal
30
+ */
23
31
  export function __escAttr(v: unknown): string {
24
32
  if (v == null) return "";
25
33
  return String(v).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
package/src/config.ts CHANGED
@@ -23,7 +23,11 @@ export interface FlowConfigShape {
23
23
  persistentMiddleware: (string | MiddlewareClass)[];
24
24
  }
25
25
 
26
- /** Global middleware re-run on every WebSocket action unless overridden. */
26
+ /**
27
+ * Global middleware re-run on every WebSocket action unless overridden.
28
+ *
29
+ * @internal
30
+ */
27
31
  export const DEFAULT_PERSISTENT_MIDDLEWARE: (string | MiddlewareClass)[] = [
28
32
  "SessionMiddleware",
29
33
  "PersistUserMiddleware",
package/src/decorators.ts CHANGED
@@ -732,6 +732,8 @@ export function resolvePresenceChannel(channel: PresenceChannel, instance: objec
732
732
  * component instance to compute a per-instance name (for dynamic rooms).
733
733
  *
734
734
  * @category Realtime
735
+ *
736
+ * @internal
735
737
  */
736
738
  export type SharedChannel = string | ((self: Record<string, any>) => string);
737
739
 
package/src/durable.ts CHANGED
@@ -66,6 +66,8 @@ interface DurableConfig {
66
66
  * `@zerotal/cache`/Redis) via {@link setDurableStore} to also survive redeploys.
67
67
  * Methods may be sync or async; a swapped store is responsible for its own TTL
68
68
  * eviction (the `ttlMs` hint is passed to {@link DurableStore.set | set}).
69
+ *
70
+ * @internal
69
71
  */
70
72
  export interface DurableStore {
71
73
  /** Fetch a stored snapshot by key, or `undefined` if absent/expired. */
@@ -130,6 +132,8 @@ export function setDurableStore(store: DurableStore): void {
130
132
  * inspects persisted snapshots directly.
131
133
  *
132
134
  * @returns The current {@link DurableStore}.
135
+ *
136
+ * @internal
133
137
  */
134
138
  export function getDurableStore(): DurableStore {
135
139
  return _store;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The one line an app's layout needs for `<form data-enhance>` to work.
3
+ *
4
+ * A helper rather than a documented string, for the reason a sibling feature
5
+ * already learned the hard way: a resource nothing links is a resource nobody
6
+ * discovers is missing. A page whose layout forgot the tag does not fail — the
7
+ * forms simply post the way they always did, and the enhancement is silently
8
+ * absent on exactly the pages it was added for.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /** The path `FlowProvider` serves the enhancement bundle at. */
14
+ export const FLOW_ENHANCE_PATH = "/__flow/enhance.js";
15
+
16
+ /**
17
+ * A `<script>` tag loading the plain-form enhancement.
18
+ *
19
+ * Put it in the layout that renders your non-Flow pages. Flow pages already get
20
+ * `/__flow/runtime.js`, which does not include this — the two are separate because
21
+ * the enhancement exists for pages that have no Flow component to run a runtime.
22
+ *
23
+ * `defer` so it never blocks the parse, and the listener is delegated on
24
+ * `document`, so a form that appears later is picked up without re-running
25
+ * anything.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * <head>
30
+ * {flowEnhanceTag()}
31
+ * </head>
32
+ * ```
33
+ */
34
+ export function flowEnhanceTag(): string {
35
+ return `<script src="${FLOW_ENHANCE_PATH}" defer></script>`;
36
+ }
package/src/index.ts CHANGED
@@ -74,6 +74,7 @@ export type {
74
74
  // `class PostsPage extends Component.using(Pagination, …)`.
75
75
  export type { Constructor, Mixin, Compose } from "./mixins.ts";
76
76
  export { Layout } from "./Layout.ts";
77
+ export { flowEnhanceTag, FLOW_ENHANCE_PATH } from "./enhanceTag.ts";
77
78
  export { Form, registerForm } from "./Form.ts";
78
79
  // Global client store — app-wide, client-only reactive UI state, read in JSX as
79
80
  // `$flow.$store.*`. `defineStore` declares its initial shape at app start; augment
@@ -99,6 +99,17 @@ export function _devErrorInfo(error: unknown, action: string): DevErrorInfo {
99
99
 
100
100
  let _runtimeBundle: string | null = null;
101
101
 
102
+ /**
103
+ * The `/__flow/enhance.js` bundle — the standalone plain-form enhancement.
104
+ *
105
+ * Separate from the runtime bundle on purpose. `/__flow/runtime.js` is injected
106
+ * only by `Router.flow` routes, and the whole point of `<form data-enhance>` is a
107
+ * page with no Flow component on it, which is therefore never served that script.
108
+ * It is also tiny and dependency-free, so an app that wants a form not to flash
109
+ * does not pay for Alpine and a WebSocket bridge to get it.
110
+ */
111
+ let _enhanceBundle: string | null = null;
112
+
102
113
  /**
103
114
  * CSP-safe mode flag (no 'unsafe-eval'). Set `flow.cspSafe` in `config/flow.ts`,
104
115
  * or the ZT_FLOW_CSP_SAFE / APP_CSP_SAFE env flag when there is no config file.
@@ -113,6 +124,51 @@ export function _runtimeJs(): string | null {
113
124
  return _runtimeBundle;
114
125
  }
115
126
 
127
+ /** @internal — the built plain-form enhancement bundle, or null when it failed to build. */
128
+ export function _enhanceJs(): string | null {
129
+ return _enhanceBundle;
130
+ }
131
+
132
+ /** In-flight build, so concurrent first requests share one rather than racing. */
133
+ let _enhanceBuild: Promise<string | null> | null = null;
134
+
135
+ /**
136
+ * Build the enhancement bundle once, on first request.
137
+ *
138
+ * No CSP variant: it has no expression evaluator to swap, because it evaluates
139
+ * nothing — it reads attributes and calls `fetch`.
140
+ *
141
+ * @internal
142
+ */
143
+ async function _buildEnhanceBundle(): Promise<string | null> {
144
+ if (_enhanceBundle) return _enhanceBundle;
145
+ // Cache the promise, not the result — two requests arriving together would
146
+ // otherwise both bundle.
147
+ _enhanceBuild ??= (async () => {
148
+ try {
149
+ const entry = fileURLToPath(new URL("../client/enhance.ts", import.meta.url));
150
+ const result = await Bun.build({
151
+ entrypoints: [entry],
152
+ target: "browser",
153
+ minify: isProdLike(deployEnv()),
154
+ format: "esm",
155
+ });
156
+ if (result.success && result.outputs.length > 0) {
157
+ _enhanceBundle = await result.outputs[0]!.text();
158
+ return _enhanceBundle;
159
+ }
160
+ console.warn("[Flow] Enhancement bundle build failed:", result.logs);
161
+ } catch (e) {
162
+ console.warn("[Flow] Could not build enhancement bundle:", e);
163
+ }
164
+ // A failed build is not cached: the next request tries again, which is right
165
+ // for a dev-time transient and harmless for a real one.
166
+ _enhanceBuild = null;
167
+ return null;
168
+ })();
169
+ return _enhanceBuild;
170
+ }
171
+
116
172
  // ── Rate limiter for HMAC failures ───────────────────────────────────────────
117
173
 
118
174
  const _checksumFailures = new Map<string, { count: number; since: number }>();
@@ -1029,6 +1085,38 @@ export class FlowProvider extends ServiceProvider {
1029
1085
  console.warn("[Flow] Could not build client bundle:", e);
1030
1086
  }
1031
1087
 
1088
+ // Serve the plain-form enhancement bundle. Deliberately cacheable, unlike the
1089
+ // runtime: it carries no per-app route table, so there is nothing in it that can
1090
+ // go stale between deploys of the same framework version.
1091
+ //
1092
+ // Built on first request rather than at boot. Most apps never serve a page that
1093
+ // uses `<form data-enhance>` — it is for the pages Flow does not otherwise touch
1094
+ // — so building it at boot spends time on an artefact nobody asks for, in every
1095
+ // process, including every test that boots a provider.
1096
+ Router.get(
1097
+ "/__flow/enhance.js",
1098
+ class FlowEnhanceHandler {
1099
+ async handle(http: HttpContext): Promise<void> {
1100
+ const bundle = await _buildEnhanceBundle();
1101
+ if (!bundle) {
1102
+ http.response = new Response("// Flow enhancement unavailable", {
1103
+ status: 503,
1104
+ headers: { "Content-Type": "text/javascript; charset=utf-8" },
1105
+ });
1106
+ return;
1107
+ }
1108
+ http.response = new Response(bundle, {
1109
+ status: 200,
1110
+ headers: {
1111
+ "Content-Type": "text/javascript; charset=utf-8",
1112
+ "Cache-Control": "public, max-age=3600",
1113
+ },
1114
+ });
1115
+ }
1116
+ },
1117
+ "handle",
1118
+ );
1119
+
1032
1120
  // Serve the client-side runtime bundle
1033
1121
  Router.get(
1034
1122
  "/__flow/runtime.js",
@@ -1371,6 +1459,8 @@ let _activeConnections = 0;
1371
1459
  * Number of currently-open Flow (reactive SSR) WebSocket connections.
1372
1460
  * @returns The live count of open `/__flow/ws` connections.
1373
1461
  * @category Connections
1462
+ *
1463
+ * @internal
1374
1464
  */
1375
1465
  export function flowActiveConnections(): number {
1376
1466
  return _activeConnections;
@@ -1379,6 +1469,8 @@ export function flowActiveConnections(): number {
1379
1469
  /**
1380
1470
  * One currently-open Flow WebSocket client, enriched as actions identify the user.
1381
1471
  * @category Connections
1472
+ *
1473
+ * @internal
1382
1474
  */
1383
1475
  export interface FlowConnection {
1384
1476
  id: string;
@@ -1397,6 +1489,8 @@ const _connections = new Map<string, FlowConnection>();
1397
1489
  * Snapshot of currently-connected Flow clients (most-recently-active first).
1398
1490
  * @returns An array of {@link FlowConnection}, sorted by `lastActivityAt` descending.
1399
1491
  * @category Connections
1492
+ *
1493
+ * @internal
1400
1494
  */
1401
1495
  export function flowConnections(): FlowConnection[] {
1402
1496
  return [..._connections.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt);
package/src/registry.ts CHANGED
@@ -56,6 +56,8 @@ export function _getPageEntries(): Map<string, PageEntry> {
56
56
  * // In a ServiceProvider's boot method:
57
57
  * registerComponent(CounterWidget);
58
58
  * ```
59
+ *
60
+ * @internal
59
61
  */
60
62
  export function registerComponent(PageClass: typeof Component, path = ""): void {
61
63
  if (!_pages.has(PageClass.name)) registerPage(path, PageClass);
@@ -103,6 +105,8 @@ export { getRenderlessMethods };
103
105
  * A Component constructor plus the optional static metadata a page class may declare
104
106
  * (`title`, `head`, `models`, `layout`). Used when resolving file-route page exports and
105
107
  * when auto-registering associated model classes.
108
+ *
109
+ * @internal
106
110
  */
107
111
  export interface PageClassWithMeta {
108
112
  new (): Component;
package/src/shared.ts CHANGED
@@ -35,6 +35,8 @@ export const SHARED_EVENT = "flow:shared";
35
35
  * The default is an in-process {@link MemorySharedStore}; swap a distributed
36
36
  * implementation (e.g. Redis-backed) via {@link setSharedStore} to converge
37
37
  * across processes. Implement all three methods synchronously.
38
+ *
39
+ * @internal
38
40
  */
39
41
  export interface SharedStore {
40
42
  /** Read the current value for a `channel::prop` key, or `undefined` if unset. */
@@ -93,6 +95,8 @@ export function setSharedStore(store: SharedStore): void {
93
95
  * @expose increment() { this.count++; } // written back + broadcast on commit
94
96
  * }
95
97
  * ```
98
+ *
99
+ * @internal
96
100
  */
97
101
  export function getSharedStore(): SharedStore {
98
102
  return _store;
@@ -1,5 +1,6 @@
1
1
  import type { BaseModel } from "@zerotal/orm";
2
2
  import { modelsByName } from "@zerotal/orm";
3
+ import { isProdLike, deployEnv } from "@zerotal/core";
3
4
  import { registerSynth } from "./index.ts";
4
5
 
5
6
  // Models register themselves here when their class is imported.
@@ -169,6 +170,71 @@ export function _writableFields(ModelClass: typeof BaseModel): readonly string[]
169
170
  return fields;
170
171
  }
171
172
 
173
+ /** Model class → fields already reported, so a hundred-row list logs once. */
174
+ const _warnedUnfillable = new WeakMap<object, Set<string>>();
175
+
176
+ /**
177
+ * Say that a bound field was dropped, once per class per field.
178
+ *
179
+ * A `flow:model` on a column missing from `fillable` is almost always a typo or a
180
+ * forgotten entry, and the symptom is a form that appears to work and saves
181
+ * nothing. The alternative — throwing — is not available: the same path receives
182
+ * whatever a browser chooses to send, and a hostile payload naming an unknown key
183
+ * must not be able to 500 the page. So: keep dropping, and say so where the person
184
+ * who can fix it will see it.
185
+ *
186
+ * Keyed per class *and* per field, so a second unfillable field on the same model
187
+ * is still reported. Silent in production, where nobody is reading the log for
188
+ * this and the payload may well be hostile.
189
+ */
190
+ function _warnUnfillableBinding(ModelClass: typeof BaseModel, dropped: string[]): void {
191
+ if (_isProductionRuntime()) return;
192
+
193
+ let seen = _warnedUnfillable.get(ModelClass);
194
+ if (!seen) {
195
+ seen = new Set<string>();
196
+ _warnedUnfillable.set(ModelClass, seen);
197
+ }
198
+ const fresh = dropped.filter((field) => !seen.has(field));
199
+ if (fresh.length === 0) return;
200
+ for (const field of fresh) seen.add(field);
201
+
202
+ const name = ModelClass.name || "the model";
203
+ const fillable = [...(ModelClass.fillable ?? [])];
204
+ const listed = fillable.map((f) => `"${f}"`).join(", ") || "empty";
205
+ console.warn(
206
+ `[Zerotal Flow] ${name} received ${fresh.map((f) => `\`${f}\``).join(", ")} from the ` +
207
+ `browser and dropped ${fresh.length === 1 ? "it" : "them"}: not in \`fillable\`, so ` +
208
+ `nothing was written and nothing failed.\n` +
209
+ ` fillable is [${listed}].\n` +
210
+ ` If the binding is intended, add the field: static fillable = […, "${fresh[0]}"];\n` +
211
+ ` If it is not, remove the flow:model — mass assignment is the reason this list exists.`,
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Forget what has been warned about, for one model class. Tests.
217
+ *
218
+ * A `WeakMap` has no `clear()`, and it does not need one here: every key a test
219
+ * seeds is a class the test itself owns, and dropping that one entry is the whole
220
+ * reset. Classes from other tests are unreachable by then.
221
+ *
222
+ * @internal
223
+ */
224
+ export function _resetUnfillableWarnings(ModelClass: object): void {
225
+ _warnedUnfillable.delete(ModelClass);
226
+ }
227
+
228
+ /**
229
+ * Whether this process is a production deployment.
230
+ *
231
+ * `deployEnv()`, not `APP_ENV` — the latter holds the runtime mode once the app
232
+ * has booted, so reading it answered "no" on every production server.
233
+ */
234
+ function _isProductionRuntime(): boolean {
235
+ return isProdLike(deployEnv()) || Bun.env["NODE_ENV"] === "production";
236
+ }
237
+
172
238
  /**
173
239
  * Whether a value is an instance of a model registered with {@link registerFlowModel}.
174
240
  *
@@ -266,12 +332,22 @@ export function _restoreSnapshotValues(model: object, data: unknown): void {
266
332
  export function _applyWireValues(model: object, data: unknown): void {
267
333
  if (data === null || typeof data !== "object" || Array.isArray(data)) return;
268
334
 
269
- const allowed = new Set(_writableFields(model.constructor as typeof BaseModel));
335
+ const ModelClass = model.constructor as typeof BaseModel;
336
+ const allowed = new Set(_writableFields(ModelClass));
270
337
  const incoming: Record<string, unknown> = {};
338
+ const dropped: string[] = [];
271
339
  for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
272
340
  if (allowed.has(key)) incoming[key] = value;
341
+ else dropped.push(key);
273
342
  }
274
343
 
344
+ // Dropping is right — a hostile payload must not become a 500, and it must not
345
+ // write a column nobody allowed. What was wrong is that it happened in silence:
346
+ // bind a field that is not `fillable` and there is no write, no error and no
347
+ // clue, which is the failure shape this project's own severity key calls the
348
+ // worst kind. Development only, once per class and field.
349
+ if (dropped.length > 0) _warnUnfillableBinding(ModelClass, dropped);
350
+
275
351
  if (Object.keys(incoming).length === 0) return;
276
352
 
277
353
  // `fill` is typed `UpdatePayload<this>`, which cannot be expressed for a model this
@@ -22,6 +22,8 @@
22
22
  * synth re-fetches by id).
23
23
  *
24
24
  * @typeParam T - The live value type this synth handles.
25
+ *
26
+ * @internal
25
27
  */
26
28
  export interface Synth<T = unknown> {
27
29
  /** Short tag stored as `meta.s` in the snapshot, e.g. `'cbn'`, `'mdl'`. */
@@ -54,6 +56,8 @@ const _synths: Synth[] = [];
54
56
  * hydrate: (data, meta) => new Money(data as number, meta.cur as string),
55
57
  * });
56
58
  * ```
59
+ *
60
+ * @internal
57
61
  */
58
62
  export function registerSynth(synth: Synth): void {
59
63
  _synths.unshift(synth); // user-registered synths take priority
package/src/types.ts CHANGED
@@ -13,6 +13,8 @@ export type SnapshotTuple = [data: unknown, meta: Record<string, unknown>];
13
13
  /**
14
14
  * The "data" map inside a snapshot: `propertyName → [value, meta]`.
15
15
  * @category Snapshot
16
+ *
17
+ * @internal
16
18
  */
17
19
  export type SnapshotData = Record<string, SnapshotTuple>;
18
20
 
@@ -20,6 +22,8 @@ export type SnapshotData = Record<string, SnapshotTuple>;
20
22
  * Metadata for a child Component component embedded inside a parent's render output.
21
23
  * Stored in memo.children (excluded from HMAC signing).
22
24
  * @category Snapshot
25
+ *
26
+ * @internal
23
27
  */
24
28
  export interface ChildMemo {
25
29
  /** The JSX `key` prop that identifies this slot in the parent's template. */
@@ -33,6 +37,8 @@ export interface ChildMemo {
33
37
  /**
34
38
  * Component identity / routing metadata embedded in the snapshot.
35
39
  * @category Snapshot
40
+ *
41
+ * @internal
36
42
  */
37
43
  export interface SnapshotMemo {
38
44
  id: string;
@@ -92,6 +98,8 @@ export interface Snapshot {
92
98
  /**
93
99
  * Outbound WS frame sent by the client to the server.
94
100
  * @category Frames
101
+ *
102
+ * @internal
95
103
  */
96
104
  export interface CallFrame {
97
105
  type: "call";
@@ -112,6 +120,8 @@ export interface CallFrame {
112
120
  * legacy `snapshot` field remains as a full fallback. `html` is omitted when the
113
121
  * re-render is byte-identical to the last patch on this connection (HTML suppression).
114
122
  * @category Frames
123
+ *
124
+ * @internal
115
125
  */
116
126
  export interface PatchFrame {
117
127
  type: "patch";
@@ -215,6 +225,8 @@ export interface FlashMessage {
215
225
  /**
216
226
  * Flash notification frame.
217
227
  * @category Frames
228
+ *
229
+ * @internal
218
230
  */
219
231
  export interface FlashFrame extends FlashMessage {
220
232
  type: "flash";
@@ -223,6 +235,8 @@ export interface FlashFrame extends FlashMessage {
223
235
  /**
224
236
  * Programmatic redirect frame.
225
237
  * @category Frames
238
+ *
239
+ * @internal
226
240
  */
227
241
  export interface RedirectFrame {
228
242
  type: "redirect";
@@ -233,6 +247,8 @@ export interface RedirectFrame {
233
247
  /**
234
248
  * Error frame (action threw, onError ran).
235
249
  * @category Frames
250
+ *
251
+ * @internal
236
252
  */
237
253
  export interface ErrorFrame {
238
254
  type: "error";
@@ -261,6 +277,8 @@ export interface StreamFrame {
261
277
  /**
262
278
  * Cross-component event frame.
263
279
  * @category Frames
280
+ *
281
+ * @internal
264
282
  */
265
283
  export interface EventFrame {
266
284
  type: "event";
@@ -271,6 +289,8 @@ export interface EventFrame {
271
289
  /**
272
290
  * File download frame.
273
291
  * @category Frames
292
+ *
293
+ * @internal
274
294
  */
275
295
  export interface DownloadFrame {
276
296
  type: "download";
package/src/urlSafety.ts CHANGED
Binary file
package/src/utils.ts CHANGED
@@ -40,6 +40,8 @@ export function toScriptJson(value: unknown): string {
40
40
  *
41
41
  * @example
42
42
  * "x-on:click": `tab = ${jsLiteral(name)}` // tab = "my-tab"
43
+ *
44
+ * @internal
43
45
  */
44
46
  export function jsLiteral(value: unknown): string {
45
47
  return JSON.stringify(String(value));