@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 +57 -0
- package/api-surface.md +3 -181
- package/package.json +6 -6
- package/src/Component.ts +3 -0
- package/src/Form.ts +5 -1
- package/src/browser/FlowBrowser.ts +1 -0
- package/src/browser/cdp.ts +2 -0
- package/src/client/enhance.ts +205 -0
- package/src/client/enhanceSupport.ts +99 -0
- package/src/compiler/helpers.ts +10 -2
- package/src/config.ts +5 -1
- package/src/decorators.ts +2 -0
- package/src/durable.ts +4 -0
- package/src/enhanceTag.ts +36 -0
- package/src/index.ts +1 -0
- package/src/provider/FlowProvider.ts +94 -0
- package/src/registry.ts +4 -0
- package/src/shared.ts +4 -0
- package/src/synths/ModelSynth.ts +77 -1
- package/src/synths/index.ts +4 -0
- package/src/types.ts +20 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +2 -0
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
51
|
-
"@zerotal/core": "1.
|
|
52
|
-
"@zerotal/orm": "1.
|
|
53
|
-
"@zerotal/validator": "1.
|
|
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.
|
|
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
|
-
/**
|
|
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;
|
package/src/browser/cdp.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/compiler/helpers.ts
CHANGED
|
@@ -13,13 +13,21 @@
|
|
|
13
13
|
* @packageDocumentation
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
/**
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
/**
|
|
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, "&").replace(/"/g, """);
|
package/src/config.ts
CHANGED
|
@@ -23,7 +23,11 @@ export interface FlowConfigShape {
|
|
|
23
23
|
persistentMiddleware: (string | MiddlewareClass)[];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
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;
|
package/src/synths/ModelSynth.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/synths/index.ts
CHANGED
|
@@ -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