@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
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, "&").replace(/"/g, """);
|
|
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
|
+
}
|