Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cds-plugin-ui5): proper dependency handling / decoupling #773

Merged
merged 1 commit into from
Jul 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 38 additions & 130 deletions packages/cds-plugin-ui5/cds-plugin.js
Original file line number Diff line number Diff line change
@@ -1,147 +1,55 @@
const path = require("path");
const fs = require("fs");
const yaml = require("js-yaml");

const cds = require("@sap/cds");
const { Router } = require("express");
// @sap/cds/lib/index.js#138: global.cds = cds // REVISIT: using global.cds seems wrong
const cds = global.cds || require("@sap/cds"); // reuse already loaded cds!

const log = require("./lib/log");
const findUI5Modules = require("./lib/findUI5Modules");
const createPatchedRouter = require("./lib/createPatchedRouter");
const applyUI5Middleware = require("./lib/applyUI5Middleware");

// marker that the cds-plugin-ui5 plugin is running
// to disable the ui5-middleware-cap if used in apps
process.env["cds-plugin-ui5"] = true;

/**
* helper to log colorful messages
* @param {string} type the type of the message
* @param {string} message the message text
*/
function log(type, message) {
const colors = {
log: "\x1b[0m", // default
info: "\x1b[32m", // green
debug: "\x1b[34m", // blue
warn: "\x1b[33m", // yellow
error: "\x1b[31m", // red
};
if (!console[type]) {
type = "log";
}
console[type](`\x1b[36m[cds-ui5-plugin]\x1b[0m %s[%s]\x1b[0m %s`, colors[type], type, message);
}

