@uniflowed/vite 0.0.0-alpha.1 → 0.0.0-alpha.4

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.
@@ -1,3 +1,5 @@
1
+ // @noflow
2
+ //
1
3
  // Plain JavaScript: executed by the host that runs Vite, before any transform.
2
4
  //
3
5
  // React Fast Refresh wiring for `uf dev`. The runtime itself is
@@ -1,3 +1,5 @@
1
+ // @noflow
2
+ //
1
3
  // Plain JavaScript: executed by the host that runs Vite, before any transform.
2
4
  //
3
5
  // The file-system router, as the build sees it.
@@ -21,6 +23,7 @@ export const RESERVED = Object.freeze({
21
23
  page: "_uf.page",
22
24
  middleware: "_uf.middleware",
23
25
  notFound: "_uf.not-found",
26
+ route: "_uf.route",
24
27
  });
25
28
 
26
29
  /** Extensions a page or layout may use; `.mdx` is a page written as content. */
@@ -43,6 +46,16 @@ const MAX_DEPTH = 32;
43
46
  * @property {boolean} mdx whether the page is MDX content
44
47
  */
45
48
 
49
+ /**
50
+ * One route handler — a path that answers a request instead of rendering.
51
+ *
52
+ * @typedef {object} Handler
53
+ * @property {string} path route path such as `/api/users/:id`
54
+ * @property {string} pattern the same path with `*` for catch-alls
55
+ * @property {ReadonlyArray<{name: string, catchAll: boolean}>} params
56
+ * @property {string} module absolute path of the handler module
57
+ */
58
+
46
59
  /**
47
60
  * Scan `appRoot` for routes.
48
61
  *
@@ -55,8 +68,9 @@ const MAX_DEPTH = 32;
55
68
  */
56
69
  export function scanRoutes(appRoot) {
57
70
  const routes = [];
71
+ const handlers = [];
58
72
  let notFound = null;
59
- if (!isDirectory(appRoot)) return { routes, notFound };
73
+ if (!isDirectory(appRoot)) return { routes, handlers, notFound };
60
74
 
61
75
  const walk = (directory, segments, layouts, middleware, depth) => {
62
76
  if (depth > MAX_DEPTH) return;
@@ -82,6 +96,15 @@ export function scanRoutes(appRoot) {
82
96
  mdx: page.endsWith(".mdx"),
83
97
  });
84
98
  }
99
+ // A handler answers the request itself, so it takes no layouts and is not
100
+ // MDX. It may sit beside a page: `/feed` can render for a browser and
101
+ // `/feed.xml` answer for a reader, and both are the same directory tree.
102
+ const handler = findModule(directory, RESERVED.route, MODULE_EXTENSIONS);
103
+ if (handler) {
104
+ const { path: routePath, pattern, params } = routeFromSegments(segments);
105
+ handlers.push({ path: routePath, pattern, params, module: handler });
106
+ }
107
+
85
108
  if (depth === 0) {
86
109
  const own = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
87
110
  if (own) notFound = { page: own, layouts: nextLayouts, mdx: own.endsWith(".mdx") };
@@ -103,8 +126,10 @@ export function scanRoutes(appRoot) {
103
126
  };
104
127
 
105
128
  walk(appRoot, [], [], [], 0);
106
- routes.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
107
- return { routes, notFound };
129
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
130
+ routes.sort(byPath);
131
+ handlers.sort(byPath);
132
+ return { routes, handlers, notFound };
108
133
  }
109
134
 
