@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/Form.ts ADDED
@@ -0,0 +1,189 @@
1
+ // A Form bundles related fields + their validation rules + reset/fill helpers into
2
+ // a reusable class, mounted on a component as a single @expose property:
3
+ //
4
+ // class LoginForm extends Form {
5
+ // email = '';
6
+ // password = '';
7
+ // rules(v: RuleBuilder) {
8
+ // return { email: v.string().email(), password: v.string().min(8) };
9
+ // }
10
+ // }
11
+ //
12
+ // class LoginPage extends Component {
13
+ // @expose form = new LoginForm();
14
+ // @expose async submit() {
15
+ // this.validate(this.form); // validates form.data() against form.rules
16
+ // await Auth.attempt(this.form.data());
17
+ // }
18
+ // }
19
+ //
20
+ // Validation uses the built-in @zerotal/validator (its fluent RuleBuilder schema).
21
+ // Bind fields with `value={this.form.email}` (compiled to flow:model="form.email").
22
+ // Forms round-trip through a synthesizer so the class + methods survive hydration.
23
+
24
+ import { ValidationError } from "./validation.ts";
25
+ import { registerSynth } from "./synths/index.ts";
26
+ import { RuleBuilder, runValidation } from "@zerotal/validator";
27
+ import type { FieldRule, Schema } from "@zerotal/validator";
28
+
29
+ /** Registry of Form subclasses by name, used to reconstruct forms on hydration. */
30
+ const _formClasses = new Map<string, new () => Form>();
31
+
32
+ /** Register a Form subclass so it can be rebuilt from a snapshot. Called automatically. */
33
+ export function registerForm(FormClass: new () => Form): void {
34
+ _formClasses.set(FormClass.name, FormClass);
35
+ }
36
+
37
+ const _RESERVED = new Set(["rules"]);
38
+
39
+ /**
40
+ * A reusable bundle of related form fields, their validation rules, and fill/reset helpers,
41
+ * mounted on a {@link Component} as a single `@expose` property.
42
+ *
43
+ * @remarks
44
+ * Declare fields as plain instance properties and their validation in {@link rules} (built with
45
+ * `@zerotal/validator`'s fluent {@link RuleBuilder}). Bind inputs with `value={this.form.email}`
46
+ * (the compiler rewrites it to `flow:model="form.email"`). Validate either from the component
47
+ * (`this.validate(this.form)`) or directly ({@link validate}); both share the same engine and
48
+ * error shape. A Form round-trips through a synthesizer, so its class and methods survive
49
+ * hydration — the subclass must be imported (instantiated at least once) on the server.
50
+ *
51
+ * @example
52
+ * ```tsx
53
+ * import { Component, expose, Form } from "@zerotal/flow";
54
+ * import type { RuleBuilder } from "@zerotal/validator";
55
+ *
56
+ * class LoginForm extends Form {
57
+ * email = "";
58
+ * password = "";
59
+ * rules(v: RuleBuilder) {
60
+ * return { email: v.string().email(), password: v.string().min(8) };
61
+ * }
62
+ * }
63
+ *
64
+ * class LoginPage extends Component {
65
+ * @expose form = new LoginForm();
66
+ *
67
+ * @expose async submit() {
68
+ * this.validate(this.form); // throws + re-renders with errors on failure
69
+ * await Auth.attempt(this.form.data());
70
+ * }
71
+ *
72
+ * override async render() {
73
+ * return (
74
+ * <form data-flow-root flow:submit="submit">
75
+ * <input value={this.form.email} />
76
+ * <input type="password" value={this.form.password} />
77
+ * <button>Log in</button>
78
+ * </form>
79
+ * );
80
+ * }
81
+ * }
82
+ * ```
83
+ */
84
+ export abstract class Form {
85
+ /** Duck-type marker (avoids cross-module instanceof / import cycles). */
86
+ readonly __isFlowForm = true as const;
87
+
88
+ constructor() {
89
+ // Auto-register so the synth can reconstruct this subclass on hydration.
90
+ registerForm(this.constructor as new () => Form);
91
+ }
92
+
93
+ /**
94
+ * Validation schema, built with the @zerotal/validator RuleBuilder. Override in
95
+ * the subclass; fields are required by default.
96
+ *
97
+ * @example
98
+ * rules(v: RuleBuilder) {
99
+ * return { email: v.string().email(), age: v.number().min(18).optional() };
100
+ * }
101
+ */
102
+
103
+ rules(_v: RuleBuilder): Record<string, FieldRule> {
104
+ return {};
105
+ }
106
+
107
+ /** The form's field values — own enumerable data props (excludes rules/methods/_*). */
108
+ data(): Record<string, unknown> {
109
+ const out: Record<string, unknown> = {};
110
+ for (const [k, v] of Object.entries(this)) {
111
+ if (_RESERVED.has(k) || k.startsWith("_") || typeof v === "function") continue;
112
+ out[k] = v;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Assign matching fields from a plain object (unknown keys ignored). */
118
+ fill(values: Record<string, unknown>): this {
119
+ for (const [k, v] of Object.entries(values)) {
120
+ if (_RESERVED.has(k) || k.startsWith("_")) continue;
121
+ if (Object.prototype.hasOwnProperty.call(this, k)) {
122
+ (this as Record<string, unknown>)[k] = v;
123
+ }
124
+ }
125
+ return this;
126
+ }
127
+
128
+ /** Reset fields to their declared defaults — all of them, or just the named ones. */
129
+ reset(...fields: string[]): this {
130
+ const fresh = new (this.constructor as new () => this)();
131
+ const keys = fields.length ? fields : Object.keys(this.data());
132
+ for (const k of keys) {
133
+ (this as Record<string, unknown>)[k] = (fresh as Record<string, unknown>)[k];
134
+ }
135
+ return this;
136
+ }
137
+
138
+ /**
139
+ * @internal Run the @zerotal/validator schema against the current data, returning
140
+ * a field → messages map (empty when valid). Used by `validate()` and by
141
+ * `Component.validate(form)` so both share the same engine + error shape.
142
+ */
143
+ _validateToErrors(): Record<string, string[]> {
144
+ const factory = this.rules(new RuleBuilder());
145
+ const schema: Schema = {};
146
+ for (const [field, rule] of Object.entries(factory)) schema[field] = rule._def;
147
+
148
+ const result = runValidation(schema, this.data());
149
+ if (result.success) return {};
150
+
151
+ // Zerotal errors are one message per field; Flow's bag is field → messages[].
152
+ const out: Record<string, string[]> = {};
153
+ for (const [field, msg] of Object.entries(result.errors)) out[field] = [msg];
154
+ return out;
155
+ }
156
+
157
+ /**
158
+ * Validate the form against its {@link rules} (via `@zerotal/validator`).
159
+ *
160
+ * @returns the form's {@link data} on success.
161
+ * @throws {ValidationError} on failure — the framework catches it and re-renders with errors.
162
+ */
163
+ validate(): Record<string, unknown> {
164
+ const errors = this._validateToErrors();
165
+ if (Object.keys(errors).length > 0) throw new ValidationError(errors);
166
+ return this.data();
167
+ }
168
+ }
169
+
170
+ // Serialize a Form to its field values + class name; rebuild the right subclass on hydrate.
171
+ registerSynth({
172
+ key: "form",
173
+ match: (v): v is Form =>
174
+ typeof v === "object" && v !== null && (v as { __isFlowForm?: unknown }).__isFlowForm === true,
175
+ dehydrate(form: Form, meta) {
176
+ meta["form"] = form.constructor.name;
177
+ return form.data();
178
+ },
179
+ hydrate(data, meta) {
180
+ const name = meta["form"] as string;
181
+ const FormClass = _formClasses.get(name);
182
+ if (!FormClass) {
183
+ throw new Error(
184
+ `[Flow] Unknown Form class "${name}". Ensure it is imported (instantiated at least once) on the server.`,
185
+ );
186
+ }
187
+ return new FormClass().fill(data as Record<string, unknown>);
188
+ },
189
+ });
package/src/Layout.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { HtmlNode } from "./jsx-runtime.ts";
2
+
3
+ /**
4
+ * Base class for Flow page layouts — persistent shell UI wrapped around page content.
5
+ *
6
+ * @remarks
7
+ * A layout wraps page content with shell UI (nav, sidebars, footers) that outlives page
8
+ * navigation. On `flow:navigate`, pages that share the same layout class only swap their
9
+ * `[data-flow-root]` content; the layout shell stays mounted and is never re-rendered or
10
+ * re-sent over the socket. Attach one with `static layout = AppLayout` on a {@link Component}
11
+ * (or override {@link Component.layout} for the JSX-native form).
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * export class AppLayout extends Layout {
16
+ * static head = `<link rel="stylesheet" href="/app.css">`;
17
+ *
18
+ * render(slot: HtmlNode) {
19
+ * return (
20
+ * <div>
21
+ * <nav>
22
+ * <a href="/dashboard" flow:navigate>Dashboard</a>
23
+ * </nav>
24
+ * <main>{slot}</main>
25
+ * </div>
26
+ * );
27
+ * }
28
+ * }
29
+ *
30
+ * export class DashboardPage extends Component {
31
+ * static layout = AppLayout;
32
+ * }
33
+ * ```
34
+ */
35
+ export abstract class Layout {
36
+ /** Extra HTML injected into `<head>` (stylesheets, fonts, global meta tags). */
37
+ static head?: string;
38
+
39
+ /**
40
+ * Render the layout shell around a page.
41
+ *
42
+ * @param slot the page's pre-rendered `[data-flow-root]` component node. Place it with
43
+ * `{slot}` in JSX — its HTML is emitted verbatim without escaping.
44
+ * @returns the shell markup wrapping `slot`.
45
+ */
46
+ abstract render(slot: HtmlNode): HtmlNode | Promise<HtmlNode>;
47
+ }
package/src/augment.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { PageClassWithMeta } from "./registry.ts";
2
+ import type { MiddlewareClass, RouteRegistration } from "@zerotal/core";
3
+ import type { FlowStore } from "./store.ts";
4
+
5
+ // Augment the core RouterMacros interface so Router.flow() is fully typed.
6
+ declare module "@zerotal/core" {
7
+ interface RouterMacros {
8
+ /**
9
+ * Register a Flow Component as a GET route.
10
+ *
11
+ * Middleware runs on the initial page load AND is re-applied on every
12
+ * WebSocket update (persistent middleware).
13
+ *
14
+ * @example
15
+ * Router.flow('/dashboard', Dashboard);
16
+ * Router.flow('/admin', AdminPage, [RequireAuthMiddleware]);
17
+ */
18
+ flow(
19
+ path: string,
20
+ PageClass: PageClassWithMeta,
21
+ middleware?: MiddlewareClass[],
22
+ ): RouteRegistration;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * $flow — the client runtime object, available directly in JSX client expressions
28
+ * (event handlers, attribute bindings, text children) and `this.client()` scripts.
29
+ *
30
+ * It is the single home for all client-only magics, so the component class itself stays
31
+ * clean. The rule: `this.name` is always YOUR member (state/action); `$flow.name` is always a
32
+ * framework helper. You write the helpers BARE (`$flow.set`, `$flow.store`, `$flow.parent`) and
33
+ * the AOT compiler rewrites each to its canonical `$`-form (`$flow.$set`, …) for the runtime.
34
+ *
35
+ * $flow.set("open", true) write + sync an @expose property
36
+ * $flow.toggle("open") toggle + sync a boolean @expose property
37
+ * $flow.store.ui.dark the global client store (typed by FlowStore)
38
+ * $flow.parent.save() call an action / read state on the parent
39
+ * onClick={this.save} dispatch YOUR server action `save`
40
+ */
41
+ declare global {
42
+ var $flow: {
43
+ // Reactive property reads/writes ($flow.count) and dynamic action calls. `any` because this
44
+ // object mixes dynamic members with the typed helpers below; the index signature can't be
45
+ // narrower than both.
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ [member: string]: any;
48
+
49
+ // Component identity (always $-prefixed — no bare form)
50
+ readonly $el: HTMLElement;
51
+ readonly $id: string;
52
+ readonly $name: string;
53
+
54
+ // Re-render
55
+ refresh(): void;
56
+ commit(): void;
57
+
58
+ // Property helpers
59
+ get(prop: string): unknown;
60
+ set(prop: string, value: unknown): void;
61
+ toggle(prop: string): void;
62
+
63
+ // Explicit method call
64
+ call(method: string, ...args: unknown[]): void;
65
+
66
+ // Cross-component events
67
+ dispatch(event: string, data?: Record<string, unknown>): void;
68
+ dispatchTo(component: string, event: string, data?: Record<string, unknown>): void;
69
+ dispatchSelf(event: string, data?: Record<string, unknown>): void;
70
+ on(event: string, fn: (detail: unknown) => void): void;
71
+
72
+ // Watch reactive property changes after server patches
73
+ watch(prop: string, fn: (newVal: unknown, oldVal: unknown) => void): void;
74
+
75
+ // Call actions on / read state from the parent component
76
+ readonly parent: Record<string, ((...args: unknown[]) => void) & unknown>;
77
+
78
+ // Global client store — app-wide, client-only reactive UI state (typed by FlowStore).
79
+ readonly store: FlowStore;
80
+
81
+ // Presence whispers (ephemeral peer state; never touches the server/DB)
82
+ whisper(event: string, data: unknown): void;
83
+ onWhisper(event: string, fn: (data: unknown) => void): void;
84
+
85
+ // Stop this component's running @task
86
+ cancel(): void;
87
+
88
+ // Optimistic list mutations (reconciled when the next action lands)
89
+ appendOptimistic<T>(prop: string, item: T): void;
90
+ removeOptimistic<T>(prop: string, match: (item: T) => boolean): void;
91
+ };
92
+ }
93
+
94
+ export {};
@@ -0,0 +1,12 @@
1
+ // Client-safe base error for Flow's browser runtime.
2
+ //
3
+ // This module is bundled into the CSP-safe client runtime, so it deliberately
4
+ // extends the native `Error` rather than `@zerotal/core`'s `ZerotalError`: pulling
5
+ // the server framework into the browser bundle is exactly what the CSP-safe runtime
6
+ // must avoid. Client-side Flow errors extend this class for a consistent base.
7
+ export class FlowClientError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = this.constructor.name;
11
+ }
12
+ }
@@ -0,0 +1,96 @@
1
+ // ── Client-side component state model ────────────────────────────────────────
2
+ //
3
+ // Mirrors Livewire v3's three-copy state model:
4
+ // canonical — last confirmed server state (used for diffing)
5
+ // ephemeral — current local state (may have unsent mutations from flow:model)
6
+ // reactive — Alpine.reactive(ephemeral) — what DOM expressions read
7
+ //
8
+ // When a server patch arrives, we diff canonical vs. new to determine what
9
+ // changed and patch reactive in-place — preserving Alpine reactivity subscriptions
10
+ // instead of wholesale replacement.
11
+
12
+ import type { Snapshot, SnapshotData } from "../types.ts";
13
+
14
+ export type ReactiveProxy = Record<string, unknown>;
15
+
16
+ /** Extract plain JS values from snapshot data (strip synth meta). */
17
+ function extractValues(data: SnapshotData): Record<string, unknown> {
18
+ const result: Record<string, unknown> = {};
19
+ for (const [key, [val]] of Object.entries(data)) {
20
+ result[key] = val;
21
+ }
22
+ return result;
23
+ }
24
+
25
+ function deepClone<T>(v: T): T {
26
+ return JSON.parse(JSON.stringify(v)) as T;
27
+ }
28
+
29
+ /** Recursively diff old/new and patch reactive in-place. */
30
+ function diffAndPatch(
31
+ oldVal: Record<string, unknown>,
32
+ newVal: Record<string, unknown>,
33
+ reactive: Record<string, unknown>,
34
+ ): void {
35
+ for (const [k, next] of Object.entries(newVal)) {
36
+ if (JSON.stringify(oldVal[k]) !== JSON.stringify(next)) {
37
+ // Deep-clone objects so that reactive mutations (from user input via flow:model)
38
+ // do not alias back into canonical/snapshot, which share the same references
39
+ // via extractValues. Without cloning, typing in a flow:model input would corrupt
40
+ // comp.snapshot.data causing the HMAC to fail on the next server call.
41
+ reactive[k] = typeof next === "object" && next !== null ? deepClone(next) : next;
42
+ }
43
+ }
44
+ // Remove keys that no longer exist
45
+ for (const k of Object.keys(oldVal)) {
46
+ if (!(k in newVal)) {
47
+ delete reactive[k];
48
+ }
49
+ }
50
+ }
51
+
52
+ export class FlowComponent {
53
+ readonly id: string;
54
+ readonly name: string;
55
+ readonly rootEl: HTMLElement;
56
+
57
+ canonical: Record<string, unknown>;
58
+ ephemeral: Record<string, unknown>;
59
+ reactive: ReactiveProxy;
60
+
61
+ snapshot: Snapshot;
62
+
63
+ constructor(rootEl: HTMLElement, snapshot: Snapshot) {
64
+ this.id = snapshot.memo.id;
65
+ this.name = snapshot.memo.name;
66
+ this.rootEl = rootEl;
67
+ this.snapshot = snapshot;
68
+
69
+ this.canonical = extractValues(snapshot.data);
70
+ this.ephemeral = deepClone(this.canonical);
71
+
72
+ // Alpine.reactive is available globally once Alpine starts
73
+ this.reactive = (
74
+ window as unknown as { Alpine: { reactive(o: object): ReactiveProxy } }
75
+ ).Alpine.reactive(this.ephemeral);
76
+ }
77
+
78
+ /** Apply a server patch: update snapshot, diff-patch reactive, update script tag. */
79
+ mergeServerPatch(newSnapshot: Snapshot): void {
80
+ const newCanonical = extractValues(newSnapshot.data);
81
+ // Compare the current reactive state (not just canonical) so that lazy
82
+ // flow:model fields that diverged during typing are corrected when the
83
+ // server returns the authoritative value (e.g., clearing draft after add()).
84
+ // Using a plain-object snapshot avoids mutating the comparison source
85
+ // while diffAndPatch iterates it for key-removal.
86
+ const reactiveSnapshot = { ...this.reactive } as Record<string, unknown>;
87
+ diffAndPatch(reactiveSnapshot, newCanonical, this.reactive);
88
+ this.canonical = newCanonical;
89
+ this.ephemeral = deepClone(newCanonical);
90
+ this.snapshot = newSnapshot;
91
+
92
+ // Keep the embedded <script id="flow-state-*"> in sync
93
+ const script = document.getElementById(`flow-state-${this.id}`);
94
+ if (script) script.textContent = JSON.stringify(newSnapshot);
95
+ }
96
+ }
@@ -0,0 +1,5 @@
1
+ declare module '@alpinejs/morph' {
2
+ import type Alpine from 'alpinejs';
3
+ const morph: (Alpine: typeof Alpine) => void;
4
+ export default morph;
5
+ }