@sap-ux/preview-middleware 0.26.10 → 0.26.11

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.
@@ -2,6 +2,22 @@ import type { Logger } from '@sap-ux/logger';
2
2
  import type { ReaderCollection } from '@ui5/fs';
3
3
  import type { Editor } from 'mem-fs-editor';
4
4
  import type { CommonChangeProperties } from '@sap-ux/adp-tooling';
5
+ /**
6
+ * Set of local module paths (fragments, controllers) used to strip
7
+ * inlined modules from the LrepConnector response so that UI5 loads
8
+ * local workspace versions via HTTP instead.
9
+ */
10
+ export type LocalModulePaths = Set<string>;
11
+ /**
12
+ * State of local module files in the workspace.
13
+ * - `active`: paths derived from local change files — deployed changes should be served from the local workspace.
14
+ * - `orphaned`: local files with no corresponding local change record (e.g. change file was deleted) —
15
+ * the associated deployed change should be suppressed entirely.
16
+ */
17
+ export interface LocalModuleState {
18
+ active: LocalModulePaths;
19
+ orphaned: LocalModulePaths;
20
+ }
5
21
  /**
6
22
  * Read changes from the file system and return them.
7
23
  *
@@ -10,6 +26,25 @@ import type { CommonChangeProperties } from '@sap-ux/adp-tooling';
10
26
  * @returns object with the file name as key and the file content as value
11
27
  */
12
28
  export declare function readChanges(project: ReaderCollection, logger: Logger): Promise<Record<string, CommonChangeProperties>>;
