@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
@@ -0,0 +1,148 @@
1
+ // ── Synthesizer registry ──────────────────────────────────────────────────────
2
+ //
3
+ // Each synthesizer knows how to:
4
+ // - match a JS value at dehydration time
5
+ // - convert it to a JSON-safe primitive + metadata object
6
+ // - reconstruct the original value from that primitive + metadata
7
+ //
8
+ // Pattern lifted directly from Livewire v3's synthesizer system.
9
+
10
+ /**
11
+ * A synthesizer ("synth") teaches flow how to send a non-JSON type across the
12
+ * wire: it matches a live value, dehydrates it to a JSON-safe primitive plus
13
+ * metadata for the snapshot, and hydrates it back on the other side.
14
+ *
15
+ * @remarks
16
+ * This is the extension point behind first-class support for `Date`, `Carbon`,
17
+ * ORM models, collections, and {@link TemporaryUploadedFile}. During
18
+ * serialization the registry tries each synth's {@link match} in order; the first
19
+ * match's {@link dehydrate} produces the stored data and its {@link key} is
20
+ * written to `meta.s`. On the way back, {@link key} selects the synth whose
21
+ * {@link hydrate} reconstructs the value. `hydrate` may be async (e.g. a model
22
+ * synth re-fetches by id). The pattern is lifted from Livewire v3's synthesizers.
23
+ *
24
+ * @typeParam T - The live value type this synth handles.
25
+ */
26
+ export interface Synth<T = unknown> {
27
+ /** Short tag stored as `meta.s` in the snapshot, e.g. `'cbn'`, `'mdl'`. */
28
+ key: string;
29
+ /** Type guard: whether this synth handles `value` (checked in registration order). */
30
+ match(value: unknown): value is T;
31
+ /** Serialize `value` to a JSON-safe form; may mutate `meta` to stash extra fields for hydrate. */
32
+ dehydrate(value: T, meta: Record<string, unknown>): unknown;
33
+ /** Reconstruct the live value from serialized `data` and its `meta` (may be async). */
34
+ hydrate(data: unknown, meta: Record<string, unknown>): T | Promise<T>;
35
+ }
36
+
37
+ const _synths: Synth[] = [];
38
+
39
+ /**
40
+ * Register a synthesizer so its type can cross the wire in snapshots. Newly
41
+ * registered synths are tried first, so a later registration can override an
42
+ * earlier (built-in) match.
43
+ *
44
+ * @param synth - The {@link Synth} to add to the registry.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * class Money { constructor(public cents: number, public currency: string) {} }
49
+ *
50
+ * registerSynth({
51
+ * key: "money",
52
+ * match: (v): v is Money => v instanceof Money,
53
+ * dehydrate: (v, meta) => { meta.cur = v.currency; return v.cents; },
54
+ * hydrate: (data, meta) => new Money(data as number, meta.cur as string),
55
+ * });
56
+ * ```
57
+ */
58
+ export function registerSynth(synth: Synth): void {
59
+ _synths.unshift(synth); // user-registered synths take priority
60
+ }
61
+
62
+ /** The registered synths, in match order (most-recently-registered first). */
63
+ export function getSynths(): readonly Synth[] {
64
+ return _synths;
65
+ }
66
+
67
+ /**
68
+ * Serialize a single value through the synth registry, recursing into plain
69
+ * arrays and objects (each element/property stored as its own `[data, meta]`
70
+ * tuple so per-element synth metadata is preserved).
71
+ *
72
+ * @param value - The value to serialize.
73
+ * @returns A `[data, meta]` tuple; `meta.s` names the synth that matched, if any.
74
+ */
75
+ export function serializeValue(value: unknown): [data: unknown, meta: Record<string, unknown>] {
76
+ const meta: Record<string, unknown> = {};
77
+
78
+ for (const synth of _synths) {
79
+ if (synth.match(value)) {
80
+ const data = synth.dehydrate(value as never, meta);
81
+ meta["s"] = synth.key;
82
+ return [data, meta];
83
+ }
84
+ }
85
+
86
+ // Recursive: plain arrays and objects.
87
+ // Store full [data, meta] tuples per element so per-element synth metadata
88
+ // (e.g. { s: 'mdl', class: 'Post' } for ORM models, Date, etc.) is preserved.
89
+ if (Array.isArray(value)) {
90
+ return [value.map((v) => serializeValue(v)), meta];
91
+ }
92
+ if (typeof value === "object" && value !== null) {
93
+ const obj: Record<string, unknown> = {};
94
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
95
+ obj[k] = serializeValue(v);
96
+ }
97
+ return [obj, meta];
98
+ }
99
+
100
+ return [value, meta];
101
+ }
102
+
103
+ /**
104
+ * Deserialize a `[data, meta]` tuple produced by {@link serializeValue} back into
105
+ * a live value, recursing into plain arrays and objects.
106
+ *
107
+ * @param data - The serialized data.
108
+ * @param meta - Its metadata; `meta.s`, when present, selects the synth to hydrate with.
109
+ * @returns The reconstructed value.
110
+ * @throws Error if `meta.s` names a synth key that isn't registered.
111
+ */
112
+ export async function deserializeValue(
113
+ data: unknown,
114
+ meta: Record<string, unknown>,
115
+ ): Promise<unknown> {
116
+ const synthKey = meta["s"] as string | undefined;
117
+ if (synthKey) {
118
+ const synth = _synths.find((s) => s.key === synthKey);
119
+ if (!synth) throw new Error(`[Flow] Unknown synth key: "${synthKey}"`);
120
+ return synth.hydrate(data, meta);
121
+ }
122
+
123
+ // Recursive: plain arrays/objects (no synth key).
124
+ // Each element/value is a [data, meta] tuple stored by serializeValue.
125
+ if (Array.isArray(data)) {
126
+ return Promise.all(
127
+ data.map((item) =>
128
+ Array.isArray(item) && item.length === 2
129
+ ? deserializeValue(item[0], item[1] as Record<string, unknown>)
130
+ : deserializeValue(item, {}),
131
+ ),
132
+ );
133
+ }
134
+ if (typeof data === "object" && data !== null) {
135
+ const result: Record<string, unknown> = {};
136
+ await Promise.all(
137
+ Object.entries(data as Record<string, unknown>).map(async ([k, v]) => {
138
+ result[k] =
139
+ Array.isArray(v) && v.length === 2
140
+ ? await deserializeValue(v[0], v[1] as Record<string, unknown>)
141
+ : await deserializeValue(v, {});
142
+ }),
143
+ );
144
+ return result;
145
+ }
146
+
147
+ return data;
148
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,361 @@
1
+ /**
2
+ * `@zerotal/flow/testing` — in-process test helpers for Flow components.
3
+ *
4
+ * Runs the full server-side Flow request pipeline in-process for Bun's test runner:
5
+ * no WebSocket connection is opened — actions are dispatched directly against a mounted
6
+ * page instance, driving its real lifecycle hooks (boot/mount/hydrate/update). The entry
7
+ * point is {@link FlowTest.mount}, which returns a chainable {@link FlowTest} exposing
8
+ * state-driving actions (`set`/`update`/`call`) and fluent assertions (`assertSee`,
9
+ * `assertRedirectedTo`, `assertHasErrors`, `assertFlashed`, …). `hydrate` is re-exported
10
+ * for advanced snapshot-level tests.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { FlowTest } from '@zerotal/flow/testing';
15
+ *
16
+ * const t = await FlowTest.mount(LoginPage);
17
+ * await t.set('email', 'test@example.com');
18
+ * await t.set('password', 'secret');
19
+ * await t.call('login');
20
+ * t.assertRedirectedTo('/dashboard');
21
+ * ```
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const t = await FlowTest.mount(CreatePostPage);
26
+ * await t.call('save');
27
+ * t.assertHasErrors('title');
28
+ * t.assertSee('The title field is required.');
29
+ * ```
30
+ *
31
+ * @packageDocumentation
32
+ */
33
+
34
+ import type { Component } from "./Component.ts";
35
+ import type { FlowEffects } from "./Component.ts";
36
+ import { _renderFlowPage } from "./jsx-runtime.ts";
37
+ import { dehydrate, hydrate } from "./dehydrate.ts";
38
+ import { ValidationError } from "./validation.ts";
39
+ import type { Snapshot } from "./types.ts";
40
+
41
+ type PageClass<T extends Component> = new () => T;
42
+
43
+ /**
44
+ * A fluent test harness around a single mounted Flow page/component. Obtain one with
45
+ * {@link FlowTest.mount}, drive state with `set`/`update`/`call`, then chain assertions
46
+ * against the rendered HTML, redirects, validation errors, flashes, and dispatched events.
47
+ * Every action and assertion returns `this` (or a Promise of it) for chaining.
48
+ *
49
+ * @typeParam T - The Component subclass under test.
50
+ */
51
+ export class FlowTest<T extends Component> {
52
+ private _page: T;
53
+ private _html: string = "";
54
+ private _effects: FlowEffects | null = null;
55
+ private _snapshot: Snapshot | null = null;
56
+ private _name: string;
57
+
58
+ private constructor(page: T, name: string) {
59
+ this._page = page;
60
+ this._name = name;
61
+ }
62
+
63
+ // ── Factory ────────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Mount a page: instantiate it, seed optional props, run its initial-request lifecycle
67
+ * (`onBoot()` then `onMount()`), render once, and return the harness for chaining.
68
+ *
69
+ * @param PageClass - The Component subclass to mount.
70
+ * @param props - Optional initial property values assigned before `onMount()` runs.
71
+ * @returns A resolved {@link FlowTest} wrapping the mounted page.
72
+ * @category Lifecycle
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const t = await FlowTest.mount(DashboardPage);
77
+ * ```
78
+ */
79
+ static async mount<T extends Component>(
80
+ PageClass: PageClass<T>,
81
+ props: Partial<T> = {},
82
+ ): Promise<FlowTest<T>> {
83
+ const page = new PageClass();
84
+ page._flowId = `test-${PageClass.name.toLowerCase()}`;
85
+ page._flowPath = "/test";
86
+
87
+ Object.assign(page, props);
88
+ // Initial request lifecycle: boot() runs on every request, then mount() once.
89
+ await page.onBoot();
90
+ await page.onMount();
91
+
92
+ const t = new FlowTest<T>(page, PageClass.name);
93
+ await t._render();
94
+ return t;
95
+ }
96
+
97
+ // ── Actions ────────────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Set a property value directly (state seeding), then return for chaining.
101
+ * Does NOT fire the updating/updated hooks — use `update()` to simulate a
102
+ * client-driven property change.
103
+ * @category Actions
104
+ */
105
+ async set(prop: keyof T, value: unknown): Promise<this> {
106
+ (this._page as Record<string, unknown>)[prop as string] = value;
107
+ return this;
108
+ }
109
+
110
+ /**
111
+ * Simulate a client-driven property update (as a `value`/`checked` input or
112
+ * `$flow.$set` would produce): routes through the allowlist and fires the
113
+ * `onUpdating()` / `onUpdated()` hooks (and their per-property variants), then
114
+ * re-renders.
115
+ * @category Actions
116
+ */
117
+ async update(prop: keyof T, value: unknown): Promise<this> {
118
+ await this._page._applyClientUpdate(prop as string, value);
119
+ await this._render();
120
+ return this;
121
+ }
122
+
123
+ /**
124
+ * Call an action method and re-render. Simulates a subsequent (WebSocket) request:
125
+ * runs `onBoot()` + `onHydrate()`, invokes the method, then `onUpdate()` and re-render.
126
+ * If the action throws ValidationError, the errors are stored and the page re-renders
127
+ * with the error bag populated; any other thrown error is routed to `onError()`.
128
+ *
129
+ * @param method - Name of the method to invoke on the page.
130
+ * @param args - Arguments forwarded to the method.
131
+ * @throws If `method` is not a function on the page instance.
132
+ * @category Actions
133
+ */
134
+ async call(method: string, ...args: unknown[]): Promise<this> {
135
+ const fn = (this._page as Record<string, unknown>)[method];
136
+ if (typeof fn !== "function") {
137
+ throw new Error(`[FlowTest] Method "${method}" not found on ${this._name}`);
138
+ }
139
+
140
+ // Each call simulates a subsequent (WebSocket) request: boot() then hydrate().
141
+ await this._page.onBoot();
142
+ await this._page.onHydrate();
143
+
144
+ try {
145
+ await (fn as (...a: unknown[]) => unknown).apply(this._page, args);
146
+ } catch (error) {
147
+ if (error instanceof ValidationError) {
148
+ // Errors already stored on page._errors
149
+ } else {
150
+ try {
151
+ await this._page.onError(error instanceof Error ? error : new Error(String(error)));
152
+ } catch {
153
+ // ignore
154
+ }
155
+ }
156
+ }
157
+
158
+ await this._page.onUpdate();
159
+ this._effects = this._page._drainEffects();
160
+ await this._render();
161
+ return this;
162
+ }
163
+
164
+ // ── Render ─────────────────────────────────────────────────────────────────
165
+
166
+ private async _render(): Promise<void> {
167
+ const compId = this._page._flowId;
168
+
169
+ if (!this._effects) {
170
+ this._html = await _renderFlowPage(this._page, () => this._page.render()).catch(() => "");
171
+ } else {
172
+ if (!this._effects.redirectUrl) {
173
+ this._html = await _renderFlowPage(this._page, () => this._page.render()).catch(() => "");
174
+ }
175
+ }
176
+
177
+ // dehydrate() hook runs at the end of the request, before serialisation.
178
+ await this._page.onDehydrate();
179
+ this._snapshot = dehydrate(this._page, {
180
+ id: compId,
181
+ name: this._name,
182
+ path: this._page._flowPath,
183
+ });
184
+ }
185
+
186
+ // ── HTML assertions ────────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * Assert the rendered HTML contains the given string.
190
+ * @category Assertions
191
+ */
192
+ assertSee(text: string): this {
193
+ if (!this._html.includes(text)) {
194
+ throw new Error(
195
+ `[FlowTest] Expected to see "${text}" but it was not found in:\n${this._html}`,
196
+ );
197
+ }
198
+ return this;
199
+ }
200
+
201
+ /**
202
+ * Assert the rendered HTML does NOT contain the given string.
203
+ * @category Assertions
204
+ */
205
+ assertDontSee(text: string): this {
206
+ if (this._html.includes(text)) {
207
+ throw new Error(`[FlowTest] Expected NOT to see "${text}" but it was found in the HTML.`);
208
+ }
209
+ return this;
210
+ }
211
+
212
+ // ── Redirect assertions ────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Assert the last action redirected to the given URL.
216
+ * @category Assertions
217
+ */
218
+ assertRedirectedTo(url: string): this {
219
+ const actual = this._effects?.redirectUrl;
220
+ if (actual !== url) {
221
+ throw new Error(`[FlowTest] Expected redirect to "${url}" but got: ${actual ?? "(none)"}`);
222
+ }
223
+ return this;
224
+ }
225
+
226
+ /**
227
+ * Assert the last action did NOT redirect.
228
+ * @category Assertions
229
+ */
230
+ assertNotRedirected(): this {
231
+ if (this._effects?.redirectUrl) {
232
+ throw new Error(`[FlowTest] Expected no redirect but got: ${this._effects.redirectUrl}`);
233
+ }
234
+ return this;
235
+ }
236
+
237
+ // ── Validation assertions ──────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Assert that a validation error exists for the given field, optionally that one of
241
+ * its messages contains `message`.
242
+ * @category Assertions
243
+ */
244
+ assertHasErrors(field: string, message?: string): this {
245
+ const errors = this._page._errors;
246
+ if (!errors[field] || errors[field]!.length === 0) {
247
+ throw new Error(`[FlowTest] Expected validation error for "${field}" but none found.`);
248
+ }
249
+ if (message && !errors[field]!.some((m) => m.includes(message))) {
250
+ throw new Error(
251
+ `[FlowTest] Expected error for "${field}" to contain "${message}" but got: ${errors[field]!.join(", ")}`,
252
+ );
253
+ }
254
+ return this;
255
+ }
256
+
257
+ /**
258
+ * Assert no validation errors exist.
259
+ * @category Assertions
260
+ */
261
+ assertNoErrors(): this {
262
+ const count = Object.keys(this._page._errors).length;
263
+ if (count > 0) {
264
+ const summary = Object.entries(this._page._errors)
265
+ .map(([f, msgs]) => ` ${f}: ${msgs.join(", ")}`)
266
+ .join("\n");
267
+ throw new Error(`[FlowTest] Expected no validation errors but found:\n${summary}`);
268
+ }
269
+ return this;
270
+ }
271
+
272
+ // ── Flash assertions ───────────────────────────────────────────────────────
273
+
274
+ /**
275
+ * The full flash payloads emitted by the last action (title/position/duration/…).
276
+ * @category Assertions
277
+ */
278
+ flashes(): FlowEffects["flashes"] {
279
+ return this._effects?.flashes ?? [];
280
+ }
281
+
282
+ /**
283
+ * Assert a flash notification was emitted, optionally filtered by `level` and/or a
284
+ * substring of its `message`.
285
+ * @category Assertions
286
+ */
287
+ assertFlashed(level?: string, message?: string): this {
288
+ const flashes = this._effects?.flashes ?? [];
289
+ const match = flashes.find(
290
+ (f) => (!level || f.level === level) && (!message || f.message.includes(message)),
291
+ );
292
+ if (!match) {
293
+ const desc = [level, message].filter(Boolean).join("/");
294
+ throw new Error(
295
+ `[FlowTest] Expected flash "${desc}" but flashes were: ${JSON.stringify(flashes)}`,
296
+ );
297
+ }
298
+ return this;
299
+ }
300
+
301
+ // ── Event assertions ───────────────────────────────────────────────────────
302
+
303
+ /**
304
+ * Assert a cross-component event was dispatched by the last action.
305
+ * @category Assertions
306
+ */
307
+ assertDispatched(eventName: string): this {
308
+ const events = this._effects?.events ?? [];
309
+ if (!events.some((e) => e.name === eventName)) {
310
+ throw new Error(
311
+ `[FlowTest] Expected event "${eventName}" to be dispatched but it wasn't. Events: ${events.map((e) => e.name).join(", ") || "(none)"}`,
312
+ );
313
+ }
314
+ return this;
315
+ }
316
+
317
+ // ── State accessors ────────────────────────────────────────────────────────
318
+
319
+ /**
320
+ * Get the rendered HTML.
321
+ * @category State
322
+ */
323
+ html(): string {
324
+ return this._html;
325
+ }
326
+
327
+ /**
328
+ * Get the last drained effects (flashes, events, redirect, downloads).
329
+ * @category State
330
+ */
331
+ effects(): FlowEffects | null {
332
+ return this._effects;
333
+ }
334
+
335
+ /**
336
+ * Get the current page state snapshot.
337
+ * @category State
338
+ */
339
+ snapshot(): Snapshot | null {
340
+ return this._snapshot;
341
+ }
342
+
343
+ /**
344
+ * Get the current page instance (for direct property inspection).
345
+ * @category State
346
+ */
347
+ page(): T {
348
+ return this._page;
349
+ }
350
+
351
+ /**
352
+ * Get current validation errors.
353
+ * @category State
354
+ */
355
+ errors(): Record<string, string[]> {
356
+ return this._page._errors;
357
+ }
358
+ }
359
+
360
+ // ── Re-export hydrate for advanced use ────────────────────────────────────────
361
+ export { hydrate } from "./dehydrate.ts";