@uniflowed/vite 0.0.0-alpha.18 → 0.0.0-alpha.21
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 +380 -46
- package/index.js +101 -15
- package/internal/a11y.js +6 -4
- package/internal/config.js +30 -3
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +2 -2
- package/internal/flow-keywords.js +1 -1
- package/internal/openapi.js +218 -0
- package/internal/routes.js +529 -70
- package/internal/rsc.js +43 -10
- package/internal/serve.js +158 -28
- package/package.json +7 -4
package/index.js
CHANGED
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
RESERVED,
|
|
81
81
|
VIRTUAL,
|
|
82
82
|
clientModuleSource,
|
|
83
|
+
resolveRouteTarget,
|
|
83
84
|
routesModuleSource,
|
|
84
85
|
scanRoutes,
|
|
85
86
|
serverModuleSource,
|
|
@@ -115,6 +116,7 @@ export function devUrlFor(id) {
|
|
|
115
116
|
* @typedef {object} UniflowedOptions
|
|
116
117
|
* @property {string} [root] absolute project root; Vite's root by default
|
|
117
118
|
* @property {object} [config] the loaded `uf.config.js` object
|
|
119
|
+
* @property {"web" | "native" | "ios" | "android"} [target] app target
|
|
118
120
|
* @property {string} [command] the `uf` binary to transform through
|
|
119
121
|
*/
|
|
120
122
|
|
|
@@ -126,6 +128,7 @@ export function devUrlFor(id) {
|
|
|
126
128
|
export default function uniflowed(options = {}) {
|
|
127
129
|
const ufConfig = options.config ?? {};
|
|
128
130
|
const app = ufConfig.app ?? {};
|
|
131
|
+
const routeTarget = resolveRouteTarget(ufConfig, options.target);
|
|
129
132
|
const routerRoot = app.router?.root ?? "app";
|
|
130
133
|
const appEntry = app.router?.entry ?? ufConfig.build?.entries?.[0] ?? "app.js";
|
|
131
134
|
const markdown = app.builtins?.markdown ?? {};
|
|
@@ -135,6 +138,20 @@ export default function uniflowed(options = {}) {
|
|
|
135
138
|
// no `uf.config.js` will mention. It only ever reaches the *development*
|
|
136
139
|
// client entry; see `flowPlugin`'s `load`. ubugeeei-prod/uf#516.
|
|
137
140
|
const strictMode = app.react?.strictMode !== false;
|
|
141
|
+
// What the browser does with a link, read here for the reason Strict Mode is
|
|
142
|
+
// and honoured in every command rather than in the build alone: it is the
|
|
143
|
+
// one setting whose whole effect is what happens on a click, so a dev server
|
|
144
|
+
// that disagreed with the deployment would be the wrong application to look
|
|
145
|
+
// at. Anything but `"document"` is the client router, which is what every
|
|
146
|
+
// project that has not heard of the key has.
|
|
147
|
+
const navigation = app.rendering?.navigation === "document" ? "document" : "client";
|
|
148
|
+
// Whether this application starts by attaching to markup or by rendering
|
|
149
|
+
// into an empty root. `["csr"]` is the only list that means the second, and
|
|
150
|
+
// `uf` refuses that value beside any other while the config is read — so the
|
|
151
|
+
// question here is "is it in the list", not "is it the only thing in it",
|
|
152
|
+
// and a driver started by hand on a config `uf` never validated gets the same
|
|
153
|
+
// answer for the same reason a project would want.
|
|
154
|
+
const mount = (app.rendering?.modes ?? []).includes("csr") ? "render" : "hydrate";
|
|
138
155
|
|
|
139
156
|
const accessibility = ufConfig.accessibility ?? {};
|
|
140
157
|
|
|
@@ -142,7 +159,10 @@ export default function uniflowed(options = {}) {
|
|
|
142
159
|
flowPlugin({
|
|
143
160
|
routerRoot,
|
|
144
161
|
appEntry,
|
|
162
|
+
routeTarget,
|
|
145
163
|
strictMode,
|
|
164
|
+
navigation,
|
|
165
|
+
mount,
|
|
146
166
|
command: options.command,
|
|
147
167
|
accessibility,
|
|
148
168
|
}),
|
|
@@ -157,7 +177,16 @@ export default function uniflowed(options = {}) {
|
|
|
157
177
|
];
|
|
158
178
|
}
|
|
159
179
|
|
|
160
|
-
function flowPlugin({
|
|
180
|
+
function flowPlugin({
|
|
181
|
+
routerRoot,
|
|
182
|
+
appEntry,
|
|
183
|
+
routeTarget,
|
|
184
|
+
strictMode,
|
|
185
|
+
navigation,
|
|
186
|
+
mount,
|
|
187
|
+
command,
|
|
188
|
+
accessibility,
|
|
189
|
+
}) {
|
|
161
190
|
let root = process.cwd();
|
|
162
191
|
let isProduction = false;
|
|
163
192
|
/**
|
|
@@ -236,10 +265,15 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
236
265
|
// survives the build — so the browser's table was shipping the absolute
|
|
237
266
|
// path of every page on the machine that built the site, to every visitor.
|
|
238
267
|
// The server's table is read where those files are and keeps them.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
268
|
+
// HTTP handlers are exclusively server capabilities. Even an unused
|
|
269
|
+
// dynamic import makes Vite traverse the handler's database/auth imports.
|
|
270
|
+
return routesModuleSource(
|
|
271
|
+
{ ...table, handlers: [] },
|
|
272
|
+
{
|
|
273
|
+
shipsPage: (route) => kept.has(route),
|
|
274
|
+
relativeTo: root,
|
|
275
|
+
},
|
|
276
|
+
);
|
|
243
277
|
};
|
|
244
278
|
|
|
245
279
|
/**
|
|
@@ -331,7 +365,10 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
331
365
|
ensureService();
|
|
332
366
|
},
|
|
333
367
|
|
|
334
|
-
resolveId(id) {
|
|
368
|
+
resolveId(id, importer, resolveOptions) {
|
|
369
|
+
if (id === "@uniflowed/react" && !isSsr(this, resolveOptions)) {
|
|
370
|
+
return this.resolve("react", importer, { ...resolveOptions, skipSelf: true });
|
|
371
|
+
}
|
|
335
372
|
if (id === RUNTIME_PUBLIC_PATH) return RUNTIME_RESOLVED_ID;
|
|
336
373
|
if (id === AUDIT_PUBLIC_PATH) return AUDIT_RESOLVED_ID;
|
|
337
374
|
if (VIRTUAL_IDS.has(id)) return resolved(id);
|
|
@@ -346,7 +383,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
346
383
|
if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
|
|
347
384
|
if (id === AUDIT_RESOLVED_ID) return auditRuntimeSource(accessibility?.axe);
|
|
348
385
|
if (id === resolved(VIRTUAL.routes)) {
|
|
349
|
-
const table = scanRoutes(appRoot);
|
|
386
|
+
const table = scanRoutes(appRoot, { target: routeTarget });
|
|
350
387
|
// The server renders every route, so the server's table is the whole
|
|
351
388
|
// one and is generated with no filter at all. Only the browser's copy
|
|
352
389
|
// is split.
|
|
@@ -356,8 +393,17 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
356
393
|
// Strict Mode belongs to the client entry and to development only: a
|
|
357
394
|
// build passes `false`, so the generated module is the one that existed
|
|
358
395
|
// before #516 and a visitor's browser renders once.
|
|
396
|
+
//
|
|
397
|
+
// `navigation` is in the same entry and has no `isProduction` beside it,
|
|
398
|
+
// deliberately: it is what a link does, and a dev server whose links
|
|
399
|
+
// behave differently from the deployment is the wrong thing to be
|
|
400
|
+
// looking at. See `clientModuleSource`.
|
|
359
401
|
if (id === resolved(VIRTUAL.client)) {
|
|
360
|
-
return clientModuleSource(entryPath, {
|
|
402
|
+
return clientModuleSource(entryPath, {
|
|
403
|
+
strictMode: strictMode && !isProduction,
|
|
404
|
+
navigation,
|
|
405
|
+
mount,
|
|
406
|
+
});
|
|
361
407
|
}
|
|
362
408
|
if (id === resolved(VIRTUAL.server)) return serverModuleSource(entryPath);
|
|
363
409
|
// Only `virtual:uf/server` imports this, so it is only ever asked for in
|
|
@@ -474,12 +520,13 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
474
520
|
const tags = [
|
|
475
521
|
{
|
|
476
522
|
tag: "script",
|
|
523
|
+
attrs: { "data-uf-dev-head-preamble": "react-devtools" },
|
|
477
524
|
children: devtoolsPreamble(),
|
|
478
525
|
injectTo: "head-prepend",
|
|
479
526
|
},
|
|
480
527
|
{
|
|
481
528
|
tag: "script",
|
|
482
|
-
attrs: { type: "module" },
|
|
529
|
+
attrs: { type: "module", "data-uf-dev-head-preamble": "react-refresh" },
|
|
483
530
|
children: preambleCode(base),
|
|
484
531
|
injectTo: "head-prepend",
|
|
485
532
|
},
|
|
@@ -509,9 +556,8 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
509
556
|
// missing `route` — so adding a route handler to a running dev server
|
|
510
557
|
// did not rebuild the table and the handler stayed invisible until a
|
|
511
558
|
// restart. A list that has to match another list has to be that list.
|
|
512
|
-
const
|
|
513
|
-
|
|
514
|
-
.join("|");
|
|
559
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
560
|
+
const stems = Object.values(RESERVED).map(escapeRegExp).join("|");
|
|
515
561
|
const reserved = new RegExp(`/(${stems})(\\.[a-z]+)?\\.(js|jsx|mdx)$`);
|
|
516
562
|
const onRouteFile = (file) => {
|
|
517
563
|
if (!reserved.test(file) || !file.startsWith(appRoot)) return;
|
|
@@ -562,7 +608,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
562
608
|
// claimed, this one decided, and the other was reached only for what
|
|
563
609
|
// this one declined. Route-handler dispatch was in the other. A
|
|
564
610
|
// `GET /feed` from a browser is `Accept: text/html` with no extension,
|
|
565
|
-
// so it looked like a document, so `app/feed
|
|
611
|
+
// so it looked like a document, so `app/feed/$route.js` was never
|
|
566
612
|
// asked and the reader got the route table's page — or the not-found
|
|
567
613
|
// page — for a path that had a handler. See ubugeeei-prod/uf#349.
|
|
568
614
|
//
|
|
@@ -598,7 +644,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
598
644
|
// list rather than something this middleware can decide on its own.
|
|
599
645
|
return () => {
|
|
600
646
|
// The browser's own reporting channel, mounted above the application
|
|
601
|
-
// so that a report never reaches a project's
|
|
647
|
+
// so that a report never reaches a project's `$middleware.js` or
|
|
602
648
|
// its route table. See `internal/diagnostics.js`.
|
|
603
649
|
devServer.middlewares.use(
|
|
604
650
|
createChannelMiddleware((diagnostic) => emit("diagnostic", diagnostic)),
|
|
@@ -687,6 +733,30 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
687
733
|
}
|
|
688
734
|
if (notDocument != null) return false;
|
|
689
735
|
|
|
736
|
+
// A single-page project's deployment answers every navigation
|
|
737
|
+
// with the same empty shell, so this does too. Rendering the
|
|
738
|
+
// route here instead would have been the better-looking dev
|
|
739
|
+
// server and the wrong one: a page that only works because the
|
|
740
|
+
// server rendered it would work all through development and be
|
|
741
|
+
// blank the day it shipped. It is the same argument
|
|
742
|
+
// `app.rendering.navigation` makes about a link, one level up.
|
|
743
|
+
//
|
|
744
|
+
// The three steps above still ran — the guard, the action, the
|
|
745
|
+
// handler — and each of them is something `uf build` refuses in
|
|
746
|
+
// a `["csr"]` project by name. A dev server that skipped them
|
|
747
|
+
// would hide the very thing the build is going to stop.
|
|
748
|
+
if (mount === "render") {
|
|
749
|
+
response.statusCode = 200;
|
|
750
|
+
response.setHeader("content-type", "text/html; charset=utf-8");
|
|
751
|
+
const shell = entry.shellDocument({
|
|
752
|
+
scripts: [devUrlFor(VIRTUAL.client)],
|
|
753
|
+
styles: [],
|
|
754
|
+
preloads: [],
|
|
755
|
+
});
|
|
756
|
+
response.end(await devServer.transformIndexHtml(url, shell));
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
|
|
690
760
|
const result = await entry.render(
|
|
691
761
|
url,
|
|
692
762
|
{ scripts: [devUrlFor(VIRTUAL.client)], styles: [], preloads: [] },
|
|
@@ -699,6 +769,22 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
699
769
|
// Vite sees the head and only the head. That is what lets the
|
|
700
770
|
// development server stream like every other host — see below.
|
|
701
771
|
transformHead: (head) => devServer.transformIndexHtml(url, head),
|
|
772
|
+
// And what the streaming actually did, when it changed. The
|
|
773
|
+
// router has already decided there is something worth saying
|
|
774
|
+
// and written the words — see its `internal/inspector.js`,
|
|
775
|
+
// which cannot be imported from here because this file is
|
|
776
|
+
// plain JavaScript that Vite loads before any Flow transform
|
|
777
|
+
// exists. `info`, because a page that streamed is a
|
|
778
|
+
// measurement and not a problem; `origin`, because a report
|
|
779
|
+
// that does not say which page produced it is one somebody
|
|
780
|
+
// has to reproduce before they can act on it.
|
|
781
|
+
onStream: (diagnostic) =>
|
|
782
|
+
emit("diagnostic", {
|
|
783
|
+
severity: "info",
|
|
784
|
+
origin: url,
|
|
785
|
+
message: diagnostic.message,
|
|
786
|
+
detail: diagnostic.detail,
|
|
787
|
+
}),
|
|
702
788
|
},
|
|
703
789
|
);
|
|
704
790
|
if (result.error != null) reportRenderError(devServer, url, result.error);
|
|
@@ -707,7 +793,7 @@ function flowPlugin({ routerRoot, appEntry, strictMode, command, accessibility }
|
|
|
707
793
|
// `transformIndexHtml` is a *whole document* hook — which made the
|
|
708
794
|
// one place a developer would notice streaming the one place it
|
|
709
795
|
// did not happen: a slow page showed nothing until it was finished
|
|
710
|
-
// and
|
|
796
|
+
// and `$loading.js` looked broken.
|
|
711
797
|
//
|
|
712
798
|
// `transformHead` above is the seam. `internal/stream.js` already
|
|
713
799
|
// held the opening chunk back until the head was complete and
|
package/internal/a11y.js
CHANGED
|
@@ -86,15 +86,17 @@ export function auditRuntimeSource(settings) {
|
|
|
86
86
|
/**
|
|
87
87
|
* The tag that loads it, or `null` when this project has no engine.
|
|
88
88
|
*
|
|
89
|
-
* `injectTo: "
|
|
90
|
-
*
|
|
91
|
-
*
|
|
89
|
+
* `injectTo: "head"` because `uf dev` streams the body. Vite's HTML hook only
|
|
90
|
+
* sees the opening chunk; a `body` injection can land at a React chunk boundary
|
|
91
|
+
* and split an attribute before the rest of the body arrives. A module in the
|
|
92
|
+
* head still waits for a settled DOM before auditing — see the runtime — while
|
|
93
|
+
* staying in markup the opening transform can safely rewrite.
|
|
92
94
|
*/
|
|
93
95
|
export function auditTag(base, available) {
|
|
94
96
|
if (!available) return null;
|
|
95
97
|
return {
|
|
96
98
|
tag: "script",
|
|
97
99
|
attrs: { type: "module", src: `${base}${AUDIT_PUBLIC_PATH.slice(1)}` },
|
|
98
|
-
injectTo: "
|
|
100
|
+
injectTo: "head",
|
|
99
101
|
};
|
|
100
102
|
}
|
package/internal/config.js
CHANGED
|
@@ -72,7 +72,7 @@ export async function loadUfConfig(root) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
const compiled = await compileConfig(source, file, root);
|
|
75
|
-
const module = await
|
|
75
|
+
const module = await importConfigModule(compiled);
|
|
76
76
|
const config = module.default;
|
|
77
77
|
if (config == null || typeof config !== "object") {
|
|
78
78
|
throw new Error(
|
|
@@ -82,6 +82,20 @@ export async function loadUfConfig(root) {
|
|
|
82
82
|
return { config, file };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
async function importConfigModule(compiled) {
|
|
86
|
+
const previous = process.env.UF_TRANSFORM_BOOTSTRAP_CONFIG;
|
|
87
|
+
process.env.UF_TRANSFORM_BOOTSTRAP_CONFIG = "1";
|
|
88
|
+
try {
|
|
89
|
+
return await import(pathToFileURL(compiled).href);
|
|
90
|
+
} finally {
|
|
91
|
+
if (previous == null) {
|
|
92
|
+
delete process.env.UF_TRANSFORM_BOOTSTRAP_CONFIG;
|
|
93
|
+
} else {
|
|
94
|
+
process.env.UF_TRANSFORM_BOOTSTRAP_CONFIG = previous;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
85
99
|
/**
|
|
86
100
|
* Transform the config to JavaScript and write it where it can be imported.
|
|
87
101
|
*
|
|
@@ -93,8 +107,14 @@ async function compileConfig(source, file, root) {
|
|
|
93
107
|
const directory = path.join(root, COMPILED_DIR);
|
|
94
108
|
const target = path.join(directory, `uf.config.${hash}.mjs`);
|
|
95
109
|
|
|
96
|
-
const out = await transformFlow(source, file, {
|
|
97
|
-
|
|
110
|
+
const out = await transformFlow(source, file, {
|
|
111
|
+
root,
|
|
112
|
+
sourceMap: false,
|
|
113
|
+
configBootstrap: true,
|
|
114
|
+
});
|
|
115
|
+
const code = rewriteConfigImports(
|
|
116
|
+
rewriteRelativeImports(out?.code ?? source, path.dirname(file)),
|
|
117
|
+
);
|
|
98
118
|
// Atomically, because two `uf` commands in one project write this same path
|
|
99
119
|
// at the same time — the hash is of the source, so they agree on the name —
|
|
100
120
|
// and `writeFileSync` truncates before it writes. A reader that caught it
|
|
@@ -125,6 +145,13 @@ export function rewriteRelativeImports(code, baseDirectory) {
|
|
|
125
145
|
);
|
|
126
146
|
}
|
|
127
147
|
|
|
148
|
+
function rewriteConfigImports(code) {
|
|
149
|
+
return code.replace(
|
|
150
|
+
/^\s*import\s*\{\s*defineConfig\s*\}\s*from\s*["']@uniflowed\/config["'];?\n?/gm,
|
|
151
|
+
"function defineConfig(config) {\n return config;\n}\n",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
/**
|
|
129
156
|
* The JSON projection of a config object.
|
|
130
157
|
*
|
package/internal/devtools.js
CHANGED
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
// how the preamble is injected, could have taken it away in a diff nobody would
|
|
24
24
|
// read as being about DevTools. See ubugeeei-prod/uf#503.
|
|
25
25
|
//
|
|
26
|
-
// So the hook is installed here, first, deliberately, and `
|
|
27
|
-
//
|
|
26
|
+
// So the hook is installed here, first, deliberately, and `devtools.test.js`
|
|
27
|
+
// beside this package runs this script's own text against a fake window.
|
|
28
28
|
//
|
|
29
29
|
// # Three things DevTools needs, and what carries each
|
|
30
30
|
//
|
package/internal/diagnostics.js
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
// `@uniflowed/router`'s `internal/diagnostics.js` are the same two strings, and
|
|
58
58
|
// they are the contract. They cannot be *imported* here: this module is loaded
|
|
59
59
|
// by Vite before any Flow transform exists, and both of those are Flow. So they
|
|
60
|
-
// are written out, and `
|
|
60
|
+
// are written out, and `packages/vite/dev-channel.test.js` asserts that all
|
|
61
61
|
// four spellings agree — a duplicated constant with a test on it is honest, and
|
|
62
62
|
// one without is how the browser ends up posting to a path nothing serves.
|
|
63
63
|
//
|
|
@@ -105,7 +105,7 @@ const SEVERITIES = new Set(["error", "warn", "info"]);
|
|
|
105
105
|
* The connect middleware that answers the channel.
|
|
106
106
|
*
|
|
107
107
|
* Mounted **before** the application middleware, so a request under `/__uf/`
|
|
108
|
-
* never reaches a project's
|
|
108
|
+
* never reaches a project's `$middleware.js` or its route table. A guard
|
|
109
109
|
* that ran for a page's own telemetry would be a guard asked a question the
|
|
110
110
|
* application never asks, and one that redirected it would turn a report into
|
|
111
111
|
* a login page.
|
|
@@ -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,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
|
+
}
|