@zerotal/flow 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/package.json +84 -0
- package/src/Component.ts +1498 -0
- package/src/Form.ts +189 -0
- package/src/Layout.ts +47 -0
- package/src/augment.ts +94 -0
- package/src/client/FlowClientError.ts +12 -0
- package/src/client/FlowComponent.ts +96 -0
- package/src/client/alpinejs-morph.d.ts +5 -0
- package/src/client/bridge.ts +3463 -0
- package/src/client/confirm.ts +338 -0
- package/src/client/cspAdapter.ts +79 -0
- package/src/client/cspEvaluator.ts +494 -0
- package/src/client/errorOverlay.ts +124 -0
- package/src/client/headless.ts +948 -0
- package/src/client/index.csp.ts +47 -0
- package/src/client/index.ts +56 -0
- package/src/client/optimistic.ts +52 -0
- package/src/client/timeline.ts +160 -0
- package/src/client/url.ts +51 -0
- package/src/commands/MakeFlowCommand.ts +290 -0
- package/src/compiler/cache.ts +101 -0
- package/src/compiler/directives.ts +107 -0
- package/src/compiler/helpers.ts +26 -0
- package/src/compiler/index.ts +172 -0
- package/src/compiler/transform.ts +2070 -0
- package/src/components.ts +1465 -0
- package/src/config.ts +52 -0
- package/src/decorators.ts +978 -0
- package/src/dehydrate.ts +333 -0
- package/src/durable.ts +243 -0
- package/src/events.ts +118 -0
- package/src/frameworkEvents.ts +40 -0
- package/src/headless.ts +1147 -0
- package/src/index.ts +267 -0
- package/src/jsx-dev-runtime.ts +14 -0
- package/src/jsx-runtime.ts +1272 -0
- package/src/mixins.ts +199 -0
- package/src/observability.ts +167 -0
- package/src/pagination.ts +300 -0
- package/src/presence.ts +81 -0
- package/src/provider/FlowProvider.ts +1401 -0
- package/src/registry.ts +118 -0
- package/src/renderContext.ts +55 -0
- package/src/router.ts +401 -0
- package/src/shared.ts +226 -0
- package/src/store.ts +116 -0
- package/src/synths/CarbonIntervalSynth.ts +22 -0
- package/src/synths/CarbonSynth.ts +20 -0
- package/src/synths/CollectionSynth.ts +62 -0
- package/src/synths/DateSynth.ts +24 -0
- package/src/synths/ModelSynth.ts +78 -0
- package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
- package/src/synths/index.ts +148 -0
- package/src/testing.ts +361 -0
- package/src/types.ts +315 -0
- package/src/uploads/FileUploads.ts +94 -0
- package/src/uploads/TemporaryUploadedFile.ts +274 -0
- package/src/uploads/sign.ts +18 -0
- package/src/urlSafety.ts +0 -0
- package/src/utils.ts +216 -0
- package/src/validation.ts +55 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialise a value for embedding inside a `<script type="application/json">` island.
|
|
3
|
+
*
|
|
4
|
+
* `JSON.stringify` alone is **not** safe here: JSON does not escape `<`, and the HTML
|
|
5
|
+
* tokenizer ends a `<script>` element at the first `</script` regardless of the element's
|
|
6
|
+
* `type` attribute. So any string in the payload containing `</script>` breaks out of the
|
|
7
|
+
* island and everything after it is parsed as HTML. Flow dehydrates every `@expose` and
|
|
8
|
+
* `@locked` property, and `_seedUrlProps` copies query-string values straight into `@url`
|
|
9
|
+
* props, so the payload routinely carries attacker-controlled strings.
|
|
10
|
+
*
|
|
11
|
+
* Escaping `<` to `\u003c` is valid JSON — it re-parses to the identical string — and closes
|
|
12
|
+
* the breakout. U+2028 and U+2029 are escaped too: both are legal inside a JSON string but are
|
|
13
|
+
* line terminators in JavaScript, so they break any consumer that evaluates rather than parses.
|
|
14
|
+
*
|
|
15
|
+
* @param value - Any JSON-serialisable value.
|
|
16
|
+
* @returns JSON text that is safe to interpolate between `<script>` tags.
|
|
17
|
+
* @category Rendering
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
export function toScriptJson(value: unknown): string {
|
|
21
|
+
return JSON.stringify(value)
|
|
22
|
+
.replace(/</g, "\\u003c")
|
|
23
|
+
.replace(/\u2028/g, "\\u2028")
|
|
24
|
+
.replace(/\u2029/g, "\\u2029");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Encode a value as a JavaScript string literal for embedding in an Alpine expression.
|
|
29
|
+
*
|
|
30
|
+
* An `x-on:click` / `x-show` / `:class` attribute is *executable code*, not display text, so
|
|
31
|
+
* HTML escaping is the wrong tool: `escapeAttr` lets an expression through intact and the
|
|
32
|
+
* browser hands Alpine exactly what was written. Interpolating a value into `'…'` by hand
|
|
33
|
+
* therefore turns any user-controlled string — a workspace slug, a tab name — into stored
|
|
34
|
+
* XSS against every viewer. `JSON.stringify` produces a literal with quotes, backslashes and
|
|
35
|
+
* line terminators already escaped; the surrounding attribute escaping then handles the HTML
|
|
36
|
+
* layer, and Alpine sees the original value.
|
|
37
|
+
*
|
|
38
|
+
* @param value - The value to embed.
|
|
39
|
+
* @returns A JavaScript string literal, quotes included.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* "x-on:click": `tab = ${jsLiteral(name)}` // tab = "my-tab"
|
|
43
|
+
*/
|
|
44
|
+
export function jsLiteral(value: unknown): string {
|
|
45
|
+
return JSON.stringify(String(value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Generate a stable-looking but unique component ID. */
|
|
49
|
+
export function randomComponentId(name: string): string {
|
|
50
|
+
// crypto.randomUUID() uses the platform CSPRNG — safe for use as DOM IDs.
|
|
51
|
+
// Slice the last 8 chars of the UUID to keep IDs short but unguessable.
|
|
52
|
+
const suffix = crypto.randomUUID().slice(-8);
|
|
53
|
+
return `${name.toLowerCase()}-${suffix}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// -- flow() unified modifier chain ------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The fluent modifier chain returned by {@link flow}. Each accessor/method appends a
|
|
60
|
+
* directive modifier and returns the same chain, so calls compose left-to-right; the
|
|
61
|
+
* chain is a no-op transparent Proxy at runtime and is consumed by the JSX compiler,
|
|
62
|
+
* which reads `__isFlow`/`__target`/`__modifiers`/`__key` to emit the `flow:*` attributes.
|
|
63
|
+
*
|
|
64
|
+
* Generic over `T` so the compiler preserves the wrapped target's type (a bound property,
|
|
65
|
+
* an `@expose` method, or a client arrow-function expression).
|
|
66
|
+
*
|
|
67
|
+
* @typeParam T - The wrapped target: a bound value, an `@expose` method reference, or a
|
|
68
|
+
* client callback (`() => …`).
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```tsx
|
|
72
|
+
* // Binding modifiers on an input, and event modifiers on a form/button:
|
|
73
|
+
* <input bind={flow(this.query).live} /> // flow:model.live="query"
|
|
74
|
+
* <form onSubmit={flow(this.save).prevent}>…</form> // flow:submit.prevent="save"
|
|
75
|
+
* <button onClick={flow(this.refresh).debounce(300)}>↻</button>
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* Binding modifiers (bind={flow(this.field)...})
|
|
79
|
+
* .live sync on every keystroke (flow:model.live)
|
|
80
|
+
* .blur sync on blur (flow:model.blur)
|
|
81
|
+
* .lazy defer until explicit save (flow:model.lazy)
|
|
82
|
+
* .fill also fill sibling fields (flow:model.fill)
|
|
83
|
+
* .debounce(ms) debounce by ms (flow:model.debounce.300ms)
|
|
84
|
+
* .throttle(ms) throttle by ms (flow:model.throttle.300ms)
|
|
85
|
+
*
|
|
86
|
+
* Server event modifiers (onClick={flow(this.method)...})
|
|
87
|
+
* .prevent event.preventDefault() (flow:click.prevent)
|
|
88
|
+
* .stop event.stopPropagation() (flow:click.stop)
|
|
89
|
+
* .once remove after first fire (flow:click.once)
|
|
90
|
+
* .self target === currentTarget (flow:click.self)
|
|
91
|
+
* .window attach to window (flow:keydown.window)
|
|
92
|
+
* .document attach to document (flow:keydown.document)
|
|
93
|
+
* .outside click outside element (flow:click.outside)
|
|
94
|
+
* .passive addEventListener passive (flow:scroll.passive)
|
|
95
|
+
* .capture addEventListener capture (flow:click.capture)
|
|
96
|
+
* .camel camelCase event name (flow:custom-event.camel)
|
|
97
|
+
* .debounce(ms) debounce action (flow:click.debounce.300ms)
|
|
98
|
+
* .throttle(ms) throttle action (flow:click.throttle.300ms)
|
|
99
|
+
* .confirm(msg) confirm dialog first (flow:click.confirm)
|
|
100
|
+
*
|
|
101
|
+
* Client expressions (onClick={flow(() => this.expr)})
|
|
102
|
+
* An anonymous arrow function as target signals a client-side expression.
|
|
103
|
+
* `this.` is rewritten to `$flow.` so TypeScript checks the access against
|
|
104
|
+
* the Component class while the browser evaluates it via the reactive proxy.
|
|
105
|
+
* Emits flow:click (not x-on:click) — the bridge detects `$flow` and
|
|
106
|
+
* evaluates it in Alpine context instead of sending a WebSocket action.
|
|
107
|
+
* All event modifiers still apply.
|
|
108
|
+
*
|
|
109
|
+
* <button onClick={flow(() => (this.ticks = 0))}>Reset</button>
|
|
110
|
+
* → flow:click="($flow.ticks = 0)"
|
|
111
|
+
*
|
|
112
|
+
* <button onClick={flow(() => this.increment()).stop}>+</button>
|
|
113
|
+
* → flow:click.stop="$flow.increment()" (calls server via $flow proxy)
|
|
114
|
+
*
|
|
115
|
+
* Usage:
|
|
116
|
+
* <input bind={flow(this.query).live} />
|
|
117
|
+
* <form onSubmit={flow(this.save).prevent}>...</form>
|
|
118
|
+
* <span text={flow(this.clock)} />
|
|
119
|
+
* import { flow as _ } from '@zerotal/flow'; // shorthand alias
|
|
120
|
+
*/
|
|
121
|
+
export interface FlowChain<T> {
|
|
122
|
+
// Binding modifiers
|
|
123
|
+
readonly live: FlowChain<T>;
|
|
124
|
+
readonly blur: FlowChain<T>;
|
|
125
|
+
readonly lazy: FlowChain<T>;
|
|
126
|
+
readonly fill: FlowChain<T>;
|
|
127
|
+
|
|
128
|
+
// Event modifiers
|
|
129
|
+
readonly prevent: FlowChain<T>;
|
|
130
|
+
readonly stop: FlowChain<T>;
|
|
131
|
+
readonly once: FlowChain<T>;
|
|
132
|
+
readonly self: FlowChain<T>;
|
|
133
|
+
readonly window: FlowChain<T>;
|
|
134
|
+
readonly document: FlowChain<T>;
|
|
135
|
+
readonly outside: FlowChain<T>;
|
|
136
|
+
readonly passive: FlowChain<T>;
|
|
137
|
+
readonly capture: FlowChain<T>;
|
|
138
|
+
readonly camel: FlowChain<T>;
|
|
139
|
+
|
|
140
|
+
// Parametric modifiers
|
|
141
|
+
debounce(ms: string | number): FlowChain<T>;
|
|
142
|
+
throttle(ms: string | number): FlowChain<T>;
|
|
143
|
+
confirm(message: string): FlowChain<T>;
|
|
144
|
+
|
|
145
|
+
// Internal -- read by the JSX compiler
|
|
146
|
+
readonly __isFlow: true;
|
|
147
|
+
readonly __target: T;
|
|
148
|
+
readonly __modifiers: string[];
|
|
149
|
+
readonly __key: string | undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Wrap an `@expose` method, a bound property, or a client callback to attach Flow
|
|
154
|
+
* directive modifiers, returning a fluent {@link FlowChain} that the JSX compiler reads.
|
|
155
|
+
*
|
|
156
|
+
* The returned chain is a transparent Proxy that is a no-op at runtime (safe during SSR):
|
|
157
|
+
* it carries only the wrapped target and the list of appended modifiers. The JSX runtime
|
|
158
|
+
* reads `__isFlow`, `__target`, and `__modifiers` to emit the correct `flow:*` attributes.
|
|
159
|
+
* Import it aliased as `_` for a terser call site: `import { flow as _ } from '@zerotal/flow'`.
|
|
160
|
+
*
|
|
161
|
+
* @typeParam T - The wrapped target's type, preserved on the returned chain for type-checking.
|
|
162
|
+
* @param target - The bound value, `@expose` method reference, or client arrow function
|
|
163
|
+
* (`() => …`, whose `this.` is rewritten to `$flow.` for browser evaluation) to wrap.
|
|
164
|
+
* @returns A {@link FlowChain} of `T` — chain modifiers off it (`.live`, `.prevent`,
|
|
165
|
+
* `.debounce(300)`, …).
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* ```tsx
|
|
169
|
+
* <input bind={flow(this.query).live} /> // flow:model.live="query"
|
|
170
|
+
* <form onSubmit={flow(this.save).prevent}>…</form> // flow:submit.prevent="save"
|
|
171
|
+
* <span text={flow(this.clock)} /> // flow:text="clock"
|
|
172
|
+
* <button onClick={flow(() => (this.count = 0))}>Reset</button> // flow:click="($flow.count = 0)"
|
|
173
|
+
* <button onClick={flow(() => this.increment()).stop}>+</button> // flow:click.stop="$flow.increment()"
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
// Shared capture variable: set by instrumented @expose getters in jsx-runtime,
|
|
177
|
+
// consumed immediately by flow() to embed __key in the chain.
|
|
178
|
+
let _exposedKeyCapture: string | undefined;
|
|
179
|
+
export function _setExposedKeyCapture(k: string | undefined): void {
|
|
180
|
+
_exposedKeyCapture = k;
|
|
181
|
+
}
|
|
182
|
+
export function _consumeExposedKeyCapture(): string | undefined {
|
|
183
|
+
const k = _exposedKeyCapture;
|
|
184
|
+
_exposedKeyCapture = undefined;
|
|
185
|
+
return k;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function flow<T>(target: T): FlowChain<T> {
|
|
189
|
+
const modifiers: string[] = [];
|
|
190
|
+
// Capture immediately so subsequent prop evaluations cannot clobber it.
|
|
191
|
+
const _capturedKey = _exposedKeyCapture;
|
|
192
|
+
_exposedKeyCapture = undefined;
|
|
193
|
+
|
|
194
|
+
function makeChain(): FlowChain<T> {
|
|
195
|
+
return new Proxy(function flowProxy() {}, {
|
|
196
|
+
get(_t, prop: string | symbol) {
|
|
197
|
+
if (prop === "__isFlow") return true;
|
|
198
|
+
if (prop === "__target") return target;
|
|
199
|
+
if (prop === "__modifiers") return modifiers;
|
|
200
|
+
if (prop === "__key") return _capturedKey;
|
|
201
|
+
if (typeof prop === "symbol") return undefined;
|
|
202
|
+
modifiers.push(prop as string);
|
|
203
|
+
return makeChain();
|
|
204
|
+
},
|
|
205
|
+
apply(_t, _thisArg, args) {
|
|
206
|
+
// Called as e.g. .debounce('300ms') -- append ".300ms" to last modifier.
|
|
207
|
+
if (args.length > 0 && (typeof args[0] === "string" || typeof args[0] === "number")) {
|
|
208
|
+
modifiers[modifiers.length - 1] += "." + String(args[0]);
|
|
209
|
+
}
|
|
210
|
+
return makeChain();
|
|
211
|
+
},
|
|
212
|
+
}) as unknown as FlowChain<T>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return makeChain();
|
|
216
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation glue for Flow.
|
|
3
|
+
*
|
|
4
|
+
* The validation ENGINE lives in `@zerotal/validator` (its fluent `RuleBuilder` schema) — Flow
|
|
5
|
+
* does not roll its own. This module defines the rule-map shape and the `ValidationError` thrown by
|
|
6
|
+
* `Component.validate()` (the WS handler catches it to re-render with the populated error bag).
|
|
7
|
+
*/
|
|
8
|
+
import { ZerotalError } from "@zerotal/core";
|
|
9
|
+
import type { RuleBuilder, FieldRule } from "@zerotal/validator";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A per-field rule map built with the framework validator's chain — the explicit-rules form of
|
|
13
|
+
* `this.validate(...)`, mirroring the `@validate` decorator. Keys are field names; each value is a
|
|
14
|
+
* builder callback that turns the fluent {@link RuleBuilder} into a {@link FieldRule}.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* this.validate({
|
|
19
|
+
* name: (rule) => rule.required().min(2).max(50),
|
|
20
|
+
* email: (rule) => rule.required().email(),
|
|
21
|
+
* } satisfies ValidationRules);
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @category Validation
|
|
25
|
+
*/
|
|
26
|
+
export type ValidationRules = Record<string, (rule: RuleBuilder) => FieldRule>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Error thrown by `Component.validate()` when one or more fields fail validation.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* Extends `ZerotalError` with code `"E_VALIDATION"` and HTTP status `422`. The failing fields are
|
|
33
|
+
* carried on `errors`, a map of field name → array of human-readable messages. The Flow
|
|
34
|
+
* WebSocket handler catches this and re-renders the component with the populated error bag rather
|
|
35
|
+
* than surfacing it as a crash.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* try {
|
|
40
|
+
* this.validate();
|
|
41
|
+
* } catch (err) {
|
|
42
|
+
* if (err instanceof ValidationError) {
|
|
43
|
+
* // err.errors === { email: ["The email field is required."] }
|
|
44
|
+
* }
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @category Validation
|
|
49
|
+
*/
|
|
50
|
+
export class ValidationError extends ZerotalError {
|
|
51
|
+
/** @param errors - Map of field name to its array of validation error messages. */
|
|
52
|
+
constructor(readonly errors: Record<string, string[]>) {
|
|
53
|
+
super("Validation failed", "E_VALIDATION", 422);
|
|
54
|
+
}
|
|
55
|
+
}
|