@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,101 @@
1
+ /**
2
+ * .zerotal/compiled/ disk cache for compiled render functions.
3
+ *
4
+ * Cache key: Bun.hash(sourceContent).toString(16)
5
+ * Cache entry: .zerotal/compiled/<hash>.js
6
+ *
7
+ * On a cache hit the compiled file is imported directly.
8
+ * On a miss the caller compiles, calls setCached, then imports the returned path.
9
+ */
10
+
11
+ import { join } from "node:path";
12
+ import { mkdir, unlink, readdir } from "node:fs/promises";
13
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const COMPILED_DIR_NAME = join(".zerotal", "compiled");
17
+
18
+ /**
19
+ * Cache version — a fingerprint of the COMPILER'S OWN SOURCE, computed once per process.
20
+ *
21
+ * This used to be a hand-bumped constant, which was a silent-staleness footgun: change the
22
+ * emit logic (or upgrade the package) without bumping it, and every page whose own source
23
+ * hadn't changed kept serving a compile from the OLD compiler — surviving server restarts,
24
+ * because the cache lives on disk. Deriving it from the compiler sources means any emit
25
+ * change invalidates every entry automatically, with nothing to remember.
26
+ *
27
+ * Falls back to a constant if the sources can't be read (e.g. an exotic bundled deploy).
28
+ */
29
+ const COMPILER_VERSION: string = (() => {
30
+ try {
31
+ const dir = fileURLToPath(new URL(".", import.meta.url));
32
+ const parts = readdirSync(dir)
33
+ .filter((f) => f.endsWith(".ts") && !f.includes(".test.") && f !== "cache.ts")
34
+ .sort()
35
+ .map((f) => String(Bun.hash(readFileSync(join(dir, f), "utf8"))));
36
+ return parts.length > 0 ? `s${Bun.hash(parts.join("|"))}` : "4";
37
+ } catch {
38
+ return "4";
39
+ }
40
+ })();
41
+
42
+ function _cacheDir(appRoot: string): string {
43
+ return join(appRoot, COMPILED_DIR_NAME);
44
+ }
45
+
46
+ function _hashSource(source: string, cspSafe = false): string {
47
+ // Bun.hash returns a number or bigint depending on input type.
48
+ // Using String() avoids any ambiguity. The cspSafe marker keeps CSP and
49
+ // default builds in separate cache entries (their emitted output differs).
50
+ return String(Bun.hash(COMPILER_VERSION + (cspSafe ? "csp:" : "") + source));
51
+ }
52
+
53
+ /** Returns the cached compiled file path if up-to-date, otherwise null. */
54
+ export function getCachedPath(
55
+ source: string,
56
+ appRoot: string,
57
+ cspSafe = false,
58
+ ext = "ts",
59
+ ): string | null {
60
+ const hash = _hashSource(source, cspSafe);
61
+ const path = join(_cacheDir(appRoot), `${hash}.${ext}`);
62
+ return existsSync(path) ? path : null;
63
+ }
64
+
65
+ /**
66
+ * Write a compiled render function to the cache and return its absolute path.
67
+ * Creates the cache directory if it doesn't exist.
68
+ */
69
+ export async function writeCache(
70
+ source: string,
71
+ compiledCode: string,
72
+ sourceFile: string,
73
+ appRoot: string,
74
+ cspSafe = false,
75
+ ext = "ts",
76
+ ): Promise<string> {
77
+ const dir = _cacheDir(appRoot);
78
+ await mkdir(dir, { recursive: true });
79
+
80
+ const hash = _hashSource(source, cspSafe);
81
+ const outPath = join(dir, `${hash}.${ext}`);
82
+ const header = `// @flow-compiled\n// Source: ${sourceFile}\n// Hash: ${hash}\n\n`;
83
+
84
+ await Bun.write(outPath, header + compiledCode);
85
+ return outPath;
86
+ }
87
+
88
+ /**
89
+ * Clear all compiled cache entries.
90
+ * Called by the dev server on full restart or when flow itself is updated.
91
+ */
92
+ export async function clearCache(appRoot: string): Promise<void> {
93
+ const dir = _cacheDir(appRoot);
94
+ if (!existsSync(dir)) return;
95
+ const files = await readdir(dir);
96
+ await Promise.all(
97
+ files
98
+ .filter((f) => f.endsWith(".js") || f.endsWith(".ts") || f.endsWith(".tsx"))
99
+ .map((f) => unlink(join(dir, f)).catch(() => undefined)),
100
+ );
101
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Compile-time directive resolution.
3
+ *
4
+ * Maps JSX prop names to their final HTML attribute equivalents.
5
+ * Mirrors PULSE_PROP_MAP in jsx-runtime.ts — kept in sync manually; both are the
6
+ * single source of truth for their respective runtime vs. compile-time roles.
7
+ */
8
+
9
+ export type DirectiveKind =
10
+ | { kind: "boolean"; attr: string } // showOnLoading → flow:loading (no value)
11
+ | { kind: "passthrough"; attr: string } // loadingAttr="disabled" → flow:loading.attr="disabled"
12
+ | { kind: "value"; attrs: [string, string] } // value={this.x} → flow:model="x" value="<dynamic>"
13
+ | { kind: "event"; prefix: string } // onClick → flow:click
14
+ | { kind: "passthrough-expr"; attr: string } // confirm={...} → flow:confirm="..."
15
+ | { kind: "text" }; // text={this.x} → flow:text="x" + child injection
16
+
17
+ /** Directive entries for boolean JSX props (no value → no value in HTML). */
18
+ export const BOOLEAN_DIRECTIVES: Record<string, string> = {
19
+ showOnLoading: "flow:loading",
20
+ hideOnLoading: "flow:loading.remove",
21
+ showOnDirty: "flow:dirty",
22
+ hideOnDirty: "flow:dirty.remove",
23
+ navigate: "flow:navigate",
24
+ navigateHover: "flow:navigate.hover",
25
+ currentExact: "flow:current.exact",
26
+ currentStrict: "flow:current.strict",
27
+ cloak: "flow:cloak",
28
+ ignore: "flow:ignore",
29
+ ignoreSelf: "flow:ignore.self",
30
+ replace: "flow:replace",
31
+ replaceSelf: "flow:replace.self",
32
+ showImportant: "flow:show.important",
33
+ intersectOnce: "flow:intersect.once", // fire onIntersect once, then stop observing
34
+ sortHandle: "flow:sort:handle",
35
+ sortIgnore: "flow:sort:ignore",
36
+ streamReplace: "flow:stream.replace",
37
+ delay: "flow:loading.delay",
38
+ collapse: "x-collapse", // Tier 3: animate x-show height
39
+ transition: "flow:transition", // enter animation on morph (bridge: _triggerEnterTransition)
40
+ showOnOffline: "flow:offline", // shown while the WS connection is down
41
+ hideOnOffline: "flow:offline.remove", // hidden while the WS connection is down
42
+ showOnError: "flow:failed", // shown after an action fails (optimistic failed state)
43
+ hideOnError: "flow:failed.remove", // hidden after an action fails
44
+ autoFocus: "flow:autofocus", // focus this field on mount
45
+ focusOnError: "flow:focus-error", // focus this field after a patch if it becomes invalid
46
+ };
47
+
48
+ /**
49
+ * String-passthrough directives: the prop name maps to a different attribute
50
+ * name but the string value is passed through unchanged.
51
+ * E.g. `loadingAttr="disabled"` → `flow:loading.attr="disabled"`.
52
+ */
53
+ export const STRING_DIRECTIVES: Record<string, string> = {
54
+ loadingClass: "flow:loading.class",
55
+ loadingClassRemove: "flow:loading.class.remove",
56
+ loadingAttr: "flow:loading.attr",
57
+ dirtyClass: "flow:dirty.class",
58
+ dirtyClassRemove: "flow:dirty.class.remove",
59
+ dirtyAttr: "flow:dirty.attr",
60
+ current: "flow:current",
61
+ ref: "flow:ref",
62
+ loadingTarget: "flow:target", // scope loading state to specific action(s)
63
+ loadingTargetExcept: "flow:target.except",
64
+ dirtyTarget: "flow:target", // scope dirty state to specific prop(s)
65
+ offlineClass: "flow:offline.class", // class toggled while offline
66
+ offlineClassRemove: "flow:offline.class.remove",
67
+ offlineAttr: "flow:offline.attr", // attribute toggled while offline
68
+ intersectMargin: "flow:intersect.margin", // observer rootMargin, e.g. "200px" (fire before entry)
69
+ intersectThreshold: "flow:intersect.threshold", // "half" | "full" | 0..1
70
+ sortItem: "flow:sort:item", // per-item reorder key (literal keys; dynamic keys use the runtime path)
71
+ sortGroup: "flow:sort:group",
72
+ stream: "flow:stream",
73
+ poll: "flow:poll",
74
+ confirm: "flow:confirm",
75
+ show: "flow:show",
76
+ onInit: "flow:init",
77
+ teleport: "flow:teleport",
78
+ mask: "x-mask", // Tier 3: input masking
79
+ trap: "x-trap", // Tier 3: focus trap (truthy expression)
80
+ anchor: "x-anchor", // Tier 3: position relative to another element
81
+ transition: "flow:transition", // enter/leave preset for show= (e.g. transition="slide-right")
82
+ draft: "flow:draft", // persist an unsubmitted input value to localStorage under this key
83
+ };
84
+
85
+ /** HTML attribute name normalisation (JSX names → HTML names). */
86
+ export const ATTR_RENAMES: Record<string, string> = {
87
+ className: "class",
88
+ htmlFor: "for",
89
+ };
90
+
91
+ /** VOID_ELEMENTS that must not have a closing tag. */
92
+ export const VOID_ELEMENTS = new Set([
93
+ "area",
94
+ "base",
95
+ "br",
96
+ "col",
97
+ "embed",
98
+ "hr",
99
+ "img",
100
+ "input",
101
+ "link",
102
+ "meta",
103
+ "param",
104
+ "source",
105
+ "track",
106
+ "wbr",
107
+ ]);
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `@zerotal/flow/compiler-helpers` — tiny HTML-escaping helpers imported by
3
+ * AOT-compiled render functions. These are the only runtime dependency of the
4
+ * generated `.zerotal/compiled/*.js` files and are not intended for direct use.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * // Emitted by the Flow compiler into a compiled render function:
9
+ * html += "<p>" + __esc(this.name) + "</p>";
10
+ * html += '<a href="' + __escAttr(this.url) + '">link</a>';
11
+ * ```
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+
16
+ /** Escape a value for safe injection as HTML text content. */
17
+ export function __esc(v: unknown): string {
18
+ if (v == null || v === false || v === true) return "";
19
+ return String(v).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
20
+ }
21
+
22
+ /** Escape a value for safe injection inside an HTML attribute (double-quoted). */
23
+ export function __escAttr(v: unknown): string {
24
+ if (v == null) return "";
25
+ return String(v).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
26
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Flow AOT compiler — orchestrates compilation of all registered Pages.
3
+ *
4
+ * Called once by FlowProvider.onBooting() before the server accepts requests.
5
+ * For each page that has a __sourceFile stored on it (set by the file-route
6
+ * resolver), we:
7
+ * 1. Read the source
8
+ * 2. Hash it and check .zerotal/compiled/ cache
9
+ * 3. On miss: transform() → write to cache
10
+ * 4. dynamic-import the compiled file
11
+ * 5. Patch PageClass.prototype.render + set __flowCompiled = true
12
+ *
13
+ * Pages that fall back (unsupported JSX patterns, manually-registered routes
14
+ * without __sourceFile) continue using the standard runtime transparently.
15
+ */
16
+
17
+ import { pathToFileURL } from "node:url";
18
+ import { frameworkLog } from "@zerotal/core/logger";
19
+ import {
20
+ transformFlowFile,
21
+ buildBindInjectedRender,
22
+ validateFlowFile,
23
+ describeBlocker,
24
+ FlowValidationError,
25
+ type BlockerReport,
26
+ } from "./transform.ts";
27
+ import { getCachedPath, writeCache } from "./cache.ts";
28
+
29
+ /**
30
+ * Set `ZT_FLOW_COMPILE_LOG=1` to have every page that falls back to the runtime
31
+ * renderer say why, with the line to look at. Off by default: falling back is
32
+ * normal and correct for component-heavy pages, so reporting it on every boot
33
+ * would be noise rather than news.
34
+ */
35
+ function _explainFallbacks(): boolean {
36
+ return Bun.env["ZT_FLOW_COMPILE_LOG"] === "1";
37
+ }
38
+
39
+ const HELPERS_IMPORT = `import { __esc, __escAttr } from '@zerotal/flow/compiler-helpers';\n\n`;
40
+
41
+ /**
42
+ * Compile all Component classes that have __sourceFile set.
43
+ *
44
+ * @param appRoot Absolute path to the app root directory (contains .zerotal/).
45
+ */
46
+ export async function compileRegisteredPages(appRoot: string, cspSafe = false): Promise<void> {
47
+ // Import registry lazily to avoid circular deps at module load time.
48
+ const { _getPageEntries } = await import("../registry.ts");
49
+ const entries = _getPageEntries();
50
+
51
+ let compiled = 0;
52
+ let cached = 0;
53
+ let skipped = 0;
54
+ let injected = 0;
55
+
56
+ const start = Date.now();
57
+
58
+ for (const [, entry] of entries) {
59
+ const PageClass = entry.PageClass as unknown as { new (): unknown } & {
60
+ prototype: { render?: (...a: unknown[]) => unknown };
61
+ __flowCompiled?: boolean;
62
+ __sourceFile?: string;
63
+ };
64
+
65
+ const sourceFile = PageClass.__sourceFile;
66
+ if (!sourceFile) {
67
+ skipped++;
68
+ continue;
69
+ }
70
+
71
+ // Already compiled in this process (e.g. shared base class registered twice)
72
+ if (PageClass.__flowCompiled) {
73
+ cached++;
74
+ continue;
75
+ }
76
+
77
+ const source = await Bun.file(sourceFile).text();
78
+
79
+ // Validate unconditionally — runs even on cache hits.
80
+ // FlowValidationError propagates here (not caught below) so the server
81
+ // refuses to start when a page references @locked props or non-@expose members
82
+ // from a client callback.
83
+ validateFlowFile(source, sourceFile);
84
+
85
+ try {
86
+ // ── Cache hit ────────────────────────────────────────────────────────
87
+ let compiledPath = getCachedPath(source, appRoot, cspSafe);
88
+
89
+ // ── Cache miss — transform and write ─────────────────────────────────
90
+ if (!compiledPath) {
91
+ const report: BlockerReport = {};
92
+ const result = transformFlowFile(source, sourceFile, { cspSafe, report });
93
+ if (!result) {
94
+ if (_explainFallbacks()) {
95
+ frameworkLog("flow").info(
96
+ `${PageClass.name} renders through the runtime.\n` +
97
+ describeBlocker(report.blocker, sourceFile).trimEnd(),
98
+ );
99
+ }
100
+ // CSP-safe mode cannot fall back to the runtime renderer (it emits
101
+ // eval-requiring expressions), so an un-compilable page is fatal.
102
+ if (cspSafe) {
103
+ throw new FlowValidationError(
104
+ `[Flow CSP] ${PageClass.name} could not be AOT-compiled, and CSP-safe mode ` +
105
+ `forbids the eval-based runtime fallback. Simplify the page's render().`,
106
+ );
107
+ }
108
+ // Bind-name injection: the page still renders through the runtime jsx()
109
+ // path, but with `__flowBinds` added so component bindings (show/bind/query)
110
+ // resolve statically instead of by the fragile value/capture heuristic.
111
+ // A faithful reprint of render() — if it fails to build or import, the
112
+ // catch below falls back to the untouched runtime render.
113
+ let injPath = getCachedPath(source, appRoot, cspSafe, "tsx");
114
+ if (!injPath) {
115
+ const injectedCode = buildBindInjectedRender(source, sourceFile);
116
+ if (!injectedCode) {
117
+ skipped++;
118
+ continue; // nothing to inject — plain runtime render
119
+ }
120
+ injPath = await writeCache(source, injectedCode, sourceFile, appRoot, cspSafe, "tsx");
121
+ }
122
+ const injMod = (await import(pathToFileURL(injPath).href)) as {
123
+ render: (...a: unknown[]) => unknown;
124
+ };
125
+ // Patch render but leave __flowCompiled unset: the injected render
126
+ // still uses jsx() and needs the runtime getter instrumentation for the
127
+ // non-component bindings (value=/checked=/text= on intrinsic elements).
128
+ PageClass.prototype.render = injMod.render;
129
+ injected++;
130
+ continue;
131
+ }
132
+
133
+ const code =
134
+ HELPERS_IMPORT +
135
+ (result.preamble ? result.preamble + "\n\n" : "") +
136
+ `export async function render() {\n${result.renderBody}\n}\n`;
137
+
138
+ compiledPath = await writeCache(source, code, sourceFile, appRoot, cspSafe);
139
+ compiled++;
140
+ } else {
141
+ cached++;
142
+ }
143
+
144
+ // ── Dynamic import + prototype patch ──────────────────────────────────
145
+ // Use file:// URL so dynamic import works on both Unix and Windows.
146
+ const mod = (await import(pathToFileURL(compiledPath).href)) as {
147
+ render: (...a: unknown[]) => unknown;
148
+ };
149
+ PageClass.prototype.render = mod.render;
150
+ PageClass.__flowCompiled = true;
151
+ } catch (err) {
152
+ // In CSP-safe mode there is no safe runtime fallback — surface the error so
153
+ // the server refuses to boot and the developer fixes the page.
154
+ if (cspSafe) throw err;
155
+ // Otherwise a non-fatal compile failure falls back to the runtime renderer.
156
+ // Compiler errors already carry a `[Flow]` tag; strip it so the line reads
157
+ // as one message rather than two stacked prefixes.
158
+ const detail = (err as Error).message.replace(/^\[Flow\]\s*/, "");
159
+ frameworkLog("flow").warn(`${PageClass.name} skipped — ${detail}`);
160
+ skipped++;
161
+ }
162
+ }
163
+
164
+ const ms = Date.now() - start;
165
+ if (compiled > 0 || cached > 0 || injected > 0) {
166
+ frameworkLog("flow").info(
167
+ `Compiled ${compiled} page(s), ${cached} from cache, ` +
168
+ `${injected} bind-injected, ${skipped} using runtime`,
169
+ { compiled, cached, injected, runtime: skipped, ms },
170
+ );
171
+ }
172
+ }