@uniflowed/vite 0.0.0-alpha.1
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/bun-preload.js +22 -0
- package/driver.js +360 -0
- package/index.js +316 -0
- package/internal/config.js +141 -0
- package/internal/events.js +68 -0
- package/internal/node-hooks.js +111 -0
- package/internal/refresh-runtime.js +670 -0
- package/internal/refresh.js +110 -0
- package/internal/routes.js +243 -0
- package/package.json +41 -0
- package/register.js +11 -0
- package/transform.js +187 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Plain JavaScript: executed by the host that runs Vite, before any transform.
|
|
2
|
+
//
|
|
3
|
+
// React Fast Refresh wiring for `uf dev`. The runtime itself is
|
|
4
|
+
// `./refresh-runtime.js`, Meta's `react-refresh` runtime as simplified by
|
|
5
|
+
// `@vitejs/plugin-react` (MIT); this file is the glue that plugin-react adds
|
|
6
|
+
// around it, kept structurally identical so the two stay comparable:
|
|
7
|
+
//
|
|
8
|
+
// * the runtime is served at `/@react-refresh` as a virtual module;
|
|
9
|
+
// * a preamble installs it on `window` before any component module loads;
|
|
10
|
+
// * every module that registered a component gets a header that points
|
|
11
|
+
// `$RefreshReg$`/`$RefreshSig$` at this module and a footer that validates
|
|
12
|
+
// the boundary and enqueues the refresh on `import.meta.hot.accept`.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
/** Public URL the refresh runtime is served from. */
|
|
18
|
+
export const RUNTIME_PUBLIC_PATH = "/@react-refresh";
|
|
19
|
+
|
|
20
|
+
/** The resolved id Vite hands back for the runtime. */
|
|
21
|
+
export const RUNTIME_RESOLVED_ID = "\0uf:react-refresh";
|
|
22
|
+
|
|
23
|
+
const RUNTIME_SOURCE_PATH = fileURLToPath(new URL("./refresh-runtime.js", import.meta.url));
|
|
24
|
+
|
|
25
|
+
/** The runtime's source, read once. */
|
|
26
|
+
export function refreshRuntimeSource() {
|
|
27
|
+
return readFileSync(RUNTIME_SOURCE_PATH, "utf8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The script every HTML document loads first in development.
|
|
32
|
+
*
|
|
33
|
+
* `base` is Vite's `config.base`, so the runtime resolves under a sub-path
|
|
34
|
+
* deployment as well as at the root.
|
|
35
|
+
*/
|
|
36
|
+
export function preambleCode(base = "/") {
|
|
37
|
+
return `import { injectIntoGlobalHook } from "${base}${RUNTIME_PUBLIC_PATH.slice(1)}";
|
|
38
|
+
injectIntoGlobalHook(window);
|
|
39
|
+
window.$RefreshReg$ = () => {};
|
|
40
|
+
window.$RefreshSig$ = () => (type) => type;`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const REFRESH_CONTENT = /\$RefreshReg\$\(/;
|
|
44
|
+
const REACT_CLASS_COMPONENT = /extends\s+(?:React\.)?(?:Pure)?Component/;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wrap a transformed module with the Fast Refresh header and footer.
|
|
48
|
+
*
|
|
49
|
+
* A module that registered nothing and defines no class component comes back
|
|
50
|
+
* untouched, so the wrapper never costs a module that has no component in it.
|
|
51
|
+
* The source map is shifted by the number of lines prepended, which is what
|
|
52
|
+
* keeps a stack trace pointing at the author's line.
|
|
53
|
+
*/
|
|
54
|
+
export function addRefreshWrapper(code, map, id) {
|
|
55
|
+
const hasRefresh = REFRESH_CONTENT.test(code);
|
|
56
|
+
const onlyReactComponent = !hasRefresh && REACT_CLASS_COMPONENT.test(code);
|
|
57
|
+
if (!hasRefresh && !onlyReactComponent) {
|
|
58
|
+
return { code, map };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const nextMap = typeof map === "string" ? JSON.parse(map) : map;
|
|
62
|
+
let nextCode = code;
|
|
63
|
+
|
|
64
|
+
if (hasRefresh) {
|
|
65
|
+
nextCode = `let prevRefreshReg;
|
|
66
|
+
let prevRefreshSig;
|
|
67
|
+
|
|
68
|
+
if (import.meta.hot && !inWebWorker) {
|
|
69
|
+
if (!window.$RefreshReg$) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"@uniflowed/vite can't detect the Fast Refresh preamble. Something is wrong."
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
prevRefreshReg = window.$RefreshReg$;
|
|
76
|
+
prevRefreshSig = window.$RefreshSig$;
|
|
77
|
+
window.$RefreshReg$ = RefreshRuntime.getRefreshReg(${JSON.stringify(id)});
|
|
78
|
+
window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
${nextCode}
|
|
82
|
+
|
|
83
|
+
if (import.meta.hot && !inWebWorker) {
|
|
84
|
+
window.$RefreshReg$ = prevRefreshReg;
|
|
85
|
+
window.$RefreshSig$ = prevRefreshSig;
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
if (nextMap) nextMap.mappings = ";".repeat(16) + nextMap.mappings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
nextCode = `import * as RefreshRuntime from "${RUNTIME_PUBLIC_PATH}";
|
|
92
|
+
const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope;
|
|
93
|
+
|
|
94
|
+
${nextCode}
|
|
95
|
+
|
|
96
|
+
if (import.meta.hot && !inWebWorker) {
|
|
97
|
+
RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => {
|
|
98
|
+
RefreshRuntime.registerExportsForReactRefresh(${JSON.stringify(id)}, currentExports);
|
|
99
|
+
import.meta.hot.accept((nextExports) => {
|
|
100
|
+
if (!nextExports) return;
|
|
101
|
+
const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(${JSON.stringify(id)}, currentExports, nextExports);
|
|
102
|
+
if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
`;
|
|
107
|
+
if (nextMap) nextMap.mappings = ";;;" + nextMap.mappings;
|
|
108
|
+
|
|
109
|
+
return { code: nextCode, map: nextMap };
|
|
110
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Plain JavaScript: executed by the host that runs Vite, before any transform.
|
|
2
|
+
//
|
|
3
|
+
// The file-system router, as the build sees it.
|
|
4
|
+
//
|
|
5
|
+
// This mirrors `uf_router` in Rust — the same reserved-name grammar
|
|
6
|
+
// (`_uf.<role>[.<variant>].js`, plus `.mdx` for pages), the same route path
|
|
7
|
+
// syntax (`[param]`, `[...rest]`, `(group)`) and the same sort order — and it
|
|
8
|
+
// must keep mirroring it: `uf lint` and `router.js`'s generated types describe
|
|
9
|
+
// the routes this module serves, so the two cannot be allowed to disagree.
|
|
10
|
+
//
|
|
11
|
+
// Everything produced here is a string of JavaScript for a virtual module. The
|
|
12
|
+
// route table imports every page and layout lazily, so a route is a chunk of
|
|
13
|
+
// its own and the client only downloads what it navigates to.
|
|
14
|
+
|
|
15
|
+
import { readdirSync, statSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
/** The file names the router reserves inside the router root. */
|
|
19
|
+
export const RESERVED = Object.freeze({
|
|
20
|
+
layout: "_uf.layout",
|
|
21
|
+
page: "_uf.page",
|
|
22
|
+
middleware: "_uf.middleware",
|
|
23
|
+
notFound: "_uf.not-found",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** Extensions a page or layout may use; `.mdx` is a page written as content. */
|
|
27
|
+
const PAGE_EXTENSIONS = [".js", ".jsx", ".mdx"];
|
|
28
|
+
const MODULE_EXTENSIONS = [".js", ".jsx"];
|
|
29
|
+
|
|
30
|
+
/** Deepest directory nesting the scan will follow. */
|
|
31
|
+
const MAX_DEPTH = 32;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One route in the table.
|
|
35
|
+
*
|
|
36
|
+
* @typedef {object} Route
|
|
37
|
+
* @property {string} path route path such as `/docs/:slug`
|
|
38
|
+
* @property {string} pattern the same path with `*` for catch-alls, for humans
|
|
39
|
+
* @property {ReadonlyArray<{name: string, catchAll: boolean}>} params
|
|
40
|
+
* @property {string} page absolute path of the page module
|
|
41
|
+
* @property {ReadonlyArray<string>} layouts absolute paths, root first
|
|
42
|
+
* @property {ReadonlyArray<string>} middleware absolute paths, root first
|
|
43
|
+
* @property {boolean} mdx whether the page is MDX content
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scan `appRoot` for routes.
|
|
48
|
+
*
|
|
49
|
+
* Returns routes sorted by path, which is the order `uf_router` uses too.
|
|
50
|
+
* Directories that do not exist yield an empty table rather than an error: a
|
|
51
|
+
* library project has no router root, and that is not a mistake.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} appRoot absolute path of the router root (`app/`)
|
|
54
|
+
* @returns {Route[]}
|
|
55
|
+
*/
|
|
56
|
+
export function scanRoutes(appRoot) {
|
|
57
|
+
const routes = [];
|
|
58
|
+
let notFound = null;
|
|
59
|
+
if (!isDirectory(appRoot)) return { routes, notFound };
|
|
60
|
+
|
|
61
|
+
const walk = (directory, segments, layouts, middleware, depth) => {
|
|
62
|
+
if (depth > MAX_DEPTH) return;
|
|
63
|
+
const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
|
|
64
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
|
|
68
|
+
const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS);
|
|
69
|
+
const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
|
|
70
|
+
const nextMiddleware = ownMiddleware ? [...middleware, ownMiddleware] : middleware;
|
|
71
|
+
|
|
72
|
+
const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS);
|
|
73
|
+
if (page) {
|
|
74
|
+
const { path: routePath, pattern, params } = routeFromSegments(segments);
|
|
75
|
+
routes.push({
|
|
76
|
+
path: routePath,
|
|
77
|
+
pattern,
|
|
78
|
+
params,
|
|
79
|
+
page,
|
|
80
|
+
layouts: nextLayouts,
|
|
81
|
+
middleware: nextMiddleware,
|
|
82
|
+
mdx: page.endsWith(".mdx"),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (depth === 0) {
|
|
86
|
+
const own = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
|
|
87
|
+
if (own) notFound = { page: own, layouts: nextLayouts, mdx: own.endsWith(".mdx") };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (!entry.isDirectory()) continue;
|
|
92
|
+
// A leading dot or underscore is private to the author: `_components/`
|
|
93
|
+
// beside a page is a place to put things, not a route.
|
|
94
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
95
|
+
walk(
|
|
96
|
+
path.join(directory, entry.name),
|
|
97
|
+
[...segments, entry.name],
|
|
98
|
+
nextLayouts,
|
|
99
|
+
nextMiddleware,
|
|
100
|
+
depth + 1,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
walk(appRoot, [], [], [], 0);
|
|
106
|
+
routes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
107
|
+
return { routes, notFound };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isDirectory(candidate) {
|
|
111
|
+
try {
|
|
112
|
+
return statSync(candidate).isDirectory();
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function findModule(directory, stem, extensions) {
|
|
119
|
+
for (const extension of extensions) {
|
|
120
|
+
const candidate = path.join(directory, stem + extension);
|
|
121
|
+
try {
|
|
122
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
123
|
+
} catch {
|
|
124
|
+
// keep looking
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Turn directory segments into a route path and its parameters.
|
|
132
|
+
*
|
|
133
|
+
* `(group)` segments organise files without appearing in the URL, `[name]`
|
|
134
|
+
* captures one segment, and `[...name]` captures the rest of the path.
|
|
135
|
+
*/
|
|
136
|
+
export function routeFromSegments(segments) {
|
|
137
|
+
const params = [];
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const segment of segments) {
|
|
140
|
+
if (segment.startsWith("(") && segment.endsWith(")")) continue;
|
|
141
|
+
if (segment.startsWith("[...") && segment.endsWith("]")) {
|
|
142
|
+
const name = segment.slice(4, -1);
|
|
143
|
+
params.push({ name, catchAll: true });
|
|
144
|
+
out.push(`:${name}*`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
148
|
+
const name = segment.slice(1, -1);
|
|
149
|
+
params.push({ name, catchAll: false });
|
|
150
|
+
out.push(`:${name}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
out.push(segment);
|
|
154
|
+
}
|
|
155
|
+
const routePath = out.length === 0 ? "/" : `/${out.join("/")}`;
|
|
156
|
+
return { path: routePath, pattern: routePath.replace(/:(\w+)\*/g, "*$1"), params };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Virtual module ids the router plugin serves. */
|
|
160
|
+
export const VIRTUAL = Object.freeze({
|
|
161
|
+
routes: "virtual:uf/routes",
|
|
162
|
+
client: "virtual:uf/client",
|
|
163
|
+
server: "virtual:uf/server",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The source of `virtual:uf/routes`.
|
|
168
|
+
*
|
|
169
|
+
* Each page and layout is a lazy `import()`, so a route is a chunk of its own.
|
|
170
|
+
* Layouts are deduplicated into one table so a layout shared by fifty routes
|
|
171
|
+
* is one dynamic import, not fifty.
|
|
172
|
+
*
|
|
173
|
+
* @param {{routes: Route[], notFound: object | null}} table
|
|
174
|
+
*/
|
|
175
|
+
export function routesModuleSource(table) {
|
|
176
|
+
const layoutIds = new Map();
|
|
177
|
+
const layoutImports = [];
|
|
178
|
+
const layoutId = (file) => {
|
|
179
|
+
let id = layoutIds.get(file);
|
|
180
|
+
if (id === undefined) {
|
|
181
|
+
id = `layout${layoutIds.size}`;
|
|
182
|
+
layoutIds.set(file, id);
|
|
183
|
+
layoutImports.push(`const ${id} = () => import(${JSON.stringify(file)});`);
|
|
184
|
+
}
|
|
185
|
+
return id;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const entries = table.routes.map((route) => {
|
|
189
|
+
const layouts = route.layouts.map(layoutId);
|
|
190
|
+
return ` {
|
|
191
|
+
path: ${JSON.stringify(route.path)},
|
|
192
|
+
params: ${JSON.stringify(route.params)},
|
|
193
|
+
mdx: ${route.mdx},
|
|
194
|
+
file: ${JSON.stringify(route.page)},
|
|
195
|
+
page: () => import(${JSON.stringify(route.page)}),
|
|
196
|
+
layouts: [${layouts.join(", ")}],
|
|
197
|
+
}`;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const notFound = table.notFound
|
|
201
|
+
? `{
|
|
202
|
+
mdx: ${table.notFound.mdx},
|
|
203
|
+
file: ${JSON.stringify(table.notFound.page)},
|
|
204
|
+
page: () => import(${JSON.stringify(table.notFound.page)}),
|
|
205
|
+
layouts: [${table.notFound.layouts.map(layoutId).join(", ")}],
|
|
206
|
+
}`
|
|
207
|
+
: "null";
|
|
208
|
+
|
|
209
|
+
return `${layoutImports.join("\n")}
|
|
210
|
+
export const routes = [
|
|
211
|
+
${entries.join(",\n")}
|
|
212
|
+
];
|
|
213
|
+
export const notFound = ${notFound};
|
|
214
|
+
export default routes;
|
|
215
|
+
`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The source of `virtual:uf/client`: hydrate the document with the app.
|
|
220
|
+
*
|
|
221
|
+
* The current route's modules are loaded *before* hydration so the first
|
|
222
|
+
* render is synchronous and matches the server's HTML; a lazy import during
|
|
223
|
+
* hydration would suspend and React would fall back to a client render.
|
|
224
|
+
*/
|
|
225
|
+
export function clientModuleSource(appEntry) {
|
|
226
|
+
return `import { hydrate } from "@uniflowed/router/client";
|
|
227
|
+
import { routes, notFound } from ${JSON.stringify(VIRTUAL.routes)};
|
|
228
|
+
import App from ${JSON.stringify(appEntry)};
|
|
229
|
+
hydrate({ App, routes, notFound });
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The source of `virtual:uf/server`: render one URL to HTML.
|
|
235
|
+
*/
|
|
236
|
+
export function serverModuleSource(appEntry) {
|
|
237
|
+
return `import { createRenderer } from "@uniflowed/router/server";
|
|
238
|
+
import { routes, notFound } from ${JSON.stringify(VIRTUAL.routes)};
|
|
239
|
+
import App from ${JSON.stringify(appEntry)};
|
|
240
|
+
export { routes, notFound };
|
|
241
|
+
export const render = createRenderer({ App, routes, notFound });
|
|
242
|
+
`;
|
|
243
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/vite",
|
|
3
|
+
"version": "0.0.0-alpha.1",
|
|
4
|
+
"description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
|
+
"directory": "packages/vite"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./driver": "./driver.js",
|
|
16
|
+
"./register": "./register.js",
|
|
17
|
+
"./bun-preload": "./bun-preload.js",
|
|
18
|
+
"./transform": "./transform.js",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.js",
|
|
23
|
+
"driver.js",
|
|
24
|
+
"register.js",
|
|
25
|
+
"bun-preload.js",
|
|
26
|
+
"transform.js",
|
|
27
|
+
"internal"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@mdx-js/rollup": "^3.1.1",
|
|
31
|
+
"rehype-slug": "^6.0.0",
|
|
32
|
+
"remark-frontmatter": "^5.0.0",
|
|
33
|
+
"remark-gfm": "^4.0.1",
|
|
34
|
+
"remark-mdx-frontmatter": "^5.2.0",
|
|
35
|
+
"vite": "^8.2.2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"react": ">=19",
|
|
39
|
+
"react-dom": ">=19"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/register.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Plain JavaScript: this file registers the loader, so it cannot need one.
|
|
2
|
+
//
|
|
3
|
+
// `node --import @uniflowed/vite/register app.js` runs a Flow project on
|
|
4
|
+
// Node.js without a build step. Importing this module installs the hooks in
|
|
5
|
+
// `./internal/node-hooks.js` for the rest of the process.
|
|
6
|
+
|
|
7
|
+
import { register } from "node:module";
|
|
8
|
+
|
|
9
|
+
register("./internal/node-hooks.js", import.meta.url, {
|
|
10
|
+
data: { root: process.env.UF_PROJECT_ROOT ?? process.cwd() },
|
|
11
|
+
});
|
package/transform.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Plain JavaScript: executed by the host that runs Vite, before any transform
|
|
2
|
+
// exists — this module is how the transform is reached, so it cannot be Flow.
|
|
3
|
+
//
|
|
4
|
+
// The Flow → JavaScript transform lives in `uf` itself (`crates/uf_transform`:
|
|
5
|
+
// the official Flow parser, Flow's own lowering rules, the official React
|
|
6
|
+
// Compiler, oxc for JSX and code generation). This module is the JavaScript
|
|
7
|
+
// side of the `uf transform` service: one long-lived `uf` process per host
|
|
8
|
+
// process, newline-delimited JSON in, replies in request order out.
|
|
9
|
+
//
|
|
10
|
+
// Every host that runs Flow — the Vite plugin, the Node loader hook, the Bun
|
|
11
|
+
// preload, the config loader — goes through here, which is what makes them
|
|
12
|
+
// all produce the same module from the same source.
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { createInterface } from "node:readline";
|
|
16
|
+
|
|
17
|
+
/** File extensions uf treats as Flow source. */
|
|
18
|
+
export const FLOW_EXTENSIONS = [".js", ".jsx", ".mjs", ".cjs"];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether uf is responsible for transforming this module.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors `uf_transform::is_flow_module`, and must keep mirroring it: a `uf
|
|
24
|
+
* dev` session and a `uf test` run that disagree about which files are Flow
|
|
25
|
+
* disagree about what the code is.
|
|
26
|
+
*
|
|
27
|
+
* A build driver synthesises modules of its own (`\0vite/client`, Rolldown's
|
|
28
|
+
* shims), and a third-party dependency ships JavaScript that is already
|
|
29
|
+
* JavaScript; neither is Flow. `@uniflowed/*` under `node_modules` is the
|
|
30
|
+
* deliberate exception: those packages ship Flow source, because that is what
|
|
31
|
+
* uf tells everyone to write.
|
|
32
|
+
*/
|
|
33
|
+
export function isFlowModule(id) {
|
|
34
|
+
if (id.startsWith("\0")) return false;
|
|
35
|
+
const clean = stripQuery(id);
|
|
36
|
+
if (!FLOW_EXTENSIONS.some((extension) => clean.endsWith(extension))) return false;
|
|
37
|
+
const at = clean.lastIndexOf("/node_modules/");
|
|
38
|
+
return at === -1 || clean.slice(at).startsWith("/node_modules/@uniflowed/");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stripQuery(id) {
|
|
42
|
+
const at = id.indexOf("?");
|
|
43
|
+
return at === -1 ? id : id.slice(0, at);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The `uf` binary to talk to.
|
|
48
|
+
*
|
|
49
|
+
* `uf dev`, `uf build` and `uf test` set `UF_BINARY` to themselves when they
|
|
50
|
+
* start a host, so the host reaches exactly the binary that started it. A host
|
|
51
|
+
* started by hand finds `uf` on PATH, which is what the installer arranges.
|
|
52
|
+
*/
|
|
53
|
+
export function ufBinary() {
|
|
54
|
+
return process.env.UF_BINARY ?? "uf";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An error the transform reported for one module, with its position when
|
|
59
|
+
* the parser or the lowering rules gave one.
|
|
60
|
+
*/
|
|
61
|
+
export class TransformError extends Error {
|
|
62
|
+
constructor(id, message, line, column) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "TransformError";
|
|
65
|
+
this.id = id;
|
|
66
|
+
this.loc = line != null ? { file: id, line, column: column ?? 0 } : undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One `uf transform` process, with requests answered in the order they were
|
|
72
|
+
* sent.
|
|
73
|
+
*
|
|
74
|
+
* `uf transform` replies once per request, in order, so a plain queue of
|
|
75
|
+
* resolvers pairs a reply with its caller — no correlation ids and no map to
|
|
76
|
+
* leak. Any exit is final: a request made after the process has gone is
|
|
77
|
+
* rejected at once rather than queued against something that will never
|
|
78
|
+
* answer.
|
|
79
|
+
*/
|
|
80
|
+
export class TransformService {
|
|
81
|
+
#child;
|
|
82
|
+
#pending = [];
|
|
83
|
+
#failure = null;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {object} [options]
|
|
87
|
+
* @param {string} [options.command] the `uf` binary; `ufBinary()` by default
|
|
88
|
+
* @param {string} [options.root] project root, so `uf.config.js` is found
|
|
89
|
+
*/
|
|
90
|
+
constructor(options = {}) {
|
|
91
|
+
const command = options.command ?? ufBinary();
|
|
92
|
+
const root = options.root ?? process.cwd();
|
|
93
|
+
this.#child = spawn(command, ["--cwd", root, "transform"], {
|
|
94
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
createInterface({ input: this.#child.stdout }).on("line", (line) => {
|
|
98
|
+
const waiting = this.#pending.shift();
|
|
99
|
+
if (!waiting) return;
|
|
100
|
+
let reply;
|
|
101
|
+
try {
|
|
102
|
+
reply = JSON.parse(line);
|
|
103
|
+
} catch {
|
|
104
|
+
waiting.reject(new Error(`uf transform sent a malformed reply: ${line}`));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (reply.error != null) {
|
|
108
|
+
waiting.reject(new TransformError(waiting.id, reply.error, reply.line, reply.column));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
waiting.resolve(reply);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.#child.on("error", (error) => {
|
|
115
|
+
this.#settleAll(new Error(`could not run \`${command} transform\`: ${error.message}`));
|
|
116
|
+
});
|
|
117
|
+
this.#child.on("close", (code) => {
|
|
118
|
+
this.#settleAll(new Error(`uf transform exited (${code})`));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#settleAll(error) {
|
|
123
|
+
this.#failure = error;
|
|
124
|
+
while (this.#pending.length > 0) this.#pending.shift().reject(error);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Transform one module.
|
|
129
|
+
*
|
|
130
|
+
* Resolves to `{ code, map, diagnostics }`, or to `null` when the module is
|
|
131
|
+
* not uf's to transform (see `isFlowModule`). Rejects with a
|
|
132
|
+
* `TransformError` carrying the position when the source is not valid Flow.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} id absolute path, used for the map and for errors
|
|
135
|
+
* @param {string} code the Flow source
|
|
136
|
+
* @param {object} [options]
|
|
137
|
+
* @param {boolean} [options.development] readable output, `jsxDEV`
|
|
138
|
+
* @param {boolean} [options.refresh] Fast Refresh registrations (development only)
|
|
139
|
+
* @param {boolean} [options.sourceMap] produce a source map; on by default
|
|
140
|
+
*/
|
|
141
|
+
transform(id, code, options = {}) {
|
|
142
|
+
if (this.#failure) return Promise.reject(this.#failure);
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
this.#pending.push({
|
|
145
|
+
id,
|
|
146
|
+
reject,
|
|
147
|
+
resolve: (reply) => {
|
|
148
|
+
if (reply.code == null) {
|
|
149
|
+
resolve(null);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
resolve({ code: reply.code, map: reply.map ?? null, diagnostics: reply.diagnostics ?? [] });
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
this.#child.stdin.write(`${JSON.stringify({ id, code, options })}\n`);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Stop the process. Outstanding requests are rejected. */
|
|
160
|
+
close() {
|
|
161
|
+
this.#child.stdin.end();
|
|
162
|
+
this.#child.kill();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let shared = null;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The process-wide service, started on first use.
|
|
170
|
+
*
|
|
171
|
+
* The loader hooks and the config loader share one process per host rather
|
|
172
|
+
* than one per module; it lives as long as the host does.
|
|
173
|
+
*/
|
|
174
|
+
export function sharedService(root) {
|
|
175
|
+
shared ??= new TransformService({ root: root ?? process.env.UF_PROJECT_ROOT ?? process.cwd() });
|
|
176
|
+
return shared;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Transform one Flow module through the shared service.
|
|
181
|
+
*
|
|
182
|
+
* Returns `{ code, map, diagnostics }`; a module that is not uf's to transform
|
|
183
|
+
* comes back as `null`.
|
|
184
|
+
*/
|
|
185
|
+
export function transformFlow(code, filename, options = {}) {
|
|
186
|
+
return sharedService(options.root).transform(filename, code, options);
|
|
187
|
+
}
|