@uniflowed/vite 0.0.0-alpha.20 → 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 CHANGED
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // <host> driver.js dev --root <dir> [--mode <m>] [--host <h>] [--port <n>] [--strict-port]
9
9
  // [--uf-env-file <file>]...
10
- // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>]
10
+ // <host> driver.js build --root <dir> [--mode <m>] [--out-dir <dir>] [--target <target>]
11
11
  // [--prerender everything|possible|nothing]
12
12
  // [--static-build] [--because <sentence>]
13
13
  // <host> driver.js library --root <dir> [--mode <m>] [--out-dir <dir>]
@@ -50,8 +50,9 @@ import { COMPILE_ASSETS_ID, compileAssetsPlugin } from "./internal/compile-asset
50
50
  import { emit, errorEvent, eventLogger } from "./internal/events.js";
51
51
  import { loadUfConfig, projectConfig } from "./internal/config.js";
52
52
  import { send, toRequest } from "./internal/http.js";
53
+ import { createOpenApiDocument } from "./internal/openapi.js";
53
54
  import { withProjectConfig } from "./merge.js";
54
- import { VIRTUAL, scanRoutes } from "./internal/routes.js";
55
+ import { VIRTUAL, resolveRouteTarget, scanRoutes } from "./internal/routes.js";
55
56
  import {
56
57
  BUILD_ID_FILE,
57
58
  assetsFromManifest,
@@ -161,6 +162,7 @@ async function viteConfig(config, mode) {
161
162
  const port = Number(argument("--port") ?? dev.port ?? 5173);
162
163
  const allowedHosts =
163
164
  Array.isArray(dev.allowedHosts) && dev.allowedHosts.length > 0 ? dev.allowedHosts : undefined;
165
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
164
166
 
165
167
  // What uf generates from the semantics it owns: where the project is, which
166
168
  // plugins make Flow compile, and the few settings uf enforces rather than
@@ -191,7 +193,7 @@ async function viteConfig(config, mode) {
191
193
  mode,
192
194
  clearScreen: false,
193
195
  customLogger: eventLogger(argument("--log-level") ?? "info"),
194
- plugins: [uniflowed({ root, config })],
196
+ plugins: [uniflowed({ root, config, target: routeTarget })],
195
197
  server: {
196
198
  host,
197
199
  port,
@@ -255,6 +257,7 @@ async function viteConfig(config, mode) {
255
257
  async function dev() {
256
258
  const { createServer } = await import("vite");
257
259
  const config = await loadConfig();
260
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
258
261
  // The mode is uf's to decide, not this file's: `uf dev` resolves `--mode`,
259
262
  // the profile `uf env use` wrote and `env.active` before it starts anything,
260
263
  // and always passes the answer. The fallback is for a driver started by hand.
@@ -266,9 +269,9 @@ async function dev() {
266
269
  emit("listening", {
267
270
  local: urls.local,
268
271
  network: urls.network,
269
- routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
270
- (route) => route.path,
271
- ),
272
+ routes: scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
273
+ target: routeTarget,
274
+ }).routes.map((route) => route.path),
272
275
  });
273
276
  watchSources(server);
274
277
  watchEnvFiles(server);
@@ -390,6 +393,7 @@ function watchEnvFiles(server) {
390
393
  async function preview() {
391
394
  const { preview: startPreview } = await import("vite");
392
395
  const config = await loadConfig();
396
+ const routeTarget = resolveRouteTarget(config, argument("--target"));
393
397
  const inline = await viteConfig(config, argument("--mode") ?? "production");
394
398
  // A build that declared it emits no server has none to mount. `uf` refuses
395
399
  // `uf start` for such a project and lets this one through, because a preview
@@ -489,9 +493,9 @@ async function preview() {
489
493
  // be the report being wrong about the thing it exists to report.
490
494
  routes:
491
495
  build == null
492
- ? scanRoutes(path.resolve(root, config.app?.router?.root ?? "app")).routes.map(
493
- (route) => route.path,
494
- )
496
+ ? scanRoutes(path.resolve(root, config.app?.router?.root ?? "app"), {
497
+ target: routeTarget,
498
+ }).routes.map((route) => route.path)
495
499
  : build.entry.routes.map((route) => route.path),
496
500
  handlers: build == null ? [] : build.entry.handlers.map((handler) => handler.path),
497
501
  });
@@ -632,6 +636,10 @@ async function build() {
632
636
  emit("phase", { name: "prerender" });
633
637
  const server = await import(pathToFileURL(path.join(serverDir, "server.js")).href);
634
638
  const assets = assetsFromManifest(manifest);
639
+ const openapi = await createOpenApiDocument(server.handlers);
640
+ const openapiFile = path.join(root, ".uf", "build", "meta", "openapi.json");
641
+ mkdirSync(path.dirname(openapiFile), { recursive: true });
642
+ writeFileSync(openapiFile, `${JSON.stringify(openapi, null, 2)}\n`);
635
643
  const plan = await renderingPlan(server, prerender);
636
644
  emit("rendering", {
637
645
  prerender,
@@ -1070,9 +1078,9 @@ async function compile() {
1070
1078
  * of what an adapter is, and keeping the differences in one object is what
1071
1079
  * stops a second one from quietly becoming a second application.
1072
1080
  *
1073
- * `bun`, `deno` and `static` are deliberately absent; `uf_config`'s
1081
+ * `static` is deliberately absent; `uf_config`'s
1074
1082
  * `DeployAdapter::is_implemented` is the other half of that fact and
1075
- * `docs/app/reference/cli/$page.mdx` says why for each of them.
1083
+ * `docs/app/reference/cli/$page.mdx` says why.
1076
1084
  */
1077
1085
  const ADAPTERS = {
1078
1086
  node: {
@@ -1101,6 +1109,12 @@ const ADAPTERS = {
1101
1109
  server: bunEntrySource("./handler.js", schedules),
1102
1110
  }),
1103
1111
  },
1112
+ deno: {
1113
+ entries: (document, cache, build, schedules) => ({
1114
+ handler: handlerEntrySource(document, cache, DENO_CAPABILITIES, build),
1115
+ server: denoEntrySource("./handler.js", schedules),
1116
+ }),
1117
+ },
1104
1118
  // The same two files. What `--adapter container` adds is a `Dockerfile` and
1105
1119
  // a `.dockerignore`, and both are plain text that `uf` writes beside this
1106
1120
  // output rather than anything the bundler produces — see `uf_cli`'s
@@ -1155,6 +1169,7 @@ const ADAPTERS = {
1155
1169
  */
1156
1170
  const NODE_CAPABILITIES = { module: "@uniflowed/server/node", name: "nodeCapabilities" };
1157
1171
  const BUN_CAPABILITIES = { module: "@uniflowed/server/bun", name: "bunCapabilities" };
1172
+ const DENO_CAPABILITIES = { module: "@uniflowed/server/deno", name: "denoCapabilities" };
1158
1173
  const EDGE_CAPABILITIES = { module: "@uniflowed/server/edge", name: "edgeCapabilities" };
1159
1174
  const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lambdaCapabilities" };
1160
1175
 
@@ -1206,7 +1221,7 @@ const SERVERLESS_CAPABILITIES = { module: "@uniflowed/server/lambda", name: "lam
1206
1221
  * It is the one implemented target with no application to link: a static host
1207
1222
  * returns files, and `uf build` has already written them. So `uf` copies the
1208
1223
  * output directory itself and never spawns this driver for it, which is why
1209
- * [`ADAPTERS`] has four rows and not five. What that target does instead of
1224
+ * [`ADAPTERS`] has six rows and not seven. What that target does instead of
1210
1225
  * linking is refuse a project whose route handlers, middleware, unprerendered
1211
1226
  * routes or server actions a static host cannot answer — in Rust, because the
1212
1227
  * facts it needs are the route table and what the prerender reported.
@@ -1415,7 +1430,7 @@ const cache = { store: createCacheStore(${
1415
1430
 
1416
1431
  `
1417
1432
  : ""
1418
- }// What this target can do, and it is not the same for all four: whether a
1433
+ }// What this target can do, and it is not the same for all six: whether a
1419
1434
  // response body reaches the client as it is produced, and whether the process
1420
1435
  // is still there once it has. A route handler that streams events or takes a
1421
1436
  // socket asks through this rather than finding out in production. Nothing is
@@ -1590,6 +1605,52 @@ serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) =>
1590
1605
  `;
1591
1606
  }
1592
1607
 
1608
+ /**
1609
+ * The source of `server.js`: the Deno socket around that handler.
1610
+ *
1611
+ * The same generated contract as Node and Bun, with `@uniflowed/server/deno`
1612
+ * owning the host-specific pieces. The static directory is resolved with
1613
+ * `import.meta.url` alone so the entry carries no `node:` imports.
1614
+ */
1615
+ function denoEntrySource(handlerSpecifier, schedules) {
1616
+ const cron = scheduleLines("@uniflowed/server/schedule", schedules);
1617
+ return `// Generated by \`uf build --adapter deno\`. Not checked in, not edited.
1618
+ import { serve } from "@uniflowed/server/deno";
1619
+ ${cron.imports}
1620
+ // \`beginRequest\` comes from the handler beside this file rather than from
1621
+ // \`@uniflowed/server/deno\` above, because the request has to be established in
1622
+ // the storage the *application* reads, which is the copy bundled into
1623
+ // \`handler.js\`. See ubugeeei-prod/uf#389.
1624
+ //
1625
+ // Deno has no Node globals, but dependency bundles may still carry a CommonJS
1626
+ // production branch that expects a few of them. Establish the small environment
1627
+ // shape before \`handler.js\` is evaluated, which requires a dynamic import here
1628
+ // rather than a static one.
1629
+ globalThis.process ??= { env: {} };
1630
+ globalThis.process.env ??= {};
1631
+ globalThis.process.env.NODE_ENV ??= "production";
1632
+ globalThis.Buffer ??= {
1633
+ byteLength(value) {
1634
+ return new TextEncoder().encode(String(value)).byteLength;
1635
+ },
1636
+ };
1637
+ globalThis.setImmediate ??= (callback, ...args) => setTimeout(callback, 0, ...args);
1638
+ globalThis.clearImmediate ??= (handle) => clearTimeout(handle);
1639
+ const { beginRequest, fetch } = await import(${JSON.stringify(handlerSpecifier)});
1640
+
1641
+ // Resolved from this file and not from the working directory: a process
1642
+ // manager and a person in a shell each start a server from wherever they
1643
+ // happen to be, and a directory that only served its own assets when it was
1644
+ // started from inside itself would be a deployment with a trap in it.
1645
+ const staticDir = decodeURIComponent(new URL("./static", import.meta.url).pathname);
1646
+ ${cron.declarations}
1647
+ serve({ handle: fetch, staticDir, beginRequest${cron.option} }).catch((error) => {
1648
+ console.error(\`uf: \${error?.message ?? String(error)}\`);
1649
+ Deno.exit(1);
1650
+ });
1651
+ `;
1652
+ }
1653
+
1593
1654
  /**
1594
1655
  * The source of `worker.js`: the Cloudflare Workers entry around that handler.
1595
1656
  *
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 ?? {};
@@ -156,6 +159,7 @@ export default function uniflowed(options = {}) {
156
159
  flowPlugin({
157
160
  routerRoot,
158
161
  appEntry,
162
+ routeTarget,
159
163
  strictMode,
160
164
  navigation,
161
165
  mount,
@@ -176,6 +180,7 @@ export default function uniflowed(options = {}) {
176
180
  function flowPlugin({
177
181
  routerRoot,
178
182
  appEntry,
183
+ routeTarget,
179
184
  strictMode,
180
185
  navigation,
181
186
  mount,
@@ -260,10 +265,15 @@ function flowPlugin({
260
265
  // survives the build — so the browser's table was shipping the absolute
261
266
  // path of every page on the machine that built the site, to every visitor.
262
267
  // The server's table is read where those files are and keeps them.
263
- return routesModuleSource(table, {
264
- shipsPage: (route) => kept.has(route),
265
- relativeTo: root,
266
- });
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
+ );
267
277
  };
268
278
 
269
279
  /**
@@ -373,7 +383,7 @@ function flowPlugin({
373
383
  if (id === RUNTIME_RESOLVED_ID) return refreshRuntimeSource();
374
384
  if (id === AUDIT_RESOLVED_ID) return auditRuntimeSource(accessibility?.axe);
375
385
  if (id === resolved(VIRTUAL.routes)) {
376
- const table = scanRoutes(appRoot);
386
+ const table = scanRoutes(appRoot, { target: routeTarget });
377
387
  // The server renders every route, so the server's table is the whole
378
388
  // one and is generated with no filter at all. Only the browser's copy
379
389
  // is split.
@@ -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 import(pathToFileURL(compiled).href);
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, { root, sourceMap: false });
97
- const code = rewriteRelativeImports(out?.code ?? source, path.dirname(file));
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
  *
@@ -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
+ }
@@ -78,6 +78,52 @@ export const LAYOUT_PROP_NAMES = Object.freeze(["children", "params"]);
78
78
  const PAGE_EXTENSIONS = [".js", ".jsx", ".mdx"];
79
79
  const MODULE_EXTENSIONS = [".js", ".jsx"];
80
80
 
81
+ /** Application targets the route scanner knows how to select files for. */
82
+ export const ROUTE_TARGETS = Object.freeze(["web", "native", "ios", "android"]);
83
+
84
+ const TARGET_VARIANTS = Object.freeze({
85
+ web: ["web", null],
86
+ native: ["native", null],
87
+ ios: ["ios", "native", null],
88
+ android: ["android", "native", null],
89
+ });
90
+
91
+ /**
92
+ * The route target a loaded `uf.config.js` and an optional CLI flag describe.
93
+ *
94
+ * `react-native` is accepted as the config-shaped spelling of the same target
95
+ * `uf build --target native` selects. The default follows the framework
96
+ * preset rather than the target list: uf's default list names both web and
97
+ * React Native, so the list is a promise the project should keep satisfying,
98
+ * not the one build to run when none was requested.
99
+ */
100
+ export function resolveRouteTarget(config = {}, requested = null) {
101
+ const app = config.app ?? {};
102
+ const named =
103
+ requested == null || requested === ""
104
+ ? app.framework === "react-native"
105
+ ? "native"
106
+ : "web"
107
+ : requested === "react-native"
108
+ ? "native"
109
+ : requested;
110
+ if (!ROUTE_TARGETS.includes(named)) {
111
+ throw new Error(
112
+ `uf: ${JSON.stringify(named)} is not an application target; choose web, native, ios or android`,
113
+ );
114
+ }
115
+ const declared = app.targets;
116
+ if (Array.isArray(declared)) {
117
+ const needs = named === "web" ? "web" : "react-native";
118
+ if (!declared.includes(needs)) {
119
+ throw new Error(
120
+ `uf: --target ${named} needs app.targets to include ${JSON.stringify(needs)}`,
121
+ );
122
+ }
123
+ }
124
+ return named;
125
+ }
126
+
81
127
  /** Deepest directory nesting the scan will follow. */
82
128
  const MAX_DEPTH = 32;
83
129
 
@@ -254,6 +300,7 @@ const MAX_DEPTH = 32;
254
300
  * about.
255
301
  *
256
302
  * @param {string} appRoot absolute path of the router root (`app/`)
303
+ * @param {{target?: "web" | "native" | "ios" | "android"}} [options]
257
304
  * @returns {{
258
305
  * routes: Route[],
259
306
  * handlers: Handler[],
@@ -262,7 +309,8 @@ const MAX_DEPTH = 32;
262
309
  * errors: ErrorBoundary[],
263
310
  * }}
264
311
  */
265
- export function scanRoutes(appRoot) {
312
+ export function scanRoutes(appRoot, options = {}) {
313
+ const target = resolveRouteTarget({}, options.target ?? "web");
266
314
  const routes = [];
267
315
  const handlers = [];
268
316
  const middleware = [];
@@ -283,7 +331,7 @@ export function scanRoutes(appRoot) {
283
331
  // A `$default.js` answers one question — what a slot renders when the
284
332
  // URL says nothing about it — and this walk is everywhere a slot is not,
285
333
  // so one found here is a file nothing would ever open.
286
- const strayDefault = findModule(directory, RESERVED.default, PAGE_EXTENSIONS);
334
+ const strayDefault = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
287
335
  if (strayDefault != null) {
288
336
  throw new Error(
289
337
  `${strayDefault}: \`$default.js\` is what a \`@slot\` renders when the URL says ` +
@@ -293,7 +341,7 @@ export function scanRoutes(appRoot) {
293
341
  );
294
342
  }
295
343
 
296
- const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
344
+ const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS, target);
297
345
  const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
298
346
  if (depth === 0) {
299
347
  rootLayouts = nextLayouts;
@@ -305,7 +353,7 @@ export function scanRoutes(appRoot) {
305
353
  // `nextLayouts.length` is therefore the count taken after the own layout is
306
354
  // added, not before. A segment with a loading file and no layout of its own
307
355
  // still gets a boundary — it just shares its parent's frame.
308
- const ownLoading = findModule(directory, RESERVED.loading, MODULE_EXTENSIONS);
356
+ const ownLoading = findModule(directory, RESERVED.loading, MODULE_EXTENSIONS, target);
309
357
  const nextLoading = ownLoading
310
358
  ? [...loading, { above: nextLayouts.length, module: ownLoading }]
311
359
  : loading;
@@ -316,7 +364,7 @@ export function scanRoutes(appRoot) {
316
364
  // Every template above a route is on that route, one inside the next, for
317
365
  // the reason every layout is — the difference between the two is a `key`,
318
366
  // not a shape.
319
- const ownTemplate = findModule(directory, RESERVED.template, MODULE_EXTENSIONS);
367
+ const ownTemplate = findModule(directory, RESERVED.template, MODULE_EXTENSIONS, target);
320
368
  const nextTemplates = ownTemplate
321
369
  ? [...templates, { above: nextLayouts.length, module: ownTemplate }]
322
370
  : templates;
@@ -339,6 +387,7 @@ export function scanRoutes(appRoot) {
339
387
  segments,
340
388
  ownLayout,
341
389
  nextLayouts.length,
390
+ target,
342
391
  depth,
343
392
  ),
344
393
  ];
@@ -347,12 +396,12 @@ export function scanRoutes(appRoot) {
347
396
  // A middleware guards this directory and everything below it, whether or
348
397
  // not this directory is itself a route: `app/dashboard/$middleware.js`
349
398
  // with no `$page.js` beside it still guards `/dashboard/settings`.
350
- const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS);
399
+ const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS, target);
351
400
  if (ownMiddleware) {
352
401
  middleware.push({ path: routeFromSegments(segments).path, module: ownMiddleware });
353
402
  }
354
403
 
355
- const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS);
404
+ const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS, target);
356
405
  if (page) {
357
406
  const { path: routePath, pattern, params } = routeFromSegments(segments);
358
407
  routes.push({
@@ -370,7 +419,7 @@ export function scanRoutes(appRoot) {
370
419
  // A handler answers the request itself, so it takes no layouts and is not
371
420
  // MDX. It may sit beside a page: `/feed` can render for a browser and
372
421
  // `/feed.xml` answer for a reader, and both are the same directory tree.
373
- const handler = findModule(directory, RESERVED.route, MODULE_EXTENSIONS);
422
+ const handler = findModule(directory, RESERVED.route, MODULE_EXTENSIONS, target);
374
423
  if (handler) {
375
424
  const { path: routePath, pattern, params } = routeFromSegments(segments);
376
425
  handlers.push({ path: routePath, pattern, params, module: handler });
@@ -380,7 +429,7 @@ export function scanRoutes(appRoot) {
380
429
  // `app/guide/$not-found.js` was never looked for and a reader who
381
430
  // followed a stale link into the manual was answered by the site's root
382
431
  // 404, outside the manual's own layout. See ubugeeei-prod/uf#263.
383
- const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
432
+ const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS, target);
384
433
  if (ownNotFound) {
385
434
  notFound.push({
386
435
  path: routeFromSegments(segments).path,
@@ -392,7 +441,7 @@ export function scanRoutes(appRoot) {
392
441
 
393
442
  // `errors` is the boundaries a project declares, not failures that
394
443
  // happened: one entry per directory holding an `$error.js`.
395
- const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS);
444
+ const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS, target);
396
445
  if (ownError) {
397
446
  errors.push({
398
447
  path: routeFromSegments(segments).path,
@@ -495,10 +544,11 @@ export function scanRoutes(appRoot) {
495
544
  * @param {ReadonlyArray<string>} segments the declaring segments, for the URL
496
545
  * @param {?string} ownLayout the declaring segment's own layout, or `null`
497
546
  * @param {number} above how many layouts are outside the slot
547
+ * @param {"web" | "native" | "ios" | "android"} target application target
498
548
  * @param {number} depth nesting depth, against `MAX_DEPTH`
499
549
  * @returns {Slot}
500
550
  */
501
- function scanSlot(parent, directoryName, name, segments, ownLayout, above, depth) {
551
+ function scanSlot(parent, directoryName, name, segments, ownLayout, above, target, depth) {
502
552
  const directory = path.join(parent, directoryName);
503
553
  if (LAYOUT_PROP_NAMES.includes(name)) {
504
554
  throw new Error(
@@ -525,7 +575,7 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, depth
525
575
  }
526
576
 
527
577
  const routes = [];
528
- const defaultPage = findModule(directory, RESERVED.default, PAGE_EXTENSIONS);
578
+ const defaultPage = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
529
579
 
530
580
  const walkSlot = (current, currentSegments, layouts, atSlotRoot, currentDepth) => {
531
581
  if (currentDepth > MAX_DEPTH) return;
@@ -534,7 +584,8 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, depth
534
584
  // is left of parallel routes; see the issue.
535
585
  for (const role of [RESERVED.notFound, RESERVED.error, RESERVED.loading, RESERVED.template]) {
536
586
  const found =
537
- findModule(current, role, MODULE_EXTENSIONS) ?? findModule(current, role, PAGE_EXTENSIONS);
587
+ findModule(current, role, MODULE_EXTENSIONS, target) ??
588
+ findModule(current, role, PAGE_EXTENSIONS, target);
538
589
  if (found != null) {
539
590
  throw new Error(
540
591
  `${found}: a \`@slot\` renders a page and the layouts inside the slot, and has no ` +
@@ -546,7 +597,7 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, depth
546
597
  }
547
598
  }
548
599
  for (const role of [RESERVED.route, RESERVED.middleware]) {
549
- const found = findModule(current, role, MODULE_EXTENSIONS);
600
+ const found = findModule(current, role, MODULE_EXTENSIONS, target);
550
601
  if (found != null) {
551
602
  throw new Error(
552
603
  `${found}: a \`@slot\` renders inside the page at a URL and answers no request of its ` +
@@ -559,15 +610,16 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, depth
559
610
  // One default per slot, at the slot. A deeper one would be a second answer
560
611
  // to a question that is asked once — the URL either addressed this slot or
561
612
  // it did not.
562
- if (!atSlotRoot && findModule(current, RESERVED.default, PAGE_EXTENSIONS) != null) {
613
+ const nestedDefault = findModule(current, RESERVED.default, PAGE_EXTENSIONS, target);
614
+ if (!atSlotRoot && nestedDefault != null) {
563
615
  throw new Error(
564
- `${findModule(current, RESERVED.default, PAGE_EXTENSIONS)}: a \`@slot\` has one ` +
616
+ `${nestedDefault}: a \`@slot\` has one ` +
565
617
  `\`$default.js\`, directly inside \`${directoryName}\`, and this one is deeper, so ` +
566
618
  "nothing would ever render it.",
567
619
  );
568
620
  }
569
621
 
570
- const layoutHere = findModule(current, RESERVED.layout, MODULE_EXTENSIONS);
622
+ const layoutHere = findModule(current, RESERVED.layout, MODULE_EXTENSIONS, target);
571
623
  const nextLayouts = layoutHere ? [...layouts, layoutHere] : layouts;
572
624
 
573
625
  const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) =>
@@ -589,12 +641,13 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, depth
589
641
  currentSegments,
590
642
  layoutHere,
591
643
  nextLayouts.length,
644
+ target,
592
645
  currentDepth,
593
646
  ),
594
647
  ];
595
648
  }
596
649
 
597
- const page = findModule(current, RESERVED.page, PAGE_EXTENSIONS);
650
+ const page = findModule(current, RESERVED.page, PAGE_EXTENSIONS, target);
598
651
  if (page) {
599
652
  const { path: routePath, params } = routeFromSegments(currentSegments);
600
653
  routes.push({
@@ -647,13 +700,16 @@ function isDirectory(candidate) {
647
700
  }
648
701
  }
649
702
 
650
- function findModule(directory, stem, extensions) {
651
- for (const extension of extensions) {
652
- const candidate = path.join(directory, stem + extension);
653
- try {
654
- if (statSync(candidate).isFile()) return candidate;
655
- } catch {
656
- // keep looking
703
+ function findModule(directory, stem, extensions, target = "web") {
704
+ for (const variant of TARGET_VARIANTS[target] ?? TARGET_VARIANTS.web) {
705
+ for (const extension of extensions) {
706
+ const fileName = variant == null ? `${stem}${extension}` : `${stem}.${variant}${extension}`;
707
+ const candidate = path.join(directory, fileName);
708
+ try {
709
+ if (statSync(candidate).isFile()) return candidate;
710
+ } catch {
711
+ // keep looking
712
+ }
657
713
  }
658
714
  }
659
715
  return null;
package/internal/rsc.js CHANGED
@@ -47,13 +47,12 @@ export const RSC_MANIFEST_ENV = "UF_RSC_MANIFEST";
47
47
  /**
48
48
  * The manifest schema this understands.
49
49
  *
50
- * Version 1 published the client boundaries and nothing that said which
51
- * modules sat *above* one, so it cannot answer the question this module asks.
52
- * An older manifest is therefore refused rather than read optimistically: a
53
- * missing `proximity` would read as `undefined`, compare unequal to
54
- * `"reaches-boundary"`, and quietly drop every route from the client bundle.
50
+ * Version 3 lets client boundaries name package specifiers as well as project
51
+ * paths. An older manifest is therefore refused rather than read
52
+ * optimistically: it cannot know a route imports a package client module, and
53
+ * quietly dropping that route would be the worst possible answer.
55
54
  */
56
- const SUPPORTED_VERSION = 2;
55
+ const SUPPORTED_VERSION = 3;
57
56
 
58
57
  /**
59
58
  * Read the RSC manifest, or `null` when there is nothing usable to read.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/vite",
3
- "version": "0.0.0-alpha.20",
3
+ "version": "0.0.0-alpha.21",
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",
@@ -34,8 +34,10 @@
34
34
  "dependencies": {
35
35
  "@mdx-js/rollup": "^3.1.1",
36
36
  "@shikijs/rehype": "^3.23.0",
37
- "@uniflowed/host": "0.0.0-alpha.20",
38
- "@uniflowed/server": "0.0.0-alpha.20",
37
+ "@uniflowed/host": "0.0.0-alpha.21",
38
+ "@uniflowed/router": "0.0.0-alpha.21",
39
+ "@uniflowed/server": "0.0.0-alpha.21",
40
+ "@uniflowed/validator": "0.0.0-alpha.21",
39
41
  "rehype-slug": "^6.0.0",
40
42
  "remark-frontmatter": "^5.0.0",
41
43
  "remark-gfm": "^4.0.1",