110
135
  function isDirectory(candidate) {
@@ -206,10 +231,25 @@ export function routesModuleSource(table) {
206
231
  }`
207
232
  : "null";
208
233
 
234
+ // Handlers are a separate table because nothing on the client wants them:
235
+ // a route handler answers a request, so shipping its module to the browser
236
+ // would ship server code to the page.
237
+ const handlerEntries = (table.handlers ?? []).map(
238
+ (handler) => ` {
239
+ path: ${JSON.stringify(handler.path)},
240
+ params: ${JSON.stringify(handler.params)},
241
+ file: ${JSON.stringify(handler.module)},
242
+ load: () => import(${JSON.stringify(handler.module)}),
243
+ }`,
244
+ );
245
+
209
246
  return `${layoutImports.join("\n")}
210
247
  export const routes = [
211
248
  ${entries.join(",\n")}
212
249
  ];
250
+ export const handlers = [
251
+ ${handlerEntries.join(",\n")}
252
+ ];
213
253
  export const notFound = ${notFound};
214
254
  export default routes;
215
255
  `;
@@ -234,10 +274,11 @@ hydrate({ App, routes, notFound });
234
274
  * The source of `virtual:uf/server`: render one URL to HTML.
235
275
  */
236
276
  export function serverModuleSource(appEntry) {
237
- return `import { createRenderer } from "@uniflowed/router/server";
238
- import { routes, notFound } from ${JSON.stringify(VIRTUAL.routes)};
277
+ return `import { createDispatcher, createRenderer } from "@uniflowed/router/server";
278
+ import { routes, handlers, notFound } from ${JSON.stringify(VIRTUAL.routes)};
239
279
  import App from ${JSON.stringify(appEntry)};
240
- export { routes, notFound };
280
+ export { routes, handlers, notFound };
241
281
  export const render = createRenderer({ App, routes, notFound });
282
+ export const dispatch = createDispatcher({ handlers });
242
283
  `;
243
284
  }
