@zerotal/flow 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -0
  4. package/package.json +84 -0
  5. package/src/Component.ts +1498 -0
  6. package/src/Form.ts +189 -0
  7. package/src/Layout.ts +47 -0
  8. package/src/augment.ts +94 -0
  9. package/src/client/FlowClientError.ts +12 -0
  10. package/src/client/FlowComponent.ts +96 -0
  11. package/src/client/alpinejs-morph.d.ts +5 -0
  12. package/src/client/bridge.ts +3463 -0
  13. package/src/client/confirm.ts +338 -0
  14. package/src/client/cspAdapter.ts +79 -0
  15. package/src/client/cspEvaluator.ts +494 -0
  16. package/src/client/errorOverlay.ts +124 -0
  17. package/src/client/headless.ts +948 -0
  18. package/src/client/index.csp.ts +47 -0
  19. package/src/client/index.ts +56 -0
  20. package/src/client/optimistic.ts +52 -0
  21. package/src/client/timeline.ts +160 -0
  22. package/src/client/url.ts +51 -0
  23. package/src/commands/MakeFlowCommand.ts +290 -0
  24. package/src/compiler/cache.ts +101 -0
  25. package/src/compiler/directives.ts +107 -0
  26. package/src/compiler/helpers.ts +26 -0
  27. package/src/compiler/index.ts +172 -0
  28. package/src/compiler/transform.ts +2070 -0
  29. package/src/components.ts +1465 -0
  30. package/src/config.ts +52 -0
  31. package/src/decorators.ts +978 -0
  32. package/src/dehydrate.ts +333 -0
  33. package/src/durable.ts +243 -0
  34. package/src/events.ts +118 -0
  35. package/src/frameworkEvents.ts +40 -0
  36. package/src/headless.ts +1147 -0
  37. package/src/index.ts +267 -0
  38. package/src/jsx-dev-runtime.ts +14 -0
  39. package/src/jsx-runtime.ts +1272 -0
  40. package/src/mixins.ts +199 -0
  41. package/src/observability.ts +167 -0
  42. package/src/pagination.ts +300 -0
  43. package/src/presence.ts +81 -0
  44. package/src/provider/FlowProvider.ts +1401 -0
  45. package/src/registry.ts +118 -0
  46. package/src/renderContext.ts +55 -0
  47. package/src/router.ts +401 -0
  48. package/src/shared.ts +226 -0
  49. package/src/store.ts +116 -0
  50. package/src/synths/CarbonIntervalSynth.ts +22 -0
  51. package/src/synths/CarbonSynth.ts +20 -0
  52. package/src/synths/CollectionSynth.ts +62 -0
  53. package/src/synths/DateSynth.ts +24 -0
  54. package/src/synths/ModelSynth.ts +78 -0
  55. package/src/synths/TemporaryUploadedFileSynth.ts +19 -0
  56. package/src/synths/index.ts +148 -0
  57. package/src/testing.ts +361 -0
  58. package/src/types.ts +315 -0
  59. package/src/uploads/FileUploads.ts +94 -0
  60. package/src/uploads/TemporaryUploadedFile.ts +274 -0
  61. package/src/uploads/sign.ts +18 -0
  62. package/src/urlSafety.ts +0 -0
  63. package/src/utils.ts +216 -0
  64. package/src/validation.ts +55 -0
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
+ }