@knapsack/app
Version:
Build Design Systems with Knapsack
553 lines • 23.8 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getEndpoints = getEndpoints;
exports.getServer = getServer;
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const file_utils_1 = require("@knapsack/file-utils");
const core_1 = require("@knapsack/core");
const types_1 = require("@knapsack/types");
const https = __importStar(require("https"));
const portfinder_1 = __importDefault(require("portfinder"));
const compression_1 = __importDefault(require("compression"));
const ws_1 = __importDefault(require("ws"));
require("isomorphic-fetch");
const path_1 = require("path");
const debounce_1 = __importDefault(require("debounce"));
const cross_fetch_1 = __importDefault(require("cross-fetch"));
const utils_1 = require("@knapsack/utils");
const url_join_1 = __importDefault(require("url-join"));
const log_1 = require("../cli/log");
const events_1 = require("./events");
const rest_api_1 = require("./rest-api");
const routes_1 = require("./routes");
const constants_1 = require("../lib/constants");
const server_utils_1 = require("./server-utils");
const commands_1 = require("../cli/commands");
const static_paths_1 = require("../lib/static-paths");
const cache_dir_1 = require("../lib/util/cache-dir");
const utils_2 = require("../lib/utils");
const ks_urls_1 = require("../lib/ks-urls");
const api_client_1 = require("../lib/api-client");
const { NODE_ENV, KS_TEST_RUN, KNAPSACK_PORT, PORT } = process.env;
const isProd = NODE_ENV === 'production';
const isKsTestRun = KS_TEST_RUN === 'yes';
async function primeRenderCache({ appClientData, patterns, }) {
// render({
// patternId: 'button',
// patterns,
// });
}
async function getEndpoints({ getDataStore, ksBrain, command, discovery, }) {
const isLocalDev = command === 'start';
const { patterns, config } = ksBrain;
const { metaState } = discovery;
const discoveryEndpoint = {
path: '/api/v2/discovery',
method: 'GET',
authLevel: 'VIEWER',
handle: async () => discovery,
};
const regularEndpoints = (0, routes_1.setupRoutes)({
patterns,
allAssetSetIds: ksBrain.assetSets.getGlobalAssetSets().map((a) => a.id),
});
const { tokensSrc } = await getDataStore();
const restApiEndpoints = (0, rest_api_1.getApiRoutes)({
ksBrain,
isLocalDev,
discovery,
collectionsParentKey: tokensSrc?.$extensions?.['cloud.knapsack']?.global?.collectionsParentKey,
});
const metaStateEndpoint = {
method: 'GET',
path: '/api/v1/data/metaState',
handle: async () => metaState,
};
const dataStoreEndpoint = {
method: 'GET',
path: '/api/v1/data-store',
handle: async () => {
const dataStore = await getDataStore();
return {
...dataStore,
metaState,
};
},
};
/**
* used by backend git integration to prepare commit file contents and paths
*/
const dataStorePrepEndpoint = {
method: 'POST',
path: '/api/v1/data-store-prep',
handle: async ({ body: { state } }) => {
if (!state) {
throw new Error(`Body must contain a key of "state" that is "KsAppClientDataNoMeta"`);
}
if (!(0, types_1.isKsAppClientDataNoMeta)(state)) {
throw new Error('Invalid KsAppClientDataNoMeta');
}
try {
const data = await (0, commands_1.savePrepNewDataStore)({
ksBrain,
state,
});
// files have path that are absolute but need to be relative to the root of the repo, this is usually the CWD (which is also where `knapsack.config.js` is) - however if `knapsack.config.js` is in a sub-dir, then `config.cloud.repoRoot` must be set b/c this server often runs in a place where it's not in a git repo (i.e. the `.git` folder is gone) so we can't find the root of the repo automatically
const rootDir = config.cloud?.repoRoot || process.cwd();
data.files = data.files.map((file) => {
return {
...file,
path: (0, path_1.relative)(rootDir, file.path),
};
});
return {
ok: true,
data,
message: `Paths set relative to "${rootDir}" - CWD ${config.cloud?.repoRoot ? 'was' : 'was not'} overridden`,
};
}
catch (err) {
const msg = `Error running savePrepNewDataStore() via "/api/data-store-prep" endpoint: ${err.message}`;
log_1.log.error(msg);
throw new Error(msg);
}
},
};
/**
* used by backend git integration to prepare commit file contents and paths
*/
const dataStoreSaveEndpoint = {
method: 'POST',
path: '/api/v1/data-store',
handle: async ({ body: { state } }) => {
if (!isLocalDev) {
throw new Error(`Can only save data-store while on localhost`);
}
if (!state) {
throw new Error(`Body must contain a key of "state" that is "KsAppClientDataNoMeta"`);
}
if (!(0, types_1.isKsAppClientDataNoMeta)(state)) {
throw new Error('Invalid KsAppClientDataNoMeta');
}
const { data, error: savePrepError } = await (0, utils_1.tryCatch)(() => (0, commands_1.savePrepNewDataStore)({
ksBrain,
state,
}));
if (savePrepError) {
throw new Error(`Could not handleNewDataStore during savePrep: ${savePrepError.message}`, { cause: savePrepError });
}
const { data: results, error: saveFilesError } = await (0, utils_1.tryCatch)(() => (0, server_utils_1.saveFilesLocally)(data));
if (saveFilesError) {
throw new Error(`Could not handleNewDataStore during saveFiles: ${saveFilesError.message}`, { cause: saveFilesError });
}
if (config.plugins) {
// console.log(`Lifecycle event: onDataUpdated`, null, 'plugins');
const { error: onDataUpdatedError } = await (0, utils_1.tryCatch)(async () => Promise.all(config.plugins
.filter((p) => p.onDataUpdated)
.map((p) =>
// console.log(
// `Calling onDataUpdated for plugin ${p.id}`,
// null,
// 'plugins',
// );
p.onDataUpdated({ brain: ksBrain, state }))));
if (onDataUpdatedError) {
throw new Error(`Could not handleNewDataStore during onDataUpdated: ${onDataUpdatedError.message}`, { cause: onDataUpdatedError });
}
}
const { error: clearCacheError } = await (0, utils_1.tryCatch)(async () => await Promise.all(Object.values(ksBrain).map(async (value) => 'clearCache' in value ? value.clearCache() : undefined)));
if (clearCacheError) {
throw new Error(`Could not handleNewDataStore during clearCache: ${clearCacheError.message}`, { cause: clearCacheError });
}
return results;
},
};
return [
...regularEndpoints,
...restApiEndpoints,
metaStateEndpoint,
dataStoreEndpoint,
dataStorePrepEndpoint,
dataStoreSaveEndpoint,
discoveryEndpoint,
];
}
async function getServer({ ksBrain, getDataStore, command, discovery, httpsCert, }) {
(0, utils_2.setCliCommand)(command);
if (!getDataStore) {
throw new Error(`Tried starting the server with no "getDataStore"`);
}
if (!ksBrain) {
throw new Error('Tried starting the server with no "ksBrain"');
}
const { config } = ksBrain;
const port = parseInt(KNAPSACK_PORT, 10) ||
parseInt(PORT, 10) ||
(await portfinder_1.default.getPortPromise({
port: config?.devServer?.port || 3999,
}));
if (command === 'start') {
// update the discovery with the websockets port
discovery.metaState.meta.websocketsPort = httpsCert
? port // if we're using HTTPS, then we can use the same port for the websockets server
: await portfinder_1.default.getPortPromise({
port: process.env.KS_WS_PORT
? parseInt(process.env.KS_WS_PORT, 10)
: 8020,
});
}
const siteId = ksBrain.config.cloud?.siteId;
const app = (0, express_1.default)();
app.use(express_1.default.json({
limit: '5000kb',
}));
app.use((0, compression_1.default)());
app.use('*',
// https://www.npmjs.com/package/cors#configuration-options
(0, cors_1.default)({
origin: '*',
// Access-Control-Allow-Headers
allowedHeaders: [
...Object.values(core_1.ksHttpHeaders),
'X-Requested-With',
'Content-Type',
'Authorization',
'Baggage',
],
// Access-Control-Max-Age
maxAge: 86_400,
// Access-Control-Allow-Methods
methods: ['GET', 'POST', 'HEAD', 'OPTIONS'],
// Access-Control-Expose-Headers
exposedHeaders: [core_1.ksHttpHeaders.appClientVersion],
// Access-Control-Allow-Credentials CORS header. Set to true to pass the header, otherwise it is omitted.
credentials: true,
}));
app.use('*', (_req, res, next) => {
res.setHeader(core_1.ksHttpHeaders.appClientVersion, constants_1.APP_CLIENT_VERSION);
next();
});
function createAuthMiddleware(endpoint) {
return async (req, res, next) => {
if (!endpoint.authLevel) {
next();
return;
}
if (command !== 'serve') {
next();
return;
}
try {
if (!siteId) {
// Missing siteId: return 401 rather than forwarding "undefined"
res
.status(constants_1.HTTP_STATUS.BAD.UNAUTHORIZED)
.send('Unauthorized, missing siteId in "config.cloud.siteId"');
return;
}
const authHeader = req.header('authorization');
const { isSitePrivate, role } = await (0, api_client_1.authCheck)({
authHeader,
siteId,
});
switch (endpoint.authLevel) {
case 'VIEWER': {
// @todo switch to `canRoleView` when this is done: KSP-6412
const isAnonymous = role === 'ANONYMOUS';
const willAllow = isSitePrivate ? !isAnonymous : true;
if (!willAllow) {
// not calling `next` here b/c we're done and returning a response
log_1.log.error(`Unauthorized: ${req.path}`, {
path: req.path,
method: req.method,
role,
});
res.status(constants_1.HTTP_STATUS.BAD.UNAUTHORIZED).send('Unauthorized');
return;
}
break;
}
default: {
const _check = endpoint.authLevel;
next(new Error(`Unsupported authLevel: ${_check}`));
return;
}
}
}
catch (e) {
next(e);
return;
}
next();
};
}
const endpoints = await getEndpoints({
ksBrain,
getDataStore,
command,
discovery,
});
log_1.log.verbose(`API Endpoints`, endpoints.map(({ path, method }) => ({ path, method })));
endpoints.forEach((endpoint) => {
switch (endpoint.method) {
case 'GET':
app.get(endpoint.path, createAuthMiddleware(endpoint), async (req, res, next) => {
try {
endpoint.validateQuery?.(req.query);
}
catch (e) {
log_1.log.error(e.message, req.query, endpoint.path);
return res.status(constants_1.HTTP_STATUS.BAD.BAD_REQUEST).send({
ok: false,
message: e.message,
});
}
try {
const result = await endpoint.handle(req);
// to prevent stale caches of data-store and metaState
res.setHeader('Surrogate-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.send(result);
}
catch (e) {
return next(e);
}
});
break;
case 'POST':
app.post(endpoint.path, createAuthMiddleware(endpoint), async (req, res, next) => {
try {
endpoint.validateBody?.(req.body);
}
catch (e) {
log_1.log.error(e.message, req.body, endpoint.path);
return res.status(constants_1.HTTP_STATUS.BAD.BAD_REQUEST).send({
ok: false,
message: e.message,
});
}
try {
const result = await endpoint.handle(req);
res.send(result);
}
catch (e) {
return next(e);
}
});
break;
default: {
const type = endpoint;
throw new Error(`Unsupported endpoint type: ${JSON.stringify(type)}`);
}
}
});
// START: Static Paths
// Any assets that are not built with cache busters should be excluded
const excludedPaths = ['ks-asset-sets', 'plugins'];
const staticServeOptions = {
lastModified: true,
setHeaders: (res, path) => {
if (!excludedPaths.some((excludedPath) => path.includes(excludedPath))) {
// Cache for 5 minutes
res.setHeader('Cache-Control', 'public, max-age=300');
}
},
};
app.use(express_1.default.static(cache_dir_1.ksCacheDir, staticServeOptions));
log_1.log.verbose(`Virtual path: ${cache_dir_1.ksCacheDir} => /`);
if (command === 'start') {
// without this, file system changes being watched appear to result in a
// detected change -> recompile -> detected change -> recompile, loop
for (const [, value] of Object.entries(ksBrain.assetSets.data.allAssetSets)) {
value.assets.forEach((asset) => {
if (asset.src && asset.publicPath) {
app.use(asset.publicPath, express_1.default.static(asset.src));
}
});
}
// remove the already addded ks-asset-sets folder so express doesn't serve
(0, file_utils_1.remove)((0, path_1.join)(cache_dir_1.ksCacheDir, 'ks-asset-sets'));
// these static directories come after the one above so it should override when running start
// when it's not running `start` (it's `serve`) then these static directories are copied to `ksCacheDir` in `knapsack build` command
(0, static_paths_1.getStaticPaths)({
assetSetsPublicPaths: ksBrain.assetSets.publicPaths,
config,
})
.filter(Boolean)
.forEach(({ dirPath, publicPathBase }) => {
log_1.log.verbose(`Virtual path: ${publicPathBase} => ${dirPath}`);
app.use(publicPathBase, express_1.default.static(dirPath, staticServeOptions));
});
// END: Static Paths
}
let wss;
/**
* @returns if successful
*/
const sendWsMessage = (0, debounce_1.default)((msg) => {
if (!wss) {
console.error('Attempted to fire "sendWsMessage" but no WebSockets Server setup due to lack of "websocketsPort" in config');
return false;
}
log_1.log.verbose('sendWsMessage', { ...msg }, 'server');
wss.clients.forEach((client) => {
if (client.readyState === ws_1.default.OPEN) {
client.send(JSON.stringify(msg));
}
});
return true;
}, 100, false);
function isHttpsServer(server) {
return server.setSecureContext !== undefined;
}
async function serve() {
const { note } = await import('@clack/prompts');
const server = httpsCert
? https.createServer({ cert: httpsCert.cert, key: httpsCert.key }, app)
: app;
const { meta } = discovery.metaState;
if (meta.websocketsPort && command === 'start') {
wss = isHttpsServer(server)
? new ws_1.default.Server({
server, // https: we re-use server
clientTracking: true,
})
: new ws_1.default.Server({
port: meta.websocketsPort,
clientTracking: true,
});
}
server.listen(port, () => {
(0, cache_dir_1.writePortFile)(port);
note(`🚀 Knapsack ${command === 'serve' ? 'Production' : 'Development'} Server running on ${httpsCert ? 'https' : 'http'}://localhost:${port}`, 'Server running');
});
if (command === 'serve') {
// Let the api know the deployment is live
try {
const siteDeployedEndpoint = new URL((0, url_join_1.default)(ks_urls_1.ksUrls.apiRootEndpoint, core_1.siteDeployedWebhookEndpoint));
// this is fire & forget so we are not using `await`
getDataStore().then((dataStore) => {
const { metaState } = discovery;
const body = {
siteId,
appClientData: { ...dataStore, metaState },
};
(0, cross_fetch_1.default)(siteDeployedEndpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
})
.then(() => {
log_1.log.verbose(`Successfully called to ${siteDeployedEndpoint.toString()}`);
})
.catch(() => {
log_1.log.verbose(`Error calling to ${siteDeployedEndpoint.toString()}`);
});
const stop = (0, utils_1.timer)();
primeRenderCache({
appClientData: dataStore,
patterns: ksBrain.patterns,
})
.then(() => {
log_1.log.verbose(`Render Cache Primed (${stop()}ms)`);
})
.catch((error) => {
log_1.log.verbose(`Render Cache Error (${stop()}ms): ${error.message}`);
});
});
}
catch (e) {
// Oh Well
log_1.log.verbose(`Error calling to "siteDeployedEndpoint"`);
}
}
if (command === 'start' && wss) {
events_1.knapsackEvents.onAppClientDataChange(() => {
if ((0, server_utils_1.getIsSavingLocally)())
return;
sendWsMessage({
event: types_1.WS_EVENTS.APP_CLIENT_DATA_CHANGED,
data: {},
});
// workaround to trigger local template changes to reload / show up consistently
sendWsMessage({
event: types_1.WS_EVENTS.RENDERER_CLIENT_RELOAD,
data: {},
});
});
events_1.knapsackEvents.onDesignTokensChanged((data) => {
if ((0, server_utils_1.getIsSavingLocally)())
return;
sendWsMessage({
event: types_1.WS_EVENTS.DESIGN_TOKENS_CHANGED,
data,
});
});
events_1.knapsackEvents.onRequestRendererClientReload((data) => {
if ((0, server_utils_1.getIsSavingLocally)())
return;
sendWsMessage({
event: types_1.WS_EVENTS.RENDERER_CLIENT_RELOAD,
data,
});
});
}
}
// error handling middleware MUST come last
// error handling middleware MUST take 4 arguments
// Error-handling middleware always takes four arguments. You must provide four arguments to identify it as an error-handling middleware function. Even if you don’t need to use the next object, you must specify it to maintain the signature. Otherwise, the next object will be interpreted as regular middleware and will fail to handle erro
const errorHandler = (err, req, res, next) => {
log_1.log.error(err, '', req.path);
res.status(constants_1.HTTP_STATUS.FAIL.INTERNAL_ERROR).send({
ok: false,
message: err.message,
});
};
app.use(errorHandler);
return {
app,
serve,
};
}
//# sourceMappingURL=server.js.map