package/merge.js ADDED
@@ -0,0 +1,87 @@
1
+ // @noflow
2
+ //
3
+ // Plain JavaScript: executed by the host that runs Vite, before any transform.
4
+ //
5
+ // Merging a project's own Vite configuration over the one uf generates.
6
+ //
7
+ // This exists because of a specific failure. uf's config re-declared Vite's
8
+ // options one at a time — `host`, `port`, `strictPort`, `outDir`, `sourcemap`
9
+ // — and the driver copied them across by hand. Anything Vite could do that uf
10
+ // had not enumerated was unreachable until uf shipped a release naming it.
11
+ // That is `react-scripts`: an integrated tool becoming the chokepoint every
12
+ // upgrade in the ecosystem has to pass through.
13
+ //
14
+ // So `vite` in `uf.config.js` is Vite's own configuration, merged over uf's,
15
+ // and uf makes no attempt to understand it. An option added to Vite tomorrow
16
+ // works in a uf project tomorrow.
17
+ //
18
+ // # What uf still decides
19
+ //
20
+ // Three things are uf's rather than the project's, and the merge protects
21
+ // them:
22
+ //
23
+ // * `plugins`, which are concatenated rather than replaced — dropping uf's
24
+ // Flow transform would leave a project whose source no longer compiles,
25
+ // which is not a thing anyone means to configure.
26
+ // * `configFile`, because a `vite.config.ts` beside `uf.config.js` is two
27
+ // files disagreeing about one project.
28
+ // * `root`, which is the project uf resolved.
29
+ //
30
+ // Everything else is the project's to set, including options uf sets itself:
31
+ // a default is a convenience, not an architecture.
32
+
33
+ /** Keys uf owns outright, whatever the project's Vite config says. */
34
+ const RESERVED = ["root", "configFile"];
35
+
36
+ /**
37
+ * Deep-merge `overrides` onto `base`.
38
+ *
39
+ * Plain objects merge key by key; arrays and everything else replace, which is
40
+ * what a caller setting `build.rollupOptions.input` means. `plugins` is the
41
+ * exception and is handled by the caller, because concatenating is right there
42
+ * and replacing is right everywhere else.
43
+ */
44
+ export function mergeConfig(base, overrides) {
45
+ if (overrides == null) return base;
46
+ const merged = { ...base };
47
+ for (const key of Object.keys(overrides)) {
48
+ const value = overrides[key];
49
+ if (value === undefined) continue;
50
+ merged[key] =
51
+ isPlainObject(value) && isPlainObject(base[key]) ? mergeConfig(base[key], value) : value;
52
+ }
53
+ return merged;
54
+ }
55
+
56
+ /**
57
+ * uf's generated config, with the project's Vite config merged over it.
58
+ *
59
+ * @param {object} generated what uf built from the semantics it owns
60
+ * @param {object | undefined} overrides `vite` from `uf.config.js`
61
+ */
62
+ export function withProjectConfig(generated, overrides) {
63
+ if (overrides == null) return generated;
64
+
65
+ const owned = {};
66
+ for (const key of RESERVED) owned[key] = generated[key];
67
+
68
+ const merged = mergeConfig(generated, { ...overrides, ...owned });
69
+
70
+ // uf's plugins first, then the project's. First because the Flow transform
71
+ // has to see a module before anything that expects JavaScript does.
72
+ merged.plugins = [
73
+ ...(generated.plugins ?? []),
74
+ ...(Array.isArray(overrides.plugins) ? overrides.plugins : []),
75
+ ];
76
+ return merged;
77
+ }
78
+
79
+ function isPlainObject(value) {
80
+ return (
81
+ typeof value === "object" &&
82
+ value !== null &&
83
+ !Array.isArray(value) &&
84
+ // A plugin, a logger or a URL is a value to replace, not a shape to merge.
85
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
86
+ );
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.1",
3
+ "version": "0.0.0-alpha.4",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,25 +13,24 @@
13
13
  "exports": {
14
14
  ".": "./index.js",
15
15
  "./driver": "./driver.js",
16
- "./register": "./register.js",
17
- "./bun-preload": "./bun-preload.js",
18
- "./transform": "./transform.js",
19
- "./package.json": "./package.json"
16
+ "./package.json": "./package.json",
17
+ "./merge": "./merge.js"
20
18
  },
21
19
  "files": [
22
- "index.js",
23
20
  "driver.js",
24
- "register.js",
25
- "bun-preload.js",
26
- "transform.js",
27
- "internal"
21
+ "index.js",
22
+ "internal",
23
+ "merge.js"
28
24
  ],
29
25
  "dependencies": {
30
26
  "@mdx-js/rollup": "^3.1.1",
27
+ "@shikijs/rehype": "^3.23.0",
28
+ "@uniflowed/host": "0.0.0-alpha.4",
31
29
  "rehype-slug": "^6.0.0",
32
30
  "remark-frontmatter": "^5.0.0",
33
31
  "remark-gfm": "^4.0.1",
34
32
  "remark-mdx-frontmatter": "^5.2.0",
33
+ "shiki": "^3.23.0",
35
34
  "vite": "^8.2.2"
36
35
  },
37
36
  "peerDependencies": {
package/bun-preload.js DELETED
@@ -1,22 +0,0 @@
1
- // Plain JavaScript: this file registers the loader, so it cannot need one.
2
- //
3
- // `bun --preload @uniflowed/vite/bun-preload app.js` runs a Flow project on
4
- // Bun without a build step, through Bun's own plugin API: every module uf is
5
- // responsible for is transformed by `uf transform` as Bun loads it. It is the
6
- // Bun counterpart of `./register.js`, and the policy of which files count is
7
- // the same `isFlowModule`.
8
-
9
- import { isFlowModule, transformFlow } from "./transform.js";
10
-
11
- Bun.plugin({
12
- name: "uniflowed-flow",
13
- setup(build) {
14
- build.onLoad({ filter: /\.(js|jsx|mjs)$/ }, async (args) => {
15
- if (!isFlowModule(args.path)) return undefined;
16
- const source = await Bun.file(args.path).text();
17
- const out = await transformFlow(source, args.path, { development: true, sourceMap: false });
18
- if (out == null) return undefined;
19
- return { contents: out.code, loader: "js" };
20
- });
21
- },
22
- });
@@ -1,111 +0,0 @@
1
- // Plain JavaScript: this *is* the loader, so it cannot be Flow.
2
- //
3
- // Node.js module customization hooks that transform Flow on import.
4
- //
5
- // Registered by `@uniflowed/vite/register` (through `node:module`'s
6
- // `register()`), which makes `node --import @uniflowed/vite/register app.js`
7
- // run a Flow project directly: every `.js` module uf is responsible for is
8
- // transformed as it is loaded through `uf transform`, and everything else is
9
- // left to Node.
10
- //
11
- // Transforms are cached on disk under `.uf/cache/transform/` keyed by a hash
12
- // of the source, so a second run of the same file is a read rather than a
13
- // round trip. The cache is content-addressed: an edited file hashes
14
- // differently, so there is no invalidation to get wrong.
15
-
16
- import { createHash } from "node:crypto";
17
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
18
- import path from "node:path";
19
- import { fileURLToPath } from "node:url";
20
-
21
- import { isFlowModule, transformFlow } from "../transform.js";
22
-
23
- /**
24
- * Write `contents` to `target` so a concurrent reader never sees half of it.
25
- *
26
- * `uf test` runs one of these processes per core and they all import the same
27
- * few modules at once, so two writers and a reader meet on the same cache
28
- * entry constantly. `writeFileSync` is not atomic — a reader can observe a
29
- * truncated file and report a module that "does not provide an export" — so
30
- * the content goes to a private temporary name first and is then renamed,
31
- * which is atomic within a filesystem.
32
- *
33
- * A failure here is not a failure: a read-only checkout still runs, just
34
- * without the cache.
35
- */
36
- function writeAtomically(target, contents) {
37
- const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}`;
38
- try {
39
- mkdirSync(cacheDirectory, { recursive: true });
40
- writeFileSync(temporary, contents);
41
- renameSync(temporary, target);
42
- } catch {
43
- try {
44
- unlinkSync(temporary);
45
- } catch {
46
- // Nothing to clean up.
47
- }
48
- }
49
- }
50
-
51
- /** Bumped whenever the transform's output shape changes, to retire old entries. */
52
- const CACHE_VERSION = "2";
53
-
54
- let cacheDirectory = null;
55
- let root = null;
56
-
57
- /**
58
- * Called once by `register()` with `{ root }`; the cache lives under it and
59
- * the transform service is started there so it reads the right config.
60
- */
61
- export async function initialize(data) {
62
- root = data?.root ?? process.cwd();
63
- cacheDirectory = path.join(root, ".uf", "cache", "transform");
64
- }
65
-
66
- /**
67
- * The `load` hook: transform Flow modules, defer everything else.
68
- */
69
- export async function load(url, context, nextLoad) {
70
- if (!url.startsWith("file:")) return nextLoad(url, context);
71
- const filename = fileURLToPath(url);
72
- if (!isFlowModule(filename)) return nextLoad(url, context);
73
-
74
- const source = readFileSync(filename, "utf8");
75
- const code = await cachedTransform(source, filename);
76
- if (code == null) return nextLoad(url, context);
77
- // uf projects are ES modules. Forcing the format here means a project whose
78
- // package.json forgot `"type": "module"` still runs, rather than failing on
79
- // an `import` in what Node would have guessed was CommonJS.
80
- return { format: "module", source: code, shortCircuit: true };
81
- }
82
-
83
- async function cachedTransform(source, filename) {
84
- const key = createHash("sha256")
85
- .update(CACHE_VERSION)
86
- .update("\0")
87
- .update(filename)
88
- .update("\0")
89
- .update(source)
90
- .digest("hex");
91
- const entry = cacheDirectory ? path.join(cacheDirectory, `${key}.mjs`) : null;
92
-
93
- if (entry) {
94
- try {
95
- return readFileSync(entry, "utf8");
96
- } catch {
97
- // not cached yet
98
- }
99
- }
100
-
101
- const out = await transformFlow(source, filename, { root, development: true, sourceMap: true });
102
- if (out == null) return null;
103
- const output = out.map
104
- ? `${out.code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(out.map).toString("base64")}\n`
105
- : out.code;
106
-
107
- if (entry) {
108
- writeAtomically(entry, output);
109
- }
110
- return output;
111
- }
package/register.js DELETED
@@ -1,11 +0,0 @@
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 DELETED
@@ -1,187 +0,0 @@
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
- }