@uniflowed/host 0.0.0-alpha.2
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 +24 -0
- package/internal/node-hooks.js +113 -0
- package/package.json +25 -0
- package/register.js +13 -0
- package/transform.js +197 -0
package/bun-preload.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this file registers the loader, so it cannot need one.
|
|
4
|
+
//
|
|
5
|
+
// `bun --preload @uniflowed/vite/bun-preload app.js` runs a Flow project on
|
|
6
|
+
// Bun without a build step, through Bun's own plugin API: every module uf is
|
|
7
|
+
// responsible for is transformed by `uf transform` as Bun loads it. It is the
|
|
8
|
+
// Bun counterpart of `./register.js`, and the policy of which files count is
|
|
9
|
+
// the same `isFlowModule`.
|
|
10
|
+
|
|
11
|
+
import { isFlowModule, transformFlow } from "./transform.js";
|
|
12
|
+
|
|
13
|
+
Bun.plugin({
|
|
14
|
+
name: "uniflowed-flow",
|
|
15
|
+
setup(build) {
|
|
16
|
+
build.onLoad({ filter: /\.(js|jsx|mjs)$/ }, async (args) => {
|
|
17
|
+
if (!isFlowModule(args.path)) return undefined;
|
|
18
|
+
const source = await Bun.file(args.path).text();
|
|
19
|
+
const out = await transformFlow(source, args.path, { development: true, sourceMap: false });
|
|
20
|
+
if (out == null) return undefined;
|
|
21
|
+
return { contents: out.code, loader: "js" };
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this *is* the loader, so it cannot be Flow.
|
|
4
|
+
//
|
|
5
|
+
// Node.js module customization hooks that transform Flow on import.
|
|
6
|
+
//
|
|
7
|
+
// Registered by `@uniflowed/host/register` (through `node:module`'s
|
|
8
|
+
// `register()`), which makes `node --import @uniflowed/host/register app.js`
|
|
9
|
+
// run a Flow project directly: every `.js` module uf is responsible for is
|
|
10
|
+
// transformed as it is loaded through `uf transform`, and everything else is
|
|
11
|
+
// left to Node.
|
|
12
|
+
//
|
|
13
|
+
// Transforms are cached on disk under `.uf/cache/transform/` keyed by a hash
|
|
14
|
+
// of the source, so a second run of the same file is a read rather than a
|
|
15
|
+
// round trip. The cache is content-addressed: an edited file hashes
|
|
16
|
+
// differently, so there is no invalidation to get wrong.
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { isFlowModule, transformFlow } from "../transform.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Write `contents` to `target` so a concurrent reader never sees half of it.
|
|
27
|
+
*
|
|
28
|
+
* `uf test` runs one of these processes per core and they all import the same
|
|
29
|
+
* few modules at once, so two writers and a reader meet on the same cache
|
|
30
|
+
* entry constantly. `writeFileSync` is not atomic — a reader can observe a
|
|
31
|
+
* truncated file and report a module that "does not provide an export" — so
|
|
32
|
+
* the content goes to a private temporary name first and is then renamed,
|
|
33
|
+
* which is atomic within a filesystem.
|
|
34
|
+
*
|
|
35
|
+
* A failure here is not a failure: a read-only checkout still runs, just
|
|
36
|
+
* without the cache.
|
|
37
|
+
*/
|
|
38
|
+
function writeAtomically(target, contents) {
|
|
39
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(cacheDirectory, { recursive: true });
|
|
42
|
+
writeFileSync(temporary, contents);
|
|
43
|
+
renameSync(temporary, target);
|
|
44
|
+
} catch {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(temporary);
|
|
47
|
+
} catch {
|
|
48
|
+
// Nothing to clean up.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Bumped whenever the transform's output shape changes, to retire old entries. */
|
|
54
|
+
const CACHE_VERSION = "2";
|
|
55
|
+
|
|
56
|
+
let cacheDirectory = null;
|
|
57
|
+
let root = null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Called once by `register()` with `{ root }`; the cache lives under it and
|
|
61
|
+
* the transform service is started there so it reads the right config.
|
|
62
|
+
*/
|
|
63
|
+
export async function initialize(data) {
|
|
64
|
+
root = data?.root ?? process.cwd();
|
|
65
|
+
cacheDirectory = path.join(root, ".uf", "cache", "transform");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The `load` hook: transform Flow modules, defer everything else.
|
|
70
|
+
*/
|
|
71
|
+
export async function load(url, context, nextLoad) {
|
|
72
|
+
if (!url.startsWith("file:")) return nextLoad(url, context);
|
|
73
|
+
const filename = fileURLToPath(url);
|
|
74
|
+
if (!isFlowModule(filename)) return nextLoad(url, context);
|
|
75
|
+
|
|
76
|
+
const source = readFileSync(filename, "utf8");
|
|
77
|
+
const code = await cachedTransform(source, filename);
|
|
78
|
+
if (code == null) return nextLoad(url, context);
|
|
79
|
+
// uf projects are ES modules. Forcing the format here means a project whose
|
|
80
|
+
// package.json forgot `"type": "module"` still runs, rather than failing on
|
|
81
|
+
// an `import` in what Node would have guessed was CommonJS.
|
|
82
|
+
return { format: "module", source: code, shortCircuit: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function cachedTransform(source, filename) {
|
|
86
|
+
const key = createHash("sha256")
|
|
87
|
+
.update(CACHE_VERSION)
|
|
88
|
+
.update("\0")
|
|
89
|
+
.update(filename)
|
|
90
|
+
.update("\0")
|
|
91
|
+
.update(source)
|
|
92
|
+
.digest("hex");
|
|
93
|
+
const entry = cacheDirectory ? path.join(cacheDirectory, `${key}.mjs`) : null;
|
|
94
|
+
|
|
95
|
+
if (entry) {
|
|
96
|
+
try {
|
|
97
|
+
return readFileSync(entry, "utf8");
|
|
98
|
+
} catch {
|
|
99
|
+
// not cached yet
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const out = await transformFlow(source, filename, { root, development: true, sourceMap: true });
|
|
104
|
+
if (out == null) return null;
|
|
105
|
+
const output = out.map
|
|
106
|
+
? `${out.code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(out.map).toString("base64")}\n`
|
|
107
|
+
: out.code;
|
|
108
|
+
|
|
109
|
+
if (entry) {
|
|
110
|
+
writeAtomically(entry, output);
|
|
111
|
+
}
|
|
112
|
+
return output;
|
|
113
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/host",
|
|
3
|
+
"version": "0.0.0-alpha.2",
|
|
4
|
+
"description": "Running Flow on a Capability JS Host, with no bundler in the way.",
|
|
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/host"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
"./register": "./register.js",
|
|
15
|
+
"./bun-preload": "./bun-preload.js",
|
|
16
|
+
"./transform": "./transform.js",
|
|
17
|
+
"./internal/node-hooks.js": "./internal/node-hooks.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"register.js",
|
|
21
|
+
"bun-preload.js",
|
|
22
|
+
"transform.js",
|
|
23
|
+
"internal/*.js"
|
|
24
|
+
]
|
|
25
|
+
}
|
package/register.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: this file registers the loader, so it cannot need one.
|
|
4
|
+
//
|
|
5
|
+
// `node --import @uniflowed/host/register app.js` runs a Flow project on
|
|
6
|
+
// Node.js without a build step. Importing this module installs the hooks in
|
|
7
|
+
// `./internal/node-hooks.js` for the rest of the process.
|
|
8
|
+
|
|
9
|
+
import { register } from "node:module";
|
|
10
|
+
|
|
11
|
+
register("./internal/node-hooks.js", import.meta.url, {
|
|
12
|
+
data: { root: process.env.UF_PROJECT_ROOT ?? process.cwd() },
|
|
13
|
+
});
|
package/transform.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: executed by the host that runs Vite, before any transform
|
|
4
|
+
// exists — this module is how the transform is reached, so it cannot be Flow.
|
|
5
|
+
//
|
|
6
|
+
// The Flow → JavaScript transform lives in `uf` itself (`crates/uf_transform`:
|
|
7
|
+
// the official Flow parser, Flow's own lowering rules, the official React
|
|
8
|
+
// Compiler, oxc for JSX and code generation). This module is the JavaScript
|
|
9
|
+
// side of the `uf transform` service: one long-lived `uf` process per host
|
|
10
|
+
// process, newline-delimited JSON in, replies in request order out.
|
|
11
|
+
//
|
|
12
|
+
// Every host that runs Flow — the Vite plugin, the Node loader hook, the Bun
|
|
13
|
+
// preload, the config loader — goes through here, which is what makes them
|
|
14
|
+
// all produce the same module from the same source.
|
|
15
|
+
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { createInterface } from "node:readline";
|
|
18
|
+
|
|
19
|
+
/** File extensions uf treats as Flow source. */
|
|
20
|
+
export const FLOW_EXTENSIONS = [".js", ".jsx", ".mjs", ".cjs"];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether uf is responsible for transforming this module.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `uf_transform::is_flow_module`, and must keep mirroring it: a `uf
|
|
26
|
+
* dev` session and a `uf test` run that disagree about which files are Flow
|
|
27
|
+
* disagree about what the code is.
|
|
28
|
+
*
|
|
29
|
+
* A build tool synthesises modules of its own — ids beginning with a NUL byte,
|
|
30
|
+
* a bundler's shims — and a third-party dependency ships JavaScript that is
|
|
31
|
+
* already JavaScript; neither is Flow. `@uniflowed/*` under `node_modules` is
|
|
32
|
+
* the deliberate exception: those packages ship Flow source, because that is
|
|
33
|
+
* what uf tells everyone to write.
|
|
34
|
+
*
|
|
35
|
+
* Which build tool is deliberately not named. This loader runs Flow on a
|
|
36
|
+
* Capability JS Host and has no bundler in it; naming one would tie the answer
|
|
37
|
+
* to a tool that is not in this file's dependency graph.
|
|
38
|
+
*/
|
|
39
|
+
export function isFlowModule(id) {
|
|
40
|
+
if (id.startsWith("\0")) return false;
|
|
41
|
+
const clean = stripQuery(id);
|
|
42
|
+
if (!FLOW_EXTENSIONS.some((extension) => clean.endsWith(extension))) return false;
|
|
43
|
+
const at = clean.lastIndexOf("/node_modules/");
|
|
44
|
+
return at === -1 || clean.slice(at).startsWith("/node_modules/@uniflowed/");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function stripQuery(id) {
|
|
48
|
+
const at = id.indexOf("?");
|
|
49
|
+
return at === -1 ? id : id.slice(0, at);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The `uf` binary to talk to.
|
|
54
|
+
*
|
|
55
|
+
* `uf dev`, `uf build` and `uf test` set `UF_BINARY` to themselves when they
|
|
56
|
+
* start a host, so the host reaches exactly the binary that started it. A host
|
|
57
|
+
* started by hand finds `uf` on PATH, which is what the installer arranges.
|
|
58
|
+
*/
|
|
59
|
+
export function ufBinary() {
|
|
60
|
+
return process.env.UF_BINARY ?? "uf";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An error the transform reported for one module, with its position when
|
|
65
|
+
* the parser or the lowering rules gave one.
|
|
66
|
+
*/
|
|
67
|
+
export class TransformError extends Error {
|
|
68
|
+
constructor(id, message, line, column) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "TransformError";
|
|
71
|
+
this.id = id;
|
|
72
|
+
this.loc = line != null ? { file: id, line, column: column ?? 0 } : undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One `uf transform` process, with requests answered in the order they were
|
|
78
|
+
* sent.
|
|
79
|
+
*
|
|
80
|
+
* `uf transform` replies once per request, in order, so a plain queue of
|
|
81
|
+
* resolvers pairs a reply with its caller — no correlation ids and no map to
|
|
82
|
+
* leak. Any exit is final: a request made after the process has gone is
|
|
83
|
+
* rejected at once rather than queued against something that will never
|
|
84
|
+
* answer.
|
|
85
|
+
*/
|
|
86
|
+
export class TransformService {
|
|
87
|
+
#child;
|
|
88
|
+
#pending = [];
|
|
89
|
+
#failure = null;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {object} [options]
|
|
93
|
+
* @param {string} [options.command] the `uf` binary; `ufBinary()` by default
|
|
94
|
+
* @param {string} [options.root] project root, so `uf.config.js` is found
|
|
95
|
+
*/
|
|
96
|
+
constructor(options = {}) {
|
|
97
|
+
const command = options.command ?? ufBinary();
|
|
98
|
+
const root = options.root ?? process.cwd();
|
|
99
|
+
this.#child = spawn(command, ["--cwd", root, "transform"], {
|
|
100
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
createInterface({ input: this.#child.stdout }).on("line", (line) => {
|
|
104
|
+
const waiting = this.#pending.shift();
|
|
105
|
+
if (!waiting) return;
|
|
106
|
+
let reply;
|
|
107
|
+
try {
|
|
108
|
+
reply = JSON.parse(line);
|
|
109
|
+
} catch {
|
|
110
|
+
waiting.reject(new Error(`uf transform sent a malformed reply: ${line}`));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (reply.error != null) {
|
|
114
|
+
waiting.reject(new TransformError(waiting.id, reply.error, reply.line, reply.column));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
waiting.resolve(reply);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
this.#child.on("error", (error) => {
|
|
121
|
+
this.#settleAll(new Error(`could not run \`${command} transform\`: ${error.message}`));
|
|
122
|
+
});
|
|
123
|
+
this.#child.on("close", (code) => {
|
|
124
|
+
this.#settleAll(new Error(`uf transform exited (${code})`));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#settleAll(error) {
|
|
129
|
+
this.#failure = error;
|
|
130
|
+
while (this.#pending.length > 0) this.#pending.shift().reject(error);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Transform one module.
|
|
135
|
+
*
|
|
136
|
+
* Resolves to `{ code, map, diagnostics }`, or to `null` when the module is
|
|
137
|
+
* not uf's to transform (see `isFlowModule`). Rejects with a
|
|
138
|
+
* `TransformError` carrying the position when the source is not valid Flow.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} id absolute path, used for the map and for errors
|
|
141
|
+
* @param {string} code the Flow source
|
|
142
|
+
* @param {object} [options]
|
|
143
|
+
* @param {boolean} [options.development] readable output, `jsxDEV`
|
|
144
|
+
* @param {boolean} [options.refresh] Fast Refresh registrations (development only)
|
|
145
|
+
* @param {boolean} [options.sourceMap] produce a source map; on by default
|
|
146
|
+
*/
|
|
147
|
+
transform(id, code, options = {}) {
|
|
148
|
+
if (this.#failure) return Promise.reject(this.#failure);
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
this.#pending.push({
|
|
151
|
+
id,
|
|
152
|
+
reject,
|
|
153
|
+
resolve: (reply) => {
|
|
154
|
+
if (reply.code == null) {
|
|
155
|
+
resolve(null);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
resolve({
|
|
159
|
+
code: reply.code,
|
|
160
|
+
map: reply.map ?? null,
|
|
161
|
+
diagnostics: reply.diagnostics ?? [],
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
this.#child.stdin.write(`${JSON.stringify({ id, code, options })}\n`);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Stop the process. Outstanding requests are rejected. */
|
|
170
|
+
close() {
|
|
171
|
+
this.#child.stdin.end();
|
|
172
|
+
this.#child.kill();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let shared = null;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The process-wide service, started on first use.
|
|
180
|
+
*
|
|
181
|
+
* The loader hooks and the config loader share one process per host rather
|
|
182
|
+
* than one per module; it lives as long as the host does.
|
|
183
|
+
*/
|
|
184
|
+
export function sharedService(root) {
|
|
185
|
+
shared ??= new TransformService({ root: root ?? process.env.UF_PROJECT_ROOT ?? process.cwd() });
|
|
186
|
+
return shared;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Transform one Flow module through the shared service.
|
|
191
|
+
*
|
|
192
|
+
* Returns `{ code, map, diagnostics }`; a module that is not uf's to transform
|
|
193
|
+
* comes back as `null`.
|
|
194
|
+
*/
|
|
195
|
+
export function transformFlow(code, filename, options = {}) {
|
|
196
|
+
return sharedService(options.root).transform(filename, code, options);
|
|
197
|
+
}
|