cds.on("bootstrap", async function bootstrap(app) {
log("debug", "bootstrap");
log.debug("bootstrap");

// lookup the app folder to determine local apps and ui5 app directories
const localApps = new Set(),
appDirs = [];
fs.readdirSync(path.join(process.cwd(), "app"), { withFileTypes: true })
.filter((f) => f.isDirectory())
.forEach((d) => localApps.add(d.name));
localApps.forEach((e) => {
const d = path.join(process.cwd(), "app", e);
if (fs.existsSync(path.join(d, "ui5.yaml"))) {
localApps.delete(e);
appDirs.push(d);
}
});
const cwd = process.cwd();
const ui5Modules = await findUI5Modules({ cwd });
const localApps = ui5Modules.localApps;

// look for a single app if no apps were found in the app directories
if(appDirs.length === 0) {
const d = path.join(process.cwd(), "app");
if (fs.existsSync(path.join(d, "ui5.yaml"))) {
appDirs.push(d);
}
}
const links = [];

// lookup the UI5 dependencies
const pkgJson = require(path.join(process.cwd(), "package.json"));
const deps = [];
deps.push(...Object.keys(pkgJson.dependencies || {}));
deps.push(...Object.keys(pkgJson.devDependencies || {}));
//deps.push(...Object.keys(pkgJson.peerDependencies || {}));
//deps.push(...Object.keys(pkgJson.optionalDependencies || {}));
appDirs.push(
...deps.filter((dep) => {
try {
require.resolve(`${dep}/ui5.yaml`, {
paths: [process.cwd()],
});
return true;
} catch (e) {
return false;
}
})
);
// register the UI5 modules via their own router/middlewares
for await (const ui5Module of ui5Modules) {
const { mountPath, modulePath } = ui5Module;

// if apps are available, attach the middlewares of the UI5 apps
// to the express of the CAP server via a express router
if (appDirs) {
const links = [];
for await (const appDir of appDirs) {
// read the ui5.yaml file to extract the configuration
const ui5YamlPath = require.resolve(path.join(appDir, "ui5.yaml"), {
paths: [process.cwd()],
});
let ui5Configs;
try {
const content = fs.readFileSync(ui5YamlPath, "utf-8");
ui5Configs = yaml.loadAll(content);
} catch (err) {
if (err.name === "YAMLException") {
log("error", `Failed to read ${ui5YamlPath}!`);
}
throw err;
}
// mounting the Router for the UI5 application to the CAP server
log.info(`Mounting ${mountPath} to UI5 app ${modulePath}`);

// by default the mount path is derived from the metadata/name
// and can be overridden by customConfiguration/mountPath
const ui5Config = ui5Configs?.[0];
let mountPath = ui5Config?.customConfiguration?.mountPath || ui5Config?.metadata?.name;
if (!/^\//.test(mountPath)) {
mountPath = `/${mountPath}`; // always start with /
}

// mounting the Router for the application to the CAP server
log("info", `Mounting ${mountPath} to UI5 app ${appDir}`);
const modulePath = path.dirname(ui5YamlPath);

// create the router and get rid of the mount path
const router = new Router();
router.use(function (req, res, next) {
// disable the compression when livereload is used
// for loading html-related content (via accept header)
const accept = req.headers["accept"]?.indexOf("html");
if (accept && res._livereload) {
req.headers["accept-encoding"] = "identity";
}
// remove the mount path from the url
req.originalUrl = req.url;
req.baseUrl = "/";
next();
});
// create a patched router
const router = await createPatchedRouter();

// apply the UI5 middlewares to the router and
// retrieve the available HTML pages
const pages = await applyUI5Middleware(router, {
basePath: modulePath,
configPath: modulePath,
});
// apply the UI5 middlewares to the router and
// retrieve the available HTML pages
const appInfo = await applyUI5Middleware(router, {
basePath: modulePath,
configPath: modulePath,
});

// append the HTML pages to the links
pages.forEach((page) => {
const prefix = mountPath !== "/" ? mountPath : "";
links.push(`${prefix}${page.getPath()}`);
});
// register the router to the specified mount path
app.use(mountPath, router);

// mount the router to the determined mount path
app.use(`${mountPath}`, router);
}
// append the HTML pages to the links
appInfo.pages.forEach((page) => {
const prefix = mountPath !== "/" ? mountPath : "";
links.push(`${prefix}${page.getPath()}`);
});
}

if (links.length > 0) {
// register the custom middleware (similar like in @sap/cds/server.js)
app.get("/", function appendLinksToIndex(req, res, next) {
var send = res.send;
const send = res.send;
res.send = function (content) {
// the first <ul> element contains the links to the
// application pages which is fully under control of
Expand Down Expand Up @@ -183,11 +91,11 @@ cds.on("bootstrap", async function bootstrap(app) {
ul.innerHTML = newLis.join("\n");
content = doc.toString();
} else {
log("warn", `Failed to inject application links into CAP index page!`);
log.warn(`Failed to inject application links into CAP index page!`);
}
send.apply(this, arguments);
};
//log("debug", req.url);
//log.debug(req.url);
next();
});

Expand All @@ -201,17 +109,17 @@ cds.on("bootstrap", async function bootstrap(app) {
if (idxOfServeStatic !== -1) {
middlewareStack.splice(idxOfServeStatic, 0, cmw);
} else {
log("error", `Failed to determine CAP overview page middleware! You need to manually open the application pages!`);
log.error(`Failed to determine CAP overview page middleware! You need to manually open the application pages!`);
}
} else {
log("error", `Failed to inject application pages to CAP overview page! You need to manually open the application pages!`);
log.error(`Failed to inject application pages to CAP overview page! You need to manually open the application pages!`);
}
}
});

// return callback for plugin activation
module.exports = {
activate: function activate(conf) {
log("debug", "activate", conf);
log.debug("activate", conf);
},
};
19 changes: 18 additions & 1 deletion packages/cds-plugin-ui5/lib/applyUI5Middleware.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
const path = require("path");

/**
* @typedef UI5AppInfo
* @type {object}
* @property {Array<string>} pages root path of the module
*/

// inspired by https://github.com/SAP/karma-ui5/blob/main/lib/framework.js#L466-L522
/**
* Applies the middlewares for the UI5 application located in the given
* root directory to the given router.
* @param {import("express").Router} router Express Router instance
* @param {object} options configuration options
* @param {string} options.basePath base path of the UI5 application
* @param {string} [options.configPath] path to the ui5.yaml (defaults to "${basePath}/ui5.yaml")
* @returns {UI5AppInfo} UI5 application information object
*/
module.exports = async function applyUI5Middleware(router, { basePath, configPath }) {
const { graphFromPackageDependencies } = await import("@ui5/project/graph");
const { createReaderCollection } = await import("@ui5/fs/resourceFactory");
Expand Down Expand Up @@ -54,5 +69,7 @@ module.exports = async function applyUI5Middleware(router, { basePath, configPat
});
await middlewareManager.applyMiddleware(router);

return await rootReader.byGlob("**/*.html");
return {
pages: await rootReader.byGlob("**/*.html"),
};
};
24 changes: 24 additions & 0 deletions packages/cds-plugin-ui5/lib/createPatchedRouter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const { Router } = require("express");

/**
* Creates a patched router removing the mount path
* from urls and disabling the encoding
* @returns {Router} patched router
*/
module.exports = async function createPatchedRouter() {
// create the router and get rid of the mount path
const router = new Router();
router.use(function (req, res, next) {
// disable the compression when livereload is used
// for loading html-related content (via accept header)
const accept = req.headers["accept"]?.indexOf("html");
if (accept && res._livereload) {
req.headers["accept-encoding"] = "identity";
}
// remove the mount path from the url
req.originalUrl = req.url;
req.baseUrl = "/";
next();
});
return router;
};
103 changes: 103 additions & 0 deletions packages/cds-plugin-ui5/lib/findUI5Modules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const path = require("path");
const fs = require("fs");
const yaml = require("js-yaml");

const log = require("./log");

/**
* @typedef UI5Module
* @type {object}
* @property {string} modulePath root path of the module
* @property {string} mountPath path to mount the module to
*/

/**
* Returns all UI5 modules from local apps and the project dependencies.
* @param {object} options configuration options
* @param {string} options.cwd current working directory
* @returns {Array<UI5Module>} array of UI5 module
*/
module.exports = async function findApps({ cwd }) {
// lookup the app folder to determine local apps and UI5 apps
const localApps = new Set();
const appDirs = [];
const appDir = path.join(cwd, "app");
if (fs.existsSync(appDir)) {
fs.readdirSync(appDir, { withFileTypes: true })
.filter((f) => f.isDirectory())
.forEach((d) => localApps.add(d.name));
localApps.forEach((e) => {
const d = path.join(appDir, e);
if (fs.existsSync(path.join(d, "ui5.yaml"))) {
localApps.delete(e);
appDirs.push(d);
}
});
}

// look for a single app if no apps were found in the app directories
if (appDirs.length === 0) {
if (fs.existsSync(path.join(appDir, "ui5.yaml"))) {
appDirs.push(appDir);
}
}

// lookup the UI5 modules in the project dependencies
const pkgJson = require(path.join(cwd, "package.json"));
const deps = [];
deps.push(...Object.keys(pkgJson.dependencies || {}));
deps.push(...Object.keys(pkgJson.devDependencies || {}));
//deps.push(...Object.keys(pkgJson.peerDependencies || {}));
//deps.push(...Object.keys(pkgJson.optionalDependencies || {}));
appDirs.push(
...deps.filter((dep) => {
try {
require.resolve(`${dep}/ui5.yaml`, {
paths: [cwd],
});
return true;
} catch (e) {
return false;
}
})
);

// if apps are available, attach the middlewares of the UI5 apps
// to the express of the CAP server via a express router
const apps = [];
if (appDirs) {
for await (const appDir of appDirs) {
// read the ui5.yaml file to extract the configuration
const ui5YamlPath = require.resolve(path.join(appDir, "ui5.yaml"), {
paths: [cwd],
});
let ui5Configs;
try {
const content = fs.readFileSync(ui5YamlPath, "utf-8");
ui5Configs = yaml.loadAll(content);
} catch (err) {
if (err.name === "YAMLException") {
log("error", `Failed to read ${ui5YamlPath}!`);
}
throw err;
}

// by default the mount path is derived from the metadata/name
// and can be overridden by customConfiguration/mountPath
const ui5Config = ui5Configs?.[0];
const isApplication = ui5Config?.type === "application";
if (isApplication) {
let mountPath = ui5Config?.customConfiguration?.mountPath || ui5Config?.metadata?.name;
if (!/^\//.test(mountPath)) {
mountPath = `/${mountPath}`; // always start with /
}

// determine the module path based on the location of the ui5.yaml
const modulePath = path.dirname(ui5YamlPath);
apps.push({ modulePath, mountPath });
}
}
}
apps.localApps = localApps; // necessary for CAP index.html rewrite
return apps;
};
Loading
Loading