mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 08:16:49 +00:00
fix(cli): resolve /ui/ in app dev and lint, bare ui/ imports from the app (#12050)
* fix(cli): resolve /ui/ in app dev and lint, and its bare imports from the app Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): match shared ui/ files by real path so a symlinked ui/ still redirects Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): keep relative css @import in shared ui/ resolving next to the file Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): let esbuild resolve relative css @import in ui/ so suffixes keep working Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d3872cb183
commit
3ee745192c
@@ -285,6 +285,84 @@ export async function ensureNodeModules(appDir?: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's shared `ui/` folder for an app: `ui/` next to the nearest
|
||||
* wmill.yaml above `appDir`, the root `wmill app push` and `wmill app bundle`
|
||||
* run from. Undefined when no wmill.yaml is found.
|
||||
*/
|
||||
export function findSharedUiDir(appDir: string): string | undefined {
|
||||
let dir = path.resolve(appDir);
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(dir, "wmill.yaml"))) {
|
||||
return path.join(dir, "ui");
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return undefined;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `/ui/<file>` imports to the workspace's shared `ui/` folder, and bare
|
||||
* package imports made from those files against the app's own node_modules: the
|
||||
* server-side bundler places `ui/` inside the app, so walking up from the shared
|
||||
* folder instead would miss the app's packages or load a second copy of them
|
||||
* (two Reacts break hooks at runtime).
|
||||
*/
|
||||
export function createSharedUiPlugin(sharedUiDir: string, appDir: string): any {
|
||||
// Real paths throughout: esbuild follows symlinks for relative imports, so a
|
||||
// symlinked ui/ would otherwise give its files two different prefixes.
|
||||
const uiPrefix = fs.realpathSync(sharedUiDir) + path.sep;
|
||||
const resolveDir = path.resolve(appDir);
|
||||
return {
|
||||
name: "wmill-shared-ui",
|
||||
setup(build: any) {
|
||||
build.onResolve({ filter: /^\/ui\// }, (args: any) => {
|
||||
const rel = args.path.slice("/ui/".length);
|
||||
const candidates = [rel];
|
||||
if (!path.extname(rel)) {
|
||||
candidates.push(
|
||||
rel + ".tsx",
|
||||
rel + ".ts",
|
||||
rel + ".jsx",
|
||||
rel + ".js",
|
||||
rel + ".css",
|
||||
path.join(rel, "index.tsx"),
|
||||
path.join(rel, "index.ts"),
|
||||
);
|
||||
}
|
||||
for (const c of candidates) {
|
||||
const full = path.join(sharedUiDir, c);
|
||||
if (fs.existsSync(full)) {
|
||||
return { path: fs.realpathSync(full) };
|
||||
}
|
||||
}
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `Could not resolve shared UI import "${args.path}" in ${sharedUiDir}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
build.onResolve({ filter: /^[^./]/ }, async (args: any) => {
|
||||
if (!args.importer.startsWith(uiPrefix)) return undefined;
|
||||
// CSS reads `@import "base.css"` as relative first; keep that when the
|
||||
// sibling resolves, and only send package names to the app.
|
||||
if (args.kind === "import-rule" || args.kind === "url-token") {
|
||||
const sibling = await build.resolve("./" + args.path, {
|
||||
resolveDir: args.resolveDir,
|
||||
kind: args.kind,
|
||||
});
|
||||
if (sibling.errors.length === 0) return sibling;
|
||||
}
|
||||
// No importer is passed, so this nested resolve does not re-enter here.
|
||||
return build.resolve(args.path, { resolveDir, kind: args.kind });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an esbuild bundle for the app
|
||||
* @param options Bundle configuration options
|
||||
@@ -365,44 +443,10 @@ export async function createBundle(
|
||||
},
|
||||
};
|
||||
|
||||
const sharedUiPlugins: any[] = [];
|
||||
if (options.sharedUiDir && fs.existsSync(options.sharedUiDir)) {
|
||||
const sharedUiDir = options.sharedUiDir;
|
||||
sharedUiPlugins.push({
|
||||
name: "wmill-shared-ui",
|
||||
setup(build: any) {
|
||||
// Intercept imports of /ui/<file> and resolve to the workspace ui/ folder.
|
||||
build.onResolve({ filter: /^\/ui\// }, (args: any) => {
|
||||
const rel = args.path.slice("/ui/".length);
|
||||
const candidates = [rel];
|
||||
if (!path.extname(rel)) {
|
||||
candidates.push(
|
||||
rel + ".tsx",
|
||||
rel + ".ts",
|
||||
rel + ".jsx",
|
||||
rel + ".js",
|
||||
rel + ".css",
|
||||
path.join(rel, "index.tsx"),
|
||||
path.join(rel, "index.ts"),
|
||||
);
|
||||
}
|
||||
for (const c of candidates) {
|
||||
const full = path.join(sharedUiDir, c);
|
||||
if (fs.existsSync(full)) {
|
||||
return { path: full };
|
||||
}
|
||||
}
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `Could not resolve shared UI import "${args.path}" in ${sharedUiDir}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
const sharedUiPlugins: any[] =
|
||||
options.sharedUiDir && fs.existsSync(options.sharedUiDir)
|
||||
? [createSharedUiPlugin(options.sharedUiDir, appDir)]
|
||||
: [];
|
||||
|
||||
const buildOptions = {
|
||||
...DEFAULT_BUILD_OPTIONS,
|
||||
|
||||
@@ -19,8 +19,10 @@ import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import {
|
||||
createFrameworkPlugins,
|
||||
createSharedUiPlugin,
|
||||
detectFrameworks,
|
||||
ensureNodeModules,
|
||||
findSharedUiDir,
|
||||
getDevBuildOptions,
|
||||
} from "./bundle.ts";
|
||||
import { wmillTsDev as wmillTs } from "./wmillTsDev.ts";
|
||||
@@ -593,6 +595,12 @@ async function dev(opts: DevOptions, appFolder?: string) {
|
||||
// Load framework-specific plugins (svelte) based on package.json
|
||||
const frameworkPlugins = await createFrameworkPlugins(appDir);
|
||||
|
||||
const sharedUiDir = findSharedUiDir(appDir);
|
||||
const sharedUiPlugins =
|
||||
sharedUiDir && fs.existsSync(sharedUiDir)
|
||||
? [createSharedUiPlugin(sharedUiDir, appDir)]
|
||||
: [];
|
||||
|
||||
const wmillPlugin = {
|
||||
name: "wmill-virtual",
|
||||
setup(build: any) {
|
||||
@@ -663,6 +671,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
|
||||
},
|
||||
},
|
||||
wmillPlugin,
|
||||
...sharedUiPlugins,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { colors } from "@cliffy/ansi/colors";
|
||||
import * as log from "../../core/log.ts";
|
||||
import { yamlParseFile } from "../../utils/yaml.ts";
|
||||
import { GlobalOptions } from "../../types.ts";
|
||||
import { createBundle, detectFrameworks } from "./bundle.ts";
|
||||
import { createBundle, detectFrameworks, findSharedUiDir } from "./bundle.ts";
|
||||
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
|
||||
import { loadRunnablesFromBackend } from "./raw_apps.ts";
|
||||
import { inferSchema } from "../../utils/metadata.ts";
|
||||
@@ -173,6 +173,7 @@ async function validateBuild(
|
||||
entryPoint,
|
||||
production: true,
|
||||
minify: false,
|
||||
sharedUiDir: findSharedUiDir(appDir),
|
||||
});
|
||||
|
||||
log.info(colors.green("✅ Build successful"));
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* `/ui/...` imports, driven through `createBundle` the way `app dev` and
|
||||
* `app lint` find the shared folder: from the app up to its wmill.yaml.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { createBundle, findSharedUiDir } from "../src/commands/app/bundle.ts";
|
||||
|
||||
let root: string;
|
||||
let originalCwd: string;
|
||||
|
||||
function write(rel: string, content: string) {
|
||||
const full = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content, "utf-8");
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
originalCwd = process.cwd();
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-shared-ui-"));
|
||||
write("wmill.yaml", "defaultTs: bun\n");
|
||||
// ui/ as a symlink, with the bare import one relative hop away: esbuild reaches
|
||||
// inner.ts by its real path, not through the link.
|
||||
write("shared/button.ts", `export { label } from "./inner";\n`);
|
||||
write("shared/inner.ts", `import { who } from "pkg";\nimport "./styles.css";\nexport const label = who;\n`);
|
||||
write("shared/styles.css", `@import "base.css?theme";\n`);
|
||||
write("shared/base.css", `.from-ui-css { color: red; }\n`);
|
||||
fs.symlinkSync(path.join(root, "shared"), path.join(root, "ui"));
|
||||
write("f/demo.raw_app/package.json", `{"name":"demo"}`);
|
||||
write("f/demo.raw_app/index.tsx", `import { label } from "/ui/button";\nconsole.log(label);\n`);
|
||||
write("f/demo.raw_app/node_modules/pkg/index.js", `export const who = "from-app";\n`);
|
||||
// A copy above ui/ must not win: it would be a second instance of the package.
|
||||
write("node_modules/pkg/index.js", `export const who = "from-root";\n`);
|
||||
process.chdir(path.join(root, "f/demo.raw_app"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("bare imports in ui/ resolve from the app's node_modules", async () => {
|
||||
const appDir = path.join(root, "f/demo.raw_app");
|
||||
const sharedUiDir = findSharedUiDir(appDir);
|
||||
expect(sharedUiDir).toBe(path.join(root, "ui"));
|
||||
|
||||
const { js, css } = await createBundle({
|
||||
entryPoint: path.join(appDir, "index.tsx"),
|
||||
minify: false,
|
||||
sharedUiDir,
|
||||
});
|
||||
expect(js).toContain("from-app");
|
||||
expect(js).not.toContain("from-root");
|
||||
expect(css).toContain("from-ui-css");
|
||||
});
|
||||
Reference in New Issue
Block a user