@uniflowed/vite 0.0.0-alpha.8 → 0.1.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/driver.js +2014 -212
- package/index.js +953 -107
- package/internal/a11y-runtime.js +234 -0
- package/internal/a11y.js +102 -0
- package/internal/assets.js +744 -0
- package/internal/barrel-imports.js +459 -0
- package/internal/compile-assets.js +67 -0
- package/internal/config.js +30 -3
- package/internal/dev-state.js +161 -0
- package/internal/devtools.js +117 -0
- package/internal/diagnostics.js +369 -0
- package/internal/events.js +8 -5
- package/internal/flight.js +803 -0
- package/internal/flow-keywords.js +1 -1
- package/internal/frontmatter.js +33 -0
- package/internal/http.js +21 -2
- package/internal/image-endpoint.js +118 -0
- package/internal/instrumentation.js +23 -0
- package/internal/module-graph.js +134 -0
- package/internal/native-web.js +26 -0
- package/internal/openapi.js +218 -0
- package/internal/relay.js +74 -0
- package/internal/routes.js +1294 -81
- package/internal/rsc.js +335 -11
- package/internal/serve.js +583 -29
- package/internal/server-components.js +109 -0
- package/internal/worker-builtins.js +170 -0
- package/package.json +28 -6
|
@@ -435,7 +435,7 @@ export function markLines(lines) {
|
|
|
435
435
|
*
|
|
436
436
|
* Exported because it is where the decision is visible without starting Shiki:
|
|
437
437
|
* give it the token split a grammar would produce and it says which words it
|
|
438
|
-
* marked. `
|
|
438
|
+
* marked. `packages/vite/highlight.test.js` uses exactly that, and the splits
|
|
439
439
|
* in it are the ones that had bugs.
|
|
440
440
|
*/
|
|
441
441
|
export function markLine(line) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// A document's YAML front matter, as `export const frontmatter`.
|
|
4
|
+
//
|
|
5
|
+
// Plain JavaScript, for the reason `index.js` gives: Vite imports this before
|
|
6
|
+
// any transform runs.
|
|
7
|
+
//
|
|
8
|
+
// This is `remark-mdx-frontmatter` for the one format uf reads, and it replaces
|
|
9
|
+
// that package for one reason. The package imported `toml` 3.0.0 at the top of
|
|
10
|
+
// its module whether or not a document held any TOML, so every project uf
|
|
11
|
+
// scaffolded installed a parser with two high advisories and failed its first
|
|
12
|
+
// `uf audit` (#1009). The TOML half was never reachable from here either:
|
|
13
|
+
// `remark-frontmatter` is given its default, which recognises YAML and nothing
|
|
14
|
+
// else, so a `+++` block was never a front-matter node to parse.
|
|
15
|
+
//
|
|
16
|
+
// For YAML the output is the package's, through the same two helpers it used:
|
|
17
|
+
// the first `yaml` node is parsed and defined as `frontmatter`, and a document
|
|
18
|
+
// with none exports `undefined`.
|
|
19
|
+
|
|
20
|
+
import { valueToEstree } from "estree-util-value-to-estree";
|
|
21
|
+
import { define } from "unist-util-mdx-define";
|
|
22
|
+
import { parse } from "yaml";
|
|
23
|
+
|
|
24
|
+
/** The remark plugin: `export const frontmatter` from a document's YAML. */
|
|
25
|
+
export default function remarkFrontmatterExport() {
|
|
26
|
+
return (tree, file) => {
|
|
27
|
+
const node = tree.children.find((child) => child.type === "yaml");
|
|
28
|
+
const data = node == null ? undefined : parse(node.value);
|
|
29
|
+
define(tree, file, {
|
|
30
|
+
frontmatter: valueToEstree(data, { preserveReferences: true }),
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
package/internal/http.js
CHANGED
|
@@ -25,13 +25,18 @@
|
|
|
25
25
|
* that accepts an upload should not need the whole thing buffered before it
|
|
26
26
|
* starts.
|
|
27
27
|
*
|
|
28
|
+
* `path` names the path and query to build it at, when that is not the one the
|
|
29
|
+
* request line carried — `uf dev` passes the one Vite's base middleware has
|
|
30
|
+
* already taken `app.router.basePath` off.
|
|
31
|
+
*
|
|
28
32
|
* @param {import("node:http").IncomingMessage} incoming
|
|
29
33
|
* @param {{server?: {https?: unknown}} | undefined} config the resolved Vite config
|
|
34
|
+
* @param {string} [path]
|
|
30
35
|
*/
|
|
31
|
-
export async function toRequest(incoming, config) {
|
|
36
|
+
export async function toRequest(incoming, config, path) {
|
|
32
37
|
const host = incoming.headers.host ?? "localhost";
|
|
33
38
|
const protocol = config?.server?.https == null ? "http" : "https";
|
|
34
|
-
const url = new URL(incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
|
|
39
|
+
const url = new URL(path ?? incoming.originalUrl ?? incoming.url ?? "/", `${protocol}://${host}`);
|
|
35
40
|
|
|
36
41
|
const headers = new Headers();
|
|
37
42
|
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
@@ -52,6 +57,20 @@ export async function toRequest(incoming, config) {
|
|
|
52
57
|
return new Request(url, init);
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/**
|
|
61
|
+
* A Node request as a `Request` that carries its address and nothing else.
|
|
62
|
+
*
|
|
63
|
+
* For the questions asked about a URL alone — `app.router`'s redirects and
|
|
64
|
+
* headers, in front of Vite's own middleware — which must not wrap the body a
|
|
65
|
+
* later middleware turns into the `Request` the application reads.
|
|
66
|
+
*
|
|
67
|
+
* @param {import("node:http").IncomingMessage} incoming
|
|
68
|
+
*/
|
|
69
|
+
export function toAddressRequest(incoming) {
|
|
70
|
+
const host = incoming.headers.host ?? "localhost";
|
|
71
|
+
return new Request(new URL(incoming.originalUrl ?? incoming.url ?? "/", `http://${host}`));
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
/**
|
|
56
75
|
* Write a `Response` to a Node response.
|
|
57
76
|
*
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: executed by the host that serves a build, and by `uf dev`.
|
|
4
|
+
//
|
|
5
|
+
// `/__uf/image` on the three doors `uf` itself runs — `uf dev`, `uf preview`
|
|
6
|
+
// and `uf start` — with `uf` as the encoder.
|
|
7
|
+
//
|
|
8
|
+
// The endpoint is `@uniflowed/server/image`'s, the fetch is
|
|
9
|
+
// `@uniflowed/server/image/node`'s, and this is the one piece those two
|
|
10
|
+
// cannot have: a transformer that reaches the `uf` binary. It writes the
|
|
11
|
+
// bounded source to a private temporary directory, asks `uf assets` for one
|
|
12
|
+
// variant of it (`crates/uf_cli/src/commands/assets.rs`'s `variant` request,
|
|
13
|
+
// which is `uf_assets::variant`), and reads the one file that comes back.
|
|
14
|
+
// Both files are removed before the answer is returned: the cache is the
|
|
15
|
+
// endpoint's `CacheStore`, not this directory.
|
|
16
|
+
//
|
|
17
|
+
// A deployed directory has no `uf` in it, which is why this is not in
|
|
18
|
+
// `@uniflowed/server`. What `--adapter` links instead is argued in
|
|
19
|
+
// `driver.js`'s `imageEndpointSource`.
|
|
20
|
+
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import { AssetService } from "@uniflowed/host/assets";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether the project asked for an endpoint at all.
|
|
30
|
+
*
|
|
31
|
+
* `app.builtins.images.remotePatterns` with something in it, and images not
|
|
32
|
+
* turned off. Nothing else constructs one: a project that listed no host has
|
|
33
|
+
* no `/__uf/image`, and the path is an ordinary 404.
|
|
34
|
+
*
|
|
35
|
+
* @param {{enabled?: boolean, remotePatterns?: unknown[]} | undefined} images
|
|
36
|
+
*/
|
|
37
|
+
export function servesRemoteImages(images) {
|
|
38
|
+
return (
|
|
39
|
+
images?.enabled !== false &&
|
|
40
|
+
Array.isArray(images?.remotePatterns) &&
|
|
41
|
+
images.remotePatterns.length > 0
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The endpoint's options from `app.builtins.images`, with uf's defaults where
|
|
47
|
+
* the project said nothing — the same defaults `uf_assets::ImagesConfig`
|
|
48
|
+
* applies, from `@uniflowed/server/image` where both sides can read them.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} images `app.builtins.images`
|
|
51
|
+
* @param {{DEFAULT_WIDTHS: number[], DEFAULT_QUALITY: number}} defaults
|
|
52
|
+
*/
|
|
53
|
+
export function endpointSettings(images, defaults) {
|
|
54
|
+
return {
|
|
55
|
+
remotePatterns: images.remotePatterns,
|
|
56
|
+
widths: images.widths ?? defaults.DEFAULT_WIDTHS,
|
|
57
|
+
quality: images.quality ?? defaults.DEFAULT_QUALITY,
|
|
58
|
+
qualities: images.qualities ?? [],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An encoder that is `uf` itself.
|
|
64
|
+
*
|
|
65
|
+
* One `uf assets` process for the life of the server, started on the first
|
|
66
|
+
* miss and not before, and one temporary directory beside it.
|
|
67
|
+
*
|
|
68
|
+
* @param {{root: string, command?: string}} options
|
|
69
|
+
*/
|
|
70
|
+
export function ufImageTransform({ root, command }) {
|
|
71
|
+
let service = null;
|
|
72
|
+
let directory = null;
|
|
73
|
+
return async function transform({ bytes, width, quality, avif }) {
|
|
74
|
+
service ??= new AssetService({ command, root });
|
|
75
|
+
directory ??= mkdtemp(path.join(os.tmpdir(), "uf-image-"));
|
|
76
|
+
const base = path.join(await directory, randomUUID());
|
|
77
|
+
const source = `${base}.source`;
|
|
78
|
+
const out = `${base}.out`;
|
|
79
|
+
await writeFile(source, bytes);
|
|
80
|
+
try {
|
|
81
|
+
const variant = await service.variant(source, { outDir: out, width, quality, avif });
|
|
82
|
+
const encoded = await readFile(path.join(out, variant.file));
|
|
83
|
+
return {
|
|
84
|
+
bytes: new Uint8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength),
|
|
85
|
+
type: variant.mime,
|
|
86
|
+
};
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(source, { force: true });
|
|
89
|
+
await rm(out, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `/__uf/image` for a door `uf` runs, or `undefined` for a project with no
|
|
96
|
+
* remote images.
|
|
97
|
+
*
|
|
98
|
+
* `store` is the `CacheStore` a durable `rendering.cache.store` gave the route
|
|
99
|
+
* cache's provider to, when there is one; without it the endpoint keeps its
|
|
100
|
+
* variants in memory.
|
|
101
|
+
*
|
|
102
|
+
* @param {{images?: object, root: string, command?: string, store?: object}} options
|
|
103
|
+
*/
|
|
104
|
+
export async function localImageEndpoint({ images, root, command, store }) {
|
|
105
|
+
if (!servesRemoteImages(images)) return undefined;
|
|
106
|
+
const [endpoint, node] = await Promise.all([
|
|
107
|
+
import("@uniflowed/server/image"),
|
|
108
|
+
import("@uniflowed/server/image/node"),
|
|
109
|
+
]);
|
|
110
|
+
return endpoint.createImageEndpoint({
|
|
111
|
+
...endpointSettings(images, endpoint),
|
|
112
|
+
fetch: node.nodeImageFetch({
|
|
113
|
+
allowPrivateAddresses: images.dangerouslyAllowPrivateAddresses === true,
|
|
114
|
+
}),
|
|
115
|
+
transform: ufImageTransform({ root, command }),
|
|
116
|
+
...(store == null ? {} : { store }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export function instrumentationFile(root, client = false) {
|
|
7
|
+
const stem = client ? "$instrumentation.client" : "$instrumentation";
|
|
8
|
+
const files = [".js", ".jsx"]
|
|
9
|
+
.map((extension) => path.join(root, stem + extension))
|
|
10
|
+
.filter(existsSync);
|
|
11
|
+
if (files.length > 1)
|
|
12
|
+
throw new Error(`uf: choose one instrumentation module: ${files.join(", ")}`);
|
|
13
|
+
return files[0] ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function clientInstrumentationSource(file) {
|
|
17
|
+
if (file == null) return "";
|
|
18
|
+
return `import * as instrumentation from ${JSON.stringify(file)};
|
|
19
|
+
import { installClientInstrumentation } from "@uniflowed/router/instrumentation";
|
|
20
|
+
const disposeInstrumentation = await installClientInstrumentation(instrumentation);
|
|
21
|
+
if (import.meta.hot) import.meta.hot.dispose(disposeInstrumentation);
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Plain JavaScript: the driver imports it, and the driver runs before any Flow
|
|
4
|
+
// loader exists.
|
|
5
|
+
//
|
|
6
|
+
// The module graph `uf build --analyze` reads: every module each bundle was
|
|
7
|
+
// built from, what it imports, and which chunk its rendered code went into.
|
|
8
|
+
//
|
|
9
|
+
// What the graph means — which route a module belongs to, the chain of imports
|
|
10
|
+
// behind it, what it weighs — is uf's, in `crates/uf_bundle/src/analysis.rs`.
|
|
11
|
+
// This file writes down only what the bundler knows and uf cannot, and that
|
|
12
|
+
// split is the reason the graph is a file at all: a builder other than this
|
|
13
|
+
// one that writes the same file gets the same analysis.
|
|
14
|
+
//
|
|
15
|
+
// {
|
|
16
|
+
// "version": 1,
|
|
17
|
+
// "builds": [{
|
|
18
|
+
// "environment": "client",
|
|
19
|
+
// "entries": ["virtual:uf/client"],
|
|
20
|
+
// "modules": [{ "id": "app/$page.js", "imports": ["app/Counter.js"], "dynamicImports": [] }],
|
|
21
|
+
// "chunks": [{
|
|
22
|
+
// "file": "assets/index-3f2a.js",
|
|
23
|
+
// "facade": "virtual:uf/client",
|
|
24
|
+
// "modules": [{ "id": "virtual:uf/client", "code": "…" }]
|
|
25
|
+
// }]
|
|
26
|
+
// }]
|
|
27
|
+
// }
|
|
28
|
+
|
|
29
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
/** Where the driver writes the graph, in the build's metadata directory. */
|
|
33
|
+
export const MODULE_GRAPH_FILE = "uf-module-graph.json";
|
|
34
|
+
|
|
35
|
+
const VERSION = 1;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A plugin that records the graph of every bundle it takes part in, and the
|
|
39
|
+
* means to write what it recorded.
|
|
40
|
+
*
|
|
41
|
+
* `isReference(file)` says whether an absolute file is a client reference: an
|
|
42
|
+
* entry the browser loads because a server component named it, rather than on
|
|
43
|
+
* every page. Its chunk still carries it as the facade, and it is left out of
|
|
44
|
+
* `entries`, which is the list of what every page of a bundle loads.
|
|
45
|
+
*/
|
|
46
|
+
export function createModuleGraphCollector(root, { isReference = () => false } = {}) {
|
|
47
|
+
const builds = [];
|
|
48
|
+
const graph = () => ({ version: VERSION, builds });
|
|
49
|
+
return {
|
|
50
|
+
plugin: {
|
|
51
|
+
name: "uf:module-graph",
|
|
52
|
+
// Last, so the bundle it reads is one every other plugin has finished.
|
|
53
|
+
enforce: "post",
|
|
54
|
+
generateBundle(_options, bundle) {
|
|
55
|
+
builds.push(describeBundle(this, root, bundle, isReference));
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
graph,
|
|
59
|
+
write(file) {
|
|
60
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
writeFileSync(file, `${JSON.stringify(graph())}\n`);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One bundle, as `generateBundle` sees it. */
|
|
67
|
+
function describeBundle(context, root, bundle, isReference) {
|
|
68
|
+
const ids = typeof context.getModuleIds === "function" ? [...context.getModuleIds()] : [];
|
|
69
|
+
const modules = ids
|
|
70
|
+
.map((id) => {
|
|
71
|
+
const info = context.getModuleInfo(id);
|
|
72
|
+
return {
|
|
73
|
+
id: moduleId(root, id),
|
|
74
|
+
imports: (info?.importedIds ?? []).map((imported) => moduleId(root, imported)),
|
|
75
|
+
dynamicImports: (info?.dynamicallyImportedIds ?? []).map((imported) =>
|
|
76
|
+
moduleId(root, imported),
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
})
|
|
80
|
+
.sort(byKey("id"));
|
|
81
|
+
const entries = new Set();
|
|
82
|
+
const chunks = [];
|
|
83
|
+
for (const output of Object.values(bundle)) {
|
|
84
|
+
if (output.type !== "chunk") {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const facade = output.isEntry ? (output.facadeModuleId ?? null) : null;
|
|
88
|
+
if (facade != null && !isReference(facade.split("?")[0])) {
|
|
89
|
+
entries.add(moduleId(root, facade));
|
|
90
|
+
}
|
|
91
|
+
chunks.push({
|
|
92
|
+
file: output.fileName,
|
|
93
|
+
facade: facade == null ? null : moduleId(root, facade),
|
|
94
|
+
// A module the bundler left no code for was still walked through to
|
|
95
|
+
// reach what it imports, so it stays in `modules` above; it has nothing
|
|
96
|
+
// to weigh, so it is not in the chunk.
|
|
97
|
+
modules: Object.entries(output.modules ?? {})
|
|
98
|
+
.map(([id, rendered]) => ({ id: moduleId(root, id), code: rendered?.code ?? "" }))
|
|
99
|
+
.filter((module) => module.code !== ""),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
environment: context.environment?.name ?? "client",
|
|
104
|
+
entries: [...entries].sort(),
|
|
105
|
+
modules,
|
|
106
|
+
chunks: chunks.sort(byKey("file")),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A module id as the analysis spells it: relative to the project root with
|
|
112
|
+
* `/`, without the `\0` a resolved virtual module carries, and with its query.
|
|
113
|
+
*
|
|
114
|
+
* Relative even when it climbs out of the root, as a workspace package does,
|
|
115
|
+
* because an absolute path would make two machines' reports of the same build
|
|
116
|
+
* disagree.
|
|
117
|
+
*/
|
|
118
|
+
export function moduleId(root, id) {
|
|
119
|
+
const bare = id.startsWith("\0") ? id.slice(1) : id;
|
|
120
|
+
const at = bare.indexOf("?");
|
|
121
|
+
const file = at === -1 ? bare : bare.slice(0, at);
|
|
122
|
+
if (!path.isAbsolute(file)) {
|
|
123
|
+
return bare;
|
|
124
|
+
}
|
|
125
|
+
const relative = path.relative(root, file);
|
|
126
|
+
if (path.isAbsolute(relative)) {
|
|
127
|
+
return bare;
|
|
128
|
+
}
|
|
129
|
+
return `${relative.split(path.sep).join("/")}${at === -1 ? "" : bare.slice(at)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function byKey(key) {
|
|
133
|
+
return (a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0);
|
|
134
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
/** React Native Web is supplied by the application, including its version. */
|
|
7
|
+
export function nativeWebPlugin(target) {
|
|
8
|
+
let root = process.cwd();
|
|
9
|
+
return {
|
|
10
|
+
name: "uf:react-native-web",
|
|
11
|
+
enforce: "pre",
|
|
12
|
+
configResolved(config) {
|
|
13
|
+
root = config.root;
|
|
14
|
+
},
|
|
15
|
+
resolveId(source) {
|
|
16
|
+
if (target !== "web" || source !== "react-native") return null;
|
|
17
|
+
try {
|
|
18
|
+
return createRequire(path.join(root, "package.json")).resolve("react-native-web");
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"A shared React Native page needs the application's react-native-web dependency: run `uf add react-native-web`.",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
//
|
|
3
|
+
// Route-handler OpenAPI output.
|
|
4
|
+
//
|
|
5
|
+
// The build driver imports this module before it registers the Node Flow
|
|
6
|
+
// hooks, so it must stay plain JavaScript and must not statically import
|
|
7
|
+
// Flow-authored packages. The schema and method vocabulary are loaded inside
|
|
8
|
+
// `createOpenApiDocument`, after the driver has installed the hooks.
|
|
9
|
+
|
|
10
|
+
const JSON = "application/json";
|
|
11
|
+
const QUERY_EXTENSION = "x-uf-query";
|
|
12
|
+
const STANDARD_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]);
|
|
13
|
+
|
|
14
|
+
export async function createOpenApiDocument(handlers, options = {}) {
|
|
15
|
+
const [{ HANDLER_METHODS }, { toJsonSchema }] = await Promise.all([
|
|
16
|
+
import("@uniflowed/router/handler"),
|
|
17
|
+
import("@uniflowed/validator/json-schema"),
|
|
18
|
+
]);
|
|
19
|
+
const paths = {};
|
|
20
|
+
for (const record of handlers ?? []) {
|
|
21
|
+
const routePath = openApiPath(record.path);
|
|
22
|
+
const pathItem = paths[routePath] ?? {};
|
|
23
|
+
paths[routePath] = pathItem;
|
|
24
|
+
const module = await loadHandlerModule(record, pathItem);
|
|
25
|
+
if (module == null) continue;
|
|
26
|
+
for (const method of implementedMethods(module, HANDLER_METHODS)) {
|
|
27
|
+
const operation = createOperation(record, method, schemaFor(module, method), toJsonSchema);
|
|
28
|
+
if (STANDARD_METHODS.has(method)) {
|
|
29
|
+
pathItem[method.toLowerCase()] = operation;
|
|
30
|
+
} else if (method === "QUERY") {
|
|
31
|
+
pathItem[QUERY_EXTENSION] = operation;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
openapi: "3.1.0",
|
|
37
|
+
info: {
|
|
38
|
+
title: options.title ?? "uf application",
|
|
39
|
+
version: options.version ?? "0.0.0",
|
|
40
|
+
},
|
|
41
|
+
paths,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadHandlerModule(record, pathItem) {
|
|
46
|
+
try {
|
|
47
|
+
return await record.load();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (record.file != null) pathItem["x-uf-source"] = record.file;
|
|
50
|
+
pathItem["x-uf-schema-unavailable"] = errorMessage(error);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function implementedMethods(module, methods) {
|
|
56
|
+
return methods.filter(
|
|
57
|
+
(method) =>
|
|
58
|
+
typeof module[method] === "function" ||
|
|
59
|
+
(method === "HEAD" && typeof module.GET === "function"),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function schemaFor(module, method) {
|
|
64
|
+
const table = module.schemas;
|
|
65
|
+
if (!isRecord(table)) return null;
|
|
66
|
+
const schema = table[method];
|
|
67
|
+
if (schema == null) return null;
|
|
68
|
+
if (!isRecord(schema)) {
|
|
69
|
+
throw new Error(`route handler schemas.${method} must be an object`);
|
|
70
|
+
}
|
|
71
|
+
return schema;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createOperation(record, method, schema, toJsonSchema) {
|
|
75
|
+
const parameters = pathParameters(record);
|
|
76
|
+
const operation = {
|
|
77
|
+
operationId: operationId(record, method),
|
|
78
|
+
responses: untypedResponses(method),
|
|
79
|
+
};
|
|
80
|
+
if (record.file != null) operation["x-uf-source"] = record.file;
|
|
81
|
+
if (parameters.length > 0) operation.parameters = parameters;
|
|
82
|
+
if (schema == null) {
|
|
83
|
+
operation["x-uf-untyped"] = true;
|
|
84
|
+
return operation;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const unrepresentable = [];
|
|
88
|
+
if (schema.query != null) {
|
|
89
|
+
addQuerySchema(operation, exportSchema(toJsonSchema, schema.query, "query", unrepresentable));
|
|
90
|
+
}
|
|
91
|
+
if (schema.body != null) {
|
|
92
|
+
operation.requestBody = {
|
|
93
|
+
required: true,
|
|
94
|
+
content: {
|
|
95
|
+
[JSON]: { schema: exportSchema(toJsonSchema, schema.body, "body", unrepresentable) },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (schema.response != null) {
|
|
100
|
+
operation.responses = {
|
|
101
|
+
"200": {
|
|
102
|
+
description: method === "HEAD" ? "Typed response headers" : "Typed response",
|
|
103
|
+
content:
|
|
104
|
+
method === "HEAD"
|
|
105
|
+
? undefined
|
|
106
|
+
: {
|
|
107
|
+
[JSON]: {
|
|
108
|
+
schema: exportSchema(toJsonSchema, schema.response, "response", unrepresentable),
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
if (method === "HEAD") delete operation.responses["200"].content;
|
|
114
|
+
}
|
|
115
|
+
if (unrepresentable.length > 0) {
|
|
116
|
+
operation["x-uf-unrepresentable"] = unrepresentable;
|
|
117
|
+
}
|
|
118
|
+
return operation;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function untypedResponses(method) {
|
|
122
|
+
return {
|
|
123
|
+
"200": {
|
|
124
|
+
description: method === "HEAD" ? "Untyped response headers" : "Untyped response",
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function exportSchema(toJsonSchema, schema, part, unrepresentable) {
|
|
130
|
+
const exported = toJsonSchema(schema);
|
|
131
|
+
for (const item of exported.unrepresentable) {
|
|
132
|
+
unrepresentable.push({ part, path: item.path, kind: item.kind });
|
|
133
|
+
}
|
|
134
|
+
return stripDialect(exported.schema);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function addQuerySchema(operation, schema) {
|
|
138
|
+
if (schema.type !== "object" || !isRecord(schema.properties) || hasOwn(schema, "$defs")) {
|
|
139
|
+
operation["x-uf-query-schema"] = schema;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
|
143
|
+
const parameters = Object.keys(schema.properties).map((name) => ({
|
|
144
|
+
name,
|
|
145
|
+
in: "query",
|
|
146
|
+
required: required.has(name),
|
|
147
|
+
schema: schema.properties[name],
|
|
148
|
+
}));
|
|
149
|
+
operation.parameters = [...(operation.parameters ?? []), ...parameters];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stripDialect(value) {
|
|
153
|
+
if (Array.isArray(value)) return value.map(stripDialect);
|
|
154
|
+
if (!isRecord(value)) return value;
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const key of Object.keys(value)) {
|
|
157
|
+
if (key === "$schema") continue;
|
|
158
|
+
out[key] = stripDialect(value[key]);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function openApiPath(routePath) {
|
|
164
|
+
return routePath
|
|
165
|
+
.split("/")
|
|
166
|
+
.map((segment) => {
|
|
167
|
+
if (!segment.startsWith(":")) return segment;
|
|
168
|
+
return `{${segment.endsWith("*") ? segment.slice(1, -1) : segment.slice(1)}}`;
|
|
169
|
+
})
|
|
170
|
+
.join("/");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function pathParameters(record) {
|
|
174
|
+
const params = record.params ?? paramsFromPath(record.path);
|
|
175
|
+
return params.map((param) => {
|
|
176
|
+
const parameter = {
|
|
177
|
+
name: param.name,
|
|
178
|
+
in: "path",
|
|
179
|
+
required: true,
|
|
180
|
+
schema: { type: "string" },
|
|
181
|
+
};
|
|
182
|
+
if (param.catchAll) {
|
|
183
|
+
parameter.description = "Catch-all route segment, slash-separated in the URL.";
|
|
184
|
+
}
|
|
185
|
+
return parameter;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function paramsFromPath(routePath) {
|
|
190
|
+
return routePath
|
|
191
|
+
.split("/")
|
|
192
|
+
.filter((segment) => segment.startsWith(":"))
|
|
193
|
+
.map((segment) => ({
|
|
194
|
+
name: segment.endsWith("*") ? segment.slice(1, -1) : segment.slice(1),
|
|
195
|
+
catchAll: segment.endsWith("*"),
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function operationId(record, method) {
|
|
200
|
+
const name = `${method.toLowerCase()} ${record.path}`;
|
|
201
|
+
return name
|
|
202
|
+
.replaceAll(/[^a-zA-Z0-9]+/g, " ")
|
|
203
|
+
.trim()
|
|
204
|
+
.replaceAll(/ ([a-zA-Z0-9])/g, (_, char) => char.toUpperCase());
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isRecord(value) {
|
|
208
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasOwn(value, key) {
|
|
212
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function errorMessage(error) {
|
|
216
|
+
if (error instanceof Error) return error.message;
|
|
217
|
+
return String(error);
|
|
218
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @noflow
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
/** CommonJS hidden behind uf's excluded Flow packages still needs prebundling. */
|
|
8
|
+
export function relayDependencies(root, server = false) {
|
|
9
|
+
const require = createRequire(path.join(root, "package.json"));
|
|
10
|
+
const entries = server
|
|
11
|
+
? ["relay-runtime", "relay-runtime/experimental", "react-relay/rsc_EXPERIMENTAL.js"]
|
|
12
|
+
: ["relay-runtime", "react-relay", "react-relay/rsc-client_EXPERIMENTAL.js"];
|
|
13
|
+
return entries.filter((entry) => {
|
|
14
|
+
try {
|
|
15
|
+
require.resolve(entry);
|
|
16
|
+
return true;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === "MODULE_NOT_FOUND" || error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED")
|
|
19
|
+
return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve config from the application, independently of the process's cwd. */
|
|
26
|
+
export async function relayConfig(root) {
|
|
27
|
+
for (const name of ["relay.config.json", "relay.config.js", "relay.config.mjs"]) {
|
|
28
|
+
const filename = path.join(root, name);
|
|
29
|
+
if (!fs.existsSync(filename)) continue;
|
|
30
|
+
const config = name.endsWith(".json")
|
|
31
|
+
? JSON.parse(fs.readFileSync(filename, "utf8"))
|
|
32
|
+
: (await import(pathToFileURL(filename).href)).default;
|
|
33
|
+
return artifactOptions(root, config);
|
|
34
|
+
}
|
|
35
|
+
const filename = path.join(root, "package.json");
|
|
36
|
+
const config = fs.existsSync(filename)
|
|
37
|
+
? JSON.parse(fs.readFileSync(filename, "utf8")).relay
|
|
38
|
+
: null;
|
|
39
|
+
return artifactOptions(root, config);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function artifactOptions(root, config) {
|
|
43
|
+
if (config?.projects != null) {
|
|
44
|
+
throw new Error("uf's Relay integration currently needs a single-project Relay config.");
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
artifactDirectory:
|
|
48
|
+
config?.artifactDirectory == null ? null : path.resolve(root, config.artifactDirectory),
|
|
49
|
+
eagerEsModules: true,
|
|
50
|
+
jsModuleFormat: "commonjs",
|
|
51
|
+
codegenCommand: "uf run relay",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Flow has already lowered components/hooks; Relay owns the GraphQL transform. */
|
|
56
|
+
export async function transformRelay(code, filename, map, options) {
|
|
57
|
+
if (
|
|
58
|
+
!code.includes("graphql") ||
|
|
59
|
+
!/["'](?:@uniflowed\/relay|react-relay|relay-runtime)["']/.test(code)
|
|
60
|
+
)
|
|
61
|
+
return null;
|
|
62
|
+
const [{ transformAsync }, { default: relay }] = await Promise.all([
|
|
63
|
+
import("@babel/core"),
|
|
64
|
+
import("babel-plugin-relay"),
|
|
65
|
+
]);
|
|
66
|
+
return transformAsync(code, {
|
|
67
|
+
filename,
|
|
68
|
+
babelrc: false,
|
|
69
|
+
configFile: false,
|
|
70
|
+
sourceMaps: true,
|
|
71
|
+
inputSourceMap: map ?? undefined,
|
|
72
|
+
plugins: [[relay, options]],
|
|
73
|
+
});
|
|
74
|
+
}
|