29
+ /**
30
+ * Reads local module state from the workspace.
31
+ *
32
+ * @param project reference to the UI5 project
33
+ * @param logger logger instance
34
+ * @returns LocalModuleState with `active` and `orphaned` sets of relative paths under /changes/
35
+ */
36
+ export declare function readLocalModulePaths(project: ReaderCollection, logger: Logger): Promise<LocalModuleState>;
37
+ /**
38
+ * Patches an LREP flex data response so that deployed changes whose module
39
+ * files exist locally in the workspace are loaded from the local workspace
40
+ * instead of from ABAP.
41
+ *
42
+ * @param responseData the parsed LREP flex data response
43
+ * @param localModuleState state of local module files (active and orphaned)
44
+ * @param logger logger instance
45
+ * @returns the (possibly patched) response data
46
+ */
47
+ export declare function stripLocalModulesFromLrepResponse(responseData: Record<string, unknown>, localModuleState: LocalModuleState, logger: Logger): Record<string, unknown>;
13
48
  /**
14
49
  * Writes flex changes to the user's workspace.
15
50
  *
package/dist/base/flex.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.readChanges = readChanges;
4
+ exports.readLocalModulePaths = readLocalModulePaths;
5
+ exports.stripLocalModulesFromLrepResponse = stripLocalModulesFromLrepResponse;
4
6
  exports.writeChange = writeChange;
5
7
  exports.deleteChange = deleteChange;
6
8
  const node_fs_1 = require("node:fs");
@@ -26,6 +28,209 @@ async function readChanges(project, logger) {
26
28
  }
27
29
  return changes;
28
30
  }
31
+ /**
32
+ * Extracts the local module path from a change's type and content.
33
+ * Returns the fragmentPath for addXML changes, codeRef for codeExt changes, and undefined otherwise.
34
+ *
35
+ * @param changeType
36
+ * @param content
37
+ */
38
+ function extractLocalModulePath(changeType, content) {
39
+ if (changeType === 'addXML' && typeof content?.fragmentPath === 'string') {
40
+ return content.fragmentPath;
41
+ }
42
+ if (changeType === 'codeExt' && typeof content?.codeRef === 'string') {
43
+ return content.codeRef;
44
+ }
45
+ return undefined;
46
+ }
47
+ /**
48
+ * Reads local module state from the workspace.
49
+ *
50
+ * @param project reference to the UI5 project
51
+ * @param logger logger instance
52
+ * @returns LocalModuleState with `active` and `orphaned` sets of relative paths under /changes/
53
+ */
54
+ async function readLocalModulePaths(project, logger) {
55
+ const active = new Set();
56
+ const orphaned = new Set();
57
+ const changeFiles = await project.byGlob('/**/changes/**/*.{change,variant,ctrl_variant,ctrl_variant_change,ctrl_variant_management_change}');
58
+ for (const file of changeFiles) {
59
+ try {
60
+ const change = JSON.parse(await file.getString());
61
+ const content = change.content;
62
+ const localPath = extractLocalModulePath(change.changeType, content);
63
+ if (localPath) {
64
+ active.add(localPath);
65
+ }
66
+ }
67
+ catch {
68
+ // ignore malformed change files — readChanges already warns about them
69
+ }
70
+ }
71
+ const moduleFiles = await project.byGlob('/**/changes/{fragments/**/*.xml,coding/**/*.js,coding/**/*.ts}');
72
+ for (const file of moduleFiles) {
73
+ const filePath = file.getPath();
74
+ const changesIdx = filePath.lastIndexOf('/changes/');
75
+ if (changesIdx !== -1) {
76
+ const relativePath = filePath.substring(changesIdx + '/changes/'.length);
77
+ // normalise .ts → .js so the orphaned key matches the codeRef in deployed changes
78
+ const normalizedPath = relativePath.endsWith('.ts') ? relativePath.slice(0, -3) + '.js' : relativePath;
79
+ if (!active.has(normalizedPath)) {
80
+ orphaned.add(normalizedPath);
81
+ }
82
+ }
83
+ }
84
+ logger.debug(`Found ${active.size} active and ${orphaned.size} orphaned local module(s) for LREP filtering`);
85
+ return { active, orphaned };
86
+ }
87
+ /**
88
+ * Strips inlined module entries from `result.modules` whose paths match active local modules.
89
+ *
90
+ * @param result the response object being patched (mutated when modules are stripped)
91
+ * @param active set of active local module paths
92
+ * @param logger logger instance
93
+ * @returns true if `result.modules` was modified
94
+ */
95
+ function stripInlinedModules(result, active, logger) {
96
+ if (!result.modules || typeof result.modules !== 'object') {
97
+ return false;
98
+ }
99
+ const originalModules = result.modules;
100
+ const filteredEntries = Object.entries(originalModules).filter(([key]) => {
101
+ // lastIndexOf anchors on the flex /changes/ segment even if the namespace contains "changes"
102
+ const changesIdx = key.lastIndexOf('/changes/');
103
+ if (changesIdx === -1) {
104
+ return true;
105
+ }
106
+ const relativePath = key.substring(changesIdx + '/changes/'.length);
107
+ if (active.has(relativePath)) {
108
+ logger.debug(`Stripping inlined module '${key}' — local version will be served instead`);
109
+ return false;
110
+ }
111
+ return true;
112
+ });
113
+ const removedCount = Object.keys(originalModules).length - filteredEntries.length;
114
+ if (removedCount === 0) {
115
+ return false;
116
+ }
117
+ logger.info(`Stripped ${removedCount} inlined module(s) from LREP response in favor of local versions`);
118
+ result.modules = Object.fromEntries(filteredEntries);
119
+ return true;
120
+ }
121
+ /**
122
+ * Injects expected `moduleName` values into changes whose local paths are active so UI5 resolves
123
+ * them via the adp.proxy instead of expecting inlined module content.
124
+ *
125
+ * @param changes the array of change objects
126
+ * @param active set of active local module paths
127
+ * @param logger logger instance
128
+ * @returns updated changes array if any moduleName was injected, otherwise null
129
+ */
130
+ function injectModuleNames(changes, active, logger) {
131
+ let changesModified = false;
132
+ const updatedChanges = changes.map((change) => {
133
+ const c = change;
134
+ const content = c.content;
135
+ const reference = typeof c.reference === 'string' ? c.reference : '';
136
+ const changeType = c.changeType;
137
+ const localPath = extractLocalModulePath(changeType, content);
138
+ if (!localPath || !active.has(localPath) || !reference) {
139
+ return change;
140
+ }
141
+ const prefix = reference.replaceAll('.', '/');
142
+ // codeExt modules are JS — strip the trailing .js so the path is a valid UI5 module ID
143
+ const modulePathSuffix = changeType === 'codeExt' && localPath.endsWith('.js') ? localPath.slice(0, -3) : localPath;
144
+ const expectedModuleName = `${prefix}/changes/${modulePathSuffix}`;
145
+ if (c.moduleName === expectedModuleName) {
146
+ return change;
147
+ }
148
+ logger.debug(`Setting moduleName '${expectedModuleName}' for local '${localPath}'`);
149
+ changesModified = true;
150
+ return { ...c, moduleName: expectedModuleName };
151
+ });
152
+ return changesModified ? updatedChanges : null;
153
+ }
154
+ /**
155
+ * Filters out deployed changes whose local file exists but whose local change record was deleted
156
+ * (i.e. orphaned). Returning these changes would re-apply ABAP state that the user already removed.
157
+ *
158
+ * @param changes the array of change objects
159
+ * @param orphaned set of orphaned local module paths
160
+ * @param logger logger instance
161
+ * @returns filtered changes array if any change was suppressed, otherwise null
162
+ */
163
+ function suppressOrphanedChanges(changes, orphaned, logger) {
164
+ const filteredChanges = changes.filter((change) => {
165
+ const c = change;
166
+ const content = c.content;
167
+ const localPath = extractLocalModulePath(c.changeType, content);
168
+ if (localPath && orphaned.has(localPath)) {
169
+ logger.debug(`Suppressing deployed change for orphaned local file '${localPath}'`);
170
+ return false;
171
+ }
172
+ return true;
173
+ });
174
+ const removedCount = changes.length - filteredChanges.length;
175
+ if (removedCount === 0) {
176
+ return null;
177
+ }
178
+ logger.info(`Suppressed ${removedCount} deployed change(s) whose local files exist but change records were deleted`);
179
+ return filteredChanges;
180
+ }
181
+ /**
182
+ * Applies moduleName injection and orphaned-change suppression to `result.changes`.
183
+ *
184
+ * @param result the response object being patched (mutated when changes are updated)
185
+ * @param state local module state (active and orphaned)
186
+ * @param logger logger instance
187
+ * @returns true if `result.changes` was modified
188
+ */
189
+ function processChanges(result, state, logger) {
190
+ if (!result.changes || !Array.isArray(result.changes)) {
191
+ return false;
192
+ }
193
+ let modified = false;
194
+ const injected = injectModuleNames(result.changes, state.active, logger);
195
+ if (injected) {
196
+ result.changes = injected;
197
+ modified = true;
198
+ }
199
+ if (state.orphaned.size > 0) {
200
+ const suppressed = suppressOrphanedChanges(result.changes, state.orphaned, logger);
201
+ if (suppressed) {
202
+ result.changes = suppressed;
203
+ modified = true;
204
+ }
205
+ }
206
+ return modified;
207
+ }
208
+ /**
209
+ * Patches an LREP flex data response so that deployed changes whose module
210
+ * files exist locally in the workspace are loaded from the local workspace
211
+ * instead of from ABAP.
212
+ *
213
+ * @param responseData the parsed LREP flex data response
214
+ * @param localModuleState state of local module files (active and orphaned)
215
+ * @param logger logger instance
216
+ * @returns the (possibly patched) response data
217
+ */
218
+ function stripLocalModulesFromLrepResponse(responseData, localModuleState, logger) {
219
+ if (localModuleState.active.size === 0 && localModuleState.orphaned.size === 0) {
220
+ return responseData;
221
+ }
222
+ const result = { ...responseData };
223
+ let modified = stripInlinedModules(result, localModuleState.active, logger);
224
+ if (result.loadModules === true && localModuleState.active.size > 0) {
225
+ logger.debug('Stripping loadModules flag — modules will be loaded on-demand so adp.proxy can serve local versions');
226
+ delete result.loadModules;
227
+ modified = true;
228
+ }
229
+ if (processChanges(result, localModuleState, logger)) {
230
+ modified = true;
231
+ }
232
+ return modified ? result : responseData;
233
+ }
29
234
  /**
30
235
  * Writes flex changes to the user's workspace.
31
236
  *
@@ -341,6 +341,33 @@ export declare class FlpSandbox {
341
341
  * @param adp AdpPreview instance
342
342
  */
343
343
  private setupAdpCommonHandlers;
344
+ /**
345
+ * Registers a middleware that intercepts LREP flex data responses to apply
346
+ * local workspace overrides: stripping inlined modules for active local files,
347
+ * removing the loadModules pre-fetch flag, injecting correct moduleNames, and
348
+ * suppressing deployed changes for orphaned local files (change record deleted).
349
+ *
350
+ * The local module state is captured once at server startup by `readLocalModulePaths`
351
+ * and is not refreshed during the preview session. If a developer adds, renames, or deletes
352
+ * a local fragment, controller, or change file while the preview is running, the filter will
353
+ * be stale until the server is restarted. This is an intentional trade-off — re-scanning the
354
+ * VFS on every flex-data request would add latency to every preview round-trip.
355
+ *
356
+ * @param adp AdpPreview instance with access to the ABAP service provider
357
+ * @param localModuleState pre-scanned local module state
358
+ */
359
+ private registerLrepFlexDataFilter;
360
+ /**
361
+ * Handler for LREP flex data requests. Strips inlined modules that exist locally
362
+ * so that UI5 fetches them via HTTP from the local workspace instead.
363
+ *
364
+ * @param req the request
365
+ * @param res the response
366
+ * @param next the next middleware
367
+ * @param provider the ABAP service provider for backend calls
368
+ * @param localModuleState pre-scanned local module state
369
+ */
370
+ private lrepFlexDataFilterHandler;
344
371
  /**
345
372
  * Setup the CF build path mode for the ADP project.
346
373
  *
package/dist/base/flp.js CHANGED
@@ -936,6 +936,8 @@ class FlpSandbox {
936
936
  const descriptor = adp.descriptor;
937
937
  const { name, manifest } = descriptor;
938
938
  await this.init(manifest, name, adp.resources, adp);
939
+ const localModuleState = await (0, flex_1.readLocalModulePaths)(this.project, this.logger);
940
+ this.registerLrepFlexDataFilter(adp, localModuleState);
939
941
  this.router.use(adp.descriptor.url, adp.proxy.bind(adp));
940
942
  await this.setupAdpCommonHandlers(adp);
941
943
  }
@@ -951,6 +953,60 @@ class FlpSandbox {
951
953
  // Register i18n store route for ADP projects (used by OVP bridge functions)
952
954
  await this.addStoreI18nKeysRoute();
953
955
  }
956
+ /**
957
+ * Registers a middleware that intercepts LREP flex data responses to apply
958
+ * local workspace overrides: stripping inlined modules for active local files,
959
+ * removing the loadModules pre-fetch flag, injecting correct moduleNames, and
960
+ * suppressing deployed changes for orphaned local files (change record deleted).
961
+ *
962
+ * The local module state is captured once at server startup by `readLocalModulePaths`
963
+ * and is not refreshed during the preview session. If a developer adds, renames, or deletes
964
+ * a local fragment, controller, or change file while the preview is running, the filter will
965
+ * be stale until the server is restarted. This is an intentional trade-off — re-scanning the
966
+ * VFS on every flex-data request would add latency to every preview round-trip.
967
+ *
968
+ * @param adp AdpPreview instance with access to the ABAP service provider
969
+ * @param localModuleState pre-scanned local module state
970
+ */
971
+ registerLrepFlexDataFilter(adp, localModuleState) {
972
+ const provider = adp.serviceProvider;
973
+ if (!provider) {
974
+ return;
975
+ }
976
+ if (localModuleState.active.size === 0 && localModuleState.orphaned.size === 0) {
977
+ this.logger.debug('No local module files found — LREP flex data filter not registered');
978
+ return;
979
+ }
980
+ const lrepFlexDataPath = '/sap/bc/lrep/flex/data/';
981
+ this.router.get(`${lrepFlexDataPath}*`, async (req, res, next) => {
982
+ await this.lrepFlexDataFilterHandler(req, res, next, provider, localModuleState);
983
+ });
984
+ this.logger.info(`Registered LREP flex data filter for ${localModuleState.active.size} active and ${localModuleState.orphaned.size} orphaned local module(s)`);
985
+ }
986
+ /**
987
+ * Handler for LREP flex data requests. Strips inlined modules that exist locally
988
+ * so that UI5 fetches them via HTTP from the local workspace instead.
989
+ *
990
+ * @param req the request
991
+ * @param res the response
992
+ * @param next the next middleware
993
+ * @param provider the ABAP service provider for backend calls
994
+ * @param localModuleState pre-scanned local module state
995
+ */
996
+ async lrepFlexDataFilterHandler(req, res, next, provider, localModuleState) {
997
+ try {
998
+ const response = await provider.get(req.url);
999
+ const responseData = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
1000
+ const filtered = (0, flex_1.stripLocalModulesFromLrepResponse)(responseData, localModuleState, this.logger);
1001
+ res.status(200).json(filtered);
1002
+ }
1003
+ catch (error) {
1004
+ this.logger.error(`LREP flex data filter failed for '${req.url}' — falling back to unfiltered backend response. Local workspace files may not take effect. Error: ${error?.message ?? error}`);
1005
+ // Fall through to the next middleware (e.g. backend-proxy-middleware)
1006
+ // so that preview remains functional even if filtering fails.
1007
+ next();
1008
+ }
1009
+ }
954
1010
  /**
955
1011
  * Setup the CF build path mode for the ADP project.
956
1012
  *
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "bugs": {
10
10
  "url": "https://github.com/SAP/open-ux-tools/issues?q=is%3Aopen+is%3Aissue+label%3Abug+label%3Apreview-middleware"
11
11
  },
12
- "version": "0.26.10",
12
+ "version": "0.26.11",
13
13
  "license": "Apache-2.0",
14
14
  "author": "@SAP/ux-tools-team",
15
15
  "main": "dist/index.js",
@@ -27,7 +27,7 @@
27
27
  "mem-fs-editor": "9.4.0",
28
28
  "qrcode": "1.5.4",
29
29
  "@sap/bas-sdk": "3.13.7",
30
- "@sap-ux/adp-tooling": "0.19.9",
30
+ "@sap-ux/adp-tooling": "0.19.10",
31
31
  "@sap-ux/btp-utils": "1.2.1",
32
32
  "@sap-ux/control-property-editor-sources": "npm:@sap-ux/control-property-editor@0.8.1",
33
33
  "@sap-ux/feature-toggle": "0.4.0",
@@ -52,7 +52,7 @@
52
52
  "nock": "14.0.11",
53
53
  "npm-run-all2": "8.0.4",
54
54
  "supertest": "7.2.2",
55
- "@private/preview-middleware-client": "npm:@sap-ux-private/preview-middleware-client@0.26.10",
55
+ "@private/preview-middleware-client": "npm:@sap-ux-private/preview-middleware-client@0.26.11",
56
56
  "@sap-ux-private/playwright": "0.3.1",
57
57
  "@sap-ux/axios-extension": "1.26.1",
58
58
  "@sap-ux/store": "1.6.1",